Make bot traces and position adjustments honest

This commit is contained in:
Jay
2026-07-26 10:42:11 -04:00
parent 0ac69b101a
commit db1f96b421
3 changed files with 115 additions and 29 deletions
+7 -6
View File
@@ -60,8 +60,9 @@ export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home"
called an advantage. called an advantage.
7. **DecisionTrace is the coach contract.** It records raw pot odds, the actual 7. **DecisionTrace is the coach contract.** It records raw pot odds, the actual
adjusted threshold, every adjustment, intended and chosen actions, and whether adjusted threshold, every adjustment, intended and chosen actions, and whether
a skill error changed the decision. The coach explains these values; it does a skill error changed the decision. Pre-flop chart percentile/range fields are
not reconstruct hidden bot logic. 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 ## 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 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). **TDA Rule 20** (odd chip to the first winner left of the button).
- Bots: controlled style calibration holds style constant against the same five - 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, opponents and deal seed. Rock is 10.5% VPIP, Maniac 67.4%; looseness ordering,
Calling Station passivity (9.8% PFR, 0.27 AF), Maniac aggression, and PFR Calling Station passivity (10.0% PFR, 0.27 AF), Maniac aggression, and PFR
relationships all pass. relationships all pass.
- Skill calibration pairs four 100k-hand seeds. Every candidate occupies the same - 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 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 are allowed to overlap, but both must beat Intermediate and Intermediate must
beat Beginner with a positive 95% lower confidence bound. Current lower bounds 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 - 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. 0.22 configured), not selected because it looks plausible.
### Rules invariants that are easy to get wrong ### Rules invariants that are easy to get wrong
@@ -17,11 +17,17 @@ import kotlin.random.Random
* LLM. The model narrates these values; it never computes them. * LLM. The model narrates these values; it never computes them.
*/ */
data class DecisionTrace( data class DecisionTrace(
val equity: Double, /** Monte Carlo equity when equity is actually estimated; null pre-flop. */
val breakEvenEquity: Double, val estimatedEquity: Double?,
/** The final threshold actually used after every adjustment. */ /** Pre-flop chart percentile, where 0 is strongest; null post-flop. */
val decisionThreshold: Double, val handStrengthPercentile: Double?,
val potOdds: String, /** 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. */ /** What the strategy selected before a skill error was applied. */
val intended: Action, val intended: Action,
val chosen: Action, val chosen: Action,
@@ -33,6 +39,26 @@ data class DecisionTrace(
data class ThresholdAdjustment(val name: String, val factor: Double) 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 * A bot that decides from equity and pot odds, then distorts that decision through
* its [SkillLevel] and [PlayStyle]. * its [SkillLevel] and [PlayStyle].
@@ -91,9 +117,14 @@ class MathBot(
bar *= styleFactor bar *= styleFactor
adjustments += ThresholdAdjustment("style risk tolerance", styleFactor) adjustments += ThresholdAdjustment("style risk tolerance", styleFactor)
// Position is worth real equity, and better players know it. // Position changes where calls are made, not how often overall. Derive
val positionFactor = if (ctx.inPosition) 1.0 - 0.12 * skill.positionAwareness // the out-of-position counterpart from the active table size.
else 1.0 + 0.10 * skill.positionAwareness val positionFactor = balancedPositionFactor(
activeOpponents = ctx.activeOpponents,
inPosition = ctx.inPosition,
awareness = skill.positionAwareness,
inPositionDelta = -0.12,
)
bar *= positionFactor bar *= positionFactor
adjustments += ThresholdAdjustment("position", positionFactor) adjustments += ThresholdAdjustment("position", positionFactor)
@@ -125,9 +156,11 @@ class MathBot(
val mistakeApplied = chosen != intended val mistakeApplied = chosen != intended
lastTrace = DecisionTrace( lastTrace = DecisionTrace(
equity = equity, estimatedEquity = equity,
handStrengthPercentile = null,
breakEvenEquity = breakEven, breakEvenEquity = breakEven,
decisionThreshold = bar, decisionThreshold = bar,
preflopRangeThreshold = null,
potOdds = if (ctx.toCall > 0) "${ctx.pot}:${ctx.toCall}" else "no bet to call", potOdds = if (ctx.toCall > 0) "${ctx.pot}:${ctx.toCall}" else "no bet to call",
intended = intended, intended = intended,
chosen = chosen, chosen = chosen,
@@ -182,13 +215,13 @@ class MathBot(
action action
} }
val mistakeApplied = final != action val mistakeApplied = final != action
val equityScore = 1.0 - pct
val threshold = 1.0 - gate
lastTrace = DecisionTrace( lastTrace = DecisionTrace(
equity = equityScore, estimatedEquity = null,
breakEvenEquity = Equity.potOdds(ctx.pot, ctx.toCall), handStrengthPercentile = pct,
decisionThreshold = threshold, breakEvenEquity = null,
potOdds = if (ctx.toCall > 0) "${ctx.pot}:${ctx.toCall}" else "no bet to call", decisionThreshold = null,
preflopRangeThreshold = gate,
potOdds = null,
intended = action, intended = action,
chosen = final, chosen = final,
mistakeApplied = mistakeApplied, mistakeApplied = mistakeApplied,
@@ -340,11 +373,12 @@ class MathBot(
} }
private fun preflopPositionFactor(ctx: DecisionContext, awareness: Double): Double { private fun preflopPositionFactor(ctx: DecisionContext, awareness: Double): Double {
val players = (ctx.activeOpponents + 1).coerceAtLeast(2) return balancedPositionFactor(
val inPositionShare = 1.0 / players activeOpponents = ctx.activeOpponents,
val widening = 0.45 * awareness inPosition = ctx.inPosition,
val narrowing = widening * inPositionShare / (1.0 - inPositionShare) awareness = awareness,
return if (ctx.inPosition) 1.0 + widening else 1.0 - narrowing inPositionDelta = 0.45,
)
} }
private companion object { private companion object {
@@ -9,6 +9,8 @@ import kotlin.random.Random
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertFalse import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue import kotlin.test.assertTrue
class DecisionTraceTest { class DecisionTraceTest {
@@ -46,8 +48,8 @@ class DecisionTraceTest {
val flop = flopBot.lastTrace!! val flop = flopBot.lastTrace!!
val river = riverBot.lastTrace!! val river = riverBot.lastTrace!!
assertEquals( assertEquals(
flop.decisionThreshold, requireNotNull(flop.decisionThreshold),
river.decisionThreshold, requireNotNull(river.decisionThreshold),
1e-12, 1e-12,
"street alone must not apply a blanket discount to pot odds", "street alone must not apply a blanket discount to pot odds",
) )
@@ -64,9 +66,58 @@ class DecisionTraceTest {
val trace = bot.lastTrace!! val trace = bot.lastTrace!!
assertEquals(trace.intended != trace.chosen, trace.mistakeApplied) 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(trace.adjustments.isNotEmpty())
assertTrue("raw pot odds require" in trace.reason) assertTrue("raw pot odds require" in trace.reason)
assertTrue("adjusted decision threshold" 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",
)
}
}
}
} }