Exercise short-stack calibration paths

This commit is contained in:
Jay
2026-07-26 16:40:06 -04:00
parent 4d8a62afb6
commit 369b1f59d4
11 changed files with 164 additions and 10 deletions
+9
View File
@@ -15,6 +15,7 @@ export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home"
./gradlew :sim:test # fast calibration-policy tests ./gradlew :sim:test # fast calibration-policy tests
./gradlew :sim:run --args="styles" # enforced controlled style experiment ./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="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 ./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 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. several seeds — a single seed will happily agree with a wrong conclusion.
The quick 50k run is diagnostic; only `calibrate` is an enforced result. 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 5. **A skill parameter must not smuggle in a style change.** Several bugs came
from exactly this: `positionAwareness` silently reduced hands played, from exactly this: `positionAwareness` silently reduced hands played,
`potOddsRespect` systematically loosened weak players (which is a *winning* `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 - 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 4×100k fixed-opponent calibration before accepting a skill change; it computes
confidence bounds and exits nonzero when the contract fails. 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 ## 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 are allowed to overlap, but both must beat Intermediate and Intermediate must
beat Beginner with a positive 95% lower confidence bound. Current lower bounds beat Beginner with a positive 95% lower confidence bound. Current lower bounds
are +24.42, +11.01, and +24.67 bb/100 respectively. 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 - Expert differs by mechanism: it alone maintains opponent reads. The aggression
prior is measured by the controlled neutral TAG experiment (0.231 observed, prior is measured by the controlled neutral TAG experiment (0.231 observed,
0.22 configured), not selected because it looks plausible. 0.22 configured), not selected because it looks plausible.
@@ -30,6 +30,11 @@ class ActionButtonsTest {
stack = stack, stack = stack,
canCheck = canCheck, canCheck = canCheck,
canRaise = canRaise, canRaise = canRaise,
activeOpponents = 1,
seatsActingAfter = 0,
bigBlind = 2,
eligiblePot = 40,
currentBet = if (canCheck) 0 else toCall,
) )
// ---------- the raise omission ---------- // ---------- the raise omission ----------
@@ -114,6 +119,11 @@ class CallCostTest {
stack = stack, stack = stack,
canCheck = false, canCheck = false,
canRaise = false, canRaise = false,
activeOpponents = 1,
seatsActingAfter = 0,
bigBlind = 2,
eligiblePot = 100,
currentBet = toCall,
) )
/** /**
@@ -50,6 +50,11 @@ class LiveOfferTest {
stack = 500, stack = 500,
canCheck = false, canCheck = false,
canRaise = true, canRaise = true,
activeOpponents = 1,
seatsActingAfter = 0,
bigBlind = 2,
eligiblePot = 40,
currentBet = 10,
) )
@Test @Test
@@ -30,13 +30,13 @@ data class DecisionOffer(
val canCheck: Boolean, val canCheck: Boolean,
val canRaise: Boolean, val canRaise: Boolean,
/** Public table state needed by deterministic post-action analysis. */ /** Public table state needed by deterministic post-action analysis. */
val activeOpponents: Int = 1, val activeOpponents: Int,
val seatsActingAfter: Int = 0, val seatsActingAfter: Int,
val bigBlind: Int = 2, val bigBlind: Int,
/** Current pot this seat can contest if it calls; excludes the call and higher side pots. */ /** 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. */ /** 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. * What calling actually costs.
@@ -82,9 +82,9 @@ class DecisionContext(
* call. The call itself is not included. Excludes unmatched excess and side * call. The call itself is not included. Excludes unmatched excess and side
* pots above this seat's final level. * 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. */ /** 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 hole: IntArray get() = seat.hole
val stack: Int get() = seat.stack val stack: Int get() = seat.stack
@@ -33,6 +33,8 @@ class DecisionTraceTest {
handNumber = 1, handNumber = 1,
decisionToken = 1, decisionToken = 1,
bettingReopened = true, bettingReopened = true,
eligiblePot = 80,
currentBet = if (street == Street.PREFLOP) 2 else 20,
) )
} }
@@ -145,6 +147,7 @@ class DecisionTraceTest {
decisionToken = 1, decisionToken = 1,
bettingReopened = false, bettingReopened = false,
eligiblePot = 40, eligiblePot = 40,
currentBet = 100,
) )
bot.act(ctx) bot.act(ctx)
@@ -51,6 +51,8 @@ class ProfileBehaviourTest {
handNumber = it + 1, handNumber = it + 1,
decisionToken = (it + 1).toLong(), decisionToken = (it + 1).toLong(),
bettingReopened = true, bettingReopened = true,
eligiblePot = 3,
currentBet = 2,
) )
val action = bot.act(ctx) val action = bot.act(ctx)
if (action.type != ActionType.FOLD) entered++ if (action.type != ActionType.FOLD) entered++
@@ -47,6 +47,8 @@ class FoldAndTokenTest {
handNumber = 1, handNumber = 1,
decisionToken = token, decisionToken = token,
bettingReopened = true, bettingReopened = true,
eligiblePot = 20,
currentBet = if (canCheck) 0 else 10,
) )
// ---------- fold is honoured, never silently substituted ---------- // ---------- fold is honoured, never silently substituted ----------
@@ -50,6 +50,8 @@ class SnapshotAndHumanAgentTest {
handNumber = 1, handNumber = 1,
decisionToken = token, decisionToken = token,
bettingReopened = true, bettingReopened = true,
eligiblePot = 15,
currentBet = 10,
) )
// ---------- snapshots ---------- // ---------- snapshots ----------
@@ -216,6 +218,8 @@ class HumanOfferAndStreetStateTest {
handNumber = 1, handNumber = 1,
decisionToken = token, decisionToken = token,
bettingReopened = true, bettingReopened = true,
eligiblePot = 15,
currentBet = 10,
) )
/** Codex note: the earlier test cancelled the coroutine, never cancel() itself. */ /** Codex note: the earlier test cancelled the coroutine, never cancel() itself. */
@@ -251,6 +255,11 @@ class HumanOfferAndStreetStateTest {
seat.committedThisRound = 999 seat.committedThisRound = 999
assertEquals(500, offer.stack, "offer must not alias live seat state") assertEquals(500, offer.stack, "offer must not alias live seat state")
assertEquals(listOf(0, 5), offer.hole) 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)) human.submit(human.offer.value!!.token, Action(ActionType.FOLD))
decision.await() decision.await()
@@ -9,6 +9,8 @@ import com.jsjdesigns.poker.core.Card
import com.jsjdesigns.poker.core.PreflopChart import com.jsjdesigns.poker.core.PreflopChart
import com.jsjdesigns.poker.core.Suit import com.jsjdesigns.poker.core.Suit
import com.jsjdesigns.poker.game.ActionType 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.Seat
import com.jsjdesigns.poker.game.Street import com.jsjdesigns.poker.game.Street
import com.jsjdesigns.poker.game.Table 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 BIG_BLIND = 2
private const val STARTING_STACK = 200 // 100 big blinds 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) { internal class Stats(val name: String, val profile: BotProfile) {
var net = 0L var net = 0L
var hands = 0 var hands = 0
@@ -46,6 +68,8 @@ private fun runTable(
hands: Int, hands: Int,
seed: Long, seed: Long,
verbose: Boolean = true, verbose: Boolean = true,
stackPolicy: StackPolicy = StackPolicy.RESET_EACH_HAND,
shortStackCoverage: ShortStackCoverage? = null,
): List<Stats> = runBlocking { ): List<Stats> = runBlocking {
// The deck gets its own RNG. If bots drew from the same stream, the number of // 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 // Monte Carlo rollouts a bot performs — which varies by skill level — would
@@ -54,7 +78,13 @@ private fun runTable(
val deckRandom = Random(seed) val deckRandom = Random(seed)
val bots = roster.mapIndexed { i, p -> MathBot(p, Random(seed * 31 + i)) } val bots = roster.mapIndexed { i, p -> MathBot(p, Random(seed * 31 + i)) }
val stats = roster.map { Stats(it.name, it) } 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) val table = Table(seats, SMALL_BLIND, BIG_BLIND, deckRandom)
if (verbose) { if (verbose) {
@@ -69,7 +99,12 @@ private fun runTable(
val raisedPre = BooleanArray(seats.size) val raisedPre = BooleanArray(seats.size)
repeat(hands) { 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(enteredPot, false)
java.util.Arrays.fill(raisedPre, false) java.util.Arrays.fill(raisedPre, false)
table.advanceButton() table.advanceButton()
@@ -134,6 +169,44 @@ private fun runTable(
stats 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( internal data class LadderSeedResult(
val seed: Long, val seed: Long,
val bbPer100: Map<SkillLevel, Double>, 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)) 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" "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>) { fun main(args: Array<String>) {
if (args.firstOrNull() == "chart") { printChart(); return } 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") { if (args.firstOrNull() == "styles") {
val hands = args.getOrNull(1)?.toIntOrNull() ?: 20_000 val hands = args.getOrNull(1)?.toIntOrNull() ?: 20_000
val seed = args.getOrNull(2)?.toLongOrNull() ?: 20_260_732L val seed = args.getOrNull(2)?.toLongOrNull() ?: 20_260_732L
@@ -99,4 +99,31 @@ class CalibrationPolicyTest {
.passes .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,
)
}
} }