Files
holdem_poker/CLAUDE.md
T
thejayman77 fffd60ad9d Tune the skill axis: profiles now play like their labels
The reported symptom was a "Rock" at 41% VPIP against a 12% setting. Chasing it
uncovered four separate places where a *skill* parameter was smuggling in a
*style* change — the two axes were supposed to be independent.

1. Error direction. blunder() pushed every mistake the same way, so a 30% error
   rate put any beginner near a 30% VPIP floor regardless of style. Making it
   style-directed fixed the Rock but collapsed the gradient, because a tight
   player's errors then became folds, which cost almost nothing. Errors are now
   split: pre-flop follows the player's character (a nit's mistake is folding a
   hand they should have played), while post-flop stays costly for everyone —
   paying off when beaten and checking back hands worth betting. That is also
   where weak players genuinely lose money.

2. potOddsRespect shifted weak players systematically toward calling. That is
   not a weakness — calling wider than break-even against bad opponents is a
   winning adjustment, so it handed low-skill bots a real edge. Discipline now
   means ACCURACY: a weak player misjudges the threshold in either direction.

3. OpponentModel treated 0.5 as a neutral bet/raise share. Folds, checks and
   calls are counted too, so a normal player sits near 0.32 — every opponent
   read as passive, and the only two levels that consult the model tightened
   against the whole table and lost money for it. The exploitation feature was
   a handicap. Baseline calibrated and named.

4. positionAwareness widened 45% in position but narrowed 25% out of it. A seat
   is last to act about a quarter of the time, so the tighter branch dominated
   and higher awareness silently meant fewer hands. Position now shifts WHERE
   hands are played, not how many.

Also: the pre-flop slop multiplier now saturates (multiplying pushed a 0.75
maniac to 0.93 while still drowning out the tight end), raw pot odds carry an
implied-odds discount, and skill levels are re-spaced.

Results: Rock 41.3% -> 14.5% VPIP, every style ordered correctly by looseness,
and win rates down from ~113 to ~20 bb/100 for a strong seat.

HONEST LIMITATION: Advanced and Expert are not separable. Over 100k hands their
order flips with the seed. The simulator now asserts each level beats the one
two tiers below it — true on every seed tried — rather than strict adjacent
ordering, which would be reading noise as signal. Separating the top two needs
either a wider parameter gap or a different distinguishing mechanism.

New ProfileBehaviourTest is the regression that was missing: it asserts styles
actually produce their own behaviour. A gradient can look healthy while every
profile is misnamed, which is exactly what happened.

Tests: 154 -> 166 (75 engine JVM, 75 Android host, 16 app).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 22:52:13 -04:00

99 lines
5.1 KiB
Markdown

# Poker — Texas Hold'em with teachable AI opponents
Kotlin Multiplatform. Ships iOS + Android; Android first (only Android hardware
for physical testing).
## Build
No `java`/`gradle` on PATH — use Android Studio's bundled JDK:
```bash
export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home"
./gradlew :engine:jvmTest # engine tests (JVM)
./gradlew :engine:testAndroidHostTest # same suite, Android variant
./gradlew :sim:run --args="50000" # simulate 50k hands, print bot stats
./gradlew :app:assembleDebug # build the APK
```
## Layout
| Path | What |
|---|---|
| `engine/src/commonMain/.../core/` | Cards, evaluator, equity, pre-flop chart |
| `engine/src/commonMain/.../bot/` | Skill/style profiles, `MathBot` |
| `engine/src/commonMain/.../game/` | `Table` — betting rounds, side pots, showdown |
| `sim/` | JVM-only headless simulator used to **tune** bot profiles |
| `app/` | Android app: Compose table, `PokerViewModel` |
| `assets/cards/` | 52 CC0 card faces + generated backs (**source of truth**) |
| `tools/generate_card_assets.sh` | Rasterises those SVGs into `app/.../drawable-*` |
`engine` is pure Kotlin with no platform APIs, so `androidTarget()` /
`iosArm64()` slot in without touching `commonMain`.
## Design rules
1. **Poker maths never goes near the LLM.** Difficulty and style are engine-side
EV/frequency calculations — instant, deterministic, testable, offline. The LLM
only narrates numbers the engine already computed (`DecisionTrace`), and adds
persona/table talk.
2. **Skill and style are orthogonal.** `SkillLevel` = how correct decisions are;
`PlayStyle` = bluffing, sandbagging, aggression, tightness. Build the strongest
bot, then inject *controlled error* for lower tiers.
3. **Pre-flop is range-based, not equity-based.** All-in equity overvalues trash
(7-2o has ~35% vs one random hand but is unplayable). `PreflopChart` ranks the
169 starting hands so `looseness` means "plays the top N%".
4. **The simulator is how bots get tuned.** Run it after any bot change, across
several seeds — a single seed will happily agree with a wrong conclusion.
5. **A skill parameter must not smuggle in a style change.** Several bugs came
from exactly this: `positionAwareness` silently reduced hands played,
`potOddsRespect` systematically loosened weak players (which is a *winning*
adjustment, so it inverted the gradient), and error direction overwrote style
entirely. Skill should change how *well* a decision is made, not how loose or
tight the player is.
## Testing notes
- `Table` takes a `CardSource`, so `StackedDeck.of(holes, board)` gives fully
deterministic hands. Use it for any rule test.
- The simulator gives the **deck its own RNG**, separate from each bot's. Never
share one: bots consume RNG proportional to their `equityIterations`, so a
shared stream means changing a profile silently changes the cards dealt.
- `./gradlew :sim:run --args="chart"` dumps the starting-hand ranking.
- Small samples lie. 1,000 hands is not enough to rank profiles — use 50,000+
before believing a gradient.
## Status
- Evaluator: verified exhaustively against published frequencies for all
2,598,960 five-card hands. ~24M evals/sec.
- Engine: chip-conserving. Covered by tests: side pots, uncalled-bet refunds,
action order, malformed agent output, **TDA Rule 47** (incomplete raises do not
reopen betting, but several that cumulatively reach a full raise do), and
**TDA Rule 20** (odd chip to the first winner left of the button).
- Bots: profiles play like their labels (Rock 14.5% VPIP against a 12% setting;
`ProfileBehaviourTest` asserts this). Win rates are in a plausible range —
roughly +20 bb/100 for a strong seat rather than the earlier +113.
- **Adjacent top tiers are not separable.** Advanced and Expert sit inside
seed-to-seed noise of each other over 100k hands. The simulator therefore
asserts each level beats the one *two* tiers below it, which holds on every
seed tried; claiming strict adjacent ordering from one seed would be reading
noise as signal.
### Rules invariants that are easy to get wrong
- Reopening betting cannot be a boolean. `Seat.lastActedAtBet` records the bet
level a player last acted at; betting reopens when `currentBet - lastActedAtBet
>= minRaiseSize`. Several short all-ins can reach that together.
- `PreflopChart` percentiles are weighted by **combination counts** (pair 6,
suited 4, offsuit 12, total 1326), so "top 12%" means 12% of *dealt hands*, not
12% of the 169 classes.
- Anything consuming `DecisionContext.history` across hands must key off
`handNumber`. History is cleared each hand, so a size comparison silently drops
events.
- Known-imperfect: win-rate magnitudes are still ~10x realistic, and several
profiles are looser than their labels (the Rock plays ~38% VPIP, should be
~12%). Tuning is the open work.
- Gradle emits an `archives` deprecation from the Kotlin Multiplatform plugin's
own `jvm()` target registration — upstream in Kotlin 2.2.10, not our build.
- Not built yet: LLM persona layer, opt-in coach, Compose UI.