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