Exercise short-stack calibration paths
This commit is contained in:
@@ -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