# Uncommitted-change triage, 2026-10-03 EOD `git status` shows 15 modified files, **5,313 insertions / 194 deletions**. That spans several sessions, not just today (date markers in the added lines: 33 from 2026-10-03, 24 from 10-02, 21 from 09-28, plus 09-29/09-30/10-01). This file sorts it into KEEP / DECIDE / DISCARD so a commit can be assembled without re-deriving the reasoning. Nothing here is committed. Evidence for every claim is in `GET_LMR_REDUCTION.md` section 10 and `SPEC.md`. --- ## KEEP -- unambiguous, no open questions ### Bug fixes (all found by exercising code, all verified) | what | file | why it is solid | |---|---|---| | `JOINT_EXTRA_TIER_COUNT` 4 -> 6 | chess.h | `JOINT_EXTRA_MAX_PLIES` is 5, so `*puExtraTier` could be 4 or 5 and both consumers indexed 4-element arrays out of bounds -- into `u64JointExtraResearch[0]` and into `u64LMPPruned`. Unreachable at sd10 (needs remaining depth >= 14), live in any real search. Counters-only, but it corrupted the exact calibration diagnostic candidates get judged on. 10.4 | | Node-type surprise line: 4 defects | search.c, root.c, searchsup.c | (1) denominator was total nodes not per-type; (2) qsearch included, ~84% of the tree, so a stand-pat cutoff read as "ALL failed high"; (3) `CommonSearchInit` early exits reached the counters un-typed and scored against a stale `eNodeType` -- produced an impossible "106.2% of PV nodes"; (4) fail-low test used the IN-PLACE-RAISED `iAlpha`, so every successful alpha-raise counted as a fail-low (PV row read 99.1%). Now uses `iInitialAlpha`. 10.12f | | FH-rate unit unification | search.c, searchsup.c, dynamic.c, chess.h | `GetMoveFailHigh*` returned per-mille while `g_uJointFHThousandths` is thousandths-of-a-PERCENT -- 100x apart, same `_THOUSANDTHS` suffix. `ShouldLatePrune` held `LMP_DYNAMIC_MAX_THOUSANDTHS (1)` meaning 0.1% four lines from `LMP_STATIC_MAX_THOUSANDTHS (1)` meaning 0.001%. Every caller was individually correct; the naming made that luck. Converted the whole dynamic family to the table's unit, renamed to `..._PCT_THOUSANDTHS`. 10.8 | | `RateBucket` band edges x100 | searchsup.c | Collateral of the unit change: edges were per-mille, so every rate >= 0.1% collapsed into the top bucket and five middle bands read zero. Caught because the histogram printed only its first and last columns. | | `local_ebf` fix | eval_tune/match_play.py | Reported 0.000 in **every match since it was added (2026-09-18)**. Two causes: `_ITER_RE` was applied only in the trailing stats parser but the PV lines arrive during the search in the move-scan loop; and `Engine.__init__` sends `nopost`, so `root.c:248` never emitted them at all. Now parsed in both loops plus an opt-in `--post`. This is the metric that proved the published `ebf` (cumulative `nodes**(1/depth)`) has been wrong in every match report -- true per-ply ratio is **~2.0, not ~3.9**. 10.10c | | `test_vs_head.sh` shift ordering | eval_tune/test_vs_head.sh | Positionals were shifted BEFORE `$2` was read, so (a) every no-extra-args run took the rebuild branch and clobbered `./typhoon` with a non-standard profile (`gmake release MP=1 PERF_COUNTERS=1`, missing GENETIC/SIXTYFOUR), and (b) the first pass-through flag's VALUE became the candidate binary path -- producing a baffling `PermissionError` from Popen trying to execute a directory. Also `${VAR:-default}` -> `${VAR-default}` so an explicitly empty `SPRT_ARGS=""` is honored. | ### PERF_COUNTERS cleanup (10.12g) The standard profile was carrying investigation debris that did real WORK, not counter increments. - **3 `rdtsc` sites moved to `EVAL_TIME`** (eval.c). Bracketed every king-safety hash probe. An `EVAL_TIME` flag already existed for exactly this and already guarded the other **32** rdtsc sites in the file -- these were the only leaks. Report side in root.c was already EVAL_TIME-guarded. - **2 one-off diagnostics deleted outright**: `u64HeadStyle*` (called `GetMoveFailHighRawCounts`) and `u64FHJointHist` (called `GetMoveFailHighPctThousandths`). Both had comments saying to remove them once their investigations concluded; both concluded, and both findings are recorded (10.2's admission rates; section 4's bimodal dynamic rate). Removed counters, `g_Options` mirrors, suite accumulation AND both report blocks -- a diagnostic printing zeros forever is worse than none. Binary -7,760 bytes. - **4D collection block re-gated to `FH_TABLE_COLLECTION`** (search.c). It calls `EstimateHangingMaterialCp` per eligible move; CLAUDE.md already keeps that flag out of the standard profile *because it costs nps*. Verified by guard inspection: `search.c:1637` is now `PERF_COUNTERS > FH_TABLE_COLLECTION`. It was also computing the hang bucket TWICE per move in any build with a prune gate. **nps payoff NOT MEASURED** -- two attempts invalid (32-engine match on the box; one produced `perf_clean` faster than `noperf`, which is impossible). Re-measure idle, round-robin arms. The changes are justified on inspection regardless. ### Infrastructure - **`match_play.py` time-odds support** (`--odds`, `--st-head`, `--st-candidate`). Strict validation because a degraded odds match still scores ~0.500 and looks healthy. Odds follow the ENGINE, not the seat -- verified live, since the harness swaps colours every other game, so keying on White would silently balance the match. `TC=` stamped into the result line. This is what produced the time-odds calibration. - **`PRUNE_VERIFY`** (GNUmakefile, search.c, chess.h, root.c, script.c). Audit-only: evaluates the prune gate, records banded by predicted FH%, then declines to prune and watches the move's own ordinary search. No shadow `Search()` call, so no null-move-bailout exposure. Banding by the decision variable answers "is `prune if pred <= K` sound" for every K in one run. This is the instrument any future pruning work needs. 10.12h - **`DISABLE_LMR`** (GNUmakefile, searchsup.c). Diagnostic flag that produced the control experiment. Keep the flag, never ship the configuration. ### QSearch changes (10.12e) -- smoke-tested with asserts - **Option B**: `fIncludeChecks` waives the opponent-stand-pat requirement at `NODE_PV`, keeping the material requirement. The suppression is sound for null-window nodes (a fail-low there IS the parent's cutoff, so precision buys nothing) but not for wide-window nodes, where the exact score propagates and mate-vs-queen-loss is a different PV -- the forced-mate case the machinery exists for. Reads `eNodeType`, never writes it. - **`iExtensionAmount` unit fix**: was written as 0 or 2 (qsearch check PLIES) into a ONE_PLY-scaled field -- a latent 32x error, benign only because both readers test sign. - **`pi->iEval` published** when the lazy `Eval()` actually runs. ### Wilson re-bake (data.c, joint_table_collection/splice_joint.py) -- 10.11e Cells store the **Wilson upper bound** instead of the point estimate, so a cell's value encodes its own sample confidence and a consumer can threshold on the value ALONE. Under point estimates **8,248 of 21,780 cells read exactly 0**, mostly on little evidence, so any "prune where the table says 0" rule was far broader than the data supported; under Wilson, 136 do. Monotone (no cell's value is ever lowered), so every consumer became strictly more conservative. Verified behaviour-neutral for LMR: admission 92.79% vs 92.88% before, tier split and ringers solve count unchanged. `LMR_STATIC_ADMIT_PCT_THOUSANDTHS` stays at 500 -- it was briefly raised to 1000 on a CELL-count argument, which was wrong because cell counts are not traffic-weighted. --- ## DECIDE -- real, but with an open question | what | open question | |---|---| | **The 4D table itself** (data.c, 1,777 lines) + `joint_table_collection/` | Its only live shipping consumer is `LMR_STATIC_ADMIT_PCT_THOUSANDTHS`, which vetoes 2.3% of cell space / 7.4% of traffic -- and that threshold was chosen on solve-count deltas 10.6 showed were noise. Decisive cheap test: disable admission (`= 100000`) and run vs current; if neutral the table buys nothing in a shipping build. **But item 1 of 10.13 (give EFP the dumb axis) may give it a real consumer -- do that first.** | | **Graded LMR** (`LMR_STATIC_ADMIT`, flat-table magnitude, dynamic/countermove vetoes) | Worth ~8 Elo/ply marginal, i.e. a few Elo total, never cleanly self-play tested against flat -1 (10.12c). The `log(d)*log(m)` magnitude contributes mean **0.164 extra plies** because at remaining depth <= 2 the curve prescribes ~1 ply, which IS flat -1. Keep or simplify per the redesign in `2026-10-03_LMR_REDESIGN.md`. | | **`PRUNE_LIVE`** | Measured +0.88 ply, +7 Elo -- exactly its corrected ceiling. Keep the flag for measurement; do not ship the configuration. | | **`split.c` zero-snapshot passing** | `HelpSearch` passes all-zero attack snapshots to `GetLMRReduction`, so `EstimateHangingMaterialCp` degrades there. Documented and deliberate (every comparison protocol runs `--cpus 1`), but it means MP search reduces differently from single-threaded. Fine as long as that stays true. | --- ## DISCARD -- scratch that leaked into the tree Per CLAUDE.md's scratch policy these belong under `/tmp/typhoon/`, not `src/`: - `cdefault_games.pgn`, `chkdefault_games.pgn`, `codds_games.pgn`, `dflt_games.pgn` -- from this session's `test_vs_head.sh` argument-construction tests (GAMES=0/2 runs). **Delete.** - `kiwi_log.txt`, `eval_tune/kiwi_log.txt`, `eval_tune/eval_comp.txt`, `blunders.pgn`, `players.dat`, `.xboardrc` -- untracked clutter from earlier sessions; not mine to judge, but none look like deliberate artifacts. Deliberately-kept untracked directories (do NOT delete): `head_reference/`, `joint_table_collection/`, `nullmove_calib/`, `eval_tune/position_cache/`, `eval_tune/opening_cache/`. --- ## Suggested commit split 1. **Bug fixes** -- OOB counter, node-type x4, unit unification, RateBucket, `local_ebf`, `test_vs_head.sh` shift. Independently correct, no behaviour bets. Run `./precommit_check.sh` first (search.c behaviour did change via the QSearch work). 2. **PERF_COUNTERS cleanup** -- after an idle-box nps measurement, so the commit message can state what it bought. 3. **Infrastructure** -- time-odds, `PRUNE_VERIFY`, `DISABLE_LMR`. 4. **QSearch changes** -- option B et al. Smoke-tested; ideally also a short self-play run, since option B is a real behaviour change never measured in isolation. 5. **Table + graded LMR + PRUNE_LIVE** -- hold until 10.13 items 1 and 5 are resolved.