Skip to content

fix(engine): preflop State::initial honors config.initial_contributions (#67)#165

Merged
amaster97 merged 1 commit into
mainfrom
pr-67-fix-cs-bug-preflop-initial
May 28, 2026
Merged

fix(engine): preflop State::initial honors config.initial_contributions (#67)#165
amaster97 merged 1 commit into
mainfrom
pr-67-fix-cs-bug-preflop-initial

Conversation

@amaster97

Copy link
Copy Markdown
Owner

Summary

Root cause documented in PR #159 and audited in PR #161. Both broken State::initial methods unconditionally hardcoded contributions=[SB+ante, BB+ante] regardless of config.initial_contributions; downstream leaves compute cs = contributions - initial_contributions, so a caller passing initial_contributions=[SB,BB]+initial_pot=0 (intending to declare posted blinds) produced cs=[0,0] and pot_total=0 at every leaf — collapsing the preflop Nash to fold-everywhere. This was HARD-BLOCKING the Premium-A blueprint.

What changed

  1. PreflopRvrState::initial (crates/cfr_core/src/preflop_rvr.rs:229-251) honors config.initial_contributions: contribution[i] = max(blind_amount[i], initial_contributions[i]). Both well-formed config shapes — [0,0]+pot=0 (engine posts blinds) and [SB+ante,BB+ante]+pot=SB+BB+2*ante (caller declared blinds as dead money) — produce Nash-equivalent strategies (leaf payoffs differ by exact per-player constants).
  2. HUNLState::initial_preflop (crates/cfr_core/src/hunl.rs:382-414) same fix. The postflop branch at hunl.rs:318 was already correct and is untouched.
  3. HUNLConfig::validate() added in crates/cfr_core/src/hunl.rs. Mirrors Python's __post_init__ guard at poker_solver/hunl.py:124-176, plus accepts the caller-declared-blinds preflop shape. Defense-in-depth per feedback_silent_skip_hazard — PyO3 callers deserialize straight into HUNLConfig and skip Python's __post_init__.
  4. Wired validate() into solve_hunl_preflop_rvr so the bug-triggering config ([50,100]+pot=0) HARD-FAILs at the entry point instead of silently producing a fold-everywhere equilibrium.

Tests added

  • crates/cfr_core/tests/preflop_initial_contributions.rs — 11 new Rust tests covering AA-does-not-fold regression, identical-strategies diff-test, six HUNLConfig::validate() accept/reject cases, dead-money subgame acceptance, and entry-point rejection.
  • tests/test_preflop_rvr_diff.py::test_engine_agnostic_to_blind_declaration — Python-side parametrized variant of the diff-test, exercising the PyO3 boundary.

Verification

  • All 11 new Rust tests PASS.
  • AA-vs-KK closed-form smoke (preflop_rvr_smoke::aa_vs_kk_closed_form_aa_does_not_fold) still PASSes — no regression on the engine-posts-blinds path.
  • All 4 test_preflop_rvr_diff.py Python diff tests PASS (test_pushfold_equivalence_15bb, test_aa_only_vs_kk_only_closed_form, test_engine_agnostic_to_blind_declaration, test_aggregator_drift_premium_ranges).
  • cargo test -p cfr_core --lib: 75 unit tests PASS.
  • cargo clippy --lib --tests -p cfr_core -- -D warnings: clean.
  • ruff check tests/test_preflop_rvr_diff.py: clean.

Diff-test result

engine_posts_vs_caller_declared_blinds_produces_identical_strategies: max_drift < 1e-4 between [0,0]+pot=0 and [SB+ante,BB+ante]+pot=SB+BB+2*ante (PASS). The engine is now agnostic to which way blinds are declared.

Constraints respected

  • Postflop hunl.rs:318 untouched (was already correct).
  • Leaf-payoff math untouched — only State::initial and the new validate().
  • Pre-existing cfr_core benchmark formatting issues (benches/preflop_rvr_profile.rs, benches/rvr_profile.rs) are not in this PR's scope.

🤖 Generated with Claude Code

…ns (#67)

Root cause documented in PR #159 and audited in PR #161: both
`PreflopRvrState::initial` and `HUNLState::initial_preflop`
unconditionally set `contributions=[SB+ante, BB+ante]` regardless of
`config.initial_contributions`. Downstream leaves compute
`cs = contributions - initial_contributions`, so a caller passing
`initial_contributions=[SB,BB]` with `initial_pot=0` (intending to
declare posted blinds) produced `cs=[0,0]` and `pot_total=0` at every
leaf — collapsing the preflop Nash to fold-everywhere.

Fix:
- `State::initial` honors `initial_contributions`:
  `contribution[i] = max(blind_amount[i], initial_contributions[i])`.
  Engine-posts-blinds (`[0,0]+pot=0`) and caller-declared-blinds
  (`[SB+ante,BB+ante]+pot=SB+BB+2*ante`) configs both produce
  Nash-equivalent strategies (per-leaf payoffs differ only by a
  constant per-player shift).
- Add Rust-side `HUNLConfig::validate()` mirroring
  `poker_solver/hunl.py:124-176` (defense-in-depth per
  `feedback_silent_skip_hazard` — PyO3 callers bypass Python's
  `__post_init__` guard).
- Wire `validate()` into `solve_hunl_preflop_rvr` so malformed
  configs (e.g., `[50,100]+pot=0`) fail loudly instead of returning
  a degenerate fold-everywhere equilibrium.

Tests:
- `crates/cfr_core/tests/preflop_initial_contributions.rs` (11
  Rust tests): AA-does-not-fold regression, identical-strategies
  diff-test between both well-formed configs, eight
  `HUNLConfig::validate()` accept/reject cases, and an entry-point
  rejection test for the bug-triggering config.
- `tests/test_preflop_rvr_diff.py::test_engine_agnostic_to_blind_declaration`:
  Python-side parametrized variant of the diff-test (max_drift <
  1e-4 between the two config shapes through the PyO3 boundary).

Existing tests verified clean:
- AA-vs-KK closed-form smoke (`preflop_rvr_smoke.rs`) still PASSes.
- All 4 `test_preflop_rvr_diff.py` Python diff tests PASS.
- `cargo test -p cfr_core --lib`: 75 unit tests PASS.
- `cargo clippy --lib --tests`: clean.
- `ruff check`: clean.

Postflop branch (`hunl.rs:318`) unchanged — it was already correct.
Leaf-payoff math unchanged — only `State::initial` and `validate()`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@amaster97 amaster97 merged commit 43ed53e into main May 28, 2026
5 of 8 checks passed
amaster97 added a commit that referenced this pull request May 28, 2026
…y check) (#168)

Empirical comparison of `_rust.solve_hunl_preflop_rvr` output at 100 BB HU
against a published GTO chart provided by the user, on top of the post-PR-#165
cs-bug fix.

10000-iter solve, ~5.8 min wall on M-series single-thread.

Verdict: PARTIAL. Premium-value spots match (AA-JJ, AK*, A5s 4-bet jam at SB
vs 3-bet -- 100% match on the high-EV-signal regions). Disagreements
concentrate in (a) SB-limp action our engine permits but the published chart
omits, (b) 3-bet sizing mismatch (closest engine output 8.5bb vs chart's
10bb under the task-spec multipliers), (c) Nash-multiplicity bluff cells
(A5s/A2s flat-call vs chart's 3-bet bluff). Per-cell qualitative match:
24/37 = 64.9%; aggregate-frequency abs_diff > 5pp on 5/8 rows; median
per-cell L1 = 0.000 (PASS); max L1 = 2.000 (FAIL, driven by limp/no-limp
labeling artifact, not solver bug).

Engine code untouched -- empirical measurement only.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
amaster97 added a commit that referenced this pull request May 28, 2026
Completes the user-requested J7o (and 27o, Test 4) 40 BB walkthrough first
asked for prior to the cs-bug fix (PR #165) and True Path B engine
(PR #171). All four tests use the 169-class True Path B fast engine
(`_rust.solve_hunl_preflop_rvr_class169`) at 10000 DCFR iterations.

Tests:
- T1 baseline: SB opens J7o, BB calls, equity walk preflop->flop->turn->river
- T2 3-bet/4-bet: SB J7o vs BB 3-bet vs SB 4-bet vs BB call line
- T3 postflop raise: J7o equity vs BB's likely raise range on A89dd
  (engine is preflop-only; postflop equity from MC enumeration)
- T4 off-distribution: 27o + 5x open, engine crash-check + defend dist

Findings: J7o opens majority at 40 BB (matches published chart);
SB prefers 2x over 3x open (GTO-consistent at 40 BB stack depth);
72o folds 100%; AA opens 100%; engine doesn't crash on off-tree play.

Total wall: ~52s (29s solve + 23s postflop equity MC).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
amaster97 added a commit that referenced this pull request May 28, 2026
Preemptive draft for v1.9.0 — ready for ship-trigger after audit
clears. Covers the Premium-A blueprint shipping milestone:

- 27 precomputed preflop blueprint shards (PR #171 + asset commit
  1783bef): 169-class engine 178x/406x/448x speedup; ~21 MB bundle
  shipped under assets/blueprints/.
- Blueprint loader API (PR #174), stack-depth interpolation (PR #173),
  postflop subgame wiring (PR #177), top-level SolverRouter (PR #181),
  UI integration (PR #178).
- Vector-form BR walk (PR #170): W2.3 strict-PASS, persona table
  17/0/0/0 (recorded in PR #184).
- CS-bug fix (PR #165): preflop State::initial honors
  config.initial_contributions.
- B10 per-combo frequency train (PRs #149/#154/#158/#160): W2.2 Sarah
  Range.diff -> PASS.
- DCFR optimization ledger (docs/rust_optimization_ledger.md):
  empirical speedups for PRs #114/#139/#150/#157/#162/#170/#171.

Known limitations disclosed honestly:
- Flop live-solve OOMs in v1.9.0; flop = blueprint-only. v1.10
  task #70 addresses (docs/v1_10_postflop_optimization_plan.md).
- top_k_per_side is a speed knob, not a correctness flag.
- "Realtime postflop" claim scoped precisely: turn + river only.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant