Replace bot tuning guesses with enforced calibration
This commit is contained in:
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user