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) {
@@ -0,0 +1,78 @@
package com.jsjdesigns.poker.bot
import com.jsjdesigns.poker.game.Action
import com.jsjdesigns.poker.game.ActionType
import com.jsjdesigns.poker.game.HandEvent
import com.jsjdesigns.poker.game.Street
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class OpponentModelTest {
private fun bet(seat: Int) =
HandEvent(Street.FLOP, seat, "P$seat", Action(ActionType.BET, 10))
private fun call(seat: Int) =
HandEvent(Street.FLOP, seat, "P$seat", Action(ActionType.CALL, 10))
/**
* The regression Codex found: history is cleared each hand, so a size
* comparison cannot detect a hand boundary. Consume 3 events in hand 1, then
* first observe hand 2 when it already holds 4 — a `4 < 3` check is false, so
* the first three events of hand 2 would be silently dropped.
*/
@Test
fun `events are not skipped when the next hand is first observed late`() {
val model = OpponentModel()
model.observe(listOf(bet(1), call(2), call(3)), handNumber = 1)
assertEquals(1, model.actionsObserved(1))
assertEquals(1, model.actionsObserved(2))
assertEquals(1, model.actionsObserved(3))
// New hand, already four events deep before we look.
model.observe(listOf(bet(1), call(2), call(3), bet(1)), handNumber = 2)
assertEquals(3, model.actionsObserved(1), "both bets in hand 2 plus hand 1's must count")
assertEquals(2, model.actionsObserved(2), "hand 2's first events must not be skipped")
assertEquals(2, model.actionsObserved(3), "hand 2's first events must not be skipped")
}
@Test
fun `repeated observation within a hand is idempotent`() {
val model = OpponentModel()
val history = mutableListOf(bet(1))
model.observe(history, handNumber = 1)
model.observe(history, handNumber = 1)
model.observe(history, handNumber = 1)
assertEquals(1, model.actionsObserved(1), "re-observing must not double count")
history += call(1)
model.observe(history, handNumber = 1)
assertEquals(2, model.actionsObserved(1), "growth within a hand is picked up")
}
@Test
fun `aggression rate reflects bet and raise share once sampled`() {
val model = OpponentModel()
// 30 actions for seat 1: 24 bets, 6 calls.
val history = ArrayList<HandEvent>()
repeat(24) { history += bet(1) }
repeat(6) { history += call(1) }
model.observe(history, handNumber = 1)
assertEquals(30, model.actionsObserved(1))
assertEquals(0.8, model.aggressionRate(1), 0.0001)
}
@Test
fun `unsampled opponents report a neutral read`() {
val model = OpponentModel()
model.observe(listOf(bet(1), bet(1)), handNumber = 1)
assertEquals(0.5, model.aggressionRate(1), "too few actions to form a read")
assertEquals(0.5, model.aggressionRate(9), "never seen at all")
assertTrue(model.actionsObserved(9) == 0)
}
}
@@ -77,15 +77,37 @@ class PreflopChartTest {
assertTrue(p("7h 2d") - p("Ah Ad") > 0.8, "range is too compressed to gate on")
}
/**
* The percentile must be weighted by how often each class is dealt, not by
* class count. The 169 classes are not equally likely — a pair is 6 of the
* 1326 combinations, a suited hand 4, an offsuit hand 12 — so an unweighted
* ranking would make "top 12%" mean 12% of classes, which is not what a
* player means and would make `looseness` lie.
*/
@Test
fun `a tight range admits few hands and a loose range admits many`() {
fun `a range gate admits that share of actually dealt hands`() {
val all = ArrayList<Double>()
for (a in 0 until 51) for (b in a + 1 until 52) {
all.add(PreflopChart.percentile(intArrayOf(a, b)))
}
val tight = all.count { it <= 0.12 }
val loose = all.count { it <= 0.60 }
assertTrue(tight < loose, "a 12% range must be narrower than a 60% range")
assertTrue(tight > 0, "a 12% range must admit something")
assertEquals(1326, all.size, "there are C(52,2) starting combinations")
for (gate in listOf(0.12, 0.25, 0.40, 0.60)) {
val admitted = all.count { it <= gate }
val share = admitted.toDouble() / all.size
// Tolerance covers landing mid-class: one class can straddle the gate.
assertTrue(
kotlin.math.abs(share - gate) < 0.03,
"gate $gate should admit ~${(gate * 100).toInt()}% of dealt hands, admitted ${"%.1f".format(share * 100)}%",
)
}
}
@Test
fun `pairs are weighted as six combinations not one class`() {
// Between AA (0.0) and KK there should be exactly 6/1326 of the range,
// because AA occupies six combinations.
val gap = p("Kh Kd") - p("Ah Ad")
assertEquals(6.0 / 1326.0, gap, 1e-9, "AA must consume 6 combinations of the range")
}
}
@@ -85,6 +85,57 @@ class TableRulesTest {
assertTrue(p0.offers[1].canRaise, "a full raise restores the right to re-raise")
}
/**
* TDA Rule 47: several incomplete all-ins that together add up to a full raise
* DO reopen the betting, even though no single one of them would.
*/
@Test
fun `cumulative short all-ins reopen betting once they total a full raise`() {
// P3 opens to 100 (min raise size becomes 80). Two short all-ins follow,
// 40 each: neither reopens alone, but together they reach 80.
val p3 = Scripted(Action(ActionType.RAISE, 100), Action(ActionType.CALL, 80))
val p0 = Scripted(Action(ActionType.RAISE, 140)) // all-in, +40
val p1 = Scripted(Action(ActionType.RAISE, 180)) // all-in, +40
val p2 = Scripted(Action(ActionType.FOLD))
val seats = listOf(
Seat(0, "P0", 140, p0),
Seat(1, "P1", 180, p1),
Seat(2, "P2", 1000, p2),
Seat(3, "P3", 1000, p3),
)
Table(seats, 10, 20, Random(1),
StackedDeck.of(listOf("Ah Ad", "Kh Kd", "Qh Qd", "Jh Jd"), "2c 7d 9s Jc 3h")).playHand()
assertEquals(2, p3.offers.size, "P3 should face the raised action again")
assertEquals(80, p3.offers[1].toCall, "P3 owes 180 - 100")
assertTrue(
p3.offers[1].canRaise,
"two 40-chip short all-ins total a full 80 raise, which reopens betting",
)
}
@Test
fun `cumulative short all-ins below a full raise still do not reopen`() {
// Same shape, but the shorts total only 40 against a min raise of 80.
val p3 = Scripted(Action(ActionType.RAISE, 100), Action(ActionType.CALL, 40))
val p0 = Scripted(Action(ActionType.RAISE, 120)) // all-in, +20
val p1 = Scripted(Action(ActionType.RAISE, 140)) // all-in, +20
val p2 = Scripted(Action(ActionType.FOLD))
val seats = listOf(
Seat(0, "P0", 120, p0),
Seat(1, "P1", 140, p1),
Seat(2, "P2", 1000, p2),
Seat(3, "P3", 1000, p3),
)
Table(seats, 10, 20, Random(1),
StackedDeck.of(listOf("Ah Ad", "Kh Kd", "Qh Qd", "Jh Jd"), "2c 7d 9s Jc 3h")).playHand()
assertEquals(2, p3.offers.size)
assertFalse(p3.offers[1].canRaise, "40 total is short of a full 80 raise")
}
@Test
fun `an illegal raise attempt is downgraded to a call`() {
// P0 tries to re-raise after only an incomplete all-in; engine must clamp it.
@@ -131,21 +182,35 @@ class TableRulesTest {
assertTrue(result.net[2] < 0, "queens should lose")
}
/**
* A genuinely ODD pot: the folded small blind's 5 chips make 25, which two
* winners cannot split evenly. TDA Rule 20 sends the odd chip to the first
* winning seat left of the button.
*/
@Test
fun `split pots conserve odd chips`() {
// P0 and P1 play the same board; the pot must split without losing a chip.
fun `odd chip in a split pot goes to the first winner left of the button`() {
val p0 = Scripted(Action(ActionType.CALL, 10), Action(ActionType.CHECK), Action(ActionType.CHECK), Action(ActionType.CHECK))
val p1 = Scripted(Action(ActionType.CHECK), Action(ActionType.CHECK), Action(ActionType.CHECK), Action(ActionType.CHECK))
val p1 = Scripted(Action(ActionType.FOLD))
val p2 = Scripted(Action(ActionType.CHECK), Action(ActionType.CHECK), Action(ActionType.CHECK), Action(ActionType.CHECK))
val seats = listOf(Seat(0, "P0", 501, p0), Seat(1, "P1", 501, p1))
// Button is seat 0 -> SB seat 1 (folds, forfeiting 5), BB seat 2.
val seats = listOf(Seat(0, "P0", 500, p0), Seat(1, "P1", 500, p1), Seat(2, "P2", 500, p2))
val result = Table(
seats, 5, 10, Random(1),
// Board plays: both hold rags, the royal flush on board is the hand.
StackedDeck.of(listOf("2c 3d", "2h 3s"), "Ah Kh Qh Jh Th"),
// Royal flush on board: everyone left plays the board and ties.
StackedDeck.of(listOf("2c 3d", "4c 5d", "2h 3s"), "Ah Kh Qh Jh Th"),
).playHand()
assertEquals(0, result.net.sum(), "odd chips must not vanish")
assertEquals(25, result.potSize, "pot must actually be odd for this test to mean anything")
assertEquals(1, result.potSize % 2, "pot must be odd")
assertEquals(0, result.net.sum(), "odd chips must not vanish or be invented")
assertEquals(2, result.winners.size, "board plays -> split pot")
// Winners are seats 0 and 2. Clockwise from the button (seat 0), seat 2
// comes first, so seat 2 takes the extra chip.
assertEquals(3, result.net[2], "seat left of the button gets the odd chip")
assertEquals(2, result.net[0], "the other winner gets the smaller share")
assertEquals(-5, result.net[1], "the folded small blind loses its post")
}
// ---------- uncalled bets ----------