Skip to content

About

Game-theory solver for heads-up pot-limit Drawmaha, built as a validation ladder: regret matching → CFR on Kuhn and Leduc → MCCFR and Deep CFR on mini-drawmaha, each rung graded by an exact best response.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

Drawmaha Solver

A game-theory solver for heads-up pot-limit Drawmaha, the split-pot draw/Omaha hybrid that no existing solver plays. It is built as a validation ladder: each rung adds one idea and is graded against a known answer before the next is climbed, so every piece of the method has been checked somewhere a mistake would show before it meets the real game.

Live site: drawmaha-solver.vercel.app — the research at /research, the solver's range interface at /solver, every graded game on /benchmarks, and a trainer that deals you in against the solved strategy at /play.

Rung Game Method Graded against Result Page
0 Rock-paper-scissors regret matching the uniform Nash 0.0009 chips/round exploitable after 100k rounds /rung0
1 Kuhn poker vanilla CFR the closed-form equilibrium; all 64 pure strategies 0.00063 chips/hand; game value −0.05555 against −1/18 /rung1
2 Leduc poker vanilla CFR an OpenSpiel solve, frozen as a fixture 0.011 chips/hand; game value −0.0874 against −0.0856 /rung2
3 Mini-drawmaha, 6.2M infosets lockstep MCCFR, four regret rules raced the exact best-response walk LCFR wins: 0.061 chips/hand at 20M hands per seat /rung3
4 Mini-drawmaha Deep CFR rung 3's grader and rung 3's answer 0.068 chips/hand at 10M hands per seat, where LCFR is at 0.126, and still 0.068 at 20M, where LCFR has reached 0.061 /solver
next Full Drawmaha Deep CFR at scale a head-to-head checkpoint ladder and a trained exploiter not started; the range interface is built ahead of it /solver

Every result is exploitability: what a perfect adversary wins against the strategy, averaged over the two seats, in chips per hand (per round for rock-paper-scissors). It is computed exactly, by a best-response walk that shares no code with the solver: zero exactly at equilibrium, and unlike a training loss it cannot be fooled by self-play.

Quick start

uv sync                  # Python 3.12+; add --extra deep for torch (rung 4 training only)
uv run pytest            # the whole suite; needs no torch, Modal, OpenSpiel or network
uv run kuhn-analysis     # solve a rung and redraw its figures: rps-, kuhn-, leduc-, minidraw-analysis
uv run kuhn-play         # play the solved strategy in the terminal: rps-, kuhn-, leduc-, minidraw-play

Repository layout

Path What is there
src/drawmaha_solver/ one subpackage per rung (rps, kuhn, leduc, minidrawmaha, deepcfr) and the regret-matching ledger they all share
tests/ pytest, one directory per rung, plus checks on the site and the agent instructions
scripts/ hand-run entry points: the OpenSpiel referee, the Modal launchers, the site build
web/ the site: five static pages, four of them generated, and five Vite + React apps
figures/ every analysis command's output, committed so a changed number shows up in the diff
paper/ a survey of the ML behind the solver (PDF)
docs/ design specs and implementation plans, the rung-4 run log, the literature notes and the rung-0 writeup

The target game

Standard Drawmaha, heads-up:

  • Each player is dealt 5 hole cards
  • It is a bomb pot: everyone antes and the flop comes out with no preflop betting. The order is ante → flop (3) → betting → one draw (discard 0–5, replace) → turn → betting → river → betting — three betting rounds, not four
  • Draw-1 rule: drawing exactly one card gets dealt face up (public); the drawer may keep it or reject it and take the next card face down
  • Split pot: half to the best Omaha hand (exactly 2 hole + 3 board), half to the best 5-card poker hand made of the hole cards themselves

Solve parameters (v1 defaults, all configurable): pot-limit with action set {fold, check/call, pot}; 100bb effective stacks; both players ante, with no blinds and no position asymmetry — that is what a bomb pot means. Bet-sizing menus are a config so multi-size/custom solves are a re-solve, not a rewrite.

Evaluation protocol (pre-registered)

Training loss is not a progress meter in self-play — the only honest question is what an adversary wins. Per rung: exact exploitability on the toys (checked against Kuhn's closed form and an OpenSpiel solve of Leduc); on mini-drawmaha, the exact best-response walk as ground truth plus the Deep-CFR-vs-tabular gap; at full scale, a duplicate-dealt head-to-head checkpoint ladder plus a trained RL exploiter against the frozen net, reported as an exploitability lower bound. Units: total chips across both pot halves, per hand, in big blinds, with per-half EV and scoop rate as split-pot diagnostics.

Rung 0 — RPS regret matching

src/drawmaha_solver/rps/: game rules and payoffs (game.py), players and the match runner (players.py), convergence analysis (analysis.py), and a human-vs-bot CLI (play.py). The regret-matching ledger itself lives one level up in src/drawmaha_solver/regret_matching.py — every rung uses it, and the learning rule never changes as the games grow.

uv sync
uv run pytest        # rules table, ledger math, convergence-to-Nash
uv run rps-analysis  # regenerates figures/rung0/
uv run rps-play      # play against the learner from the terminal

The ledger measures regret against its strategy's expected utility (u − ⟨σ, u⟩ — the same counterfactual form CFR uses at every infoset). Self-play average strategy reaches (0.334, 0.333, 0.333) after 100k iterations, exploitable for 0.0009 chips/round; against a 50%-rock opponent the ledger converges to pure paper and earns +0.24/round (best response: +0.25).

Self-play average strategy converges to the uniform Nash

Exploitability of the average strategy falls like 1/sqrt(T)

Rung 1 — Kuhn poker

src/drawmaha_solver/kuhn/game.py: the environment a solver iterates over, with no solver in it yet. Three-card deck (J < Q < K), both players ante 1, one card each, one round of betting with a single bet size of 1.

Every decision node offers exactly two actions, PASS and BET, so regret vectors stay uniformly 2-wide; what they mean depends on context, and action_label renders the poker word (a PASS is a check with nothing to call and a fold facing a bet; a BET is a bet or a call). The tree is 4 decision nodes and 5 terminals, listed explicitly rather than derived. KuhnState is frozen, so apply returns a new node and a tree walker never has to undo a move; returns() pays in chips with the ante as the unit — the ante on a fold or a checked-down showdown, ante plus bet when a bet is called.

InfoSet carries the acting player's own card and the public history, and deliberately not the opponent's card: two deals differing only in what the opponent holds must collapse to one equal, equally hashing key. The 12 infosets from all_infosets() are exactly the ledgers tabular CFR will allocate.

The solver's memory

src/drawmaha_solver/kuhn/infoset_table.py: a dict[InfoSet, RegretMatcher] with all twelve ledgers pre-allocated, plus current_strategy / average_strategy readouts and a format_strategy_grid that prints P(BET) as the 4×3 grid. That dict is the entire persistent state of a Kuhn solve — twelve boxes of four numbers, 48 floats.

The shared ledger grows one thing for rung 1: update(utilities, *, regret_weight, strategy_weight). In RPS every round counted the same; in a tree a visit is only reached sometimes, and the two accumulators want different scales — regret weighted by the counterfactual reach π₋ᵢ (chance and the opponent, excluding your own choices, so a line you currently avoid keeps learning at full strength), the average weighted by your own reach πᵢ. Both default to 1, so every rung-0 caller is untouched. Passing them swapped is the classic CFR bug and both versions run silently, so the weights are keyword-only and a test pins that the two scales are distinguishable.

The walk

src/drawmaha_solver/kuhn/cfr.py: tabular vanilla CFR. In RPS the environment handed the ledger a utility vector straight out of a payoff-matrix column; a tree deletes that luxury, so the utilities have to be computed. Two quantities travel through walk in opposite directions — reach probabilities down (reach[p] is player p's own action probabilities, nothing else), node values up (plain conditional expectations, unweighted). The weights apply once, at the moment a ledger is banked. walk returns the value to both seats rather than to the player to act, so nothing is ever negated on the way up.

uv run pytest tests/kuhn   # payoff table, tree shape, infoset inventory, one
                           # traversal against a hand-worked trace, convergence

After 20,000 iterations the average strategy is the textbook answer sheet, printed by format_strategy_grid (each cell is P(BET), which means "call" on the two rows facing a bet):

             J       Q       K
     -   0.221   0.000   0.661     open:  bluff the jack, value-bet the king
     p   0.333   0.000   1.000
     b   0.000   0.341   1.000     call:  never fold the king, fold the jack
    pb   0.000   0.561   1.000

The solver finds α = 0.221 and K-open = 0.661 ≈ 3α: the jack bluffs exactly one third as often as the king value-bets, discovered from nothing. Game value to the first player is −0.05556 against Kuhn's closed-form −1/18. Because the equilibrium is a family over α ∈ [0, ⅓], the tests pin the nine determined infosets plus the two invariants (K-open = 3α, Qpb = α + ⅓) and never a hardcoded α̂; the game value is graded by an evaluator written independently of the solver, so the grader cannot share a bug with the graded.

Swap the two reach weights and the same run still completes, still reports a converged-looking grid, and folds the king to a bet 18% of the time — game value −0.00001 instead of −1/18. That failure mode is why the weights are keyword-only.

The meter

src/drawmaha_solver/kuhn/exploitability.py: what a perfect adversary wins against a strategy, as (BR₀ + BR₁) / 2 — zero exactly at Nash, positive everywhere else. Each player owns 6 infosets with 2 actions, so all 2⁶ = 64 of their pure strategies are enumerated and the best taken. Nothing is sampled: mixed strategies are handled by weighting both branches at every node, hidden cards by enumerating all six deals at 1/6, so these are exact expectations rather than estimates.

Written against game.py alone. It grades cfr.py, so it must not be able to share a bug with it — an error in the walk would otherwise appear in both and cancel, and the meter would certify the thing that broke it.

Enumerating pure strategies over infosets is also what keeps the adversary honest: a pure strategy names one action per infoset, fixed before any card is dealt, so "maximize per deal" — an opponent who sees your cards — is not expressible. That bug is not hypothetical; a test pins that a peeking meter would score a genuine Nash equilibrium at 0.278 chips/hand and send you hunting a bug in a correct solver.

iterations exploitability (average) exploitability (current) game value
10 0.09114 0.24191 −0.03597
100 0.02330 0.25000 −0.05472
1,000 0.00650 0.27885 −0.05518
10,000 0.00149 0.20359 −0.05553
100,000 0.00063 0.24649 −0.05555

Two things that table settles. The average strategy's exploitability falls steadily while the current strategy's sits around 0.2 forever and is briefly worse at 1,000 iterations than at 10 — vanilla CFR's current iterate cycles and never converges, which is why the dashboard at rung 4 must query the average-policy network and never the final iterate. And exploitability is the stricter grader: at 100 iterations the game value is already within 0.0009 of −1/18 and looks solved, while the strategy is still 15× more exploitable than it will be at 10,000.

Running it

uv run kuhn-analysis   # solve, regenerate figures/rung1/, print the answer sheet
uv run kuhn-play       # play hands against the solved equilibrium

analysis.py runs one solve to 100k iterations, measuring both candidate answers at 60 log-spaced checkpoints. It has no seed: vanilla CFR enumerates the whole tree and never samples, so two runs agree bit for bit and figures/rung1/ doubles as a regression fingerprint — any change that moves a number shows up as a dirty working tree.

Only the average strategy converges; the current one cycles forever

The average marches down the 1/√T guide to 0.00063 chips/hand. The current strategy sawtooths around 0.2 for the entire run and never improves — the same claim as the table above, now visible in one glance.

The jack's bluff locks to one third of the king's value bet

α is free in [0, ⅓] and the solver lands wherever it lands (0.220 here), but the king's opening bet tracks the dashed 3α line regardless. The bluff-to-value-bet ratio is discovered, not supplied.

All twelve infosets match the closed form

Seeing it

web/rung1-viz, served at /rung1 — the solve as an interactive page. The whole tree fits on screen, so each of the twelve information sets is drawn where it actually sits: four decision nodes, three bars each, one per card the acting player might hold. Scrub the iteration slider and watch the bars walk onto their hairlines, which mark the closed-form answer at the α this run found.

cd web/rung1-viz && npm install && npm run dev

The page renders a solve exported from the Python solver (kuhn-analysis --json), not a TypeScript reimplementation of CFR. Rung 0's visualizer could safely port its 63-line ledger to the browser; porting the reach-weighted tree walk would create a second copy of the one routine where a swapped weight converges silently to the wrong answer. Only the payoff rules are transcribed, and kuhn.test.ts pins them against the same 5×6 table the Python suite uses.

Playing it

kuhn-play solves the game, then deals hands and plays the average strategy — a genuine Nash equilibrium, not a learner adapting to you. Seats alternate every hand, because Kuhn is asymmetric (the first player's game value is −1/18) and a fixed seat would confound your mistakes with the seat's built-in edge. The bot reads only its own infoset, so when it calls your bluff with a queen it is doing so at the equilibrium frequency rather than because it looked.

Both cards are revealed at the end of every hand including folds: knowing whether you were bluffed is the entire lesson, and the hand is already over.

Breaking it

src/drawmaha_solver/kuhn/exploiter.py, and the Exploit tab at /rung1#exploit: lock a strategy of your own and watch CFR find the most that can be won against it.

The solver is unchanged — cfr.py grew one parameter. Infosets named in locked play the probabilities you supply and never bank an update; everything else trains as usual. Their probabilities still advance the reach, so they still enter the learner's counterfactual weight π₋ᵢ, and that is the whole mechanism: the learner is never told the opponent is fixed, it is only weighted by how often that opponent brings it here. Minimizing regret against something that cannot move back is what makes CFR converge to a best response instead of to Nash. Freezing one side turns an equilibrium finder into an exploit finder, with no new algorithm.

The answer is graded, not trusted. exploitability.best_response enumerates all 64 pure strategies over the 54-node tree and imports only game.py, so the ceiling CFR is measured against cannot share a bug with the walk that approached it. Take a P1 that plays the solved equilibrium everywhere except two spots — it calls a bet with the queen 90% of the time instead of a third, and folds its king 40% of the time. That is Lock all of P1, then drag Qb to 0.90 and Kb to 0.60:

chips/hand to P0
exact best response (the ceiling) +0.183334
CFR's counter-strategy, 20k iterations +0.183328
equilibrium play against the same leak −0.011502

Six decimals because five would hide the finding in the first two rows: CFR arrives 6.1e-06 under the ceiling and never above it, which is what "converged to the best response" is supposed to look like.

The counter-strategy CFR discovers is value-bet the queen, never bluff the jack — bluffing is worthless against a station, so the exploit is pure value. And the third row is the point: Nash is unbeatable but not punishing. It declines to make mistakes rather than collecting for yours, leaving 0.195 chips/hand that only a best response picks up.

Those four numbers are not transcribed from a session. tests/kuhn/test_exploit_server.py recomputes them from the same solve.json the page reads and asserts them to the digit, so the table cannot go stale behind a change to the solver — the same standing fingerprint the committed figures get.

Locking a whole seat is the case with a ground truth. Lock a single node instead and the rest of that seat keeps learning — a constrained equilibrium, not a best response — so the page reports no ceiling and draws no hairlines rather than implying a proof it does not have.

uv run kuhn-exploit-server            # the page POSTs locked spots to this
cd web/rung1-viz && npm run dev       # then http://localhost:5173/rung1/#exploit

Exploit runs are live rather than replayed: the input is a strategy you type, so no committed file could cover it. Nothing about CFR is re-implemented in TypeScript — the browser posts six numbers and draws the trace Python returns.

Rung 2 — Leduc poker

src/drawmaha_solver/leduc/. Six cards — two each of J, Q, K — one to each player, a betting round, one public card, a second betting round. Bets are 2 then 4, two raises to a round, both players ante 1.

Two things change from Kuhn, and between them they are the whole rung. The deck acts in the middle of the hand, so the training walk meets a chance node rather than enumerating deals at the top; a jack is trash until a jack hits the board, at which point it is the best hand in the game. And spots no longer all offer the same actions — facing a raise you may fold, call or raise; at the open you may not fold, because there is nothing to fold to. So a ledger is 2 or 3 wide depending on where it sits (192 and 96 of them), and column 0 means call at the open but fold facing a raise. Every loop in the rung pairs enumerate(legal_actions()) with the ledger's columns; nothing else keeps the two in step.

288 spots, against Kuhn's 12. exploitability.py is a different algorithm as a result: rung 1 scored a strategy by listing all 64 pure rulebooks the opponent could follow, and one Leduc seat has about 6.3 × 10⁵¹. The replacement computes one best action per spot — sum across the hidden hands the spot could be, then take the maximum, because a maximum inside the sum is an opponent who peeks at face-down cards.

The referee

Kuhn's equilibrium can be written down; Leduc's cannot. So ground truth comes from OpenSpiel — an independent implementation of the same game, plus an exact solve of it.

scripts/leduc/generate_referee.py interrogates it once and writes tests/leduc/referee.json: the node census, the infoset collapse, the payoff ladder, the path probabilities, the sequence-form LP's value of the game, and OpenSpiel's own CFR convergence curve. tests/leduc/test_referee.py then re-derives every one of those facts from our code and demands agreement.

open_spiel is deliberately not a dependency — its wheels are large and the Vercel build must not pull them. The generator is the only file that imports it, nothing imports the generator, and it runs by hand:

uv run --with open_spiel --with cvxpy --with ecos --no-project \
    python3 scripts/leduc/generate_referee.py

The re-derivation is the point. A test that read a number out of the fixture and asserted it equalled itself would pass forever while the solver rotted, so the census comes from walking our own tree, the payoff ladder from our own terminals, and the referee's 936 suit-distinct spots from keying our own infosets on card instead of rank — which turns "936 collapse to 288 with nothing lost" from OpenSpiel's word into something CI checks.

Running it

uv run leduc-analysis   # solve, regenerate figures/rung2/, print the strategy tables
uv run leduc-play       # play hands against the solved equilibrium

One iteration walks 9,450 nodes at about 50 ms, so leduc-analysis defaults to 2,000 iterations — roughly two minutes — where rung 1 could afford 100,000 in seconds. Like rung 1 it has no seed: vanilla CFR enumerates the whole tree and never samples, so two runs agree bit for bit and figures/rung2/ doubles as a regression fingerprint.

iterations exploitability (average) exploitability (current) game value
10 0.34112 0.75510 −0.13645
100 0.06118 1.01113 −0.08933
500 0.02182 1.30054 −0.08862
1,000 0.01653 1.47580 −0.08802
2,000 0.01129 0.97171 −0.08739

The same two lessons rung 1 taught, on a game 460 times larger. The average strategy's exploitability falls steadily while the current strategy's wanders between 0.75 and 1.48 and is worse at 1,000 iterations than at 10. And the game value is the softer grader: by 100 iterations it is within 0.004 of the exact −0.08561 and looks finished, while the strategy is still five times more exploitable than it will be at 2,000.

Only the average strategy converges; the current one cycles forever

2,000 iterations bring the game value within 0.0018 of the exact answer

The early dive to −0.158 is real: at five iterations the average strategy is still mostly the uniform one it started from, which folds and calls in places no equilibrium would.

Three plays no equilibrium can miss, arriving out of the regret ledgers

Rung 1's answer sheet compared all twelve infosets against a closed form. There is no closed form here, so the certificate is three plays that dominance pins whatever else the solve does — and the first one is a genuine poker idea nobody encoded. A jack on a jack board holds the best hand possible and still does not bet at the round-2 open: it checks, and raises when bet into. Leading would fold out every hand it beats; checking induces the bluff it wants to punish. Regret matching finds the check-raise on its own.

Seeing it

web/rung2-viz, served at /rung2 — the solved strategy as a dashboard. Rung 1 could draw its whole tree on screen; 288 information sets cannot be drawn at once, so this page shows one betting line at a time, drawn as a timeline: one station per action, the board card a station too, the rail solid as far as the hand has been played. Click any station to rewind — rewinding never deletes the stations ahead; only choosing a different action forks the line, and the page warns first. Under the timeline both seats are on screen at every node, one bar per rank, each bar that rank's whole mixed strategy — range against range, never card against card.

cd web/rung2-viz && npm install && npm run dev

The page renders a solve exported from the Python solver (leduc-analysis --json, committed at web/rung2-viz/src/data/solve.json), not a TypeScript reimplementation of CFR. Only the rules arithmetic is transcribed — betting grammar, pot accounting, infoset labels, reach weights, the payout ladder — and leduc.test.ts / play.test.ts pin it, including the deal-weighting the reach pass needs because the seat behind a bet is no longer a uniform deck, and the referee's ±3 fold-to-a-re-raise.

Playing it

The Play the solver tab deals one hand at a time on a monoline oval: your card face up, the solver's face down, the board landing mid-table when round 1 closes. The bot samples the committed average strategy and never adapts.

Breaking it

Lock any rank's row with ◇ in a seat panel, drag its frequencies off Nash, and press Run: the locked rows are POSTed to a local Python process (uv run leduc-exploit-server, port 8001 — src/drawmaha_solver/leduc/exploiter.py re-solving with your rows held still). Every bar then shows the response in full colour over a muted lane where Nash stood, so the baseline is never hidden while you read what your edit did to the whole game. Lock a whole seat and the run is graded against an exact best response, rung 1's four numbers included.

Rung 3 — Mini-drawmaha

src/drawmaha_solver/minidrawmaha/. A bomb pot with a draw and a pot that pays twice, on a 15-card deck (ranks 2–6, three suits). Both players ante 1 from 26-chip stacks. Each gets three cards, then a board card and a betting round. Then comes the draw: each player may throw one card for a face-down replacement, P1 knowing only whether P0 drew. Then a second board card and a second betting round. Half the pot goes to the best three cards held (inner), half to the best two held plus both board cards (outer). No engine plays this game, so there is no referee: the exact best-response walk in exploitability.py is the ground truth.

Mini-drawmaha has 6,220,050 infosets, about 21,000 times Leduc's 288. One pass over the whole tree is out of the question, so the learner is external-sampling MCCFR (mccfr.py). It is parallelised as lockstep MCCFR (lockstep.py): each iteration, W = 10 workers each walk one sampled hand per seat against the same frozen strategy, and every touched row is banked once with the sum of the workers' regrets. The 6.2M ledgers live in four flat arrays (packed_table.py), and the workers share them across processes (parallel.py).

The race

Four regret rules, each trained for 2,000,000 lockstep iterations (20M hands per seat) on Modal and graded by the exact walk (regret_rules.py, scripts/minidrawmaha/modal_rung3.py):

hands per seat vanilla LCFR CFR+ DCFR
1M 114.5 113.1 156.4 156.0
10M 16.7 12.6 36.9 37.0
20M 8.6 6.1 24.8 25.0

Exploitability in chips per 100 hands, ante 1, linear average, seed 0. For scale, uniform random play is exploitable for 484: that is the mean of the two best responses against it, not what it loses to any one opponent.

Keeping negative regret wins

The two rules that keep negative regret beat the two that throw it away by 3–4×. CFR+ floors it at zero; DCFR halves it every iteration. Under sampling, those treat negative regret the same way. At 2M iterations the median row was last visited about 37,000 iterations earlier, and the ledgers are float64, where halving a regret about 1,075 times takes it to exactly zero. So by the time a row comes round again its negative regret is gone, which is CFR+'s floor. The two are still not one algorithm: DCFR also discounts positive regret and averages differently, so the curves lie close (24.8 against 25.0) rather than on top of each other. This is one seed, so the 6.1 against 8.6 between LCFR and vanilla is suggestive, not settled.

Averaging columns

Each run also banked further averages of the same strategies at other weights. These cost nothing to keep and cannot change the play. Linear beats uniform for both vanilla (8.6 against 15.5) and CFR+ (24.8 against 36.5).

The answer

The winner is frozen as rung 3's solution: LCFR's linear average at 2M. That is 14.25M probabilities, 18 MB compressed, published as the rung3-lcfr-2m release asset. strategy.py downloads it once and checks it against a pinned SHA-256. Graded, it is exploitable for 0.061346 chips/hand, with BR₀ −0.034421 and BR₁ +0.157114. In self-play P0 is worth −0.0906 a hand: P1 acts last in both rounds and in the draw, and that is worth about a tenth of an ante.

Round 1 betting by inner category

The bars average each strategy over every physical deal of a category with equal weight, so they show what the solution does holding that hand, not how often the line happens:

  • P0's lead. P0 bets out with 19–40% of every category and slowplays trips (21%).
  • P1 checked to. P1 bets 97% of its trips and straight flushes.
  • P1 facing a bet. P1 folds a pair (39%) more often than high card (28%).

The draw

High cards and pairs throw a card 88–98% of the time, and flushes and better almost never do. P1 throws more often after P0 stands pat: with a straight, 41% against 32% after P0 drew.

These are readings of what the strategy does, not tested reasons for it. A category is the three held cards alone, and its deals meet different boards, so a gap between two categories can come from that mix rather than from the category itself.

Where the exploitability lives

Where the 0.061 sits is measured too. A best responder's edge telescopes over its own decisions as one-step deviations — at each infoset, how often the strategy's own play brings it there times what the best response earns there over the strategy's mix — so every spot has a share, and the shares sum to the grade. uv run minidraw-exploit-by-reach --release tabular bins the 6.2M spots by how often each comes up when the strategy plays itself (figures/rung3/exploit_by_reach.json, with the check that the shares sum to each seat's best-response edge). Read per visit, a row rots as the sampler sees it less: a spot in the commonest band leaks 0.4 chips per hundred visits, one reached once in ten million hands leaks about 20, and the rarest band about 67, rising at every step between, while 72% of the total sits in round 2. The same command on the two Deep CFR averages (--release deep-exact, --release deep-pi, figures/rung4/) shows the net leaking about three times the table per visit at the commonest spots and a fifth of it at the rarest: a table is exact where it has samples and bleeds where it does not, a net fills the rare rows from the common ones and pays in the common rows. /research draws both. With --by-line the same gains are split by the 141 betting lines that targeted sampling ranks (figures/rung3/exploit_by_line.json): the eight round-1 lines hold 22% of the total on 0.13% of the reached infosets, and the 20 leakiest lines 57% on a quarter of them.

Running it

uv run minidraw-analysis                         # figures/rung3/ + answer_sheet.json, ~2 min
uv run minidraw-analysis --grade-runs runs/modal  # first grade new checkpoints into grades.json
uv run minidraw-play                             # deal yourself in against the frozen strategy

minidraw-analysis reads two inputs: the committed figures/rung3/grades.json (every grid checkpoint of the race, graded) and the release strategy. It does not need the 555 MB checkpoints unless it is asked to grade new ones. To retrain, use uv run --with modal modal run scripts/minidrawmaha/modal_rung3.py::main (it costs Modal money; ::status shows progress). To freeze a checkpoint, use scripts/minidrawmaha/freeze_strategy.py.

Playing it in the terminal

minidraw-play deals hands against the frozen strategy, alternating seats. At the draw the prompt names each throw by the position the solver's ledgers use (low, mid, top) and shows the physical card it would throw. The bot's draw is reported only as the table sees it: whether it drew, never what. Every hand ends by revealing both holdings and who took each half of the pot.

Seeing it

web/rung3-viz, served at /research (and still at /rung3) — the research as a publication. One title block for the page (the finding, the date, the author, a dek, and a results strip of three numbers, each linked to its chapter), then nine chapters with a contents rail, each heading stating its finding, each figure with a title that states its finding, a sub-title naming the measure and the run, a caption, and the file it was read from. Chapters 01 to 04 are rung 3 — the game and the exact grader, external sampling and lockstep, the four rules and the race (Fig. 2, hover or arrow a checkpoint), and the frozen answer: what it does by category and where its exploitability lives, binned by how often play reaches a row (Fig. 7). Chapters 05 to 08 are rung 4 on the same game — the two clocks on one ruler of hands (Fig. 8), the price at equal hands with its ratio strip (Fig. 9) and the same reach decomposition over the net's two averages (Fig. 10), width against depth, refit steps against learning rate and the regret targets over √pot (Fig. 13), the three averages and the policy-net loss sweep, and the per-sample regret-rule screen. Chapter 09 is what is and is not settled, then Methods names every file. Every number is read from a copy of a committed file under src/data/ (figures/rung3/, figures/rung4/ and the sweep), held byte for byte to the originals by tests/test_rung3_page_data.py; the page speaks in chips per hundred hands and multiplies the per-hand files by 100 in one place.

cd web/rung3-viz && npm install && npm run dev

Playing it in the browser

The Play the solver tab (/play, or /rung3#play) is the trainer, in the range explorer's register: a turn line that states the hand, your seat, the round and who is to act; a table of three hairline lanes — the solver's cards face down, the two board frames and the pot, your three cards face up — the draw as buttons that name the physical card each throw discards, and a showdown that says who took each half and with what. Rung 2's coach watches from the column beside the table: a roll shown beside the buttons before each of your decisions, then the solver's mix at your spot drawn as bands in the site's action colours, the ones Learn's grid uses, with a tick where the roll fell, and a grade for the deviation. Under the table, the sitting's numbers and a hairline ledger of the settled hands.

The browser never looks the strategy up. The table is 6.2M rows behind a joint canonical suit relabelling, which is the one routine this repo most wants to exist exactly once, and the site runs no Python. So uv run minidraw-deals deals 600 hands as fixed deck orders and records, for each, the strategy's mix at every decision either player can reach (about 480 nodes a deal), the canonical low/mid/top order of each player's cards, and the showdown of all 16 post-draw worlds — web/rung3-viz/public/deals/, six chunks of 100 fetched as the queue runs low, with a manifest recording the seed, the strategy's release and digest, and its grade. The strategy is the Deep CFR exact average of the ÷ √pot arm at iteration 200, rung 4's best graded one when it was packed (2M hands per seat, exploitability 0.086; the ÷ pot arm's 0.068 at 10M hands is graded but not released; release rung4-deep-pot0.5-t200, named in src/drawmaha_solver/minidrawmaha/answer/releases.py with the other four), fetched by digest and graded from figures/rung4/pot_0.5-256x4-s16000-lr5e-4.json; the panel names it and quotes the grade from the manifest, in the words the Learn tab uses for the same strategy, and the Python tests hold the manifest's copy to the grades file. The page follows the path it is on and reads the vector, and refuses a chunk whose deal count disagrees with the manifest or a deal with a node its grammar can reach but the pack lacks. The pack is one seeded sample: every visitor plays the same 600 deals, reshuffled each sitting, not fresh draws from the deal distribution; the seats alternate and the coach's roll is drawn live, so one deal plays differently each time it comes round — plenty for a trainer, and said so on the page. deal_pack.py is tested with a stand-in profile so the suite never needs the 18 MB file. Only the pot-limit betting grammar is transcribed to TypeScript, and minidraw.test.ts pins it against the same thirteen round-1 lines the Python suite pins.

The middle tab, Learn, is the solver's range interface over this game — every hand the actor could hold at any spot, what the strategy does with each, range vs range — with the switch between the frozen tabular LCFR and rung 4's Deep CFR. It is the mini-drawmaha half of /solver, imported from web/solver-viz rather than copied, and it reads the exports from /solver/mini*/; see the /solver paragraph below for what it shows.

Rung 4 — Deep CFR on mini-drawmaha

src/drawmaha_solver/deepcfr/. The same game, the same walk, and the same exact grader as rung 3, with one change: the 6.2M regret ledgers are replaced by a network. Rung 3's answer is the yardstick, so rung 4 asks one question at every point of the race's grid:

price = exploitability(Deep CFR's average) ÷ exploitability(LCFR's linear average), at equal hands per seat

Deep CFR at K = 10,000 walks per seat per iteration has seen the same hands at iteration t as LCFR had at lockstep iteration 1,000 t, so the eight marks 10, 20, 50, 100, 200, 500, 1,000 and 2,000 sit on the race's 10k to 2M checkpoints exactly (figures/rung3/grades.json). There is no pass mark: the ratio is recorded and read.

The engine

One iteration (train.py) is rung 3's lockstep with the ledger's regret half swapped out. For each seat in turn: write both seats' nets into the packed table's regret slots (materialise.py, which obtains Deep CFR's eq. 3 by what is written, a one-hot at the argmax when every output is non-positive, so RegretMatcher.strategy() is unchanged); let the exact average see the table; walk K hands on W spawned processes (collect.py), which return the traverser's regret samples and the opponent's strategy samples as rows; merge them into fixed-size reservoirs (reservoir.py, Algorithm R, 50M rows each); refit the seat's net from scratch on its memory (fit.py, 4,000 Adam steps of 10,000 rows, t-weighted squared error, each regret target first divided by √pot at its spot so the fit's accuracy is spread by chip cost rather than by pot² — regret matching plays the same σ from the same regrets either way, so only the fit's errors move, see pot_scaled in train.py; net.py, 136 → 64 → 64 → 64 → 7). Every random draw is a function of (seed, t, seat, walk index), never of W, so a run resumes and re-runs bit for bit (tests/deepcfr/test_train.py pins the loop against rung 3's LCFR with a perfect "net").

Three objects are graded at every mark, because they answer different questions:

  • the exact average (average.py): CFR's actual answer, Σ_t t · own_reach_t · σ_t summed per column at the moment each iteration's strategy sits in the table, with own reach pushed through a predecessor table over the 141 public points (a post-draw key has three pre-draw predecessors, all of which count);
  • Π, the policy net (policy.py): the original Deep CFR's average, a fresh net fit at the mark on the policy memory, which is the only average a game without a table can keep;
  • the current iterate: regret matching over the materialised table, which CFR never promises will converge.

Running it on Modal

scripts/deepcfr/modal_rung4.py is rung 3's launcher for this run: one trainer container (33 cores, 16 GB) that claims its directory on the Volume drawmaha-rung4, heartbeats, saves resume/ every 30 minutes and a full marks/t00010/… at each grid mark, and spawns its own continuation at 23 hours; one grader container per mark, which freezes the three objects and grades them with the untouched exact meter. Each modal run starts paid containers; deploy first, and launch in this order:

uv run --with modal modal deploy scripts/deepcfr/modal_rung4.py                 # free; builds the image
uv run --with modal modal run scripts/deepcfr/modal_rung4.py::smoke            # 3 iterations at K = 1,000, one mark graded: seconds, hours and dollars for the run
uv run --with modal modal run scripts/deepcfr/modal_rung4.py::sweep            # 3×64, 4×64, 3×128 to 100 iterations, three containers at once
uv run --with modal modal run scripts/deepcfr/modal_rung4.py::status           # where every run stands
uv run --with modal modal run scripts/deepcfr/modal_rung4.py::sweep --grade    # once all three are at 100: figures/rung4/sweep.json and the winner
uv run --with modal modal run scripts/deepcfr/modal_rung4.py::train            # 2,000 iterations at K = 10,000; add --width --depth if the sweep picked another size
uv run --with modal modal run scripts/deepcfr/modal_rung4.py::grade            # every kept mark, three objects each → figures/rung4/grades.json
uv run deep-analysis                                                          # figures/rung4/price.png and answer_sheet.json

The sweep's verdict is fixed before its numbers exist: the run uses 3 × 64 unless another size's exact average grades lower by more than 10% of 3 × 64's at 100 iterations. It picked 3 × 128 (0.243 against 3 × 64's 0.382 and 4 × 64's 0.375 at 1M hands per seat, figures/rung4/sweep.json). The same loop runs on a Mac with scripts/deepcfr/run_deep_cfr.py, and ::grade --local DIR grades such a run's marks here.

The answer so far

The 2,000-iteration run was made on a Mac, not on Modal: the ÷ pot arm in the last column, run to t = 2,000 (20M hands per seat, the race's own endpoint) and graded at 1M, 2M, 5M, 10M and 20M. A 3 × 64 Modal run was started and stopped at t = 64 once the sweep chose a wider net, and its marks are ungraded. Everything graded is the Modal size sweep plus arms trained on a Mac and graded through ::grade --local, all at K = 10,000, seed 0. They see identical hands, because the rows never depend on W. The exact average, in chips per hand, the best of each row in bold:

hands per seat LCFR 3 × 64 3 × 128 4 × 256 4 × 256, 8,000-step refit 4 × 256, 16,000 steps, lr 5e-4 the same, targets ÷ √pot the same, targets ÷ pot
100k 3.048 0.581 0.643 0.879
200k 2.697 0.444 0.294 0.330 0.330
500k 1.876 0.396 0.173 0.167 0.169
1M 1.131 0.382 0.243 0.152 0.134 0.133 0.108 0.114
2M 0.630 0.234 0.115 0.086 0.084
5M 0.264 0.108 0.071
10M 0.126 0.068
20M 0.061 0.068

Uniform random play is 4.842 (figures/rung4/answer_sheet.json). Every column uses Adam at 1e-3 and a 4,000-step refit unless its header says otherwise. The 3 × 64 column joins two runs of that config: the Mac run in figures/rung4/grades.json (W = 8) to 500k, and the Modal sweep (W = 32, figures/rung4/sweep.json) at 1M. In the 3 × 128 column, 1M is the Modal sweep and 2M is a separate Mac run (figures/rung4/sweep_128x3_t200.json). The 4 × 256 columns are Mac runs (figures/rung4/sweep_256x4.json, figures/rung4/sweep_256x4-s8000.json, figures/rung4/sweep_256x4-s16000-lr5e-4.json, figures/rung4/pot_0.5-256x4-s16000-lr5e-4.json, figures/rung4/pot_1-256x4-s16000-lr5e-4.json). Every arm, with its Π and current-iterate grades, is in docs/notes/rung4-run-log.md.

Deep CFR ÷ LCFR at equal hands

Five readings, all on one seed, so a difference under about 10% is suggestive rather than settled:

  • Width is the lever, and a longer refit helps less. 3 × 64 → 3 × 128 cut the 1M-hand grade by 36%. An extra layer did less: 2% at 64 wide (0.382 → 0.375) and 9% at 128 wide (0.243 → 0.221, figures/rung4/sweep_128x4.json). Doubling the refit to 8,000 steps took 12% off 4 × 256 (0.152 → 0.134).
  • At 1M hands the best net is about a tenth as exploitable as the table (0.108 against 1.131). That is consistent with generalisation, where one learned strategy covers spots the table has not yet banked, but the share of its rows LCFR had visited at 1M hands is not measured here. Equal hands is not equal compute: an iteration with two large refits costs tens of seconds to minutes of training (docs/notes/rung4-run-log.md, finding 6), far more than ten table updates.
  • The nets flatten past 1M hands; the table does not, and it passes the net between 10M and 20M hands. 3 × 128 went from 0.243 at 1M (the Modal sweep) to 0.234 at 2M (a Mac run of the same config, whose own 1M mark is not graded). 4 × 256 with a 16,000-step refit at half the learning rate went 0.133 → 0.115 → 0.108 from 1M to 5M hands with raw targets; its two averages at iteration 500 — Π (0.136) and the exact average (0.108) — are released as rung4-deep-t500 and shown on /solver beside the tabular strategy. The same net with its targets ÷ pot was run to the race's own endpoint: 0.114 → 0.084 → 0.071 → 0.068 → 0.068 from 1M to 20M hands (figures/rung4/pot_1-256x4-s16000-lr5e-4.json), while LCFR fell 1.131 → 0.630 → 0.264 and reaches 0.126 at 10M and 0.061 at 20M. The net is below the table at every mark to 10M and above it at 20M, so the table passes it between the two: a measurement on one seed and one net, not an extrapolation. The net's last 10M hands, 49 hours of training, moved its grade from 0.0679 to 0.0685.
  • Dividing each regret target by √pot or by the pot before the refit moved that plateau. At 2M hands the same arm grades 0.086 with ÷ √pot and 0.084 with ÷ pot, against 0.115 raw, one seed each (figures/rung4/pot_1-256x4-s16000-lr5e-4.json for ÷ pot), and the ÷ pot arm, run on, flattens at 0.068 where the raw arm flattened at 0.108. ÷ √pot is the default (docs/notes/rung4-run-log.md, finding 7); the √pot arm stopped at 2M hands, so whether it lands on the same floor is ungraded. The √pot arm's two averages at iteration 200 are released as rung4-deep-pot0.5-t200, and its exact average is what the Play tab at /play deals against.
  • Per-sample DCFR and CFR+ lose to linear by about 17× at 500k hands (2.870 and 3.092 against 0.173, 16.6× and 17.9×) and get worse with training. The measured mechanism is consistent with that: testing each sample's sign instead of a running total's lets a bad action's positive samples outvote its negative regret, in a share of cells that grows with t (figures/rung4/rule_sign_flips.json; docs/notes/regret-rules-dcfr-cfrplus.md).
  • Distilling the table into the net splits the 0.068 plateau: at 4 × 256 at most about a third is what the net cannot hold and the rest is what Deep CFR's training did not find, and one doubling of the width removes almost all of the first part. The LCFR table's linear average at iteration 10,000,000 (100M hands per seat, graded 0.0157) was fitted straight into the rung-4 net by reach-weighted cross-entropy over every infoset (deepcfr/distill.py, scripts/deepcfr/distill_teacher.py, results in figures/rung4/distill/results.json), then frozen and graded exactly. The 4 × 256 copy grades 0.0389 against the table's 0.0157 and Deep CFR's 0.0685; the 4 × 512 copy grades 0.0175 and the 4 × 1024 copy 0.0158. Drawing rows by reach rather than uniformly is worth 2.6× in the grade (0.0389 against 0.0995), and a copy trained on half the infosets grades 0.1046, exploited at the half it never saw: this net on these features does not transfer to a random half of the game (the teacher's own smoothness across infosets is not controlled for). One seed; the copy's grade is an upper bound on the net's floor, and part of it is the fit's: after 100,000 steps the split is about 44% / 56%, and a 400,000-step fit takes the 4 × 256 copy to 0.0331 and the split to 33% / 67%. The split is a reading of grades on one axis, not a decomposition of causes: exploitability is not additive in a strategy's errors. docs/notes/rung4-run-log.md, finding 9.

The site

The nav names five surfaces: Research (/research), Solver (/solver), Benchmarks (/benchmarks), Play (/play) and Rules (/rules), with the homepage at / and the release table at /releases. The homepage, Rules, Benchmarks and Releases are static pages written by scripts/site/make_static_pages.py, which reads every number on them from a committed file; the old front page is kept unlinked from the nav at /cover (see web/cover/README.md). Research, Learn (/learn) and Play are the three tabs of web/rung3-viz, and /rung3 still serves it. The three benchmark apps stay at /rung0, /rung1 and /rung2, linked from Benchmarks.

Every surface is set in one theme, written in docs/superpowers/specs/2026-10-29-paper-lab-site-design.md and enforced by web/DESIGN.md. The apps carry it in five files that are byte-identical across the five apps — src/theme.css, src/main.tsx, src/ui/site.tsx, src/ui/site.test.tsx, src/ui/mark.ts — deliberately copied rather than extracted to a package, and held identical by tests/test_shared_chrome.py; the static pages link the same theme.css, which the build serves at /theme.css. Warm paper and near-black ink with one accent, oxblood, the deeper shade of the chip's red: the winning series in a chart, bet and raise, links and the focus ring. The chip is the mark: an ASCII poker chip turning in the top bar and again where the footer closes the page. A figure is a titled block on a top hairline — its number, a bold title that states the finding, a sub-title naming the measure and the run, the figure, a caption of at most two sentences — never a window. Anything tabular is hairline rows, never cards.

Two type voices and one mono: Newsreader (serif) for display, headings and the research body, Geist (sans) for the UI, tables, charts and every number, and IBM Plex Mono only for the chip's glyph grid.

Colour inside a figure reads three series tokens (ink, the accent, muted ink, with a dash for a fourth) and an action triplet: bet and raise in the accent, call green, check and fold blue. At rung 0 the average strategy is the accent, the current strategy muted ink and cumulative regret ink; at rung 1 the jack — the card that bluffs — is the accent, the king ink and the queen muted ink; at rung 2 the ranks are a ramp of ink, light to dark, so they never compete with an action; at rung 3 the four regret rules keep their inks across every figure, LCFR in the accent, vanilla in ink, CFR+ and DCFR in muted ink, DCFR dashed.

/solver (web/solver-viz) is the solver's range interface, and it runs two games. Mini-drawmaha is solved, three ways: uv run minidraw-ranges --release tabular|deep-pi|deep-exact exports a released strategy once per public decision point (141 of them, 6.2 million rows, 4.8 to 8.6 MB gzipped under web/solver-viz/public/mini*/) — rung 3's tabular LCFR average (20M hands per seat, exploitability 0.061), and rung 4's Deep CFR at iteration 500 (5M hands per seat) as Π, the policy net (0.136), and as the exact average (0.108), the release assets rung3-lcfr-2m and rung4-deep-t500 — and the Strategy select in the page head picks which one the page reads, keeping the spot, the held cards and the filters, because the three exports enumerate the same states and differ only in each state's mix. The browser rebuilds the actor's range at any spot exactly — every hole and discard it could hold given the board, suits relabelled jointly the way the solver's key does, each state's mix read from the export and weighted by the product of the actor's own action probabilities along the line — with range vs range and exact showdown equities at round 2; the select and the "This spot" rows name the strategy showing and quote its grade from the manifest, which copies it from the committed grades file. Full Drawmaha is the interface ahead of the rung-4 network, built so it can be used and criticised now. Pick a flop and the page samples 50,000 five-card holdings from the 49 unseen cards and classifies each twice — the five cards alone (the inner hand) and the best two with the board (the outer, Omaha hand), with draw flags — then draws your whole range as a grid of inner rows × outer columns, with the strength ribbon and the two-equity plane beside it, a ledger of hairline rows that opens any region down to a single hand, a deck to build your hand card by card, and a draw board for the throw decision. The hand so far is a numbered list of its steps in the left column, with the next decision's options branching under it. The composition is real to sampling error; the action mix is illustrative (a smooth function of each hand's two equities), and the page says so in its status line. src/engine/policy.ts is the one file that changes when the network exists.

npm run dev in an app serves that app alone, without the static pages. To see the whole site wired the way Vercel wires it:

bash scripts/site/vercel_build.sh          # builds public/
uv run python scripts/site/serve_site.py   # http://localhost:4321

What is next

  • The paid 2,000-iteration Modal run of rung 4 at the chosen net, which Alec launches. Every rung-4 grade so far is the Modal size sweep or a Mac arm.
  • Full Drawmaha. /solver already runs on full Drawmaha with an illustrative action mix (web/solver-viz/src/engine/policy.ts is the one file that changes when the network exists); the solve itself, and its evaluation by a head-to-head checkpoint ladder and a trained RL exploiter, are not started.
  • A PPO chapter: train a PPO agent by self-play and measure what the CFR solution wins against it.
  • A test job in CI. The only workflow is an automated code review, so the suite of about 1,600 tests is run by hand rather than enforced on a pull request.

About

Game-theory solver for heads-up pot-limit Drawmaha, built as a validation ladder: regret matching → CFR on Kuhn and Leduc → MCCFR and Deep CFR on mini-drawmaha, each rung graded by an exact best response.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages