diff --git a/CLAUDE.md b/CLAUDE.md index 56878cb..dbc6aa4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/app/src/test/java/com/jsjdesigns/poker/ActionButtonsTest.kt b/app/src/test/java/com/jsjdesigns/poker/ActionButtonsTest.kt index 6b7e5a6..21a26a4 100644 --- a/app/src/test/java/com/jsjdesigns/poker/ActionButtonsTest.kt +++ b/app/src/test/java/com/jsjdesigns/poker/ActionButtonsTest.kt @@ -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, ) /** diff --git a/app/src/test/java/com/jsjdesigns/poker/LiveOfferTest.kt b/app/src/test/java/com/jsjdesigns/poker/LiveOfferTest.kt index ccfd6ee..5a415cc 100644 --- a/app/src/test/java/com/jsjdesigns/poker/LiveOfferTest.kt +++ b/app/src/test/java/com/jsjdesigns/poker/LiveOfferTest.kt @@ -50,6 +50,11 @@ class LiveOfferTest { stack = 500, canCheck = false, canRaise = true, + activeOpponents = 1, + seatsActingAfter = 0, + bigBlind = 2, + eligiblePot = 40, + currentBet = 10, ) @Test diff --git a/engine/src/commonMain/kotlin/com/jsjdesigns/poker/game/DecisionOffer.kt b/engine/src/commonMain/kotlin/com/jsjdesigns/poker/game/DecisionOffer.kt index e30cfaf..ae34964 100644 --- a/engine/src/commonMain/kotlin/com/jsjdesigns/poker/game/DecisionOffer.kt +++ b/engine/src/commonMain/kotlin/com/jsjdesigns/poker/game/DecisionOffer.kt @@ -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. diff --git a/engine/src/commonMain/kotlin/com/jsjdesigns/poker/game/Table.kt b/engine/src/commonMain/kotlin/com/jsjdesigns/poker/game/Table.kt index fec9a77..ac31910 100644 --- a/engine/src/commonMain/kotlin/com/jsjdesigns/poker/game/Table.kt +++ b/engine/src/commonMain/kotlin/com/jsjdesigns/poker/game/Table.kt @@ -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 diff --git a/engine/src/commonTest/kotlin/com/jsjdesigns/poker/bot/DecisionTraceTest.kt b/engine/src/commonTest/kotlin/com/jsjdesigns/poker/bot/DecisionTraceTest.kt index baad065..1ccc2bc 100644 --- a/engine/src/commonTest/kotlin/com/jsjdesigns/poker/bot/DecisionTraceTest.kt +++ b/engine/src/commonTest/kotlin/com/jsjdesigns/poker/bot/DecisionTraceTest.kt @@ -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) diff --git a/engine/src/commonTest/kotlin/com/jsjdesigns/poker/bot/ProfileBehaviourTest.kt b/engine/src/commonTest/kotlin/com/jsjdesigns/poker/bot/ProfileBehaviourTest.kt index ce988bb..5b5ec8f 100644 --- a/engine/src/commonTest/kotlin/com/jsjdesigns/poker/bot/ProfileBehaviourTest.kt +++ b/engine/src/commonTest/kotlin/com/jsjdesigns/poker/bot/ProfileBehaviourTest.kt @@ -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++ diff --git a/engine/src/commonTest/kotlin/com/jsjdesigns/poker/game/FoldAndTokenTest.kt b/engine/src/commonTest/kotlin/com/jsjdesigns/poker/game/FoldAndTokenTest.kt index c602d93..e0366ab 100644 --- a/engine/src/commonTest/kotlin/com/jsjdesigns/poker/game/FoldAndTokenTest.kt +++ b/engine/src/commonTest/kotlin/com/jsjdesigns/poker/game/FoldAndTokenTest.kt @@ -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 ---------- diff --git a/engine/src/commonTest/kotlin/com/jsjdesigns/poker/game/SnapshotAndHumanAgentTest.kt b/engine/src/commonTest/kotlin/com/jsjdesigns/poker/game/SnapshotAndHumanAgentTest.kt index 39f8899..44c992d 100644 --- a/engine/src/commonTest/kotlin/com/jsjdesigns/poker/game/SnapshotAndHumanAgentTest.kt +++ b/engine/src/commonTest/kotlin/com/jsjdesigns/poker/game/SnapshotAndHumanAgentTest.kt @@ -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() diff --git a/sim/src/main/kotlin/com/jsjdesigns/poker/sim/Main.kt b/sim/src/main/kotlin/com/jsjdesigns/poker/sim/Main.kt index 1b0ed06..e6df434 100644 --- a/sim/src/main/kotlin/com/jsjdesigns/poker/sim/Main.kt +++ b/sim/src/main/kotlin/com/jsjdesigns/poker/sim/Main.kt @@ -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 = 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, @@ -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) { 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 diff --git a/sim/src/test/kotlin/com/jsjdesigns/poker/sim/CalibrationPolicyTest.kt b/sim/src/test/kotlin/com/jsjdesigns/poker/sim/CalibrationPolicyTest.kt index a958323..7b5b725 100644 --- a/sim/src/test/kotlin/com/jsjdesigns/poker/sim/CalibrationPolicyTest.kt +++ b/sim/src/test/kotlin/com/jsjdesigns/poker/sim/CalibrationPolicyTest.kt @@ -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, + ) + } }