Replace bot tuning guesses with enforced calibration

This commit is contained in:
Jay
2026-07-26 05:13:54 -04:00
parent fffd60ad9d
commit 0ac69b101a
11 changed files with 690 additions and 162 deletions
@@ -2,6 +2,7 @@ package com.jsjdesigns.poker.sim
import com.jsjdesigns.poker.bot.BotProfile
import com.jsjdesigns.poker.bot.MathBot
import com.jsjdesigns.poker.bot.OpponentModel
import com.jsjdesigns.poker.bot.PlayStyle
import com.jsjdesigns.poker.bot.SkillLevel
import com.jsjdesigns.poker.core.Card
@@ -12,6 +13,7 @@ import com.jsjdesigns.poker.game.Seat
import com.jsjdesigns.poker.game.Street
import com.jsjdesigns.poker.game.Table
import kotlin.math.abs
import kotlin.math.sqrt
import kotlinx.coroutines.runBlocking
import kotlin.random.Random
@@ -19,7 +21,7 @@ private const val SMALL_BLIND = 1
private const val BIG_BLIND = 2
private const val STARTING_STACK = 200 // 100 big blinds
private class Stats(val name: String, val profile: BotProfile) {
internal class Stats(val name: String, val profile: BotProfile) {
var net = 0L
var hands = 0
var vpip = 0
@@ -27,14 +29,24 @@ private class Stats(val name: String, val profile: BotProfile) {
var wins = 0
var postflopBets = 0
var postflopCalls = 0
var actions = 0
var aggressiveActions = 0
fun bbPer100(): Double = if (hands == 0) 0.0 else (net.toDouble() / BIG_BLIND) / hands * 100.0
fun pct(n: Int): Double = if (hands == 0) 0.0 else n * 100.0 / hands
fun aggressionFactor(): Double =
if (postflopCalls == 0) postflopBets.toDouble() else postflopBets.toDouble() / postflopCalls
fun aggressiveActionShare(): Double =
if (actions == 0) 0.0 else aggressiveActions.toDouble() / actions
}
private fun runTable(label: String, roster: List<BotProfile>, hands: Int, seed: Long): List<Stats> = runBlocking {
private fun runTable(
label: String,
roster: List<BotProfile>,
hands: Int,
seed: Long,
verbose: Boolean = true,
): 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
// shift every subsequent deal, so changing a profile would silently change the
@@ -45,8 +57,10 @@ private fun runTable(label: String, roster: List<BotProfile>, hands: Int, seed:
val seats = roster.mapIndexed { i, p -> Seat(i, p.name, STARTING_STACK, bots[i]) }
val table = Table(seats, SMALL_BLIND, BIG_BLIND, deckRandom)
println("\n=== $label ===")
println("$hands hands, ${roster.size}-handed, ${STARTING_STACK / BIG_BLIND}bb stacks, seed=$seed")
if (verbose) {
println("\n=== $label ===")
println("$hands hands, ${roster.size}-handed, ${STARTING_STACK / BIG_BLIND}bb stacks, seed=$seed")
}
val started = System.nanoTime()
// VPIP/PFR are per-hand booleans, not action counts: a player who calls and
@@ -64,6 +78,10 @@ private fun runTable(label: String, roster: List<BotProfile>, hands: Int, seed:
for (e in result.events) {
val st = stats[e.seat]
st.actions++
if (e.action.type == ActionType.BET || e.action.type == ActionType.RAISE) {
st.aggressiveActions++
}
if (e.street == Street.PREFLOP) {
when (e.action.type) {
ActionType.CALL -> enteredPot[e.seat] = true
@@ -97,20 +115,272 @@ private fun runTable(label: String, roster: List<BotProfile>, hands: Int, seed:
}
val elapsed = (System.nanoTime() - started) / 1_000_000.0
println("%.1f ms (%.0f hands/sec)\n".format(elapsed, hands / (elapsed / 1000.0)))
if (verbose) {
println("%.1f ms (%.0f hands/sec)\n".format(elapsed, hands / (elapsed / 1000.0)))
println("%-7s %-28s %9s %7s %7s %6s %7s".format("Player", "Profile", "bb/100", "VPIP%", "PFR%", "AF", "Won%"))
println("-".repeat(78))
for (s in stats.sortedByDescending { it.bbPer100() }) {
println("%-7s %-28s %9s %7s %7s %6s %7s".format("Player", "Profile", "bb/100", "VPIP%", "PFR%", "AF", "Won%"))
println("-".repeat(78))
for (s in stats.sortedByDescending { it.bbPer100() }) {
println(
"%-7s %-28s %9.2f %7.1f %7.1f %6.2f %7.1f".format(
s.name, s.profile.description, s.bbPer100(),
s.pct(s.vpip), s.pct(s.pfr), s.aggressionFactor(), s.pct(s.wins),
)
)
}
println("chip conservation: %d (must be 0)".format(stats.sumOf { it.net }))
}
check(stats.sumOf { it.net } == 0L) { "$label seed=$seed did not conserve chips" }
stats
}
internal data class LadderSeedResult(
val seed: Long,
val bbPer100: Map<SkillLevel, Double>,
)
internal data class SeparationResult(
val label: String,
val meanDifference: Double,
val lower95: Double,
) {
val passes: Boolean get() = lower95 > 0.0
}
internal data class CalibrationReport(
val seeds: List<LadderSeedResult>,
val separations: List<SeparationResult>,
) {
val passes: Boolean get() = separations.all { it.passes }
}
internal data class StyleMetrics(
val label: String,
val loosenessSetting: Double,
val vpip: Double,
val pfr: Double,
val aggressionFactor: Double,
val aggressiveActionShare: Double,
)
internal data class ContractResult(val label: String, val passes: Boolean, val detail: String)
internal fun evaluateStyleCalibration(metrics: List<StyleMetrics>): List<ContractResult> {
val byName = metrics.associateBy { it.label }
fun style(style: PlayStyle): StyleMetrics =
requireNotNull(byName[style.label]) { "missing ${style.label} style metrics" }
val rock = style(PlayStyle.ROCK)
val maniac = style(PlayStyle.MANIAC)
val station = style(PlayStyle.CALLING_STATION)
val lag = style(PlayStyle.LOOSE_AGGRESSIVE)
val tag = style(PlayStyle.TIGHT_AGGRESSIVE)
val expectedOrder = metrics.sortedBy { it.loosenessSetting }.map { it.label }
val actualOrder = metrics.sortedBy { it.vpip }.map { it.label }
return listOf(
ContractResult(
"Rock remains tight",
rock.vpip in 7.0..20.0,
"VPIP ${"%.1f".format(rock.vpip)}% for ${"%.1f".format(rock.loosenessSetting * 100)}% setting",
),
ContractResult(
"Maniac remains loose",
maniac.vpip in 55.0..90.0,
"VPIP ${"%.1f".format(maniac.vpip)}% for ${"%.1f".format(maniac.loosenessSetting * 100)}% setting",
),
ContractResult(
"Styles preserve looseness order",
expectedOrder == actualOrder,
"expected $expectedOrder, observed $actualOrder",
),
ContractResult(
"Calling Station stays passive",
station.aggressionFactor < 0.8,
"AF ${"%.2f".format(station.aggressionFactor)}",
),
ContractResult(
"Calling Station rarely raises pre-flop",
station.pfr < 10.0,
"PFR ${"%.1f".format(station.pfr)}%",
),
ContractResult(
"Maniac stays aggressive",
maniac.aggressionFactor > 1.5,
"AF ${"%.2f".format(maniac.aggressionFactor)}",
),
ContractResult(
"LAG raises more often than Calling Station",
lag.pfr > station.pfr,
"LAG PFR ${"%.1f".format(lag.pfr)}%, station ${"%.1f".format(station.pfr)}%",
),
ContractResult(
"Opponent-model prior matches neutral TAG",
abs(tag.aggressiveActionShare - OpponentModel.NEUTRAL_AGGRESSION) <= 0.05,
"observed ${"%.3f".format(tag.aggressiveActionShare)}, prior ${OpponentModel.NEUTRAL_AGGRESSION}",
),
)
}
/**
* Evaluates the skill contract without pretending Advanced and Expert have a
* reliable order. Both top tiers must beat Intermediate, and Intermediate must
* beat Beginner. Each comparison is paired by seed so card/run variance cancels
* as much as this simulator permits.
*/
internal fun evaluateCalibration(results: List<LadderSeedResult>): CalibrationReport {
require(results.size >= 2) { "calibration needs at least two independent seeds" }
fun separation(label: String, stronger: SkillLevel, weaker: SkillLevel): SeparationResult {
val differences = results.map { result ->
val high = requireNotNull(result.bbPer100[stronger]) { "$stronger missing for seed ${result.seed}" }
val low = requireNotNull(result.bbPer100[weaker]) { "$weaker missing for seed ${result.seed}" }
high - low
}
val mean = differences.average()
val variance = differences.sumOf { (it - mean) * (it - mean) } / (differences.size - 1)
val standardError = sqrt(variance / differences.size)
val lower95 = mean - studentTCritical95(differences.size - 1) * standardError
return SeparationResult(label, mean, lower95)
}
return CalibrationReport(
seeds = results,
separations = listOf(
separation("Expert > Intermediate", SkillLevel.EXPERT, SkillLevel.INTERMEDIATE),
separation("Advanced > Intermediate", SkillLevel.ADVANCED, SkillLevel.INTERMEDIATE),
separation("Intermediate > Beginner", SkillLevel.INTERMEDIATE, SkillLevel.BEGINNER),
),
)
}
/** Two-sided 95% Student-t critical value, used for a one-sided conservative lower bound. */
private fun studentTCritical95(degreesOfFreedom: Int): Double = when (degreesOfFreedom) {
1 -> 12.706
2 -> 4.303
3 -> 3.182
4 -> 2.776
5 -> 2.571
6 -> 2.447
7 -> 2.365
8 -> 2.306
9 -> 2.262
10 -> 2.228
in 11..14 -> 2.201
in 15..19 -> 2.120
in 20..29 -> 2.086
else -> 1.960
}
private fun skillCandidateRoster(candidate: SkillLevel): List<BotProfile> = listOf(
BotProfile("Target", candidate, PlayStyle.TIGHT_AGGRESSIVE),
BotProfile("ControlTAG", SkillLevel.INTERMEDIATE, PlayStyle.TIGHT_AGGRESSIVE),
BotProfile("ControlLAG", SkillLevel.INTERMEDIATE, PlayStyle.LOOSE_AGGRESSIVE),
BotProfile("ControlStation", SkillLevel.INTERMEDIATE, PlayStyle.CALLING_STATION),
)
private fun styleRoster(target: PlayStyle): List<BotProfile> = buildList {
add(BotProfile("Target", SkillLevel.INTERMEDIATE, target))
repeat(5) { index ->
add(BotProfile("Control${index + 1}", SkillLevel.INTERMEDIATE, PlayStyle.TIGHT_AGGRESSIVE))
}
}
private fun runStyleCalibration(hands: Int, seed: Long): List<ContractResult> {
// One target at the same seat against the same neutral roster and deck seed.
// Putting all styles in one table changes each player's matchup, so a style
// comparison would be confounded by who happened to sit around it.
val metrics = PlayStyle.ALL.map { style ->
val target = runTable(
label = "Style calibration: ${style.label}",
roster = styleRoster(style),
hands = hands,
seed = seed,
verbose = false,
).first()
StyleMetrics(
label = style.label,
loosenessSetting = style.looseness,
vpip = target.pct(target.vpip),
pfr = target.pct(target.pfr),
aggressionFactor = target.aggressionFactor(),
aggressiveActionShare = target.aggressiveActionShare(),
)
}
println("\nStyle behavior (${hands} hands, seed=$seed):")
for (metric in metrics.sortedBy { it.loosenessSetting }) {
println(
"%-7s %-28s %9.2f %7.1f %7.1f %6.2f %7.1f".format(
s.name, s.profile.description, s.bbPer100(),
s.pct(s.vpip), s.pct(s.pfr), s.aggressionFactor(), s.pct(s.wins),
" %-18s VPIP=%5.1f PFR=%5.1f AF=%4.2f AggShare=%5.3f".format(
metric.label,
metric.vpip,
metric.pfr,
metric.aggressionFactor,
metric.aggressiveActionShare,
)
)
}
println("chip conservation: %d (must be 0)".format(stats.sumOf { it.net }))
stats
return evaluateStyleCalibration(metrics)
}
private fun runCalibration(handsPerSeed: Int, seedCount: Int, baseSeed: Long) {
require(handsPerSeed >= 50_000) { "calibration requires at least 50,000 hands per seed" }
require(seedCount >= 2) { "calibration requires at least two seeds" }
println("=== Skill calibration ===")
println("$seedCount seeds × $handsPerSeed hands; top pair unordered by contract")
val results = (0 until seedCount).map { offset ->
val seed = baseSeed + offset
// Each candidate occupies the same seat against the same opponents and
// deal seed. Putting all candidates in one table makes every score depend
// on the other candidates' strengths and non-transitive matchups.
val rates = SkillLevel.entries.associateWith { candidate ->
runTable(
label = "Skill candidate: ${candidate.label}",
roster = skillCandidateRoster(candidate),
hands = handsPerSeed,
seed = seed,
verbose = false,
).first().bbPer100()
}
println(
"seed=$seed E=%7.2f A=%7.2f I=%7.2f B=%7.2f".format(
rates.getValue(SkillLevel.EXPERT),
rates.getValue(SkillLevel.ADVANCED),
rates.getValue(SkillLevel.INTERMEDIATE),
rates.getValue(SkillLevel.BEGINNER),
)
)
LadderSeedResult(seed, rates)
}
val report = evaluateCalibration(results)
println("\nPaired 95% lower bounds:")
for (result in report.separations) {
println(
" %-26s mean=%7.2f lower95=%7.2f %s".format(
result.label,
result.meanDifference,
result.lower95,
if (result.passes) "PASS" else "FAIL",
)
)
}
val styleContracts = runStyleCalibration(
hands = maxOf(20_000, handsPerSeed / 5),
seed = baseSeed + seedCount,
)
println("\nStyle contracts:")
for (contract in styleContracts) {
println(" %-42s %s %s".format(contract.label, if (contract.passes) "PASS" else "FAIL", contract.detail))
}
check(report.passes && styleContracts.all { it.passes }) {
"bot calibration failed; do not tune constants against a single seed or one aggregate number"
}
}
/** Dumps the starting-hand ranking so the ordering can be eyeballed against a real chart. */
@@ -137,6 +407,24 @@ private fun printChart() {
fun main(args: Array<String>) {
if (args.firstOrNull() == "chart") { printChart(); return }
if (args.firstOrNull() == "styles") {
val hands = args.getOrNull(1)?.toIntOrNull() ?: 20_000
val seed = args.getOrNull(2)?.toLongOrNull() ?: 20_260_732L
val contracts = runStyleCalibration(hands, seed)
println("\nStyle contracts:")
for (contract in contracts) {
println(" %-42s %s %s".format(contract.label, if (contract.passes) "PASS" else "FAIL", contract.detail))
}
check(contracts.all { it.passes }) { "style calibration failed" }
return
}
if (args.firstOrNull() == "calibrate") {
val hands = args.getOrNull(1)?.toIntOrNull() ?: 100_000
val seeds = args.getOrNull(2)?.toIntOrNull() ?: 4
val baseSeed = args.getOrNull(3)?.toLongOrNull() ?: 20_260_724L
runCalibration(hands, seeds, baseSeed)
return
}
val hands = args.getOrNull(0)?.toIntOrNull() ?: 50_000
val seed = args.getOrNull(1)?.toLongOrNull() ?: 20_260_724L
@@ -152,15 +440,16 @@ fun main(args: Array<String>) {
), hands, seed
)
// Controlled: identical style, only skill varies. This is the experiment that
// actually tests whether the difficulty axis produces a real skill gradient.
// Quick diagnostic only. The enforced calibration evaluates each candidate
// separately against a fixed pool; this mixed ladder is intentionally not
// accepted as evidence because matchups are non-transitive.
val ladder = runTable(
"Skill ladder (style held constant at Tight-Aggressive)", listOf(
BotProfile("Expert", SkillLevel.EXPERT, PlayStyle.TIGHT_AGGRESSIVE),
BotProfile("Advncd", SkillLevel.ADVANCED, PlayStyle.TIGHT_AGGRESSIVE),
BotProfile("Interm", SkillLevel.INTERMEDIATE, PlayStyle.TIGHT_AGGRESSIVE),
BotProfile("Begin", SkillLevel.BEGINNER, PlayStyle.TIGHT_AGGRESSIVE),
), hands, seed + 1
"Skill ladder (style held constant at Tight-Aggressive)",
SkillLevel.entries.reversed().map { level ->
BotProfile(level.label, level, PlayStyle.TIGHT_AGGRESSIVE)
},
hands,
seed + 1,
)
println("\nDifficulty gradient:")
@@ -172,19 +461,9 @@ fun main(args: Array<String>) {
var strictlyMonotonic = true
for (i in 1 until byLevel.size) if (byLevel[i].second > byLevel[i - 1].second) strictlyMonotonic = false
// Adjacent tiers can sit within seed-to-seed noise of each other, so the
// invariant actually worth asserting is separation across a two-step gap.
// Claiming strict adjacent ordering from a single seed would be reading noise
// as signal — see the gap check below for what is genuinely verified.
var twoStepOk = true
for (i in 2 until byLevel.size) if (byLevel[i].second >= byLevel[i - 2].second) twoStepOk = false
println(
if (strictlyMonotonic) " -> strictly monotonic this run."
else " -> adjacent tiers overlap this run (expected; they are close by design)."
)
println(
if (twoStepOk) " -> PASS: every level beats the one two tiers below it."
else " -> FAIL: the skill axis is not separating levels at all."
)
println(" -> diagnostic only; run `:sim:run --args=\"calibrate\"` for an enforced multi-seed result.")
}
@@ -0,0 +1,102 @@
package com.jsjdesigns.poker.sim
import com.jsjdesigns.poker.bot.SkillLevel
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class CalibrationPolicyTest {
private fun seed(
id: Long,
expert: Double,
advanced: Double,
intermediate: Double,
beginner: Double,
) = LadderSeedResult(
seed = id,
bbPer100 = mapOf(
SkillLevel.EXPERT to expert,
SkillLevel.ADVANCED to advanced,
SkillLevel.INTERMEDIATE to intermediate,
SkillLevel.BEGINNER to beginner,
),
)
@Test
fun `advanced and expert may overlap while both clear intermediate`() {
val report = evaluateCalibration(
listOf(
seed(1, expert = 18.0, advanced = 22.0, intermediate = -5.0, beginner = -30.0),
seed(2, expert = 24.0, advanced = 17.0, intermediate = -8.0, beginner = -27.0),
seed(3, expert = 16.0, advanced = 25.0, intermediate = -10.0, beginner = -35.0),
seed(4, expert = 21.0, advanced = 19.0, intermediate = -6.0, beginner = -31.0),
)
)
assertTrue(report.passes)
}
@Test
fun `intermediate beating advanced is not hidden by a two-tier comparison`() {
val report = evaluateCalibration(
listOf(
seed(1, expert = 30.0, advanced = 2.0, intermediate = 8.0, beginner = -30.0),
seed(2, expert = 28.0, advanced = 3.0, intermediate = 9.0, beginner = -28.0),
seed(3, expert = 32.0, advanced = 1.0, intermediate = 7.0, beginner = -32.0),
seed(4, expert = 29.0, advanced = 2.0, intermediate = 8.0, beginner = -29.0),
)
)
assertFalse(report.passes)
assertFalse(report.separations.first { it.label == "Advanced > Intermediate" }.passes)
}
@Test
fun `beginner beating intermediate fails calibration`() {
val report = evaluateCalibration(
listOf(
seed(1, expert = 30.0, advanced = 28.0, intermediate = -8.0, beginner = 2.0),
seed(2, expert = 31.0, advanced = 27.0, intermediate = -7.0, beginner = 3.0),
seed(3, expert = 29.0, advanced = 26.0, intermediate = -9.0, beginner = 1.0),
seed(4, expert = 32.0, advanced = 29.0, intermediate = -6.0, beginner = 4.0),
)
)
assertFalse(report.passes)
assertFalse(report.separations.first { it.label == "Intermediate > Beginner" }.passes)
}
@Test
fun `style contracts reject a misnamed rock and passive maniac`() {
val normal = listOf(
StyleMetrics("Rock", 0.12, vpip = 12.0, pfr = 4.0, aggressionFactor = 0.4, aggressiveActionShare = 0.18),
StyleMetrics("Tight-Aggressive", 0.22, vpip = 20.0, pfr = 12.0, aggressionFactor = 2.0, aggressiveActionShare = 0.22),
StyleMetrics("Trapper", 0.28, vpip = 26.0, pfr = 9.0, aggressionFactor = 0.7, aggressiveActionShare = 0.20),
StyleMetrics("Loose-Aggressive", 0.45, vpip = 42.0, pfr = 28.0, aggressionFactor = 2.8, aggressiveActionShare = 0.39),
StyleMetrics("Calling Station", 0.62, vpip = 58.0, pfr = 8.0, aggressionFactor = 0.3, aggressiveActionShare = 0.12),
StyleMetrics("Maniac", 0.75, vpip = 70.0, pfr = 45.0, aggressionFactor = 3.5, aggressiveActionShare = 0.46),
)
assertTrue(evaluateStyleCalibration(normal).all { it.passes })
val broken = normal.map {
when (it.label) {
"Rock" -> it.copy(vpip = 31.0)
"Maniac" -> it.copy(aggressionFactor = 0.5)
else -> it
}
}
val results = evaluateStyleCalibration(broken)
assertFalse(results.first { it.label == "Rock remains tight" }.passes)
assertFalse(results.first { it.label == "Maniac stays aggressive" }.passes)
val overAggressiveStation = normal.map {
if (it.label == "Calling Station") it.copy(pfr = 19.0) else it
}
assertFalse(
evaluateStyleCalibration(overAggressiveStation)
.first { it.label == "Calling Station rarely raises pre-flop" }
.passes
)
}
}