summaryrefslogtreecommitdiff
path: root/src/docs/NNUE.md
diff options
context:
space:
mode:
authorScott Gasch <[email protected]>2026-10-20 18:37:20 -0700
committerScott Gasch <[email protected]>2026-10-20 18:37:20 -0700
commit9cf14e14e6999420f234cda951894337e034f57c (patch)
treed4845d0cf127ef4226a5cd2e60472ee750213614 /src/docs/NNUE.md
parentc8c6bbc2c1a103e5b07b079a0f8bdf66c7724d80 (diff)
NNUE: re-fit raw-to-score calibration, NNU4 weight format, super-lazy default-on, lazy probe measured net-negative
Fits an affine (a + b*raw) correction into every exported .nnue file (export_nnue_weights.py's CalibrateScoreMapping) so the engine's consumed centipawn output tracks Stockfish's real score scale rather than the net's nominal raw WDL-sigmoid output -- training variance alone shifts this mapping across retrains even with an identical recipe, so it's re-fit and baked in per export rather than assumed constant. Weight format bumped to "NNU4" to carry the two new floats; older NNU1/2/3 files are no longer readable. Super-lazy (material-only) pre-filter promoted to default-on (g_fUseNnueSuperLazy = TRUE) with a recalibrated per-material-bucket margin table (measured against the recalibrated net, 16-shard cross_sectional_200k.ep_ sample) -- real, measured nps win across all three curated suites. The separate lazy-probe gate (accumulator-based early exit) is measured net-negative on real search traffic at this net's probe-vs-forward latency ratio and stays off by default (g_fUseNnueLazyProbe = FALSE); code kept live since margin tuning could still flip this, per docs/NNUE.md. Also: direct per-call latency instrumentation for NnueProbeScore/ NnueForward (root.c, EVAL_TIME build) to make the lazy-probe breakeven math measurable directly instead of inferred; QSearch futility margins given real-data-derived NNUE_FULL/NNUE_LAZY starting points. Co-Authored-By: Claude Sonnet 5 <[email protected]>
Diffstat (limited to 'src/docs/NNUE.md')
-rw-r--r--src/docs/NNUE.md407
1 files changed, 407 insertions, 0 deletions
diff --git a/src/docs/NNUE.md b/src/docs/NNUE.md
index dbc5d54..d1481c6 100644
--- a/src/docs/NNUE.md
+++ b/src/docs/NNUE.md
@@ -2690,3 +2690,410 @@ adding a new orchestrator script.
architecture or export-format change -- so there's no reason to
gate it behind the factorization/augmentation experiments above;
can be added and swept independently.
+
+## Status (2026-10-20): losing-position score compression fixed (K
+## recalibration), per-generation score calibration automated, lazy
+## probe refit wired into the pipeline, margin-safety recalibrated
+## against real search traffic, LOP-based promotion gate added
+
+Prompted by a direct observation: the production net's score in
+clearly-losing positions barely distinguished "lost" (-2) from "about
+to be mated" (-3) -- no meaningful magnitude gradation at the losing
+extreme, despite real Stockfish scores in the same game ranging to
+-13 or worse just before the actual mate.
+
+**Root cause, confirmed analytically and empirically**: `raw` (the
+net's pre-sigmoid scalar) is only ever supervised through
+`sigmoid(raw/K)` against a WDL-probability target -- never regressed
+against a centipawn/pawn target directly. That sigmoid saturates hard
+once a position is clearly decided; with this corpus's originally
+fitted `K=2.0659`, `sigmoid(8/K)=0.9796` vs `sigmoid(13/K)=0.9982` --
+a target spread of under 0.02 for a 5-pawn difference in the real
+score, leaving almost no training gradient to differentiate them. No
+value of `lambda` (the `p_search`/`r_outcome` blend) fixes this, since
+both blend candidates are already post-sigmoid probabilities.
+
+**What did NOT fix it, tried in order, each measured at a medium
+scale (500k positions) before committing further**:
+1. An auxiliary `score_probe` head (nnue_model.py, same mechanism as
+ `material_probe` -- a separate `Linear` on the shared accumulator,
+ trained with its own MSE loss against the real Stockfish score).
+ Modest, inconsistent improvement -- its gradient only ever reaches
+ the *shared embedding tables*, never `fc1`/`fc2`/`fc3`, so nothing
+ forces the actually-exported path to widen its range.
+2. A direct `MSE(raw, clipped_score)` term added to the main loss
+ (replacing `score_probe` -- puts gradient straight on `fc1-3`).
+ Also modest/inconsistent across weights (0.001, 0.005) and loss
+ shapes (plain MSE vs. Huber) -- sometimes better on the winning
+ side, not reliably better on the losing side that actually
+ prompted this investigation.
+3. **The actual fix: widen K itself.** Measured `raw` vs. real score
+ at two K values (2.07, 5.0) on the same held-out sample; the ratio
+ `slope/K` came out nearly identical at both points (0.3577 vs.
+ 0.3574) -- strong confirmation that `raw` scales linearly with K
+ as theory predicts, letting the right K be solved for directly:
+ `K* = 1/0.3575 ≈ 2.8`. Confirmed by actually training at K=2.8:
+ aggregate slope landed at 1.0005 (vs. 0.74 at the original fit),
+ with per-bucket magnitude tracking the real score far more closely
+ across the whole severity range, no overshoot the way K=5.0
+ produced. Cross-checked against Stockfish's own documented NNUE
+ training write-up (`scaling_factor≈410cp=4.1 pawns`) -- our
+ corpus's own re-fit (2.8) came in lower, a reminder that this
+ project's corpus-specific fit isn't automatically wrong just for
+ disagreeing with a different engine's number, but also isn't
+ assumed right just for being "ours."
+4. Also adopted: `--loss-exponent 2.5` (Stockfish's own documented
+ practice, already wired as `powered_loss()`'s `--loss-exponent`,
+ just not previously used) -- `|error|^2.5` instead of plain MSE in
+ WDL-probability space, which shrinks every error (since errors are
+ <1) but shrinks small ones proportionally more, concentrating
+ gradient on the worst-wrong predictions. Small, not dramatic,
+ improvement on top of the K fix; adopted since it's free.
+
+**Real residual tradeoff, not a flawless fix**: after K=2.8/exponent
+2.5, aggregate calibration is essentially exact (slope ~1.0), but
+there's genuine non-linearity left (overshoot at the extremes,
+undershoot mid-range) and per-position noise went up slightly (mean
+abs error 1.78 vs. 1.57 pawns on the full-scale retrain's held-out
+sample) -- widening K to fix systematic compression also amplifies
+whatever per-position imprecision the net already had, since the same
+logit-space noise now maps to a bigger pawn-space error. Not treated
+as disqualifying, but recorded so a future reader doesn't expect this
+to be perfect.
+
+**Keeping this stable across future retrains -- the real answer isn't
+"just reuse the same K"**: tested directly, holding K fixed within one
+run still left measurable residual non-linearity/noise -- a fixed
+*recipe* (K/lambda/loss-exponent) is not the same as a fixed *output
+scale*, since training variance (data, convergence) shifts the
+empirical raw-to-score mapping even with an identical recipe. Built
+instead:
+- `export_nnue_weights.py`'s new `CalibrateScoreMapping`: fits a fresh
+ affine correction `(a, b)` via OLS, `real_score ≈ a + b*raw`,
+ against the HOLDOUT tensor dir (not train -- an honest fit needs
+ positions the checkpoint didn't see), every single export. Baked
+ into a new file format version, `NNU4` (magic bump from `NNU3`):
+ two extra float32 fields, `CALIB_A`/`CALIB_B`, read by
+ `nnueeval.c`'s `NnueLoadWeights` and applied in both `NnueForward`
+ (`(CALIB_A + CALIB_B*fOut) * 100.0f`, replacing the old bare
+ `fOut * 100.0f`) and `NnueProbeScore` (same correction, since that
+ function's own doc comment promises it stays directly comparable to
+ `NnueForward`). This decouples engine search-tuning from needing the
+ raw net's internal scale to stay perfectly constant forever -- the
+ centipawn numbers search actually consumes stay calibrated by
+ construction, regenerated fresh every generation, regardless of how
+ much the underlying raw net drifts.
+- Target for this fit is deliberately Stockfish's own score (the same
+ ground truth used throughout this project), not game outcome and
+ not HCE's scale -- outcome is noisier per-position by design
+ (lambda's whole point), and matching HCE's scale would just
+ reintroduce a version of the original compression problem in a
+ different flavor.
+- **Real, previously-undiscovered bug found and fixed in the same
+ pass**: `run_nnue_pipeline.py` never threaded `--k` through to
+ `nnue_train.py` at all -- every prior pipeline-driven production run
+ (`sf_20261019`, `sf_20261020`, `sf98k`/the then-current production
+ net) silently trained its loss against `nnue_train.py`'s own
+ `DEFAULT_K=1.3123` (a Phase-A placeholder), completely independent
+ of whatever K `fit_k_and_label.py` actually fit for that corpus's
+ own `p_search` labels. `--k` is now a required argument to
+ `run_nnue_pipeline.py` (no silent default) specifically so this
+ can't happen again unnoticed.
+
+**Lazy-eval probe now refit automatically too**: new
+`annotate_pgns/fit_lazy_probe.py` (ridge regression on the trained
+net's own accumulator, targeting that same checkpoint's raw
+`forward()` output pre-calibration, matching `NnueProbeScore`'s own
+convention) wired into `run_nnue_pipeline.py` as stage 8, run
+unconditionally after export -- every retrain now produces a fresh,
+matching probe file alongside the `.nnue` weights, written to
+`<outfile stem>_probe.bin`. The real search-node-traffic margin-safety
+calibration (next item) is deliberately **not** automated the same
+way -- too expensive to justify every generation, and not yet
+established that it needs to be redone every generation either (see
+below).
+
+**Full retrain done with the fixed recipe**: pooled all three
+Stockfish self-play PGN files (`stockfish_self_play_training_data{,.0,
+.1}.pgn`, ~120k games total), `lambda=0.99`, `K=2.8`,
+`loss-exponent=2.5`, `score-loss-weight=0` (the score_probe/direct-term
+experiments above were abandoned in favor of the K fix). 10,484,835
+training positions, 1,163,529 held out. Early-stopped at epoch 20 (5
+epochs past the best, epoch 15, per `--patience 5`), `holdout_mse=
+0.00457`. Output: `annotate_pgns/runs/sf_20261020_recal/
+nnue_weights.nnue` (+ `nnue_weights_probe.bin`).
+
+**Margin-safety calibration re-run for real** (not the offline-sample
+proxy that was all that existed before): 16 parallel shards of 12,500
+positions each (200,000 total) from `tests/cross_sectional_200k.ep_`
+at `sd 6` -- same protocol as the original 2026-10-15 calibration,
+re-run because that one was fit against an earlier net generation,
+before this recalibration widened the net's raw output scale; the old
+margins (`{180, 200, 190, 180, 170, 160, 150, 150}`) would have been
+unsafe (too tight) against the new, wider-scale net's real swings.
+New `iNnueLazyMarginByMatBucket` (p98 per material bucket, ~100-435M
+real search-node swing samples per bucket): `{300, 320, 410, 640, 770,
+770, 540, 230}`, now live in `eval.c`. New tooling:
+`annotate_pgns/aggregate_marginsafety.py` sums the raw per-shard
+histograms (`eval.c`'s `PersistMarginSafetyHistogramsToFile` format)
+bucket-and-bin-wise and computes exact combined percentiles -- shard
+percentiles don't combine validly on their own, only the raw counts
+do.
+- **Side finding**: NNUE super-lazy (material-only, no net at all)
+ measured much more competitive than the earlier "too wild to use"
+ verdict suggested -- p98 `{420, 450, 520, 730, 860, 850, 640, 290}`,
+ consistently ~30-40% looser than the real probe but the same order
+ of magnitude, not wildly worse. Likely explanation: the earlier
+ verdict was measured against the *old, uncalibrated* `NnueForward`
+ output, so comparing a stable-scale quantity (material) against a
+ compressed/wrong-scale one (the old net) spuriously inflated the
+ apparent disagreement. Not re-enabled by default -- `g_fUseNnueLazyProbe`
+ and `g_fUseNnueSuperLazy` both remain opt-in flags; flipping either
+ on by default is a strength/speed tradeoff decision that needs its
+ own match-based validation, not something to bundle into a
+ calibration-table update.
+- **Open question, deliberately not resolved**: whether this expensive
+ live-search-traffic pass needs re-running every future generation,
+ or whether a cheap offline-residual proxy (same mechanism as
+ `CalibrateScoreMapping`, just also recording percentiles) tracks it
+ closely enough to trust between occasional live re-checks. Not yet
+ tested head-to-head; treat the live pass as the trusted source until
+ that comparison is actually done.
+
+**Validation: real, independent movement on the LOP benchmark**
+(`tests/lop/` -- eval/move-agreement against a real Stockfish reference
+on a fixed 10,000-position set, much cheaper than a match -- see that
+section's own history below). Prior checkpoints (pre-recalibration):
+87.089%/87.076% eval correlation, 46.800% move correlation --
+statistically indistinguishable from classic HCE's own 87.064%/46.830%
+on the same benchmark. This recalibrated net: **90.319% eval
+correlation, 48.550% move correlation** -- the first real movement on
+this benchmark all session, and the basis on which this net was
+promoted to production (see below).
+
+**Status correction on match results -- the "has not won a single
+head-to-head match" note elsewhere in this document is now stale**:
+against the *pre-recalibration* net, a real `sd 10`-matched self-play
+run (`--sd 10`, isolating "is the net smarter per node" from the nps
+handicap) produced LLR=+2.28 against an H1 bound of +2.94, score 0.635
+over 360 games (`-72 =119 +169`), climbing every update, halted early
+as conclusive. NNUE now soundly beats classic HCE at matched search
+depth -- this was not true when the adoption-criterion section below
+was written. At matched real time (`--st 4`), the same
+pre-recalibration net was close to even (score 0.447, LLR -0.37,
+inconclusive, consistent with the known ~3.3x nps gap and
+not-yet-recalibrated search margins costing it ~3 plies of depth) --
+**the recalibrated net in this status update has not been st4-tested
+yet**; that is the planned next step, separate from this work.
+
+**Promotion policy changed, 2026-10-20**: `head_reference/nnue/
+promote.sh` now accepts LOP evidence (`--lop-baseline-log`/
+`--lop-candidate-log`, parsing `compare_lop`'s own stdout) as an
+alternative to a match log, gated on the candidate's eval correlation
+strictly exceeding the baseline's (no `--allow-negative` equivalent --
+if LOP doesn't even show improvement, it isn't evidence of anything).
+Decision, not just convenience: requiring a full self-play match for
+every incremental retrain (more data, same recipe) is impractical --
+a genuine few-Elo improvement is often not reliably resolvable in a
+match without a very large game count, and this project's own history
+(ECM solve-rate improving while early NNUE nets still lost every real
+match, see below) already shows a cheap static benchmark isn't
+*proof* of match strength either -- match evidence has its own noise
+problems at this scale, it isn't pure ground truth being traded away
+for convenience. LOP is accepted as the *routine* gate specifically
+because requiring a match every time is the impractical alternative,
+not because LOP has been shown to track match strength reliably yet.
+An occasional real match is still recommended periodically to confirm
+LOP-based promotions haven't quietly drifted away from actual playing
+strength -- this recalibrated net was itself promoted on LOP evidence
+alone (see `head_reference/nnue/current/metadata.json`), with a real
+match against the previous baseline still outstanding.
+
+## Status (2026-10-20, continued): lazy probe measured net-negative on
+## real search traffic, super-lazy confirmed a clean win and now
+## defaults on
+
+Follow-up to the "Engine calibration checklist" section above, picking
+up item 1's lazy-eval fast path -- the goal was to actually turn the
+two gates on (both were built and opt-in per the recalibration section
+just above) and measure real before/after numbers, not just trust the
+earlier margin-safety calibration as sufficient justification on its
+own.
+
+**Methodology**: `ecm_ringers`/`ecm_confident_quick`/`ecm_hard_quick`
+(the three curated suites), each at both `sd 10` (fixed depth) and
+`sn 5000000` (fixed node budget), run as a clean baseline (gates off),
+then with only the lazy probe on, then with only super-lazy on, all
+against the same `eval.nnue` weights -- never mixed/parallel-vs-
+sequential in a way that could introduce load-noise into the
+comparison (confirmed box load ~4.3 on a lightly-loaded multi-core
+machine throughout; `sd`/`sn` hold depth/node-budget fixed by
+construction regardless of load, so nps and solve-count differences
+here are real, not noise).
+
+**Baseline** (gates off): 108/191 solved across all three suites at
+`sd 10` (7/11, 73/90, 27/90), 117/191 at `sn 5000000` (8/11, 78/90,
+31/90); nps 479k-539k depending on suite/mode.
+
+**Lazy probe alone, at the real p98 margin table (`{300, 320, 410,
+640, 770, 770, 540, 230}` by material bucket, from the real
+`cross_sectional_200k.ep_`/16-shard run)**: a clear net LOSS --
+**nps down 3-7% in every configuration**, and fewer solves in 4 of 6
+configs (ringers 6/11 and 7/11 vs baseline's 7/11 and 8/11;
+confident_quick 70/90 flat/down; hard_quick 24/90 and 28/90 vs 27/90
+and 31/90). Not explained by the probe being expensive -- see the
+latency measurement below.
+
+**Direct per-call latency measurement** (new instrumentation, piled
+into the existing `EVAL_TIME` build profile rather than a new flag --
+`TIMED_EVAL_CALL` wraps the `NnueProbeScore()`/`NnueForward()` call
+sites in `eval.c`'s production NNUE branch, accumulating into two new
+always-present-but-only-`EVAL_TIME`-populated `COUNTERS.tree` fields,
+`u64CyclesNnueProbeCall`/`u64CyclesNnueForwardCall`, plus a new
+`u64NnueProbeAttempts` counter -- unlike `u64LazyEvals`, which only
+counts *hits*, this counts every attempt, hit or miss, since the gate
+pays its cost on every attempt and only recoups it on a hit).
+Reported in `root.c` right after the (NNUE-irrelevant, classic-HCE-
+only) `LAZY_EVAL` exit-breakdown block, with the measurement's own
+~38-cycle rdtsc-pair overhead printed alongside rather than silently
+subtracted, so the raw numbers can be sanity-checked against it:
+
+```
+NnueProbeScore: ~272 cyc/call (net ~235 after overhead)
+NnueForward: ~4100 cyc/call (net ~4060 after overhead)
+```
+
+**A clean 15:1 ratio, extremely consistent across all 11 `ecm_ringers`
+positions (272±3 cyc every time)** -- the probe itself is cheap and
+behaving exactly as designed. Implied breakeven hit rate: `272/4100 ≈
+6.6%`. The docs' earlier 2026-10-16 measurement (before the K/lambda
+rescale) claimed a 14-23% real hit rate, comfortably above breakeven.
+
+**But the real hit rate on this suite, at the current (post-rescale)
+p98 table, is ~1.0% overall** (71,022 hits / 7,100,023 attempts on
+`ecm_ringers`, per-position rates ranging 0.03%-6.98%) -- an order of
+magnitude under breakeven. Expected per-call cost with the gate on:
+`272 + 0.99*4100 ≈ 4331` cyc vs. `4100` cyc with it off -- a ~5.6%
+per-call slowdown, matching the measured nps regression almost
+exactly. **Root cause, not just a symptom**: the p98 margin table is
+calibrated purely for *safety* (bounding `|NnueForward() -
+NnueProbeScore()|` error to the 98th percentile), a completely
+different statistic from *how close typical nodes sit to their own
+alpha/beta window*, which is what actually determines hit rate. A
+margin can be a perfectly correct p98 safety bound and still almost
+never let the gate fire if real windows at that material bucket are
+typically narrower than the error the probe needs room for -- which
+is apparently true for most of this table's buckets (only bucket 7's
+230 happens to land in useful territory; the large buckets, 640-770,
+are far out past where any real window would resolve a cutoff).
+
+**Empirical margin-vs-hit-rate sweep** (flat override across all
+buckets via `nnue lazymargin <cp>`, `ecm_ringers`/`sd 10` only -- fast
+enough to run live rather than needing a real calibration pass):
+
+| flat margin | hit rate | solved/11 | nps |
+|---|---|---|---|
+| 770 | 0.00% | 6 | 509k |
+| 400 | 1.80% | 6 | 510k |
+| 230 | 16.0% | 6 | 538k |
+| 150 | 34.9% | 5 | 582k |
+| 100 | 45.0% | 4 | 632k |
+| 70 | 61.6% | 4 | 696k |
+| 50 | 67.9% | 3 | 775k |
+| 30 | 78.9% | 2 | 896k |
+| 15 | 87.9% | 2 | 1024k |
+
+Smooth tradeoff curve, no cliff -- the knee sits around margin≈200-230,
+where nps is clearly improving (538k, a real win over baseline) with
+no solve cost yet; solves start falling steadily from 150 down. This
+is genuine evidence a per-bucket table *could* be built to win cleanly
+-- it just isn't the p98 table, which was never tuned for this
+objective at all.
+
+**p95 recalibration attempt -- inconclusive, not a real retest**:
+tried to avoid a full hour+ `cross_sectional_200k.ep_` re-collection by
+recomputing percentiles from already-persisted histogram data. Found
+`eval.c`'s `PersistMarginSafetyHistogramsToFile` writes to
+`/var/tmp/typhoon/marginsafety_raw_histograms.txt` by default, and
+`annotate_pgns/aggregate_marginsafety.py` keeps the full binned
+histogram (not just the percentiles it prints), so an arbitrary
+percentile is cheap to recompute *if the real per-shard files still
+exist*. **They don't** -- only one small leftover file was found
+(~1.9M total `nnue_lazy` samples across buckets vs. the real run's
+~100-435M/bucket; almost certainly one shard or an early smoke test,
+not the aggregated dataset), and a project-wide `find -name "*hist*"`
+turned up nothing else NNUE-related (one old, unrelated classic-HCE
+residual-calibration file from a `guess2_30k` run). Computed p95 from
+this small/wrong-provenance file anyway as a fast, low-confidence
+stand-in (`{180, 240, 520, 530, 670, 720, 480, 310}` by bucket) and
+re-ran all three suites, both depths, with the lazy probe on this
+table. **Result was noisier than the p98 run, not clearly better**:
+some solve counts recovered (ringers back to 8/11 at `sn5M`,
+hard_quick up to 26/90 and 33/90), but nps actually *dropped* in
+several configs relative to p98 (`sn5M` confident_quick 504k vs p98's
+537k; hard_quick 483k vs 512k) -- backwards from what a looser margin
+should do, strongly suggesting the small sample's per-bucket numbers
+don't reliably reflect the real distribution. **Not trusted as a real
+retune** -- a legitimate hit-rate-targeted per-bucket calibration
+still needs the full `cross_sectional_200k.ep_` collection (or
+equivalent), this time explicitly measuring node-level alpha/beta
+proximity (what determines hit rate), not just probe-vs-forward error
+(what the existing harness measures, which only bounds safety).
+
+**Super-lazy alone, at the real p98 margin table (newly built this
+session -- previously a single flat runtime override with no
+per-bucket fit at all, `g_iNnueSuperLazyMargin`)**: measured a clean
+win across the board. `sd 10`: ringers 5/11 (down from 7, but ringers
+is only 11 positions -- noisy), confident_quick 70/90 (down from 73,
+flat vs lazy's own 70), hard_quick 26/90 (close to baseline's 27);
+nps 478k-504k, roughly matching or beating baseline (479k-502k).
+`sn 5000000`: ringers 6/11 (down from 8), confident_quick **79/90**
+(up from 78), hard_quick **33/90** (up from 31); nps **540k-601k**,
+a genuine 1-12% win over baseline (514k-539k) in every suite. Net
+read: real nps win, solve-count effect mixed but mostly neutral-to-
+positive outside the small `ecm_ringers` suite (where single-position
+flips swing the ratio a lot) -- per the user's own judgment, ECM
+solve-count noise at this suite size isn't treated as a correctness
+signal worth chasing into `tests/ECM.md` lookups; the nps win is the
+real, load-bearing result.
+
+**Same-bucketing confirmed**: super-lazy's own margin-safety
+calibration (`RecordNnueSuperLazyMarginSafetySwing`,
+`g_uNnueSuperLazyMarginSwingMax[BASE_MARGIN_MAT_BUCKETS]` etc.) uses
+the identical `_MaterialBucket()` (8 buckets, combined-army-scaler
+width 8) as the lazy probe's own table.
+
+**No bucket excluded outright** (unlike classic HCE's own super-lazy,
+which structurally excludes material bucket 0 -- bare-king/pawn-
+dominated endgames -- per a specific *exceeded-rate* finding, 6.8%
+blow-through from pawn-structure blindness, since HCE's super-lazy
+runs before `_EvalPawns` entirely). NNUE's measured p98 swing by
+bucket, `{420, 450, 520, 730, 860, 850, 640, 290}`, has bucket 0 as
+the *second-lowest* value, not an outlier -- nothing in the data says
+to disable it. Caveat: no exceeded-rate-specific check (as opposed to
+plain p98 magnitude) has been run for NNUE's super-lazy the way it was
+for HCE's, so this is "nothing visible says disable a bucket," not
+"confirmed safe at every bucket."
+
+**Decision**: lazy probe gate code kept live (cheap to leave dormant,
+and the latency math + margin sweep both say a real win is plausible
+with the right margins -- just not demonstrated yet), but
+`g_fUseNnueLazyProbe` stays `FALSE` by default (`nnueeval.c`), margin
+table reverted to the real p98 values (the p95 guess is preserved only
+as a commented-out, documented-as-unreliable alternative). Super-lazy
+promoted to default-on (`g_fUseNnueSuperLazy = TRUE`, `nnueeval.c`),
+using its own new per-bucket `iNnueSuperLazyMarginByMatBucket` table
+(`eval.c`) rather than the old flat-only override, which remains
+available via `g_iNnueSuperLazyMargin >= 0` for experimentation. Both
+gates only take effect when `g_fUseNnueEval` is also on.
+
+**Follow-up, not yet done**: a real hit-rate-targeted per-bucket lazy
+probe retune, against ordinary (non-adversarial-tactical) search
+traffic such as `twic_sample.ep_` rather than ECM -- the current
+margin-vs-hit-rate sweep and the docs' original 14-23%-hit-rate/
+10.7%-nps-win measurement were on different corpora, and ECM's
+narrower forcing-line alpha/beta windows may themselves suppress hit
+rate in a way that doesn't generalize to normal play. Also worth
+measuring: an exceeded-rate check for NNUE super-lazy's bucket 0,
+matching the specific diagnostic that led HCE to exclude it, rather
+than relying on p98 magnitude alone.