Exercise short-stack calibration paths
This commit is contained in:
@@ -15,6 +15,7 @@ export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home"
|
||||
./gradlew :sim:test # fast calibration-policy tests
|
||||
./gradlew :sim:run --args="styles" # enforced controlled style experiment
|
||||
./gradlew :sim:run --args="calibrate" # enforced 4×100k fixed-pool skill + style calibration
|
||||
./gradlew :sim:run --args="short-stacks" # persistent stacks; asserts side-pot/call-clamp coverage
|
||||
./gradlew :app:assembleDebug # build the APK
|
||||
```
|
||||
|
||||
@@ -48,6 +49,8 @@ export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home"
|
||||
4. **The simulator is how bots get tuned.** Run it after any bot change, across
|
||||
several seeds — a single seed will happily agree with a wrong conclusion.
|
||||
The quick 50k run is diagnostic; only `calibrate` is an enforced result.
|
||||
Its paired skill comparisons reset equal stacks to isolate skill, then a
|
||||
mandatory persistent-stack pass verifies that short-stack pricing is live.
|
||||
5. **A skill parameter must not smuggle in a style change.** Several bugs came
|
||||
from exactly this: `positionAwareness` silently reduced hands played,
|
||||
`potOddsRespect` systematically loosened weak players (which is a *winning*
|
||||
@@ -77,6 +80,10 @@ export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home"
|
||||
- Small samples lie. 1,000 hands is not enough to rank profiles. Use the paired
|
||||
4×100k fixed-opponent calibration before accepting a skill change; it computes
|
||||
confidence bounds and exits nonzero when the contract fails.
|
||||
- Equal starting stacks are deliberate in the paired skill experiment, but they
|
||||
cannot exercise side-pot pricing. `calibrate` therefore finishes with 3,000
|
||||
persistent-stack hands and fails unless both `eligiblePot != pot` and
|
||||
`toCall > stack` occur. `:sim:test` pins the same paths at a fixed seed.
|
||||
|
||||
## Status
|
||||
|
||||
@@ -95,6 +102,8 @@ export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home"
|
||||
are allowed to overlap, but both must beat Intermediate and Intermediate must
|
||||
beat Beginner with a positive 95% lower confidence bound. Current lower bounds
|
||||
are +24.42, +11.01, and +24.67 bb/100 respectively.
|
||||
- Persistent-stack calibration currently reaches 351 decisions with inaccessible
|
||||
side-pot chips and stack-clamped calls in 3,000 hands (seed 20260729).
|
||||
- Expert differs by mechanism: it alone maintains opponent reads. The aggression
|
||||
prior is measured by the controlled neutral TAG experiment (0.231 observed,
|
||||
0.22 configured), not selected because it looks plausible.
|
||||
|
||||
@@ -30,6 +30,11 @@ class ActionButtonsTest {
|
||||
stack = stack,
|
||||
canCheck = canCheck,
|
||||
canRaise = canRaise,
|
||||
activeOpponents = 1,
|
||||
seatsActingAfter = 0,
|
||||
bigBlind = 2,
|
||||
eligiblePot = 40,
|
||||
currentBet = if (canCheck) 0 else toCall,
|
||||
)
|
||||
|
||||
// ---------- the raise omission ----------
|
||||
@@ -114,6 +119,11 @@ class CallCostTest {
|
||||
stack = stack,
|
||||
canCheck = false,
|
||||
canRaise = false,
|
||||
activeOpponents = 1,
|
||||
seatsActingAfter = 0,
|
||||
bigBlind = 2,
|
||||
eligiblePot = 100,
|
||||
currentBet = toCall,
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -50,6 +50,11 @@ class LiveOfferTest {
|
||||
stack = 500,
|
||||
canCheck = false,
|
||||
canRaise = true,
|
||||
activeOpponents = 1,
|
||||
seatsActingAfter = 0,
|
||||
bigBlind = 2,
|
||||
eligiblePot = 40,
|
||||
currentBet = 10,
|
||||
)
|
||||
|
||||
@Test
|
||||
|
||||
@@ -30,13 +30,13 @@ data class DecisionOffer(
|
||||
val canCheck: Boolean,
|
||||
val canRaise: Boolean,
|
||||
/** Public table state needed by deterministic post-action analysis. */
|
||||
val activeOpponents: Int = 1,
|
||||
val seatsActingAfter: Int = 0,
|
||||
val bigBlind: Int = 2,
|
||||
val activeOpponents: Int,
|
||||
val seatsActingAfter: Int,
|
||||
val bigBlind: Int,
|
||||
/** Current pot this seat can contest if it calls; excludes the call and higher side pots. */
|
||||
val eligiblePot: Int = pot,
|
||||
val eligiblePot: Int,
|
||||
/** Highest amount committed by any seat in the current betting round. */
|
||||
val currentBet: Int = if (street == Street.PREFLOP) bigBlind else 0,
|
||||
val currentBet: Int,
|
||||
) {
|
||||
/**
|
||||
* What calling actually costs.
|
||||
|
||||
@@ -82,9 +82,9 @@ class DecisionContext(
|
||||
* call. The call itself is not included. Excludes unmatched excess and side
|
||||
* pots above this seat's final level.
|
||||
*/
|
||||
val eligiblePot: Int = pot,
|
||||
val eligiblePot: Int,
|
||||
/** Highest amount committed by any seat in the current betting round. */
|
||||
val currentBet: Int = if (street == Street.PREFLOP) bigBlind else 0,
|
||||
val currentBet: Int,
|
||||
) {
|
||||
val hole: IntArray get() = seat.hole
|
||||
val stack: Int get() = seat.stack
|
||||
|
||||
@@ -33,6 +33,8 @@ class DecisionTraceTest {
|
||||
handNumber = 1,
|
||||
decisionToken = 1,
|
||||
bettingReopened = true,
|
||||
eligiblePot = 80,
|
||||
currentBet = if (street == Street.PREFLOP) 2 else 20,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -145,6 +147,7 @@ class DecisionTraceTest {
|
||||
decisionToken = 1,
|
||||
bettingReopened = false,
|
||||
eligiblePot = 40,
|
||||
currentBet = 100,
|
||||
)
|
||||
|
||||
bot.act(ctx)
|
||||
|
||||
@@ -51,6 +51,8 @@ class ProfileBehaviourTest {
|
||||
handNumber = it + 1,
|
||||
decisionToken = (it + 1).toLong(),
|
||||
bettingReopened = true,
|
||||
eligiblePot = 3,
|
||||
currentBet = 2,
|
||||
)
|
||||
val action = bot.act(ctx)
|
||||
if (action.type != ActionType.FOLD) entered++
|
||||
|
||||
@@ -47,6 +47,8 @@ class FoldAndTokenTest {
|
||||
handNumber = 1,
|
||||
decisionToken = token,
|
||||
bettingReopened = true,
|
||||
eligiblePot = 20,
|
||||
currentBet = if (canCheck) 0 else 10,
|
||||
)
|
||||
|
||||
// ---------- fold is honoured, never silently substituted ----------
|
||||
|
||||
@@ -50,6 +50,8 @@ class SnapshotAndHumanAgentTest {
|
||||
handNumber = 1,
|
||||
decisionToken = token,
|
||||
bettingReopened = true,
|
||||
eligiblePot = 15,
|
||||
currentBet = 10,
|
||||
)
|
||||
|
||||
// ---------- snapshots ----------
|
||||
@@ -216,6 +218,8 @@ class HumanOfferAndStreetStateTest {
|
||||
handNumber = 1,
|
||||
decisionToken = token,
|
||||
bettingReopened = true,
|
||||
eligiblePot = 15,
|
||||
currentBet = 10,
|
||||
)
|
||||
|
||||
/** Codex note: the earlier test cancelled the coroutine, never cancel() itself. */
|
||||
@@ -251,6 +255,11 @@ class HumanOfferAndStreetStateTest {
|
||||
seat.committedThisRound = 999
|
||||
assertEquals(500, offer.stack, "offer must not alias live seat state")
|
||||
assertEquals(listOf(0, 5), offer.hole)
|
||||
assertEquals(1, offer.activeOpponents)
|
||||
assertEquals(0, offer.seatsActingAfter)
|
||||
assertEquals(10, offer.bigBlind)
|
||||
assertEquals(15, offer.eligiblePot)
|
||||
assertEquals(10, offer.currentBet)
|
||||
|
||||
human.submit(human.offer.value!!.token, Action(ActionType.FOLD))
|
||||
decision.await()
|
||||
|
||||
@@ -9,6 +9,8 @@ import com.jsjdesigns.poker.core.Card
|
||||
import com.jsjdesigns.poker.core.PreflopChart
|
||||
import com.jsjdesigns.poker.core.Suit
|
||||
import com.jsjdesigns.poker.game.ActionType
|
||||
import com.jsjdesigns.poker.game.DecisionContext
|
||||
import com.jsjdesigns.poker.game.PlayerAgent
|
||||
import com.jsjdesigns.poker.game.Seat
|
||||
import com.jsjdesigns.poker.game.Street
|
||||
import com.jsjdesigns.poker.game.Table
|
||||
@@ -21,6 +23,26 @@ private const val SMALL_BLIND = 1
|
||||
private const val BIG_BLIND = 2
|
||||
private const val STARTING_STACK = 200 // 100 big blinds
|
||||
|
||||
private enum class StackPolicy {
|
||||
RESET_EACH_HAND,
|
||||
PERSIST_WITH_REBUY,
|
||||
}
|
||||
|
||||
internal data class ShortStackCoverage(
|
||||
var decisions: Int = 0,
|
||||
var eligiblePotDiffersFromPot: Int = 0,
|
||||
var callClampedByStack: Int = 0,
|
||||
) {
|
||||
val passes: Boolean
|
||||
get() = eligiblePotDiffersFromPot > 0 && callClampedByStack > 0
|
||||
|
||||
fun observe(ctx: DecisionContext) {
|
||||
decisions++
|
||||
if (ctx.eligiblePot != ctx.pot) eligiblePotDiffersFromPot++
|
||||
if (ctx.toCall > ctx.stack) callClampedByStack++
|
||||
}
|
||||
}
|
||||
|
||||
internal class Stats(val name: String, val profile: BotProfile) {
|
||||
var net = 0L
|
||||
var hands = 0
|
||||
@@ -46,6 +68,8 @@ private fun runTable(
|
||||
hands: Int,
|
||||
seed: Long,
|
||||
verbose: Boolean = true,
|
||||
stackPolicy: StackPolicy = StackPolicy.RESET_EACH_HAND,
|
||||
shortStackCoverage: ShortStackCoverage? = null,
|
||||
): List<Stats> = runBlocking {
|
||||
// The deck gets its own RNG. If bots drew from the same stream, the number of
|
||||
// Monte Carlo rollouts a bot performs — which varies by skill level — would
|
||||
@@ -54,7 +78,13 @@ private fun runTable(
|
||||
val deckRandom = Random(seed)
|
||||
val bots = roster.mapIndexed { i, p -> MathBot(p, Random(seed * 31 + i)) }
|
||||
val stats = roster.map { Stats(it.name, it) }
|
||||
val seats = roster.mapIndexed { i, p -> Seat(i, p.name, STARTING_STACK, bots[i]) }
|
||||
val seats = roster.mapIndexed { i, p ->
|
||||
val observedAgent = PlayerAgent { ctx ->
|
||||
shortStackCoverage?.observe(ctx)
|
||||
bots[i].act(ctx)
|
||||
}
|
||||
Seat(i, p.name, STARTING_STACK, observedAgent)
|
||||
}
|
||||
val table = Table(seats, SMALL_BLIND, BIG_BLIND, deckRandom)
|
||||
|
||||
if (verbose) {
|
||||
@@ -69,7 +99,12 @@ private fun runTable(
|
||||
val raisedPre = BooleanArray(seats.size)
|
||||
|
||||
repeat(hands) {
|
||||
for (s in seats) s.stack = STARTING_STACK
|
||||
when (stackPolicy) {
|
||||
StackPolicy.RESET_EACH_HAND ->
|
||||
for (s in seats) s.stack = STARTING_STACK
|
||||
StackPolicy.PERSIST_WITH_REBUY ->
|
||||
for (s in seats) if (s.stack < BIG_BLIND) s.stack = STARTING_STACK
|
||||
}
|
||||
java.util.Arrays.fill(enteredPot, false)
|
||||
java.util.Arrays.fill(raisedPre, false)
|
||||
table.advanceButton()
|
||||
@@ -134,6 +169,44 @@ private fun runTable(
|
||||
stats
|
||||
}
|
||||
|
||||
/**
|
||||
* Exercises the pricing paths that an equal-stack, reset-every-hand simulation
|
||||
* cannot reach. Stacks persist exactly as they do for app-controlled opponents:
|
||||
* a busted seat reloads only when it has less than one big blind.
|
||||
*/
|
||||
internal fun runShortStackCoverage(
|
||||
hands: Int,
|
||||
seed: Long,
|
||||
verbose: Boolean = true,
|
||||
): ShortStackCoverage {
|
||||
require(hands > 0) { "short-stack coverage requires at least one hand" }
|
||||
val coverage = ShortStackCoverage()
|
||||
val roster = PlayStyle.ALL.map { style ->
|
||||
// Beginner keeps this structural coverage pass inexpensive. Pricing is
|
||||
// shared by every skill level and is asserted separately by engine tests.
|
||||
BotProfile(style.label, SkillLevel.BEGINNER, style)
|
||||
}
|
||||
runTable(
|
||||
label = "Persistent-stack coverage",
|
||||
roster = roster,
|
||||
hands = hands,
|
||||
seed = seed,
|
||||
verbose = false,
|
||||
stackPolicy = StackPolicy.PERSIST_WITH_REBUY,
|
||||
shortStackCoverage = coverage,
|
||||
)
|
||||
if (verbose) {
|
||||
println(
|
||||
"Persistent-stack coverage ($hands hands, seed=$seed): " +
|
||||
"decisions=${coverage.decisions} " +
|
||||
"eligiblePotDiffersFromPot=${coverage.eligiblePotDiffersFromPot} " +
|
||||
"callClampedByStack=${coverage.callClampedByStack} " +
|
||||
if (coverage.passes) "PASS" else "FAIL",
|
||||
)
|
||||
}
|
||||
return coverage
|
||||
}
|
||||
|
||||
internal data class LadderSeedResult(
|
||||
val seed: Long,
|
||||
val bbPer100: Map<SkillLevel, Double>,
|
||||
@@ -378,7 +451,13 @@ private fun runCalibration(handsPerSeed: Int, seedCount: Int, baseSeed: Long) {
|
||||
println(" %-42s %s %s".format(contract.label, if (contract.passes) "PASS" else "FAIL", contract.detail))
|
||||
}
|
||||
|
||||
check(report.passes && styleContracts.all { it.passes }) {
|
||||
println()
|
||||
val shortStackCoverage = runShortStackCoverage(
|
||||
hands = 3_000,
|
||||
seed = baseSeed + seedCount + 1,
|
||||
)
|
||||
|
||||
check(report.passes && styleContracts.all { it.passes } && shortStackCoverage.passes) {
|
||||
"bot calibration failed; do not tune constants against a single seed or one aggregate number"
|
||||
}
|
||||
}
|
||||
@@ -407,6 +486,14 @@ private fun printChart() {
|
||||
|
||||
fun main(args: Array<String>) {
|
||||
if (args.firstOrNull() == "chart") { printChart(); return }
|
||||
if (args.firstOrNull() == "short-stacks") {
|
||||
val hands = args.getOrNull(1)?.toIntOrNull() ?: 3_000
|
||||
val seed = args.getOrNull(2)?.toLongOrNull() ?: 20_260_729L
|
||||
check(runShortStackCoverage(hands, seed).passes) {
|
||||
"persistent-stack calibration did not exercise both short-stack pricing paths"
|
||||
}
|
||||
return
|
||||
}
|
||||
if (args.firstOrNull() == "styles") {
|
||||
val hands = args.getOrNull(1)?.toIntOrNull() ?: 20_000
|
||||
val seed = args.getOrNull(2)?.toLongOrNull() ?: 20_260_732L
|
||||
|
||||
@@ -99,4 +99,31 @@ class CalibrationPolicyTest {
|
||||
.passes
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `persistent-stack calibration exercises short-stack pricing`() {
|
||||
val coverage = runShortStackCoverage(
|
||||
hands = 1_000,
|
||||
seed = 20_260_729L,
|
||||
verbose = false,
|
||||
)
|
||||
|
||||
assertTrue(coverage.eligiblePotDiffersFromPot > 0)
|
||||
assertTrue(coverage.callClampedByStack > 0)
|
||||
assertTrue(coverage.passes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `short-stack coverage requires both pricing paths`() {
|
||||
assertFalse(ShortStackCoverage().passes)
|
||||
assertFalse(ShortStackCoverage(10, eligiblePotDiffersFromPot = 1).passes)
|
||||
assertFalse(ShortStackCoverage(10, callClampedByStack = 1).passes)
|
||||
assertTrue(
|
||||
ShortStackCoverage(
|
||||
decisions = 10,
|
||||
eligiblePotDiffersFromPot = 1,
|
||||
callClampedByStack = 1,
|
||||
).passes,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user