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