From db1f96b4213b683eea6f322fc4dceb66ec7f2209 Mon Sep 17 00:00:00 2001 From: Jay Date: Sun, 26 Jul 2026 10:42:11 -0400 Subject: [PATCH] Make bot traces and position adjustments honest --- CLAUDE.md | 13 ++-- .../com/jsjdesigns/poker/bot/MathBot.kt | 74 ++++++++++++++----- .../jsjdesigns/poker/bot/DecisionTraceTest.kt | 57 +++++++++++++- 3 files changed, 115 insertions(+), 29 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index dc93aa9..13bdd5e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -60,8 +60,9 @@ export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home" called an advantage. 7. **DecisionTrace is the coach contract.** It records raw pot odds, the actual adjusted threshold, every adjustment, intended and chosen actions, and whether - a skill error changed the decision. The coach explains these values; it does - not reconstruct hidden bot logic. + a skill error changed the decision. Pre-flop chart percentile/range fields are + structurally separate from post-flop equity fields; inapplicable values are + null. The coach explains these values; it does not reconstruct hidden bot logic. ## Testing notes @@ -84,16 +85,16 @@ export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home" reopen betting, but several that cumulatively reach a full raise do), and **TDA Rule 20** (odd chip to the first winner left of the button). - Bots: controlled style calibration holds style constant against the same five - opponents and deal seed. Rock is 10.3% VPIP, Maniac 67.5%; looseness ordering, - Calling Station passivity (9.8% PFR, 0.27 AF), Maniac aggression, and PFR + opponents and deal seed. Rock is 10.5% VPIP, Maniac 67.4%; looseness ordering, + Calling Station passivity (10.0% PFR, 0.27 AF), Maniac aggression, and PFR relationships all pass. - Skill calibration pairs four 100k-hand seeds. Every candidate occupies the same seat against the same fixed opponent pool and deal seed. Advanced and Expert are allowed to overlap, but both must beat Intermediate and Intermediate must beat Beginner with a positive 95% lower confidence bound. Current lower bounds - are +26.63, +16.19, and +24.71 bb/100 respectively. + are +24.42, +11.01, and +24.67 bb/100 respectively. - Expert differs by mechanism: it alone maintains opponent reads. The aggression - prior is measured by the controlled neutral TAG experiment (0.229 observed, + prior is measured by the controlled neutral TAG experiment (0.231 observed, 0.22 configured), not selected because it looks plausible. ### Rules invariants that are easy to get wrong diff --git a/engine/src/commonMain/kotlin/com/jsjdesigns/poker/bot/MathBot.kt b/engine/src/commonMain/kotlin/com/jsjdesigns/poker/bot/MathBot.kt index e4dc62f..b271cb4 100644 --- a/engine/src/commonMain/kotlin/com/jsjdesigns/poker/bot/MathBot.kt +++ b/engine/src/commonMain/kotlin/com/jsjdesigns/poker/bot/MathBot.kt @@ -17,11 +17,17 @@ import kotlin.random.Random * LLM. The model narrates these values; it never computes them. */ data class DecisionTrace( - val equity: Double, - val breakEvenEquity: Double, - /** The final threshold actually used after every adjustment. */ - val decisionThreshold: Double, - val potOdds: String, + /** Monte Carlo equity when equity is actually estimated; null pre-flop. */ + val estimatedEquity: Double?, + /** Pre-flop chart percentile, where 0 is strongest; null post-flop. */ + val handStrengthPercentile: Double?, + /** Raw pot-odds threshold when it drives the decision; null pre-flop. */ + val breakEvenEquity: Double?, + /** Final post-flop equity threshold after adjustments; null pre-flop. */ + val decisionThreshold: Double?, + /** Top fraction of starting hands played in this spot; null post-flop. */ + val preflopRangeThreshold: Double?, + val potOdds: String?, /** What the strategy selected before a skill error was applied. */ val intended: Action, val chosen: Action, @@ -33,6 +39,26 @@ data class DecisionTrace( data class ThresholdAdjustment(val name: String, val factor: Double) +/** + * Balances an in-position multiplier against all out-of-position seats. + * + * If exactly one of N active players is in position, the weighted average factor + * is 1.0 for any table size. Positive [inPositionDelta] widens a range in + * position; negative values lower a calling threshold in position. + */ +internal fun balancedPositionFactor( + activeOpponents: Int, + inPosition: Boolean, + awareness: Double, + inPositionDelta: Double, +): Double { + val players = (activeOpponents + 1).coerceAtLeast(2) + val inPositionShare = 1.0 / players + val delta = inPositionDelta * awareness + val outOfPositionDelta = -delta * inPositionShare / (1.0 - inPositionShare) + return if (inPosition) 1.0 + delta else 1.0 + outOfPositionDelta +} + /** * A bot that decides from equity and pot odds, then distorts that decision through * its [SkillLevel] and [PlayStyle]. @@ -91,9 +117,14 @@ class MathBot( bar *= styleFactor adjustments += ThresholdAdjustment("style risk tolerance", styleFactor) - // Position is worth real equity, and better players know it. - val positionFactor = if (ctx.inPosition) 1.0 - 0.12 * skill.positionAwareness - else 1.0 + 0.10 * skill.positionAwareness + // Position changes where calls are made, not how often overall. Derive + // the out-of-position counterpart from the active table size. + val positionFactor = balancedPositionFactor( + activeOpponents = ctx.activeOpponents, + inPosition = ctx.inPosition, + awareness = skill.positionAwareness, + inPositionDelta = -0.12, + ) bar *= positionFactor adjustments += ThresholdAdjustment("position", positionFactor) @@ -125,9 +156,11 @@ class MathBot( val mistakeApplied = chosen != intended lastTrace = DecisionTrace( - equity = equity, + estimatedEquity = equity, + handStrengthPercentile = null, breakEvenEquity = breakEven, decisionThreshold = bar, + preflopRangeThreshold = null, potOdds = if (ctx.toCall > 0) "${ctx.pot}:${ctx.toCall}" else "no bet to call", intended = intended, chosen = chosen, @@ -182,13 +215,13 @@ class MathBot( action } val mistakeApplied = final != action - val equityScore = 1.0 - pct - val threshold = 1.0 - gate lastTrace = DecisionTrace( - 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", + estimatedEquity = null, + handStrengthPercentile = pct, + breakEvenEquity = null, + decisionThreshold = null, + preflopRangeThreshold = gate, + potOdds = null, intended = action, chosen = final, mistakeApplied = mistakeApplied, @@ -340,11 +373,12 @@ class MathBot( } 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 + return balancedPositionFactor( + activeOpponents = ctx.activeOpponents, + inPosition = ctx.inPosition, + awareness = awareness, + inPositionDelta = 0.45, + ) } private companion object { diff --git a/engine/src/commonTest/kotlin/com/jsjdesigns/poker/bot/DecisionTraceTest.kt b/engine/src/commonTest/kotlin/com/jsjdesigns/poker/bot/DecisionTraceTest.kt index 2ff10b9..871e92d 100644 --- a/engine/src/commonTest/kotlin/com/jsjdesigns/poker/bot/DecisionTraceTest.kt +++ b/engine/src/commonTest/kotlin/com/jsjdesigns/poker/bot/DecisionTraceTest.kt @@ -9,6 +9,8 @@ import kotlin.random.Random import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull import kotlin.test.assertTrue class DecisionTraceTest { @@ -46,8 +48,8 @@ class DecisionTraceTest { val flop = flopBot.lastTrace!! val river = riverBot.lastTrace!! assertEquals( - flop.decisionThreshold, - river.decisionThreshold, + requireNotNull(flop.decisionThreshold), + requireNotNull(river.decisionThreshold), 1e-12, "street alone must not apply a blanket discount to pot odds", ) @@ -64,9 +66,58 @@ class DecisionTraceTest { val trace = bot.lastTrace!! assertEquals(trace.intended != trace.chosen, trace.mistakeApplied) - assertTrue(trace.decisionThreshold >= 0.0) + assertTrue(requireNotNull(trace.decisionThreshold) >= 0.0) + assertNotNull(trace.estimatedEquity) + assertNull(trace.handStrengthPercentile) + assertNull(trace.preflopRangeThreshold) assertTrue(trace.adjustments.isNotEmpty()) assertTrue("raw pot odds require" in trace.reason) assertTrue("adjusted decision threshold" in trace.reason) } + + @Test + fun `preflop trace reports a percentile and never calls it equity`() = runTest { + val bot = MathBot( + BotProfile("E", SkillLevel.EXPERT, PlayStyle.TIGHT_AGGRESSIVE), + Random(17), + ) + bot.act(context(bot, Street.PREFLOP, "")) + + val trace = bot.lastTrace!! + assertNull(trace.estimatedEquity, "pre-flop does not run an equity simulation") + assertNotNull(trace.handStrengthPercentile) + assertTrue(trace.handStrengthPercentile in 0.0..1.0) + assertNotNull(trace.preflopRangeThreshold) + assertTrue(trace.preflopRangeThreshold in 0.0..1.0) + assertNull(trace.breakEvenEquity, "pre-flop strategy is chart-based, not pot-odds based") + assertNull(trace.decisionThreshold) + assertNull(trace.potOdds) + } + + @Test + fun `position factors average to one at every table size`() { + for (players in listOf(2, 4, 6, 9)) { + for (inPositionDelta in listOf(0.45, -0.12)) { + val inPosition = balancedPositionFactor( + activeOpponents = players - 1, + inPosition = true, + awareness = 1.0, + inPositionDelta = inPositionDelta, + ) + val outOfPosition = balancedPositionFactor( + activeOpponents = players - 1, + inPosition = false, + awareness = 1.0, + inPositionDelta = inPositionDelta, + ) + val weighted = (inPosition + (players - 1) * outOfPosition) / players + assertEquals( + 1.0, + weighted, + 1e-12, + "$players-handed position adjustment must not alter average volume", + ) + } + } + } }