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.
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
@@ -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 {
@@ -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",
)
}
}
}
}