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.
|
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",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user