Skip to content

docs(critical): preflop RvR degenerate Nash investigation#159

Open
amaster97 wants to merge 1 commit into
mainfrom
docs/preflop-rvr-degenerate-investigation
Open

docs(critical): preflop RvR degenerate Nash investigation#159
amaster97 wants to merge 1 commit into
mainfrom
docs/preflop-rvr-degenerate-investigation

Conversation

@amaster97

Copy link
Copy Markdown
Owner

Summary

Root cause identified for the degenerate Nash equilibrium in _rust.solve_hunl_preflop_rvr (PR #122) where SB folds J7o 100% and BB folds 99.25% facing 3bb at 40 BB / 1000 iters.

The bug: PreflopRvrState::initial (crates/cfr_core/src/preflop_rvr.rs:113-135) IGNORES config.initial_contributions and unconditionally sets contributions = [sb_blind, bb_blind] from config.small_blind/big_blind. When the user's repro config passes initial_contributions: [50, 100] (matching the blinds), the downstream chip-flow math at every leaf (preflop_rvr.rs:489-492 equity, :580-583 fold) double-subtracts the blinds:

cs0 = contributions[0] - initial_contributions[0] = 50 - 50 = 0  // should be 50
cs1 = contributions[1] - initial_contributions[1] = 100 - 100 = 0 // should be 100
pot_total = initial_pot + cs0 + cs1 = 0                           // should be 150

Fold/equity payoffs collapse to ~0 BB at root, so folding becomes dominant. The AA-vs-KK smoke test passed only because it uses the correct initial_contributions: [0, 0] (preflop_rvr_smoke.rs:38).

Suggested fix (NOT applied — user decides)

Hard-reject non-zero initial_contributions in solve_hunl_preflop_rvr (lines 1050-1059 already validate initial_hole_cards; add a parallel check). The full-tree preflop solver only has one valid root-state shape; rejecting other shapes prevents this class of silent miscalibration.

Test plan

  • Re-run /tmp/test1_40bb_1000iter.py with initial_contributions: [0, 0] and verify SB opens J7o >50%, BB defends KQs ~100%
  • Once the fix is applied, add a regression smoke test analogous to aa_vs_kk_closed_form for a J7o vs defending-range pair
  • Smoke test (AA-vs-KK in preflop_rvr_smoke.rs) continues to pass under either fix

🤖 Generated with Claude Code

Root cause identified: `PreflopRvrState::initial` (`crates/cfr_core/src/preflop_rvr.rs:113-135`) IGNORES `config.initial_contributions` and unconditionally sets `contributions = [sb_blind, bb_blind]` from `config.small_blind`/`big_blind`. When the user's config passes `initial_contributions: [50, 100]` (the blinds), the downstream chip-flow math at every leaf (lines 489-492 and 580-583) double-subtracts the blinds, yielding `cs0 = cs1 = 0` and `pot_total = 0` at the root. Fold/equity payoffs collapse to ~0 BB so folding becomes dominant -> SB folds 100% J7o, BB folds 99.25% facing 3bb, KQs/98s defend 0%.

The AA-vs-KK smoke test passed only because it uses the correct `initial_contributions: [0, 0]` (`crates/cfr_core/tests/preflop_rvr_smoke.rs:38`); the user's repro used `[50, 100]`.

Two fixes proposed: hard-reject non-zero `initial_contributions` in `solve_hunl_preflop_rvr` (safest), or document the invariant. NO code fix auto-applied — user decides.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
amaster97 added a commit that referenced this pull request May 28, 2026
…lidation (#161)

Audit of `cs = contributions - initial_contributions` bug pattern (PR #159
context) across all subgame solver entry points.

Findings:
- Bug scope: PARTIAL. Same `State::initial` pattern in TWO solvers
  (`preflop_rvr` AND `preflop`), but Python `HUNLConfig.__post_init__`
  blocks the trigger combination (`Preflop` + `initial_contributions !=
  [0,0]`). Reachable only via Rust-direct callers bypassing Python.
- Postflop solvers (`solve_hunl_postflop`, `solve_range_vs_range_*`,
  exploit BR-walk) are NOT affected — they use the correct postflop
  branch at `crates/cfr_core/src/hunl.rs:318`.
- All shipped perf claims (#114, #139, #150, #157) STAND. Every bench
  either uses `initial_contributions=[0,0]` (degenerate-subtraction-safe)
  or the unaffected postflop solver.
- Recommendation: fix `PreflopRvrState::initial` (preflop_rvr.rs:229-251)
  + `HUNLState::initial_preflop` (hunl.rs:382-414) to honor
  `config.initial_contributions`, and add a Rust-side
  `HUNLConfig::validate()` mirroring Python's `__post_init__`.

No engine code touched — surface-only audit.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
amaster97 added a commit that referenced this pull request May 28, 2026
#164)

Empirical investigation of degenerate preflop strategy hypothesis.

100-iter (post PR #157 .so) results with initial_contributions:[50,100] config:
- SB J7s root: 99.99% fold (target: 20-40% fold)
- BB KdQd vs 3bb: 71.71% fold (target: <=20% fold)
- BB 8s9s vs 3bb: 98.76% fold (target: ~40% fold)
- Trajectory: 92.12% (10 iters) -> 99.99% (100 iters); strategy NARROWS to fold

VERDICT: bug confirmed, NOT a convergence issue. Direction of travel is
toward more fold not less, consistent with PR #159 RCA (leaf payoffs collapse
to ~0 when initial_contributions matches the blinds, so folding becomes the
no-loss dominant action).

10000-iter run was launched in parallel (still in flight at commit time at
~47 min wall, slower per-iter rate than 100-iter sample suggested); doc will
be amended with full numbers when summary lands.

Engine code NOT modified. 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
…ns (#67) (#165)

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 added a commit that referenced this pull request May 28, 2026
1000-iter rerun (10x original baseline, post-PR #157 .so):
- Wall: 179.49 s (0.1795 s/iter)
- SB J7s root: 100.00% fold
- SB AA (AsAd) at root: [0.0, 0.0016, 0.3737, 0.4103, 0.1331, 0.0806, 0.0006]
  (100% non-fold, plays normally — confirms bug only affects fold-at-root leaves)
- BB aggregate vs 3bb: 99.25% fold (1225 unblocked hands)
- BB KdQd vs 3bb: 99.97% fold
- BB 8s9s vs 3bb: 100.00% fold

Trajectory: 10 iters (92% J7s fold) -> 100 iters (99.99%) -> 1000 iters (100%).
Strategy monotonically narrows to fold, never broadens — opposite of what a
convergence issue would show.

10000-iter run was launched but killed externally at ~50 min wall before
writing its summary (suspected macOS memory pressure, peak RSS ~4 GB). Verdict
does not depend on it: the trajectory at 10/100/1000 iters is unambiguous.

VERDICT: BUG CONFIRMED, NOT a convergence issue. Bumping default iter count
will NOT fix this. Leaf math must be fixed per PR #159.

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