Fix four rules and modelling defects found in review

TDA Rule 47 — cumulative incomplete raises:
A boolean could not express "facing at least a full raise since acting", so
several short all-ins that together reached a full raise failed to reopen
betting. Seat now records lastActedAtBet (the currentBet when it last acted);
mayRaise() reopens when currentBet - lastActedAtBet >= minRaiseSize. This
subsumes the single-incomplete-raise case, so the hasActed reset in apply() is
gone.

TDA Rule 20 — odd chips:
Split-pot remainders were awarded in seat-list order. They now go to the first
winning seat clockwise from the button. The old test also never produced an odd
pot (20 chips heads-up), so it only ever proved an even split; it now builds a
genuinely odd 25-chip pot via a folded small blind and asserts which seat takes
the extra chip.

OpponentModel skipped events across hands:
It inferred a new hand from a shrinking history, but history is cleared each
hand: having consumed 3 events, first observing the next hand at 4 events left
4 < 3 false and silently dropped the first three. observe() now takes an
explicit handNumber, exposed via DecisionContext and Table.handNumber.

PreflopChart percentile semantics:
The 169 classes were ranked equally, but they are not equally likely — a pair is
6 of 1326 combinations, suited 4, offsuit 12. "Top 12%" therefore meant 12% of
classes, not of dealt hands, so looseness did not mean what it claimed.
Percentiles are now weighted by combination count.

Tests: 30 -> 37. Each new test was verified to fail with its fix reverted.
Skill gradient still monotonic: 85.9 / 79.8 / 17.4 / -183.1 bb/100 over 50k
hands, chips conserved on both tables.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jay
2026-07-25 05:03:11 -04:00
parent 479be1f6b9
commit f1222401fb
8 changed files with 277 additions and 33 deletions
@@ -48,7 +48,7 @@ class MathBot(
val style = profile.style
val opponents = ctx.activeOpponents.coerceAtLeast(1)
if (skill.readsOpponents) reads.observe(ctx.history)
if (skill.readsOpponents) reads.observe(ctx.history, ctx.handNumber)
if (ctx.street == Street.PREFLOP) return actPreflop(ctx)
// Weaker players run fewer rollouts, so they genuinely misjudge their hand
@@ -15,10 +15,21 @@ class OpponentModel {
private val aggressiveActions = HashMap<Int, Int>()
private val totalActions = HashMap<Int, Int>()
private var consumed = 0
private var currentHand = -1
/** Folds in any events not yet seen. History resets each hand, so detect that. */
fun observe(history: List<HandEvent>) {
if (history.size < consumed) consumed = 0
/**
* Folds in any events not yet seen.
*
* The hand number is required, not inferred. History is cleared each hand, so
* a size comparison is not enough: if we consumed 3 events last hand and first
* look at the next hand once it already has 4, `4 < 3` is false and the first
* three events would be silently skipped.
*/
fun observe(history: List<HandEvent>, handNumber: Int) {
if (handNumber != currentHand) {
currentHand = handNumber
consumed = 0
}
while (consumed < history.size) {
val e = history[consumed++]
totalActions[e.seat] = (totalActions[e.seat] ?: 0) + 1
@@ -129,12 +129,27 @@ object PreflopChart {
}
}
// Convert raw equity into a percentile ranking.
// Convert scores into a percentile weighted by how often each class is
// actually dealt. The 169 classes are NOT equally likely — a pair is 6 of
// the 1326 combinations, a suited hand 4, an offsuit hand 12 — so ranking
// them evenly would make "top 12%" mean 12% of *classes* rather than 12%
// of hands, which is what a player means by it.
entries.sortByDescending { it.second }
val out = DoubleArray(TABLE_SIZE) { 1.0 }
for ((rank, entry) in entries.withIndex()) {
out[entry.first] = rank.toDouble() / (entries.size - 1)
var cumulative = 0
for ((slot, _) in entries) {
out[slot] = cumulative.toDouble() / TOTAL_COMBOS
cumulative += combosForSlot(slot)
}
return out
}
/** Combinations dealt for a slot: 6 for a pair, 4 suited, 12 offsuit. */
private fun combosForSlot(slot: Int): Int = when {
slot < 13 -> 6
slot < 13 + 169 -> 4
else -> 12
}
private const val TOTAL_COMBOS = 1326 // C(52,2)
}
@@ -36,6 +36,17 @@ class Seat(
var allIn = false
var hasActed = false
/**
* The table's `currentBet` at the moment this player last acted this round;
* -1 before they have acted.
*
* A boolean cannot express TDA Rule 47: betting reopens when a player is
* facing *at least a full raise* since their last action, which several
* incomplete all-ins can reach cumulatively even though no single one does.
* Storing the level they last acted at makes that a subtraction.
*/
var lastActedAtBet = -1
val canAct: Boolean get() = !folded && !allIn && stack > 0
val contesting: Boolean get() = !folded
}
@@ -54,6 +65,10 @@ class DecisionContext(
val seatsActingAfter: Int,
val bigBlind: Int,
val history: List<HandEvent>,
/** Increments once per hand; use it to detect hand boundaries. */
val handNumber: Int,
/** TDA Rule 47: is this player facing at least a full raise since acting? */
val bettingReopened: Boolean,
) {
val hole: IntArray get() = seat.hole
val stack: Int get() = seat.stack
@@ -62,11 +77,12 @@ class DecisionContext(
/**
* True when this player may still put in a raise.
*
* A player who has already acted at the current bet level and is only facing
* an *incomplete* raise (a short all-in) owes the difference but may not
* re-raise. A full raise resets [Seat.hasActed], restoring the right.
* A player already acted at this level and facing only an *incomplete* raise
* owes the difference but may not re-raise. Betting reopens once the increase
* since their last action reaches a full raise — whether from one raise or
* several short all-ins adding up.
*/
val canRaise: Boolean get() = seat.stack > toCall && !seat.hasActed
val canRaise: Boolean get() = seat.stack > toCall && bettingReopened
val inPosition: Boolean get() = seatsActingAfter == 0
}
@@ -102,6 +118,10 @@ class Table(
var button: Int = 0
private set
/** Increments once per hand so stateful observers can detect hand boundaries. */
var handNumber: Int = 0
private set
val board = ArrayList<Int>(5)
private val events = ArrayList<HandEvent>()
@@ -127,6 +147,7 @@ class Table(
}
fun playHand(): HandResult {
handNumber++
val startingStacks = IntArray(seats.size) { seats[it].stack }
resetForHand()
@@ -256,10 +277,19 @@ class Table(
return actual
}
/**
* TDA Rule 47. A player who has not yet acted may always raise. One who has
* may raise again only when the bet has climbed by at least a full raise
* since they acted — which multiple incomplete all-ins can reach together.
*/
private fun mayRaise(seat: Seat): Boolean =
seat.lastActedAtBet < 0 || (currentBet - seat.lastActedAtBet) >= minRaiseSize
private fun runBettingRound(street: Street, firstSeat: Int) {
for (s in seats) {
s.committedThisRound = 0
s.hasActed = false
s.lastActedAtBet = -1
}
if (street == Street.PREFLOP) {
@@ -292,6 +322,8 @@ class Table(
val action = sanitise(seat, toCall, seat.agent.act(ctx))
apply(street, seat, action, toCall)
seat.hasActed = true
// Record the level they acted at — after their own raise, if any.
seat.lastActedAtBet = currentBet
if (seats.count { it.contesting } <= 1) return
}
@@ -329,6 +361,8 @@ class Table(
seatsActingAfter = after,
bigBlind = bigBlind,
history = events,
handNumber = handNumber,
bettingReopened = mayRaise(seat),
)
}
@@ -340,7 +374,7 @@ class Table(
ActionType.CALL -> if (toCall == 0) Action(ActionType.CHECK) else action
ActionType.BET, ActionType.RAISE -> {
// Facing only an incomplete raise after already acting: call or fold.
if (seat.hasActed && toCall > 0) return Action(ActionType.CALL, toCall)
if (!mayRaise(seat) && toCall > 0) return Action(ActionType.CALL, toCall)
val maxTo = seat.committedThisRound + seat.stack
val minTo = (currentBet + minRaiseSize).coerceAtMost(maxTo)
val target = action.amount.coerceIn(minTo, maxTo)
@@ -361,11 +395,11 @@ class Table(
ActionType.BET, ActionType.RAISE -> {
val raiseSize = action.amount - currentBet
commit(seat, action.amount - seat.committedThisRound)
// A short all-in that does not complete a full raise must not reopen betting.
if (raiseSize >= minRaiseSize) {
minRaiseSize = raiseSize
for (other in seats) if (other !== seat && other.canAct) other.hasActed = false
}
// Only a full raise raises the bar for subsequent raises. An
// incomplete all-in leaves minRaiseSize alone, but still lifts
// currentBet — so it counts toward reopening cumulatively, which
// mayRaise() computes from each seat's lastActedAtBet.
if (raiseSize >= minRaiseSize) minRaiseSize = raiseSize
currentBet = maxOf(currentBet, seat.committedThisRound)
}
}
@@ -428,7 +462,12 @@ class Table(
for (p in pots) {
val best = p.eligible.maxOf { scores.getValue(it) }
val potWinners = p.eligible.filter { scores.getValue(it) == best }
// TDA Rule 20: the odd chip goes to the first winning seat left of
// the button, so order winners clockwise from there rather than by
// seat-list index.
val potWinners = p.eligible
.filter { scores.getValue(it) == best }
.sortedBy { (it - button - 1 + seats.size) % seats.size }
val share = p.amount / potWinners.size
var remainder = p.amount - share * potWinners.size
for (w in potWinners) {