summaryrefslogtreecommitdiff
path: root/src/lmr_testing/2026-10-03_LMR_REDESIGN.md
diff options
context:
space:
mode:
authorScott Gasch <[email protected]>2026-10-04 08:17:52 -0700
committerScott Gasch <[email protected]>2026-10-04 08:17:52 -0700
commitbbdf1bda8f410df6aa9be0a38e71b7db04982198 (patch)
treee8315247f700ee94875cd6d2eb5dd81aef71395d /src/lmr_testing/2026-10-03_LMR_REDESIGN.md
parent9b28080f8b3dca4588cb2aaaf748451f45e3c626 (diff)
Dynamic move-ordering/LMR session: honest FH tracking, decoupled admission/magnitude, bugfixes, tooling
Lands the 2026-09-28..10-03 move-ordering investigation (full history in lmr_testing/*.md). Summary of what ships: LMR/move-ordering redesign (searchsup.c, dynamic.c, data.c, chess.h): - GetLMRReduction's admission decision (do we reduce at all) is now decoupled from magnitude (how much): LMR_STATIC_ADMIT_THOUSANDTHS=500 gates admission via the 4D joint fail-high table (data.c) plus a countermove-match veto; magnitude is a flat Crafty/Ethereal-shaped log(depth)*log(moves) table (g_iLMRQuietReduction), which measured as good or better than the old per-tier joint-table payout while being structurally immune to the per-bucket miscalibration that sank the tiered approach. g_FailHighs honesty fix (real 0/0 init, not a fake prior) underlies the dynamic-danger signal. - Retired NumLeftoverMovesToSelect's depth-indexed selection budget -- evidence showed the leftover pool has real findable signal a bailout was discarding. - ShouldLatePrune (overt late-move pruning) implemented but disabled at its call site -- measured net-negative at the tested threshold; kept for reference and as a base for a future hang-bucket-9-targeted retry. - PRUNE_VERIFY, PRUNE_LIVE, DISABLE_LMR: opt-in diagnostic build flags (GNUmakefile), off in the standard profile. Bug fixes found and fixed while exercising this work: - JOINT_EXTRA_TIER_COUNT 4->6 (chess.h): fixed an out-of-bounds counter write reachable at remaining depth >= 14 (unreachable at sd10, routine in real search/self-play). - Node-type instrumentation (search.c, root.c, searchsup.c): fixed four compounding defects that produced impossible percentages (e.g. 106.2% of PV nodes) -- wrong denominator, qsearch misclassified as main search, untyped early-exit nodes, and fail-low tests using the in-place-raised alpha instead of the initial one. - FH-rate unit unification (search.c, searchsup.c, dynamic.c, chess.h): GetMoveFailHigh* returned per-mille while g_uJointFHThousandths is thousandths-of-a-percent, two different units sharing a "_THOUSANDTHS" suffix. Unified to one scale, renamed to "_PCT_THOUSANDTHS". RateBucket's band edges fixed to match. - QSearch option B (search.c): waives the opponent-stand-pat requirement at NODE_PV only (sound there since the exact score propagates; not sound at null-window nodes). iExtensionAmount unit fix (was 0/2 into a ONE_PLY-scaled field). pi->iEval now published when Eval() runs. - eval_tune/match_play.py local_ebf: was reporting 0.000 in every match since 2026-09-18 (regex only matched the trailing-stats parser, not the live move-scan loop; engines were also spawned with "nopost" so root.c never emitted the lines at all). True per-ply EBF is ~2.0, not the ~3.9 every prior match report implied. - eval_tune/test_vs_head.sh: positional-arg shift ordering bug caused every no-extra-args run to silently rebuild ./typhoon with a non-standard profile and, separately, could feed a flag's value in as the candidate binary path. - PERF_COUNTERS cleanup (eval.c, search.c): moved 3 leaked rdtsc sites to the existing EVAL_TIME guard, deleted two concluded one-off diagnostics (u64HeadStyle*, u64FHJointHist/RateBucket/AttemptsBucket) and their report plumbing, and re-gated a per-move hang-material estimate behind FH_TABLE_COLLECTION instead of running it unconditionally. Infrastructure: - match_play.py time-odds support (--odds/--st-head/--st-candidate), validated against the engine seat rather than color. - Wilson-upper-bound re-bake of the joint table (data.c): cells store a confidence-aware bound instead of a raw point estimate, so a consumer can threshold on the value alone; monotone, so every consumer became strictly more conservative. Verified behavior-neutral for LMR. Known-open items (not resolved here, see lmr_testing/GET_LMR_REDUCTION.md section 10 and 2026-10-03_UNCOMMITTED_TRIAGE.md): the self-play gate on this candidate is underpowered (+5 Elo, 95% CI roughly [-6, +17]); the curated-suite solve-count deltas used earlier in the investigation turned out to be noise-dominated cancellation when diffed per position, not the clean signal they first appeared to be. Co-Authored-By: Claude Sonnet 5 <[email protected]>
Diffstat (limited to 'src/lmr_testing/2026-10-03_LMR_REDESIGN.md')
-rw-r--r--src/lmr_testing/2026-10-03_LMR_REDESIGN.md172
1 files changed, 172 insertions, 0 deletions
diff --git a/src/lmr_testing/2026-10-03_LMR_REDESIGN.md b/src/lmr_testing/2026-10-03_LMR_REDESIGN.md
new file mode 100644
index 0000000..68ed068
--- /dev/null
+++ b/src/lmr_testing/2026-10-03_LMR_REDESIGN.md
@@ -0,0 +1,172 @@
+# Re-imagining the baseline LMR, 2026-10-03
+
+Question: the dumb flat `-1 ply on every leftover move` is load-bearing to
+~40-50 Elo and +1.17 ply (`GET_LMR_REDUCTION.md` 10.12a). Five attempts to
+improve on it have all measured as noise. If we rebuilt the baseline from
+scratch with what we now know, what would it be?
+
+Not looking for a miracle. Looking for ~5 Elo and some sound depth.
+
+---
+
+## The reframing that matters
+
+**Every one of the five attempts pushed in the same direction: trade accuracy
+for depth.** Graded magnitude, wider admission, bigger cuts, LMP, deep
+pruning -- all of them reduce or remove more search in exchange for a higher
+root iteration number.
+
+We now have that direction's exchange rate: **~8 Elo per marginal ply**
+(10.12b). It is a bad rate, and it is bad for a structural reason -- a
+reduction is a *transfer*, not new resource. It lowers the depth of the
+refutation lines (where the move choice is actually decided) to raise the
+depth of the PV. Time-bought plies, which raise the whole distribution, are
+worth ~95 Elo/ply.
+
+**Nobody has ever tried the other direction: trade depth for accuracy.**
+
+That is the gap. If accuracy converts better than depth -- and there is no
+reason to assume it converts at 8 Elo/ply, because it is not buying plies at
+all -- then the remaining few Elo are on that side of the ledger, not this one.
+
+---
+
+## What the five attempts actually taught us
+
+| lesson | evidence |
+|---|---|
+| **Coverage beats magnitude.** Breadth of admission moves total tree size far more than depth-of-cut per move. | section 3 (HEAD-style reduces 76% of decisions and beat the "honest" 62-66% design on node count); section 6 |
+| **Magnitude is capped by the depth DISTRIBUTION, not the formula.** 76% of the LMR population sits at remaining depth buckets 2-3, where `iMaxExtra = depth - 3` permits zero extra plies -- and where the `log(d)*log(m)` curve prescribes ~1 ply anyway, i.e. exactly flat. The curve's whole contribution is **mean 0.164 extra plies ~= +0.05 root ply**. | 10.8, 10.12c |
+| **Learned per-situation grading loses to a flat formula.** Table-driven tiers vs flat `log(d)*log(m)`: 0.224 vs 0.244 mean extra plies -- a wash, and the tier version broke with a re-search-rate inversion when widened. | section 8, 10.8 |
+| **Per-move dynamic signals cannot identify the dangerous moves.** 11 of 23 verified low-band prune misses sit at own-FH-rate **exactly 0** -- the per-move history agreed with the static table and both were wrong. A veto catching the rest would refuse ~49,000 sound prunes per miss prevented. | 10.12h |
+| **`eNodeType` is uninformative here.** Full-width ALL nodes fail HIGH 69-71% of the time. The structural label is close to meaningless. | 10.12f, 10.12d |
+| **Move ordering is the real sophistication, and it is already good.** FMBC 90-95%; `r ~= 2.17`. By the time a move is in the leftover pool the generator has already triaged it -- the table is re-triaging an already-triaged population. | 10.11a, 10.12c |
+| **Only fail-HIGHS are verified.** The re-search path fires on `iScore >= iBeta`. A reduced search that fails low is accepted silently, forever. | search.c move loop |
+
+That last row is the one nobody has acted on.
+
+---
+
+## The redesign
+
+### A. Keep (this is where the 40 Elo lives)
+- **Broad admission.** ~93% of eligible decisions get reduced. Five attempts
+ to be cleverer about *which* moves all failed.
+- **One magnitude formula, `log(depth)*log(moves)`.** Already present
+ (`g_iLMRQuietReduction`, dynamic.c, built by `InitLMRTable`). Keep it as the
+ single source of magnitude. No table, no tiers.
+- **The one-full-width-ply floor.** `JOINT_EXTRA_MIN_REMAINING_DEPTH = ONE_PLY`
+ guarantees a reduced move still gets a real node before qsearch. Stockfish
+ keeps the identical invariant (`search.cpp:1370`, `std::max(1, ...)`) and
+ gets its leanness from separate explicit pruning. Twice falsified as a
+ target (10.9a).
+- **Killer-adjacency vetoes** (this ply's killers, ply-3's). Cheap, exact,
+ principled -- a specific move at a specific ply, not an aggregate rate.
+
+### B. ADD: verify fail-lows near the margin <-- the actual proposal
+The only unverified error direction, attacked with the search's own evidence
+rather than a static prior.
+
+Today, in `search.c`'s move loop:
+```c
+iScore = -Search(ctx, -iAlpha - 1, -iAlpha, uNextDepth); /* reduced */
+if ((iExtend < 0) && (iScore >= iBeta)) { ...re-search full depth... }
+```
+At a null window `iBeta == iAlpha + 1`, so that branch means "the reduced
+search beat alpha". A reduced search that returns `iScore <= iAlpha` is taken
+at face value. Proposed addition:
+
+```c
+else if ((iExtend < 0) && (iScore > iAlpha - LMR_NEAR_MISS_MARGIN))
+{
+ /* Reduced search failed low, but only just. The reduced search is the
+ weakest evidence in the tree and this is the one direction the
+ existing machinery never checks. Re-search at full depth. */
+ uNextDepth -= iExtend;
+ pi->iExtensionAmount = 0; /* defuse the null-move bailout */
+ iScore = -Search(ctx, -iAlpha - 1, -iAlpha, uNextDepth);
+}
+```
+
+Why this is different from everything tried:
+- **Sound by construction.** It only ever ADDS search. It cannot introduce a
+ wrong answer; the worst case is wasted nodes. Every previous attempt risked
+ correctness to buy depth.
+- **It trades depth for accuracy** -- the untried direction. It will cost
+ nominal plies (marginal plies are worth ~8 Elo, so that is a cheap thing to
+ spend) in exchange for catching refutations the reduction hid.
+- **Self-measuring, and falsifiable in one suite run.** Instrument
+ `near_miss_researches` and `near_miss_flips` (how many came back
+ `>= iBeta`). If the flip rate is ~0, the idea is dead immediately and
+ cost nothing. If it is a few percent, those are refutations currently being
+ missed, and the margin can be swept.
+- **It is targeted.** A move returning `alpha - 5` nearly made it; one
+ returning `alpha - 300` did not. Score proximity is real evidence, unlike
+ the cell averages the 4D table offers.
+
+Sweep `LMR_NEAR_MISS_MARGIN` over 0 / 10 / 25 / 50 / 100 cp. Measure flip rate
+and node cost per margin before any self-play.
+
+### C. DELETE (each is unmeasured or measured-as-noise, and each costs real
+### work per decision in a SHIPPING build)
+- **The 4D table admission gate.** `LMR_STATIC_ADMIT_PCT_THOUSANDTHS` vetoes
+ 509 of 21,780 cells -- 2.3% of cell space, 7.4% of traffic -- and the
+ threshold was chosen in section 6 on solve-count deltas 10.6 showed were
+ noise. Removing it also removes `EstimateHangingMaterialCp` plus four bucket
+ functions plus a table lookup *per eligible move*, which is the same class of
+ per-move cost the PERF_COUNTERS cleanup just went after. (Hold if EFP ends up
+ needing the table -- see 10.13 item 1.)
+- **`fDynamicDanger`'s extras gate.** Fires on 27.4% of decisions and only
+ zeroes the extra plies -- which 85% of reduced moves never get anyway. The
+ section 1 "honesty fix" that motivated the whole 2026-10-02 session is
+ therefore nearly inert in shipped behaviour.
+- **The bare countermove-match veto.** Unconditional, unmeasured, and
+ generate.c's own use of a countermove match is evidence-GATED precisely
+ because a bare match was shown not to carry signal.
+- **`JOINT_EXTRA_TIER1/2/3_PCT_THOUSANDTHS`** -- vestigial (TIER1 in comments
+ only, TIER3 only in the disabled LMP path).
+
+### D. CONSIDER: widen coverage past the leftover pool
+`fPruneReduceEligible` requires `fIsLeftoverMove` (sub-`GOOD_MOVE`). Crafty and
+Stockfish reduce **all** quiet moves past a move-count threshold, including ones
+the generator scored into a higher tier. Coverage is the single lever with
+replicated evidence behind it (section 3, section 6, 10.2), and this is the
+largest untapped slice.
+
+But: it buys marginal plies, so expect ~8 Elo/ply and bound expectations
+accordingly. Do it *after* B, because B makes additional coverage safer -- the
+verification catches the extra mistakes wider coverage would make.
+
+### E. DO NOT (all falsified; reasons in 10.13 item 8)
+Per-situation magnitude grading; `eNodeType` modulation; clamp removal /
+reducing into qsearch; dynamic per-move vetoes; chasing bigger cuts.
+
+---
+
+## What this adds up to
+
+A baseline LMR that is **simpler** than today's (no 4D table, no dynamic
+extras gate, no countermove veto, one magnitude formula) and has **one new
+mechanism** that is sound by construction and pushes in the only untried
+direction.
+
+Honest expectation: the deletions are roughly Elo-neutral and buy a little
+nps; the near-miss verification is the only part with real upside, and its
+upside is unknown rather than bounded at 8 Elo/ply, because it is not buying
+plies. If its flip rate comes back near zero it dies in one suite run, which
+is the cheapest possible failure mode.
+
+That is the whole pitch: stop buying depth at 8 Elo/ply, spend some of it back
+on the one error the search never checks.
+
+## Order of work
+1. Instrument the near-miss flip rate ONLY (no behaviour change): count how
+ many reduced fail-lows land within each margin band, and -- behind a
+ verify flag -- how many of those flip to `>= iBeta` at full depth. One
+ suite run answers whether B is worth building.
+2. If the flip rate is material, implement B and sweep the margin on node
+ count + `sn` suites, then self-play with `elo0=0 elo1=15`.
+3. Do the deletions in C as a separate commit, measured on nps (idle box,
+ round-robin arms) rather than on Elo -- they are a complexity/speed play,
+ not a strength play.
+4. D last, and only if B worked.