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: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,
)
}
}