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
@@ -19,11 +19,20 @@ import kotlin.random.Random
data class DecisionTrace(
val equity: Double,
val breakEvenEquity: Double,
/** The final threshold actually used after every adjustment. */
val decisionThreshold: Double,
val potOdds: String,
/** What the strategy selected before a skill error was applied. */
val intended: Action,
val chosen: Action,
val mistakeApplied: Boolean,
/** Multipliers applied to the raw threshold, in evaluation order. */
val adjustments: List<ThresholdAdjustment>,
val reason: String,
)
data class ThresholdAdjustment(val name: String, val factor: Double)
/**
* A bot that decides from equity and pot odds, then distorts that decision through
* its [SkillLevel] and [PlayStyle].
@@ -66,27 +75,27 @@ class MathBot(
val breakEven = Equity.potOdds(ctx.pot, ctx.toCall)
// Discipline is ACCURACY, not strictness.
//
// This used to shift weak players systematically toward calling, which is
// not a weakness at all — calling wider than break-even against bad
// opponents is a winning adjustment, so it handed low-skill bots a real
// edge and inverted the difficulty gradient. A weak player misjudges the
// threshold in *either* direction; being wrong is what costs money.
// Raw pot odds OVERSTATE the threshold. Calling also buys the chance to
// win more on later streets, so the genuinely correct bar sits below
// break-even. Anchoring a perfect player at raw break-even made them fold
// profitable hands, and Advanced — whose imprecision sometimes dipped
// below it — beat Expert consistently across seeds.
val trueBar = breakEven * IMPLIED_ODDS_DISCOUNT
// Pot odds are the honest baseline. In particular, the river has no
// future street from which to earn "implied" chips, so a blanket discount
// is mathematically wrong. Flop/turn future value can be added later only
// as an explicit model that also accounts for future costs and reverse
// implied odds; until then raw pot odds are the defensible threshold.
val adjustments = ArrayList<ThresholdAdjustment>()
val discipline = skill.potOddsRespect
val misjudgement = (1.0 - discipline) * 0.50
var bar = trueBar * (1.0 + (random.nextDouble() * 2 - 1) * misjudgement)
bar *= (1.0 - looseness * 0.35)
val accuracyFactor = 1.0 + (random.nextDouble() * 2 - 1) * misjudgement
var bar = breakEven * accuracyFactor
adjustments += ThresholdAdjustment("skill estimate", accuracyFactor)
val styleFactor = 1.0 - looseness * 0.35
bar *= styleFactor
adjustments += ThresholdAdjustment("style risk tolerance", styleFactor)
// Position is worth real equity, and better players know it.
bar *= if (ctx.inPosition) 1.0 - 0.12 * skill.positionAwareness
val positionFactor = if (ctx.inPosition) 1.0 - 0.12 * skill.positionAwareness
else 1.0 + 0.10 * skill.positionAwareness
bar *= positionFactor
adjustments += ThresholdAdjustment("position", positionFactor)
// Exploitation: a habitual bettor's bet means less, so call wider against
// them; a passive player's bet means strength, so fold more.
@@ -96,27 +105,37 @@ class MathBot(
}?.seat
if (bettor != null && bettor != ctx.seat.index && reads.actionsObserved(bettor) >= 25) {
val edge = reads.aggressionRate(bettor) - OpponentModel.NEUTRAL_AGGRESSION
bar *= (1.0 - edge * 0.50).coerceIn(0.7, 1.3)
val readFactor = (1.0 - edge * 0.50).coerceIn(0.7, 1.3)
bar *= readFactor
adjustments += ThresholdAdjustment("opponent read", readFactor)
}
}
val raiseBar = (0.62 - aggression * 0.22).coerceIn(0.30, 0.75)
var decision = decide(ctx, equity, bar, raiseBar, aggression, style, opponents)
val intended = decide(ctx, equity, bar, raiseBar, aggression, style, opponents)
// Outright mistakes, on top of misjudgement.
if (random.nextDouble() < skill.errorRate) {
decision = blunder(ctx, decision)
// Skill errors degrade the selected action; style has already chosen how
// this player prefers to express a close decision.
val chosen = if (random.nextDouble() < skill.errorRate) {
postflopMistake(ctx, intended, equity, breakEven)
} else {
intended
}
val mistakeApplied = chosen != intended
lastTrace = DecisionTrace(
equity = equity,
breakEvenEquity = breakEven,
decisionThreshold = bar,
potOdds = if (ctx.toCall > 0) "${ctx.pot}:${ctx.toCall}" else "no bet to call",
chosen = decision,
reason = traceReason(equity, breakEven, ctx),
intended = intended,
chosen = chosen,
mistakeApplied = mistakeApplied,
adjustments = adjustments,
reason = traceReason(equity, breakEven, bar, intended, chosen, mistakeApplied, ctx),
)
return decision
return chosen
}
/**
@@ -132,26 +151,21 @@ class MathBot(
val looseness = (style.looseness + mood.loosenessBonus()).coerceIn(0.02, 1.0)
val aggression = (style.aggression + mood.aggressionBonus()).coerceIn(0.0, 1.0)
// Weak players enter a few more pots than their style says — but the
// widening has to saturate. Multiplying overshot badly at the loose end
// (a 0.75 maniac became 0.93) while still drowning out the tight end.
// Interpolating toward "play everything" scales with the room left.
val slop = (1.0 - skill.potOddsRespect) * 0.08
// Position awareness must shift WHERE hands are played, not how many.
// Widening 45% in position while narrowing 25% out of it is not neutral:
// a seat is last to act only about a quarter of the time, so the tighter
// branch dominated and higher awareness silently became lower volume.
// Expert then played fewer pots than Advanced and collected less from the
// weak seats, which is why it kept losing to a strictly worse profile.
val positional = if (ctx.inPosition) 1.0 + 0.45 * skill.positionAwareness
else 1.0 - 0.12 * skill.positionAwareness
// Position awareness shifts where a range is played without changing its
// average width. One of N players is last to act; the out-of-position
// reduction is derived from that share rather than tuned independently.
val positional = preflopPositionFactor(ctx, skill.positionAwareness)
val facingRaise = ctx.toCall > ctx.bigBlind
var gate = (looseness + (1.0 - looseness) * slop) * positional
var gate = looseness * positional
if (facingRaise) gate *= 0.45
gate = gate.coerceIn(0.01, 1.0)
val raiseGate = gate * (0.30 + aggression * 0.45)
// Aggression controls what share of the playable range is raised. The old
// 30% floor made even a zero-aggression "Calling Station" raise nearly a
// third of every entered range, contradicting the profile name.
val raiseShare = (0.05 + aggression * 0.75).coerceIn(0.05, 0.85)
val raiseGate = gate * raiseShare
val action = when {
pct <= raiseGate && ctx.canRaise ->
@@ -162,14 +176,31 @@ class MathBot(
if (ctx.canCheck) Action(ActionType.CHECK) else Action(ActionType.FOLD)
}
val final = if (random.nextDouble() < skill.errorRate) blunder(ctx, action) else action
val final = if (random.nextDouble() < skill.errorRate) {
preflopMistake(ctx, action, pct, gate)
} else {
action
}
val mistakeApplied = final != action
val equityScore = 1.0 - pct
val threshold = 1.0 - gate
lastTrace = DecisionTrace(
equity = 1.0 - pct,
equity = equityScore,
breakEvenEquity = Equity.potOdds(ctx.pot, ctx.toCall),
decisionThreshold = threshold,
potOdds = if (ctx.toCall > 0) "${ctx.pot}:${ctx.toCall}" else "no bet to call",
intended = action,
chosen = final,
reason = "Starting hand is in the top ${(pct * 100).roundToInt()}% " +
"and this profile plays about the top ${(gate * 100).roundToInt()}%.",
mistakeApplied = mistakeApplied,
adjustments = listOf(
ThresholdAdjustment("position", positional),
ThresholdAdjustment("facing raise", if (facingRaise) 0.45 else 1.0),
),
reason = buildString {
append("Starting hand is in the top ${(pct * 100).roundToInt()}%; ")
append("this profile's range here is ${(gate * 100).roundToInt()}%.")
if (mistakeApplied) append(" Skill error changed $action to $final.")
},
)
return final
}
@@ -256,85 +287,93 @@ class MathBot(
}
/**
* A mistake, in the direction this particular player tends to make them.
* Pre-flop errors stay local to the style's range boundary.
*
* The previous model pushed every error the same way — FOLD became CALL,
* bets became checks, and only half of CALL ever tightened — so a 30% error
* rate put a "Rock" near a 30% VPIP floor no matter how tight its style said
* it was. Errors degraded skill by overwriting character.
*
* Real players have characteristic leaks. A nit's mistake is folding a hand
* they should have played or flat-calling a hand they should have raised; a
* maniac's is firing at nothing. So the *direction* is drawn from the
* player's own looseness, and only passivity — missing value with a hand
* worth betting, the most common beginner leak of all — is style-neutral.
* A global FOLD→CALL rewrite gave every beginner a 30% VPIP floor. Here a
* marginal hand can cross the boundary, and a marginal call can be folded,
* while trash remains trash and a missed raise remains inside the same range.
*/
private fun blunder(ctx: DecisionContext, intended: Action): Action =
if (ctx.street == Street.PREFLOP) preflopBlunder(ctx, intended)
else postflopBlunder(ctx, intended)
/**
* Pre-flop mistakes follow the player's character.
*
* A nit's pre-flop error is folding a hand they should have played, not
* suddenly playing like a maniac. Letting the error direction ignore style is
* what put a 12% Rock at 41% VPIP: it overwrote the label rather than
* degrading the skill behind it.
*/
private fun preflopBlunder(ctx: DecisionContext, intended: Action): Action {
val loose = random.nextDouble() < profile.style.looseness
return when (intended.type) {
ActionType.FOLD ->
if (loose && ctx.toCall > 0) Action(ActionType.CALL, ctx.toCall) else intended
ActionType.CHECK ->
if (loose && ctx.canRaise) Action(ActionType.BET, ctx.minRaiseTo) else intended
ActionType.CALL -> when {
loose && ctx.canRaise -> Action(ActionType.RAISE, ctx.minRaiseTo)
!loose && ctx.toCall > 0 -> Action(ActionType.FOLD)
else -> intended
private fun preflopMistake(
ctx: DecisionContext,
intended: Action,
percentile: Double,
gate: Double,
): Action = when (intended.type) {
ActionType.FOLD ->
if (ctx.toCall > 0 && percentile <= gate + PREFLOP_ERROR_BAND) {
Action(ActionType.CALL, ctx.toCall)
} else {
intended
}
ActionType.BET, ActionType.RAISE ->
if (ctx.canCheck) Action(ActionType.CHECK) else Action(ActionType.CALL, ctx.toCall)
}
ActionType.CALL ->
if (percentile >= (gate - PREFLOP_ERROR_BAND).coerceAtLeast(0.0)) {
Action(ActionType.FOLD)
} else {
intended
}
ActionType.BET, ActionType.RAISE ->
if (ctx.canCheck) Action(ActionType.CHECK) else Action(ActionType.CALL, ctx.toCall)
ActionType.CHECK -> intended
}
/**
* Post-flop mistakes cost money, whatever the player's style.
*
* This is where weak players actually lose: paying off when beaten and
* checking back hands that should have bet. Style must NOT soften these, or
* a high error rate becomes free — a tight player would simply fold more,
* which costs almost nothing, and the difficulty gradient collapses. That
* inversion showed up immediately in the simulator when errors were made
* style-directed everywhere.
* Post-flop errors choose an action with worse immediate value whenever that
* comparison is available. Style has already selected the intended action.
*/
private fun postflopBlunder(ctx: DecisionContext, intended: Action): Action = when (intended.type) {
// Curiosity call: the classic way a beginner donates.
private fun postflopMistake(
ctx: DecisionContext,
intended: Action,
equity: Double,
rawBreakEven: Double,
): Action = when (intended.type) {
ActionType.FOLD ->
if (ctx.toCall > 0) Action(ActionType.CALL, ctx.toCall) else Action(ActionType.CHECK)
ActionType.CHECK -> intended
ActionType.CALL ->
if (random.nextDouble() < 0.35 && ctx.toCall > 0) Action(ActionType.FOLD) else intended
// Missing value with a hand worth betting.
if (ctx.toCall > 0) Action(ActionType.CALL, ctx.toCall) else intended
ActionType.CALL -> when {
equity >= rawBreakEven -> Action(ActionType.FOLD)
ctx.canRaise -> Action(ActionType.RAISE, ctx.minRaiseTo)
else -> intended
}
ActionType.BET, ActionType.RAISE ->
if (ctx.canCheck) Action(ActionType.CHECK) else Action(ActionType.CALL, ctx.toCall)
ActionType.CHECK ->
if (equity < 0.25 && ctx.canRaise) Action(ActionType.BET, ctx.minRaiseTo) else intended
}
private fun preflopPositionFactor(ctx: DecisionContext, awareness: Double): Double {
val players = (ctx.activeOpponents + 1).coerceAtLeast(2)
val inPositionShare = 1.0 / players
val widening = 0.45 * awareness
val narrowing = widening * inPositionShare / (1.0 - inPositionShare)
return if (ctx.inPosition) 1.0 + widening else 1.0 - narrowing
}
private companion object {
/**
* How far below raw pot odds the correct calling threshold sits, allowing
* for the value a call captures on later streets.
*/
const val IMPLIED_ODDS_DISCOUNT = 0.88
const val PREFLOP_ERROR_BAND = 0.08
}
private fun traceReason(equity: Double, breakEven: Double, ctx: DecisionContext): String {
private fun traceReason(
equity: Double,
breakEven: Double,
threshold: Double,
intended: Action,
chosen: Action,
mistakeApplied: Boolean,
ctx: DecisionContext,
): String {
val pct = (equity * 100).roundToInt()
return if (ctx.toCall > 0) {
val need = (breakEven * 100).roundToInt()
"About $pct% equity against ${ctx.activeOpponents} opponent(s); needed $need% to call profitably."
val used = (threshold * 100).roundToInt()
buildString {
append("About $pct% equity against ${ctx.activeOpponents} opponent(s); ")
append("raw pot odds require $need%, adjusted decision threshold $used%.")
if (mistakeApplied) append(" Skill error changed $intended to $chosen.")
}
} else {
"About $pct% equity against ${ctx.activeOpponents} opponent(s), no bet to face."
buildString {
append("About $pct% equity against ${ctx.activeOpponents} opponent(s), no bet to face.")
if (mistakeApplied) append(" Skill error changed $intended to $chosen.")
}
}
}
}
@@ -56,14 +56,13 @@ class OpponentModel {
private const val MIN_SAMPLE = 25
/**
* What an ordinary player's bet-and-raise share of actions looks like.
* Prior for an ordinary player's bet-and-raise share of actions.
*
* NOT 0.5. Actions counted include folds, checks and calls, so even an
* aggressive player only bets or raises about a third of the time.
* Treating 0.5 as neutral made every opponent read as passive, so the two
* skill levels that consult this model tightened against the whole table
* and lost money for it — the exploitation feature was a handicap.
* Actions counted include folds, checks and calls. The controlled style
* calibration measures a neutral Tight-Aggressive profile at about 0.22;
* this prior is guarded there rather than justified by an assertion that
* merely checks whether a constant looks plausible.
*/
const val NEUTRAL_AGGRESSION = 0.32
const val NEUTRAL_AGGRESSION = 0.22
}
}
@@ -22,7 +22,7 @@ enum class SkillLevel(
// hands, which is not two difficulty levels — it is one, labelled twice.
BEGINNER("Beginner", equityIterations = 120, errorRate = 0.30, potOddsRespect = 0.20, positionAwareness = 0.10, readsOpponents = false),
INTERMEDIATE("Intermediate", equityIterations = 500, errorRate = 0.16, potOddsRespect = 0.52, positionAwareness = 0.40, readsOpponents = false),
ADVANCED("Advanced", equityIterations = 1200, errorRate = 0.07, potOddsRespect = 0.76, positionAwareness = 0.70, readsOpponents = true),
ADVANCED("Advanced", equityIterations = 1200, errorRate = 0.07, potOddsRespect = 0.76, positionAwareness = 0.70, readsOpponents = false),
EXPERT("Expert", equityIterations = 3000, errorRate = 0.010, potOddsRespect = 1.00, positionAwareness = 1.00, readsOpponents = true),
}
@@ -0,0 +1,72 @@
package com.jsjdesigns.poker.bot
import com.jsjdesigns.poker.core.cardsOf
import com.jsjdesigns.poker.game.DecisionContext
import com.jsjdesigns.poker.game.Seat
import com.jsjdesigns.poker.game.Street
import kotlinx.coroutines.test.runTest
import kotlin.random.Random
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class DecisionTraceTest {
private fun context(bot: MathBot, street: Street, board: String): DecisionContext {
val seat = Seat(0, "Expert", 200, bot)
seat.hole = cardsOf("Ah Qh")
return DecisionContext(
street = street,
seat = seat,
board = cardsOf(board),
pot = 80,
toCall = 20,
minRaiseTo = 60,
maxRaiseTo = 200,
activeOpponents = 1,
seatsActingAfter = 1,
bigBlind = 2,
history = emptyList(),
handNumber = 1,
decisionToken = 1,
bettingReopened = true,
)
}
@Test
fun `river does not receive a fictional implied-odds discount`() = runTest {
val profile = BotProfile("E", SkillLevel.EXPERT, PlayStyle.TIGHT_AGGRESSIVE)
val flopBot = MathBot(profile, Random(77))
val riverBot = MathBot(profile, Random(77))
flopBot.act(context(flopBot, Street.FLOP, "2c 7d 9s"))
riverBot.act(context(riverBot, Street.RIVER, "2c 7d 9s Jc 3h"))
val flop = flopBot.lastTrace!!
val river = riverBot.lastTrace!!
assertEquals(
flop.decisionThreshold,
river.decisionThreshold,
1e-12,
"street alone must not apply a blanket discount to pot odds",
)
assertFalse(river.adjustments.any { "implied" in it.name.lowercase() })
}
@Test
fun `trace records the rule that actually produced the action`() = runTest {
val bot = MathBot(
BotProfile("B", SkillLevel.BEGINNER, PlayStyle.TIGHT_AGGRESSIVE),
Random(11),
)
bot.act(context(bot, Street.RIVER, "2c 7d 9s Jc 3h"))
val trace = bot.lastTrace!!
assertEquals(trace.intended != trace.chosen, trace.mistakeApplied)
assertTrue(trace.decisionThreshold >= 0.0)
assertTrue(trace.adjustments.isNotEmpty())
assertTrue("raw pot odds require" in trace.reason)
assertTrue("adjusted decision threshold" in trace.reason)
}
}
@@ -83,15 +83,13 @@ class OpponentModelTest {
}
/**
* The neutral point is NOT 0.5. Folds, checks and calls are counted too, so
* even an aggressive player bets or raises only about a third of the time.
* Assuming 0.5 made every opponent read as passive, and the skill levels that
* consult this model tightened against the whole table and lost money for it.
* This is only a broad sanity bound. The actual prior is validated by the
* controlled simulator style calibration against a neutral TAG profile.
*/
@Test
fun `the neutral baseline reflects a realistic bet-raise share`() {
assertTrue(
OpponentModel.NEUTRAL_AGGRESSION in 0.2..0.45,
OpponentModel.NEUTRAL_AGGRESSION in 0.15..0.35,
"a plausible neutral bet/raise share, was ${OpponentModel.NEUTRAL_AGGRESSION}",
)
}
@@ -43,8 +43,9 @@ class ProfileBehaviourTest {
minRaiseTo = 4,
maxRaiseTo = 200,
activeOpponents = 5,
// Alternate position so the measurement is not biased either way.
seatsActingAfter = if (it % 2 == 0) 0 else 2,
// Six-handed: one seat per orbit is last to act. A 50/50 split
// materially over-weighted the in-position widening branch.
seatsActingAfter = if (it % 6 == 0) 0 else 2,
bigBlind = 2,
history = emptyList(),
handNumber = it + 1,
@@ -57,7 +58,7 @@ class ProfileBehaviourTest {
return entered.toDouble() / hands
}
private suspend fun assertPlaysLikeLabel(style: PlayStyle, skill: SkillLevel, tolerance: Double = 0.14) {
private suspend fun assertPlaysLikeLabel(style: PlayStyle, skill: SkillLevel, tolerance: Double = 0.06) {
val vpip = measureVpip(BotProfile(style.label, skill, style))
assertTrue(
abs(vpip - style.looseness) <= tolerance,
@@ -108,7 +109,7 @@ class ProfileBehaviourTest {
val beginner = measureVpip(BotProfile("R", SkillLevel.BEGINNER, PlayStyle.ROCK))
val expert = measureVpip(BotProfile("R", SkillLevel.EXPERT, PlayStyle.ROCK))
assertTrue(
abs(beginner - expert) < 0.12,
abs(beginner - expert) < 0.06,
"a beginner rock (${"%.1f".format(beginner * 100)}%) and an expert rock " +
"(${"%.1f".format(expert * 100)}%) should both be recognisably tight",
)
@@ -0,0 +1,20 @@
package com.jsjdesigns.poker.bot
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class SkillMechanismTest {
@Test
fun `expert has a distinct opponent-reading mechanism`() {
assertFalse(
SkillLevel.ADVANCED.readsOpponents,
"Advanced should play strong card-and-position poker without exploitation reads",
)
assertTrue(
SkillLevel.EXPERT.readsOpponents,
"Expert should differ by mechanism, not merely a nearby constant",
)
}
}