1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
|
# 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.
|