Skip to content

Releases: amaster97/poker_solver

v1.10.0 — Postflop optimization milestone + Premium-A blueprint

Choose a tag to compare

@amaster97 amaster97 released this 29 May 01:33

v1.8.3 — Engine + UI: full-tree preflop RvR, BR-walk cache, chained orchestrator, true Nash default

Choose a tag to compare

@amaster97 amaster97 released this 28 May 02:43
555b78e

poker-solver v1.8.3 — Full-tree preflop RvR + BR-walk caching + chained orchestrator + UI True Nash default (major engine + UI release)

Status: DRAFT (compile-since-v1.8.2). Compiled 2026-05-28 from the
post-v1.8.2 main branch; refreshed 2026-05-27 after the PR
#139/#122/#121/#126/#20 merge wave.

Baseline commit on origin/main: 16c92e6 (v1.8.2 tag, 2026-05-28).
Compilation tip: 30cbd9f (PR #20 cross-platform CI matrix, last of
the 2026-05-27 merge wave).
Release date: TBD (user-gated; release notes are user-facing).
Tag: v1.8.3 (to be created at ship time).
Final tag SHA will be set at git tag time.

Hold for user review. This is a draft anticipating the next ship. The
contents reflect what has landed on main after 16c92e6. The four
previously-held major PRs ([#139][pr139] BR-walk caching, [#122][pr122]
full-tree preflop RvR engine, [#121][pr121] chained orchestrator Phase A,
[#126][pr126] UI True Nash default toggle) plus [#20][pr20] cross-platform
CI matrix have all merged and are promoted to first-class entries below.


Headline

v1.8.3 — Full-tree preflop RvR engine + BR-walk caching + chained
orchestrator + UI True Nash default — major engine + UI release.

Six user-visible items land in this release:

  1. CLI true-Nash default for range queries. poker-solver river and
    poker-solver subgame now dispatch multi-combo villain ranges through
    solve_range_vs_range_nash (vector-form CFR, joint imperfect-info Nash)
    by default. Sample wall: 3-combo villain, river, 50 iters: ~0.46 s
    default vs ~1.70 s legacy
    . See "CLI" §1.
  2. UI True Nash RvR default flip (PR [#126][pr126]). The GUI
    range-vs-range run panel now defaults solver_mode = "true_nash". The
    user-visible story is now "CLI and GUI default to true Nash for
    range queries". See "UI" §6.
  3. Full-tree preflop RvR engine — Phase A (PR [#122][pr122]). First
    landing of the full-tree preflop range-vs-range engine. Unblocks W2.1
    (Sarah preflop chart) at the engine layer. See "Engine" §7.
  4. Chained preflop orchestrator — Phase A (PR [#121][pr121]). Single-pass
    • lazy + Route A chained orchestrator infrastructure that drives the
      full-tree preflop engine. Unblocks W2.1 at the orchestration layer. See
      "Engine" §8.
  5. BR-walk terminal-leaf caching (PR [#139][pr139]). Applies PR #114's
    TerminalCache pattern to the best-response walk in exploit.rs.
    cargo test -p cfr_core --lib --release: 58 passed in 2.83 s (was
    ~110 s pre-cache). Unblocks W2.3 (Sarah deep-stack turn RvR) at the
    exploitability-compute layer. See "Engine perf" §9.
  6. Sarah W2.4 (batch-solve) PARTIAL → PASS. Post-PR-#133 retest hits
    3/3 fixture rows in 2.01 s wall (149× safety margin). See "Persona
    milestones" §2.

Persona delta (pre-retest baseline, post-PR #140): PASS 14 / PARTIAL 2
/ BLOCKED 1 / FAIL 0. Expected target post-empirical retest (with
PR #122 unblocking W2.1 and PR #139 unblocking W2.3): PASS 16 / PARTIAL 1
/ BLOCKED 0 / FAIL 0
— pending empirical confirmation at production scale
per memory rule feedback_post_ship_persona_retest.md.

The release also folds in the v1.8.2 post-ship audit (PR [#138][pr138];
8 checks, 6 PASS / 2 INFORMATIONAL / 0 release-blockers), the held-PR
backlog snapshot (PR [#137][pr137]), the 2026-05-28 current-state persona
snapshot (PR [#135][pr135]), and infra hardening via cross-platform CI
matrix (PR [#20][pr20]).


Highlights

1. CLI — range queries default to true Nash; --legacy-blueprint for opt-in fast mode (PR [#136][pr136])

Merge SHA: 5a6dd08.

Refactors _run_subgame_solve (the shared core for the river and
subgame subcommands) to dispatch multi-combo villain ranges through
range_aggregator.solve_range_vs_range_nash (vector-form CFR — true joint
imperfect-info Nash) instead of the per-combo blueprint-shape loop.

Why now. Post-PR-#114 TerminalCache (~213× river speedup), the
true-Nash path is competitive with — often faster than — the loop, and it
is mathematically correct (joint range-vs-range solve, not per-combo 1v1
averages). The default flip is the user-visible payoff of the v1.8.2
TerminalCache work.

Dispatch table:

Input Default behavior --legacy-blueprint opt-in
Single-combo villain range Diagnostic fixed-hand path (solve_hunl_postflop, legacy action_N positional keys) (no effect — single-combo never used the loop)
Multi-combo villain range solve_range_vs_range_nash (true Nash; engine action labels check/bet_75/fold/...) Per-combo blueprint loop (backward-compat opt-in)
Presentation modes (--walk-tree/--node/`--format=json csv`) Per-combo loop unconditionally (tree-walk formatters require a full SolveResult per villain combo)

Bench (3-combo villain QcQh,JcJh,JcJd, river, 50 iters):

Mode Wall time
Default (true Nash) ~0.46 s (Rust solve ~15 ms + Python overhead)
--legacy-blueprint ~1.70 s

Test plan PASS:

  • All existing CLI tests pass: 7 test_cli_subgame.py + 7
    test_cli_subcommands.py + 7 test_cli_walk_tree.py (incl.
    backward-compat structural assertion).
  • 9 new tests in tests/test_cli_range_default_true_nash.py covering
    both river and subgame paths.

Migration impact: existing scripts that compared against the
per-combo blueprint output will see engine action labels
(check/bet_75/fold/...) instead of the legacy action_N positional
keys. Pin the legacy behavior with --legacy-blueprint to retain the
pre-v1.8.3 output. See "Migration / breaking changes" below.

2. Persona — Sarah W2.4 PARTIAL → PASS via PR #133 batch-solve Rust backend (PR [#140][pr140])

Merge SHA: 14b09b0.

PR [#135][pr135] landed the 2026-05-28 persona snapshot but did not verify
W2.4 against the new batch-solve --backend rust CLI surface that
PR #133 added (PR #133 itself shipped pre-v1.8.2). PR #140 amends the
snapshot with an empirical retest.

W2.4 fixture: 3-row river CSV (scripts_retest/w2_4_test_spots.csv,
iter=100) — the same fixture used to declare W2.4 PARTIAL pre-PR-#133.

Result: poker-solver batch-solve --backend rust --input <fixture>
completes 3/3 OK in 2.01 s wall (per-row 0.58–0.68 s). Sarah's 5-min
session gate hit with 149× safety margin.

Reclassification: W2.4 PARTIAL → PASS.

Bottom-line persona counts (pre-retest baseline, snapshot at HEAD
14b09b0):

Verdict Pre-PR-#140 Post-PR-#140 Delta
PASS 13 14 +1
PARTIAL 3 2 −1
BLOCKED 1 1 =
FAIL 0 0 =

Sarah: 1/5 PASS → 2/5 PASS (W2.4 closed; W2.1, W2.2 remain PARTIAL;
W2.3 BLOCKED at this snapshot).

Expected target post-empirical retest (PR #122 + #121 + #139 merge wave):
PASS 16 / PARTIAL 1 / BLOCKED 0 / FAIL 0 — W2.1 PARTIAL → PASS (PR
#122 + #121) and W2.3 BLOCKED → PASS (PR #139), pending empirical
confirmation at production scale per memory rule
feedback_post_ship_persona_retest.md. The empirical retest is in flight.

Build caveat called out in the per-workflow row. The PR #140
verification rebuilds the _rust.so from current source via
maturin develop --release. The shipped v1.8.0 .dmg wheel (May 23
build, pre-PR #16) hard-fails on the W2.4 fixture with
index out of bounds: the len is 65 but the index is 70 at
dcfr_vector.rs:651 — the asymmetric-range hand-count panic that
PR #16 already fixed at the source layer. The next .dmg rebuild needs
to ship a source-current .so so the end-user W2.4 path mirrors the
buildable-from-source PASS state recorded here.

Verification artifact:
/tmp/persona_retests/w2_4_post133_rust_rebuilt.json. PR #133's own test
suite (tests/test_cli_batch_solve_rust.py) also passes end-to-end
(4/4) on the rebuilt .so.

3. Persona — current-state snapshot 2026-05-28 (PR [#135][pr135])

Merge SHA: e6df209.

Empirical snapshot taken at main HEAD 261fb7e after multiple
persona-affecting PRs landed on 2026-05-27 (PR #125 W1.5 return_ev,
PR #128 W4.2 amendment, PR #129 off-path annotation, PR #130 W3.5
amendment, PR #114 TerminalCache, PR #94 + PR #120 earlier retests).

Net delta from the prior snapshot (post-PR #128 + #130):

  • PASS: 12 → 13 (+1: W1.5 via PR #125)
  • PARTIAL: 4 → 3 (−1: W1.5 moved up)
  • BLOCKED: 1 → 1 (=)
  • FAIL: 0 → 0 (=)

Single reclassification on 2026-05-28 (pre-PR #140): W1.5 Marcus
push/fold sanity (76s @ 10 BB) PARTIAL → PASS via PR #125 (return_ev=True
keyword shipped; the Type C-NICE structural blocker is closed). Empirical
measurement:
get_pushfold_strategy(10, 'sb_jam', '76s', return_ev=True) -> {'strategy': 1.0, 'ev_bb': -0.207}
(0.16 s wall, well under Marcus 30s budget).

All other expected effects (PR #128 W4.2, PR #130 W3.5) were already
absorbed into the prior baseline. PR #129 off-path annotation surface
verified on SolveResult (8/8 tests pass); does not flip a persona
because dependent workflows were already PASS. PR #114 vector-RvR perf
gain alone was not enough to unblock W2.3 turn fixture (>20 min) — W2.3
retest deferred to the post-PR #139 BR-walk caching retest now folded
into this release (see "Engine perf §9" and "Persona table" below).

No unexpected reclassifications, no regressions.

4. Held PRs snapshot (PR [#137][pr137])

Merge SHA: c4843d5.

Captures a 2026-05-28 snapshot of the four major HELD PRs awaiting user
merge decision, with per-PR scope, lines/files, test coverage, hold
rationale, merge recommendation, and downstream blockers:

  • PR [#121][pr121] — chained orchestrator Phase A.
  • PR [#122][pr122] — full-tree preflop RvR engine (Phase A).
  • PR [#126][pr126] — True Nash UI default flip.
  • PR [#20][pr20] — cross-platform CI matrix.

All four were HELD per the project memory rule at PR #137 compilation
time...

Read more

v1.8.2 — CLI walk-tree + TerminalCache 213× + DCFR alpha-guard

Choose a tag to compare

@amaster97 amaster97 released this 28 May 02:13
16c92e6

poker-solver v1.8.2 — TerminalCache 213× river win + CLI walk-tree + DCFR safety guards

Status: DRAFT (compile-since-v1.8.0). Compiled 2026-05-27 from the
post-v1.8.0 main branch. Skips the v1.8.1 tag — the v1.8.1 candidate
patches (#91 / #95 / #96) landed on main directly during the
post-v1.8.0 doc-hygiene wave and are folded into v1.8.2 rather than
shipping under a separate tag, mirroring the v1.7.x → v1.8.0 fold-in
decision (per docs/v1_8_1_release_decision_2026-05-27.md).

Baseline commit on origin/main: 8a9c8d2 (v1.8.0 tag, 2026-05-27).
Compilation tip: 27e6b1d (PR #130, 2026-05-28 01:58 UTC).
Release date: TBD (user-gated; release notes are user-facing).
Tag: v1.8.2 (to be created at ship time; v1.8.1 skipped per
fold-in decision above).
Final tag SHA will be set at git tag time.


Headline

v1.8.2 — TerminalCache 213× river RvR speedup + CLI walk-tree/drill-down + DCFR α-guard hard-fail.

Three things land together in this release:

  1. TerminalCache: 213× speedup on vector-form river RvR. Profiling
    identified evaluate_7 calls in the O(N²) per-pair terminal loop as
    ~100% of inner-kernel time. The new TerminalCache precomputes
    per-player hand-strength vectors at each Showdown leaf and constant
    chip-flow payoffs at each Fold leaf, once per solve. Result on a
    1081-hand × 30-decision-node full-tree river fixture: 28.62 s/iter
    → 0.134 s/iter (~213×).
    The bit-equality unit test
    (cached_matches_uncached_terminal_value) confirms zero strategy
    drift vs the uncached path. See "Engine perf" §1.
  2. CLI walk-tree + per-class drill-down + JSON/CSV output. The
    poker-solver river CLI grows three composable presentation knobs
    (--walk-tree, --node, --format) that let users navigate the
    full decision tree instead of only the first-decision aggregate. The
    new subgame --street flop|turn|river command generalizes the same
    surface across all postflop streets with byte-identical backward
    compatibility for the legacy river invocation. See "CLI" §3.
  3. DCFR α-guard: hard-fail on α ≤ 0, warn on 0 < α < 0.5. Closed a
    silent non-Nash footgun where α=0 produced flat exploitability on
    Kuhn (8.6e-2 vs the correct ~1.27e-3 at α=1.5) by halving
    positive regrets every iteration. Production default α=1.5 was
    never affected; the guard prevents reasonable-looking experimental
    configs from silently passing. See "Engine quality" §2.

The release also folds in the v1.8.1 candidate doc/test-hygiene wave
(#91 Brown dump literals, #95 .dmg build absolute path, #96 release
notes broken refs + naming collision + silent-skip hazard), the
EV(action) invariance gauntlet (#98) as a Nash-invariant cross-solver
sanity check that complements strict-σ parity at deep-cap multiplicity
manifolds, and four ship-script hazard checks (#119) encoding the
post-v1.8.0 burst's release-process lessons. Marcus W1.5 (pushfold
return_ev=True), Priya W4.2 (spec amendment), and Daniel W3.5
(range-setup-mode spec amendment) all move into PASS in the persona
table. PR [#129][pr129] also adds reach_probability +
off_path_keys fields to SolveResult so downstream consumers can
filter phantom 5% infosets (closes user-issue #47).


Highlights

1. TerminalCache: 213× speedup on vector-form river RvR (PR [#114][pr114])

Merge SHA: 036a101.

A per-phase profile of dcfr_vector::traverse on a 1326-hand × 30-decision-node
full-tree river fixture (crates/cfr_core/benches/rvr_profile.rs,
new in this PR) pinpointed terminal_value_vector as ~100% of inner-kernel
time. Root cause: the O(N²) per-pair loop at every terminal leaf called
evaluate_7 afresh on every iteration even though the board is fixed
across the entire solve.

Fix: TerminalCache (new in crates/cfr_core/src/dcfr_vector.rs)
precomputes:

  • Per-player Strength vectors at each Showdown leaf, once per
    solve.
  • Constant chip-flow payoffs at each Fold leaf, once per solve.

terminal_value_vector_cached reads from the cache instead of calling
evaluate_7 per-pair-per-iter. The old uncached function is retained
as the parity reference and exercised by a new bit-exact Rust unit test
(cached_matches_uncached_terminal_value).

Bench numbers (full-tree river, 1081 hands × 30 decision nodes,
M4 Pro arm64):

Variant s / iter Notes
Baseline (uncached) 28.62 Matches HIGH-2's ~26 s/iter framing
Cached 0.134 Cache build amortized over iters
Speedup ~213× Well past the 2× HIGH-2 target

After caching, terminal_value_vector still dominates (~99% of cached
inner-kernel time) but in absolute terms it's now 0.134 s/iter from
28.6 s/iter. All other phases (compute_strategy, update_regret_sum,
update_strategy_sum, etc.) sum to <0.001 s/iter — no remaining
low-hanging optimization at this fixture size.

Bit-exactness gates:

  • New Rust unit test cached_matches_uncached_terminal_value asserts
    bit-identical output between cached + uncached paths across all
    terminal leaves of a 1081-hand fixture. PASSES.
  • All 57 cfr_core library unit tests pass (cargo test --lib).
  • All 37 RvR Python differential / aggregator / Nash tests pass
    (tests/test_range_vs_range_rust_diff.py,
    tests/test_range_vs_range_aggregator.py,
    tests/test_range_vs_range_nash.py).

Tooling shipped alongside:

  • crates/cfr_core/benches/rvr_profile.rs: per-phase Instant-based
    bench harness. Reports total wall, per-iter wall, and (with
    --features profile_rvr) a cost breakdown across 11 named phases.
  • CFR_VECTOR_NO_TERMINAL_CACHE=1 env knob routes through the uncached
    path for baseline comparison; production has this unset.
  • profile_rvr feature flag is opt-in; without it the profiling macros
    expand to no-ops (zero overhead on the production hot path).

Why caching instead of wider SIMD? The original HIGH-2 framing
assumed the bottleneck was inner-f64 vector ops missing wider SIMD. The
profile refuted that: the kernel was bottlenecked by repeated O(N²)
evaluate_7 calls on a constant board, not by f64 arithmetic. NEON
SIMD on the post-cache hot path remains a clean follow-up but the ~200×
speedup from caching alone takes river RvR wall-clock from minutes to
seconds at typical iteration counts — sufficient to unblock the
downstream UI True-Nash work (PR #126, HOLD) and the W2.3 perf-budget
retest (still BLOCKED on best-response walk caching, separate follow-on
wall).

Full bench report: see PR #114 description.

2. DCFR α-guard: hard-fail α ≤ 0, warn α < 0.5 (PR [#113][pr113])

Merge SHA: 3ef9b76. Implements Option B from the merged
proposal in PR [#99][pr99] (c534bf0).

Closes the HIGH-1 silent non-Nash bug from
docs/perpetual_qa_findings_2026-05-27.md. The DCFR positive-regret
discount kernel computed pos_scale = t^α / (t^α + 1), which at α=0
collapses to 1/2 for all t, halving positive regrets every
iteration
regardless of t. On Kuhn at 10k iter, α=0 stays at
exploitability ~8.6e-2 vs ~1.27e-3 at the production default α=1.5
(50× worse). Game value lands at −0.093 BB instead of the correct
~−0.056 BB. Max strategy-cell diff vs α=1.5 @ 10k iter: 0.411.

Behavior in v1.8.2:

α value Behavior
α ≤ 0 HARD-FAIL (PyValueError / Rust panic) at solver init.
Non-finite (NaN/±∞) HARD-FAIL at solver init.
0 < α < 0.5 WARNUserWarning (Python) / eprintln! (Rust).
α ≥ 0.5 Silent OK. Includes production α=1.5 and paper-range α=2.0.

The warn band reflects that Brown & Sandholm 2019 validates α=3/2
only; values in (0, 0.5) are below the paper's analyzed range but
not provably broken.

Surface area covered (12 entry points per the proposal):

  • Rust: Single cfr_core::dcfr::validate_alpha helper called from
    4 DCFR ctors (DCFRSolver, PreflopDcfr, VectorDCFR, HUNLDcfr).
    The 5 PyO3 entries (solve_kuhn, solve_leduc, solve_hunl_postflop,
    solve_hunl_preflop, solve_range_vs_range_rust) already wrap
    invocations in catch_unwind, so panics surface as PyValueError.
  • Python: poker_solver.dcfr._validate_alpha called from
    DCFRSolver.__init__, solver._solve_rust,
    range_aggregator.solve_range_vs_range_nash.

Test plan PASS:

  • crates/cfr_core/tests/test_dcfr_alpha_guard.rs — 7 Rust cases (α=0,
    <0, NaN, ±inf, warn-band 0.3, prod 1.5, paper-range 2.0). All PASS.
  • tests/test_dcfr_alpha_guard.py — 14 Python cases including the
    user-requested test_alpha_zero_hard_fails / test_alpha_small_warns
    / test_alpha_default_silent on _rust.solve_kuhn + _solve_rust.
    All PASS.
  • Regression: test_dcfr_core.py + test_dcfr_diff.py +
    test_kuhn_dcfr.py + test_leduc_dcfr.py — 20/20 PASS. Existing
    α=2.0 sensitivity probes remain silent.
  • Rust lib: cargo test -p cfr_core --release --lib — 56/56 PASS.

Migration impact: any downstream call passing α ≤ 0 will now raise
instead of silently producing wrong strategies. See "Migration /
breaking changes" below.

3. EV(action) invariance gauntlet (PR [#98][pr98])

Merge SHA: 3cc5eba.

A new test-only deliverable
(tests/test_ev_invariance_gauntlet.py, ~530 LOC) implementing the
Nash-invariance EV(action) gauntlet on the canonical K72 + A83 deep-cap
multiplicity fixtures. Per Brown 2019 Thm 2 / von Neumann minimax,
Q_p(I, a) is unique across all Nash of a 2-player constant-sum game
even when strategy probabilities are not (Nash multiplicity at
indifference manifolds). This compares EV-of-action at depth=0 (true
root) between Brown's solver and o...

Read more

v1.8.0 — Cross-platform SIMD + .dmg fork-bomb fix

Choose a tag to compare

@amaster97 amaster97 released this 27 May 09:18

poker-solver v1.8.0 — Cross-platform SIMD portability + terminal-utility convention purge + .dmg fork-bomb fix

Status: DRAFT (post-purge framed) — Phases 1-4 + AVX2 all merged to
main as of 77e751c (PR #32, 2026-05-26). The terminal-utility
convention purge lands as PR 78 (37e5be1)
and is the engine correctness fix in v1.8.0 — see "Engine fixes" §5
and "Migration / breaking changes" below. Engine + parity fixes from
the v1.7.1 bundle and v1.7.2 (.dmg fork-bomb fix + CI hardening) are
folded into v1.8.0 per docs/v1_6_1_ship_hold_review_2026-05-26.md
and docs/v1_7_1_tag_decision_2026-05-26.md. The v1.6.1 ship hold is
LIFTED under the canonical convention (see "Known issues remaining"
A83 entry). All substantive placeholders have been substituted; final
ship step is tag + GitHub release.

Release date: 2026-05-27
Tag: v1.8.0 (to be created at ship time)
Baseline commit on origin/main: eb74fb3 (PR #60, 2026-05-26),
plus the terminal-utility convention purge at 37e5be1
(PR 78).
Final tag SHA will be set at git tag time.


Headline

v1.8.0 — Cross-platform SIMD portability + terminal-utility convention purge + .dmg fork-bomb fix.

Three things land together in this release:

  1. Cross-platform SIMD complete (portability win). The Discounted
    CFR solver's hot inner loops are now hand-vectorized across NEON
    (Apple Silicon), AVX2 + SSE2 (x86_64, runtime-detected), with an
    automatic scalar fallback for anything else. This is a portability
    win, not a speedup
    — measured wall-clock on Apple Silicon
    (M4 Pro, aarch64) is within noise (~1.0×) because LLVM's -O3
    autovectorizer already covers the small-slice case. The headline
    value is closing the ~3-month gap between Apple Silicon and x86_64
    coverage with a stable hand-written floor that doesn't depend on the
    compiler's heuristics.
  2. Terminal-utility convention purge (engine correctness fix). The
    prior "rust" terminal-utility convention treated initial_contributions
    as recoverable, which produced a per-action regret bias of 12-50pp
    versus the reference Brown solver at deep cap. v1.8.0 adopts the
    single canonical real-poker convention (winner collects the full pot
    including dead money from prior streets). See "Engine fixes" and
    "Migration / breaking changes" for the formula, a one-line numeric
    example, and the rebaseline policy. Pre-v1.8.0 solver outputs are
    NOT comparable to v1.8.0 outputs.
  3. .dmg fork-bomb fix (CRITICAL). The v1.6.0 .dmg had an
    uncontrolled-spawn bug on Finder launch
    (multiprocessing.freeze_support() missing from the PyInstaller
    entry point). v1.6.0 .dmg has been pulled from its GitHub Release;
    v1.8.0 ships the repackaged build.

SIMD vector kernels (cross-platform): Explicit NEON/AVX2/SSE2
intrinsics replace the previous scalar inner loops in dcfr_vector.rs.
Bit-identical SIMD-vs-scalar output on the same convention; measured
wall-clock impact on Apple Silicon (M4 Pro, aarch64) is within noise
(~1.0×)
because LLVM's autovectorizer at -O3 already covers the
small-slice case (action_count = 2-5 per decision row). Primary value
is portability (x86_64 with explicit AVX2 dispatch, runtime-detected)
and a stable hand-written floor that doesn't depend on the compiler's
heuristics. x86_64 wall-clock measurement is pending (no AVX2 hardware
in the bench fleet at time of write). Full benchmark report:
docs/v1_8_simd_perf_benchmark_2026-05-26.md.


Highlights

1. Cross-platform SIMD vector kernels (Phase 1-4)

Four hot loops in crates/cfr_core/src/dcfr_vector.rs are now
vectorized, each behind a single safe public API with runtime ISA
detection. Bit-identical output verified by an end-to-end cross-backend
smoke test that runs 1000 iterations of a Kuhn poker solve on both the
scalar and SIMD paths and asserts equality.

Phase Kernel PR Merge SHA
1 discount_regrets + discount_strategy_sum [#23][pr23] 485aa8c
1+ AVX2 runtime-detect path (x86_64) [#35][pr35] db8d646
2 update_regret_sum [#41][pr41] 8073bcc
3 update_strategy_sum [#33][pr33] a712950
4 compute_strategy [#32][pr32] 77e751c

Per-backend coverage:

  • NEON for target_arch = "aarch64" (Apple Silicon, ARM servers) —
    128-bit, 2-lane f64.
  • AVX2 + SSE2 for target_arch = "x86_64", with
    is_x86_feature_detected!("avx2") runtime dispatch:
    • AVX2 path: 256-bit, 4-lane f64 (Haswell+, ~2013 and later).
    • SSE2 baseline: 128-bit, 2-lane f64 (any x86_64 CPU).
  • Scalar fallback for any other architecture and for the epilogue
    (the 0-3 trailing elements that don't fit a SIMD lane).

Why this matters: the previous v1.7.x line was already NEON-optimized
for Apple Silicon, but x86_64 users (Intel Macs, Windows PCs, Linux
desktops, CI runners) got the scalar fallback. That's a ~3-month gap
between Apple Silicon and x86_64 performance, now closed (at the
architecture level — the empirical x86_64 wall-clock measurement
remains pending). The cross-platform spec
(docs/pr_proposals/v1_8_cross_platform_simd_spec.md) drives the
architecture. Sarah persona W2.3 remains pending the post-v1.8 retest
(turn-fixture, agent in flight 2026-05-26); the original "unblocked
on M-series" projection was tied to a 4-8× SIMD speedup that did not
materialize on M4 Pro arm64 (see "Persona test status" below).

2. .dmg fork-bomb fix (CRITICAL)

The v1.6.0 .dmg had a critical fork-bomb on Finder launch.
Double-clicking Poker Solver.app on macOS spawned the app's
multiprocessing workers recursively because
multiprocessing.freeze_support() was missing from the PyInstaller
entry point. Each spawned child re-launched the parent process,
exponentially — freezing the user's Mac.

Resolution:

  • PR #42 ([728206e][pr42]) adds the multiprocessing.freeze_support()
    guard to the PyInstaller entry point, eliminating the fork-bomb on
    the v1.8.0 build.
  • The v1.6.0 .dmg asset has been retroactively pulled from its
    GitHub Release.
    The v1.6.0 release page now carries a critical
    warning pointing users at the v1.8.0 repackaged build (or the
    from-source install) instead.
  • A user-facing warning was also added to the CHANGELOG retroactive
    amendment for v1.6.0.

If you previously downloaded the v1.6.0 .dmg, delete the
Poker Solver.app bundle and switch to the v1.8.0 .dmg
when this
release publishes. If you ran the v1.6.0 .dmg and your Mac froze on
launch, no persistent damage was done — the workers stopped when the
parent process was killed.

Full RCA: docs/dmg_spawn_loop_rca_2026-05-26.md.

3. Lint / format / deps green-up (PR #43)

[PR #43][pr43] (cfc6bc5) is a non-functional cleanup pass that brings
the main-branch lint gates back to green after the v1.7.x churn:

  • cargo clippy --all-targets --release — clean.
  • ruff check . — clean (lint).
  • ruff format --check . — clean (format). black is removed in favor
    of ruff format; the two formatters had diverged on a handful of
    multi-line argument lists.
  • rich>=13.0 added as an explicit runtime dependency. It was
    implicitly required by poker-solver parity (Brown reference parity
    CLI subcommand) via rich.console.Console imports, but the install
    wouldn't fail until users actually invoked the subcommand.

No public API or algorithm changes.

4. Documentation accuracy (PRs #44, #45)

Two doc-only PRs land alongside the SIMD bundle:

  • [PR #44][pr44] (a6b89f7) fixed three executable code bugs in
    README.md and USAGE.md examples:
    1. Range parser example used the wrong constructor signature.
    2. A solve_river_nash() example was missing the iterations=
      keyword.
    3. A hero/board overlap example assumed a card slot that the API
      rejects.
      Plus a stale claim about "no CLI for parity comparison" was scrubbed
      (the poker-solver parity subcommand has shipped since v1.7.0).
  • [PR #45][pr45] (dbfc8d0) cleaned up downstream doc drift:
    • USAGE.md header version stamp brought to v1.7.x.
    • CHANGELOG.md .dmg retroactive amendment for v1.6.0 (see
      Highlight 2).
    • docs/dmg_install_guide.md banner pointing to the v1.8.0 repackaged
      build.
    • Supersede banners on a handful of stale planning docs.

5. Engine + parity-wrapper fixes carried from the v1.7.1 bundle

v1.8.0 inherits a 10-PR bundle of engine and parity-wrapper fixes that
landed piecewise on main between v1.7.0 and v1.8.0. No formal
v1.7.1 tag was created
(per docs/v1_7_1_tag_decision_2026-05-26.md);
the fixes are folded into this release. The v1.6.1 ship hold has been
lifted per docs/v1_6_1_ship_hold_review_2026-05-26.md.

Engine correctness:

  • PR 78 (37e5be1) — Terminal-utility
    convention purge (canonical real-poker convention).
    Prior versions
    used a "rust" terminal-utility formula that treated
    initial_contributions as recoverable by the player who folded; this
    produced a per-action regret bias that diverged 12-50pp from the
    reference Brown solver at deep cap (PR #93 ablation,
    docs/a83_terminal_utility_ablation_results_2026-05-26.md). v1.8.0
    deletes that path and ships a single canonical utility() function.

    Canonical formula (per feedback_brown_convention_adopt.md):

    winner_utility = pot_total - contrib_subgame_winner
                   = base_pot + contrib_subgame_loser     (in BB)
    loser_utility  = -contrib_subgame_loser               (in BB)
    tie_each       = pot_total/2 - contrib_subgame_player (in BB)
    

    where contrib_subgame[i] = state.contributions[i] - cfg.initial_contributions[i]
    and `pot_total = cfg.initial_pot + contri...

Read more

v1.7.0: aggregator->vector wiring + CLI subcommands

Choose a tag to compare

@amaster97 amaster97 released this 23 May 23:06

What's New

solve_range_vs_range_nash direct API (PR 43)

Joint range Nash equilibrium routed through the Rust vector-form CFR. Distinct from the aggregator pattern's per-combo blueprint approach; 12 tests including W3.5-style monotone polarization validation.

CLI subcommands (PR 39)

  • poker-solver pushfold - preflop push/fold chart lookup
  • poker-solver river - river-only solve
  • poker-solver parity - Brown reference parity comparison

Status notes

  • v1.6.1 engine bundle: HELD pending acceptance gate redefinition (deep-cap Brown apples-to-apples reveals architectural divergence in payoff convention)
  • PR 44 .dmg packaging fix: verified on disk; ready for Gate 5 attachment

Test results

  • Rust lib: 50/50 passing
  • Python nash wrapper: 12/12 passing
  • CLI subcommands: 6/6 passing (1 env-skipped)

Post-release validation findings (2026-05-23 late)

Persona acceptance retesting after this release surfaced an API semantics nuance that users should understand. Initial retests suggested a wrapper bug; a subsequent diff-test investigation confirmed there is no bug -- but the result clarifies how solve_range_vs_range_nash interprets range inputs.

Class-label inputs vs. combo-level inputs

solve_range_vs_range_nash accepts ranges as class labels (e.g., AA, KK, AKs, AKo). Internally, the wrapper expands each class to its full combo set: AA -> 6 combos, KK -> 6, AKs -> 4, AKo -> 12, etc. A 15-class symmetric input expands to ~79 combos per player.

The class-expanded range can have a Nash equilibrium that differs meaningfully from a hand-curated combo subset of the same hand classes. For example, on a monotone Ts8s6s4c2d-ish board:

  • A hand-curated 15-combo input (one specific combo per class) yields AA pure-check
  • The full class-expansion (79 combos) yields AA mixing toward small bets -- because in the larger range, AA dominates more of villain's range AND its specific suit combos (AhAd, AhAc, etc.) block villain's AKs/AKo bluff candidates

Both are valid Nash equilibria for their respective inputs. Neither is a bug.

Workflow guidance

  • For population-level frequency reads on production ranges: use solve_range_vs_range (aggregator path, faster, designed for blueprint-style analysis)
  • For tight per-combo pot-odds bluff-catch decisions: use solve_range_vs_range_nash with hand-curated combo lists (use 4-char labels like AhKh, JhJd instead of class labels like AKs, JJ)
  • For Nash on full class-expanded ranges: use class labels as designed; understand that the answer reflects the class-expanded range's Nash, not a subset's

Performance scope

Nash path scales linearly in iterations but super-linearly in street depth and class count. Viable envelopes (Sarah's <=5 min budget):

  • River: any practical size
  • Turn: <=4 classes x <=200 iter, OR <=8 classes x <=100 iter
  • Flop: NOT interactive on Nash path; use the aggregator (solve_range_vs_range, ~1 s/flop solve)

See USAGE.md section 5.6 for code examples and full guidance.

What v1.7.0 ships

  • True joint Nash for class-expanded ranges via solve_range_vs_range_nash [shipped]
  • Distinct from aggregator's per-combo blueprint pattern [shipped]
  • 12 tests in tests/test_range_vs_range_nash.py validating correctness on small fixtures [shipped]
  • CLI subcommands: pushfold, river, parity [shipped]

See CHANGELOG.md for full details.


Post-publication notes (2026-05-26)

Three days after v1.7.0 shipped, the release roadmap was restructured. Readers landing here from search engines or release lists should know:

  • v1.6.1 hold has been LIFTED. The hold listed above ("v1.6.1 engine bundle: HELD pending acceptance gate redefinition") was lifted on 2026-05-26 per the ship review. See docs/v1_6_1_ship_hold_review_2026-05-26.md.

  • A83 deep-cap divergence cause corrected. The status note above attributed the divergence to "architectural divergence in payoff convention." The actual cause is Nash multiplicity at deep-cap indifference manifolds (empirically confirmed via three independent DCFR-math audits + a Track A bench). It is a design difference vs. Brown/Pluribus, not a bug. See docs/a83_nash_multiplicity_confirmed_2026-05-26.md.

  • Next release boundary is v1.8.0. v1.6.1 and v1.7.1 will not ship as separate tagged releases; their fixes shipped piecewise on main and are folded into v1.8.0. See docs/v1_7_1_tag_decision_2026-05-26.md.

The solve_range_vs_range_nash API-semantics note above remains accurate.

v1.6.0: GUI Gate 2 (range editor, RvR, node-locking, asymmetric, slider tiers)

Choose a tag to compare

@amaster97 amaster97 released this 23 May 20:14

⚠️ CRITICAL — v1.6.0 .dmg is BROKEN. Do NOT launch from Finder.

The .app bundle has a multiprocessing fork-bomb: launching from
Finder causes uncontrolled process spawning that can freeze your Mac.
Root cause is a missing multiprocessing.freeze_support() call in
scripts/pyinstaller_entry.py. The patch is in flight on
PR #42; a
repackaged .dmg will ship in v1.7.2.

Until v1.7.2, install from source instead:

git clone https://github.com/amaster97/poker_solver.git
cd poker_solver
pip install -e .

Full RCA: docs/dmg_spawn_loop_rca_2026-05-26.md on the
pr-78-dmg-freeze-support-fix branch.


v1.6.0 — GUI Gate 2

User-facing surfaces for v1.3.0+ engine features that were previously library-only.

New in this release

  • Range editor: per-hand weight editing (drag/paste/preset ranges); state round-trips through solver
  • Range-vs-range UI: in the GUI, not just the library
  • Node-locking controls: freeze a node's strategy and re-solve against it (exploitative analysis primitive)
  • Asymmetric contributions input: per-player pot contribution (for non-standard stack/contribution scenarios)
  • Exploitability slider: Draft (1% pot) - Standard (0.5%) - Tight (0.25%) - Library (0.1%) - tier numeric defaults measured against the real solver

Install

git clone https://github.com/amaster97/poker_solver.git
cd poker_solver
pip install -e .

See README.md for full quick-start and library usage.

macOS installer

Poker-Solver-1.6.0-arm64.dmg attached below. Apple silicon (arm64) only. Adhoc-signed (no Apple Developer enrollment) — on first launch, right-click → Open, or System Settings → Privacy & Security → Open Anyway.

For users who prefer source install, pip install -e . is supported and recommended for non-arm64 Macs.

Known issues

  • .dmg installer: CRITICAL — DO NOT LAUNCH (see warning at top of these notes). Source install via pip install -e . is the only safe path until v1.7.2 ships a repackaged .dmg. The earlier "experimental" framing here understated the danger.
  • v1.5.0 Brown apples-to-apples acceptance test: currently fails at deep-cap facing-raise spots. Per docs/aggregator_vs_true_nash_explainer.md, three independent code reviews verified the vector-form CFR is structurally faithful to Brown's reference; an empirical divergence at deep-cap A83 spots is under investigation (root cause identified: a non-zero-sum vs zero-sum payoff convention divergence; documentation + tolerance widening in v1.6.1 candidate).
  • Range fractional-frequency: set-membership only in current Range class. Refactor scoped for a future release.

What's next

  • v1.6.1: engine bundle (Python delegate + paired cap-guard + tolerance docs)
  • v1.7.0: aggregator->vector wiring (solve_range_vs_range_nash API) + CLI subcommands

v1.5.1: Test rigor + docs honesty (engine fixes deferred to v1.5.2)

Choose a tag to compare

@amaster97 amaster97 released this 23 May 17:29

v1.5.1 — Test rigor + docs honesty (PATCH)

Headline: A small, no-engine-touch patch release combining new equity test-helper scaffolding, rigorous memory-profiler oracles, and an honest framing correction on a long-standing performance claim. Engine fixes (per-action divergence diagnosis, Python delegate, off-by-one fixes) are deferred to v1.5.2.

What changed

  • Equity test-helper (PR 37). New tests/_equity_helpers.py exposes equity_of, equity_vs_range, and assert_equity_close built on top of poker_solver.equity.equity(). Re-exported via tests/conftest.py. Lets persona / acceptance tests assert exact equity values inline. NO source-code change.

  • Memory-profiler test rigor (PR 36). 4 new tests upgrade the return-non-empty-without-crashing baseline to genuine external oracles: a closed-form 3-action x 4-infoset toy where the profiler output matches byte-for-byte; a real-config closed-form calibration on a small river solve; a golden-file check; and a structure-invariant check on per-street partitioning. NO source-code change.

  • PR 7 docs honesty (PR 32). The "<10s/spot on a typical dev box" comment in tests/test_river_diff_self_sanity.py:42 was aspirational (PR 7 / v0.5.1) and never empirically validated. The canonical parity test takes >660s on the Python tier due to chance-enum-at-root (1.6M hole-card combos per iter) — see docs/river_parity_timeout_investigation_2026-05-23.md. Comment reframed; no assertion or test-behavior change.

Honest framing

  • PATCH bump. NO poker_solver/ source change. NO crates/cfr_core/ source change. NO public API change. NO behavior change.
  • v1.5.0 _rust.cpython-313-darwin.so is reused byte-identically; users on v1.5.0 do not need to rebuild Rust for v1.5.1.
  • v1.5.0 acceptance-test status is unchanged. This release does NOT address the per-action divergence observed in the v1.5.0 Brown apples-to-apples acceptance test. The engine bundle that WILL address it (PR 33 Python delegate + PR 34 P0 off-by-one fix + PR 35 canonicalization) is deferred to v1.5.2 pending the divergence diagnosis.
  • Smoke regression: test_equity_helpers.py, test_memory_profiler.py, test_range.py, test_dcfr_diff.py, test_exploit_diff.py, test_range_vs_range_aggregator.py, test_node_locking.py — 91 passed, 5 skipped.

v1.5.0: True Nash range-vs-range

Choose a tag to compare

@amaster97 amaster97 released this 23 May 10:46

True Nash range-vs-range via Brown's vector-form CFR

v1.5.0 closes the documented architectural gap in v1.4.x: the range_aggregator.solve_range_vs_range path was an Option B approximation (chance-enum-at-root with averaged-strategy aggregation), which is structurally non-Nash for true range-vs-range play. v1.5.0 ships a Rust port of Brown's vector-form CFR (trainer.cpp:138-209, MIT-licensed) that walks the betting tree once per iteration with per-infoset hand_count x action_count regret + strategy_sum tables — the canonical CFR architecture for solving public-game-tree range-vs-range Nash equilibria.

Acceptance test caveat (RESOLVED in v1.6.1)

The v1.5.0 release initially shipped with the Brown apples-to-apples acceptance test in a FAILING state. The failure was traced to two compound TEST bugs (not solver bugs):

  1. Action-ordering mismatch: Brown's binary emits actions in order [c, f, r_low, r_med, r_high]; our Rust solver emits [f, c, r_low, r_med, A]. The test compared position-by-position, so position 0 lined up Brown's CALL with our FOLD. The cell that looked like "Brown=0% call, Rust=99% call" was actually "Brown=0% call, Rust=99% FOLD" — both engines agreeing on FOLD with a labeling mismatch.

  2. Range-to-player-slot misassignment: Brown's P0 acts first; our P1 acts first. The test passed ranges in the wrong slots, so the two engines weren't even solving the same game.

After fixing both test bugs (PR 40 in v1.6.1), the acceptance test passes; residual divergence is ~10% of cells with max ~0.10 magnitude — characteristic Nash polytope sizing-mix non-uniqueness (legit), not bug territory.

The PR 23 vector-form CFR algorithm itself was algorithmically correct from the start. Brown's cpp/trainer.cpp:138-209 line-by-line match was confirmed by audit. v1.5.0's solve_range_vs_range_rust entry point delivers genuine vector-form Nash; the empirical confirmation just had to wait for the test plumbing to be debugged.

The previously reported DCFR "100x slowdown" against v1.4.x was a measurement artifact (apples-to-oranges configuration), not a real regression. Scalar-path performance is byte-identical to v1.4.0.

Added

  • New Rust entry point _rust.solve_range_vs_range_rust(config_json, iters, alpha, beta, gamma, p0_holes=None, p1_holes=None) -> dict. Output dict matches the Python tier's shape (average_strategy keyed by <hole>|<board>|<street>|<history>), plus decision_node_count, iterations, wallclock_seconds, hand_count_per_player, memory_profile, backend = "rust_vector".
  • Game::hand_count() trait method (default 1) for backward-compatible opt-in. All existing scalar paths (DCFRSolver<G>, hunl_solver.rs, preflop.rs) remain byte-identical to v1.4.0.
  • Per-street memory profiler (VectorMemoryProfile) surfaced in the PyO3 dict's memory_profile field with total_bytes, infoset_count, bytes_by_street, infoset_count_by_street.
  • Brown apples-to-apples acceptance test (tests/test_v1_5_brown_apples_to_apples.py, opt-in via -m parity_noambrown) — runs Brown's river_solver_optimized reference binary against the new Rust vector-form CFR on dry_K72_rainbow and dry_A83_rainbow, comparing average strategies at matching histories. Now passes on both spots after the v1.6.1 test-plumbing fix (PR 40).
  • Differential tests (tests/test_range_vs_range_rust_diff.py) — 4 active tests covering exploitability-under-restricted-game vs Python dcfr.py, structural smoke checks, and end-to-end binding chain verification. Both Python and Rust achieve <= 0.05 BB exploitability on the Case A spot.

Performance

  • ~72x faster than the Python aggregator on a medium 10x10 RvR river spot (measured; honest single-machine comparison on macOS arm64).

Unchanged

  • All existing scalar diff tests (Kuhn / Leduc / fixed-combo river / exploit / node-locking) remain green; 40 differential tests confirm v1.4.0 byte-identical behavior on the scalar paths.
  • Public API surface for solve_hunl_postflop / solve_hunl_preflop / solve() is unchanged. v1.4.3 input-validation guards (PR 31) remain in force.

Caveats and v1.5.x roadmap

  • Postflop only in v1.5.0 — preflop RvR deferred to v1.5.1 (16 GB memory edge at full-1326 hand vector without suit-iso reduction).
  • Terminal-leaf O(N^2) blocker check is the next perf candidate (SIMD kernels expected to deliver 4-8x speedup based on PR 8 NEON-on-scalar experience).
  • EMD hand-bucketing in the vector dimension deferred to v1.5.1.
  • range_aggregator.solve_range_vs_range is not yet wired to the new Rust tier (Q3 default for v1.5.0 keeps it internal-only); user-facing surfacing deferred to a later minor.

License

The Rust port follows Brown's MIT-licensed reference implementation with attribution in source comments.

v1.4.3: Validation hardening + Range.diff + docs refresh

Choose a tag to compare

@amaster97 amaster97 released this 23 May 10:30

v1.4.3 — Validation hardening + Range.diff + docs refresh (PATCH)

A bundled patch release combining an input-validation hardening pass on HUNLConfig, a small set-difference utility on Range, and a documentation refresh covering v1.4.x capabilities, CLI ergonomics gaps, and two-tier architecture honesty.

HUNLConfig input-validation hardening

HUNLConfig.__post_init__ now raises TypeError/ValueError early on malformed inputs (type-level guards on contribution tuple shape, scalar types for starting_stack/big_blind/small_blind/ante/initial_pot, bet-size-fraction containers, and bool rejected where int is expected). Loud failure at the Python/Rust boundary instead of silent corruption propagating to the Rust backend. Builds on v1.4.1's validation foundation (which caught negative and over-stack contributions). 28 new test cases in tests/test_hunl_config_validation.py cover the expanded guard surface.

Range.diff() set-difference utility

New Range.diff(other) method returns a new Range of combos in self not in other — directional, non-mutating set-difference. Equivalent to frequency-aware max(self.freq - other.freq, 0) under the current Range invariant (all stored frequencies are 1.0). Useful for computing range complements without rebuilding combo lists by hand. 8 new test cases in tests/test_range.py (22/22 passing) cover empty/self/superset/disjoint diffs, directionality, partial overlap, and non-mutation. Fractional-frequency exemplars remain pending the Range refactor.

USAGE.md + DEVELOPER.md refresh

USAGE.md updated for v1.4.x capabilities (node-locking, asymmetric contributions, the new Range.diff() utility), known CLI ergonomics gaps (pushfold subcommand, exploit-target positional convention, batch-solve CSV quoting), and observed performance cliffs (range-vs-range walk vs. Rust port). DEVELOPER.md adds two-tier (Python solve_hunl_postflop chance-enum-at-root vs. Rust solve_range_vs_range_rust vector-form CFR) honesty framing, action-abstraction notes (ActionAbstractionConfig defaults; library round-trip behavior including exploitability_history truncation), and operational guidance for contributors.

Honest framing

PATCH bump on the v1.4.x line: additive utility + input-validation hardening + docs refresh. No public API breakage, no behavior change to existing call sites, no Rust source changes. The v1.4.2 compiled binding is byte-identical for this release — users on v1.4.2 do not need to rebuild Rust to pick up v1.4.3. Smoke regression on test_range.py (22 tests) and test_hunl_config_validation.py (28 tests) all green. No new persona workflow structurally unblocked beyond v1.4.1; PR 27 enables the W2.2 categorical-leak-slice workflow at the set-membership level (utility-level convenience).

v1.4.2: Docs honesty + test marker

Choose a tag to compare

@amaster97 amaster97 released this 23 May 09:46

Type: PATCH (no API or behavior changes)

Fixes:

  • Docs honesty in range_aggregator.py: The hero_player parameter docstring and the module-level docstring both described P0 as making the "first postflop decision," which is backwards. In HUNL, slot 0 is the SB seat (button) — first to act PREFLOP, last to act POSTFLOP. Slot 1 is the BB seat — last to act PREFLOP, first to act POSTFLOP. Both docstrings now state the position semantics correctly and include a note on the BB-defending workflow (hero_player=1 AND hero_range=bb_range so BB cards land in the BB seat).

  • Slow-test marker: tests/test_river_parity_vs_brown.py::test_river_parity_vs_brown now carries @pytest.mark.slow so it is excluded from the default CI suite (which runs -m 'not slow') and only executes when developers explicitly opt in. Formalizes the opt-in posture that was already implicit in CI config.

Honest framing:

No API or behavior changes. Docs say what the code has always done; the slow-test marker formalizes existing opt-in posture. Safe drop-in upgrade from v1.4.1.

Smoke tests: 31/31 passed (test_dcfr_diff, test_exploit_diff, test_range_vs_range_aggregator).