Make bot traces and position adjustments honest
This commit is contained in:
@@ -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",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user