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
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
|
# Null-move-skip calibration plan (2026-10-12)
## Motivation
Two FH%-prediction tables now exist in `data.c`:
- `g_uJointFHThousandths` ("alpha table") -- predicts a move's FH% given
[depth][hang][gap-to-alpha][late][improve]. Used today for LMP/EFP/LMR
decisions on individual leftover moves.
- `g_uBetaFHThousandths` ("beta table") -- predicts a node's FH% given
[depth][gap-to-beta][ownImprove][enemyImprove], for nodes where
`iEval > iBeta`. Used today for the static-NM shortcut
(`search.c`'s `fTryBetaPruningHere` block, >95% -> return `iBeta`
without searching).
Question: is there a *low*-confidence corner in either table reliable
enough to justify **skipping the real null-move search attempt**
entirely at a node (not pruning the node itself -- just not trying the
null probe, falling through to the normal move loop: hash move,
killers, etc.)? Unlike the static-NM shortcut, this is a pure
efficiency play with no score-injection risk: if we're wrong, the real
move loop still runs and finds the correct score. We just forgo the
cheap shortcut on the rare occasion it would have worked.
## Why the alpha table, not the beta table, for this
At non-PV nodes, `iAlpha == iBeta - 1`, so `iEval <= iAlpha` and
`iEval >= iBeta` together cover the entire range -- no gap. Checked
both tables' actual baked data (`data.c`) for a usable low-FH corner:
- **Alpha table**, sliced at hang=0 ("nothing hung"), late=0 ("even the
single best-looking move"): deep remaining depth (shipped buckets
8-10) x large gap-to-alpha (buckets 8-10) x improve bucket 0 (own
eval declining) gives real, non-fallback predicted FH% as low as
0.001-0.1% -- two-plus orders of magnitude below the ~0.4-0.9%
baseline leftover-move rate. A strong, well-observed signal.
- **Beta table**: its 2026-10-11 inversion (storing gap-to-100% instead
of FH% directly, to keep resolution near 100% for the static-NM
shortcut) saturates every cell predicting below 34.466% FH to the
same stored value. No resolution exists below that floor in the
current bake -- there is no usable low corner to find here. Would
need a dedicated unsaturated rebake to pursue; out of scope for now.
So: implement the skip using the alpha table only, for the
`iEval <= iAlpha` side.
## The EV algebra
Trying null-move at a node has expected value (in nodes saved):
```
EV(try) = p * realLoopCost - nullProbeCost
```
where `p` = probability null-move fails high, `nullProbeCost` = the
reduced-depth probe's node cost, `realLoopCost` = what the real move
loop costs to reach its own cutoff (the cost skipped entirely when the
probe succeeds). Skip trying null-move iff
`p < nullProbeCost / realLoopCost`.
- `nullProbeCost` is trivial to measure: a node-counter delta around
the probe's recursive `Search()` call (same as `TEST_NULL`'s existing
`uNullCalibProbeNodeCost`).
- `realLoopCost` is conceptually simple (it's the cost the node always
pays via the real move loop when null isn't tried) but the
*counterfactual* isolation -- measuring it specifically for the
"null was never attempted" branch, without contamination from
production's own null-move logic firing afterward -- needs a
deliberate harness. Not yet built; see "Future work" below.
### R-factor / branching-factor estimate
Using `p* ~= b^(-R)` with `b = 2.5` (assumed average branching) and
`R = 3 + floor(depth/6)` (actual `SelectNullmoveRFactor` formula):
| remaining depth (plies) | R | p* = 2.5^-R |
|---|---|---|
| 0-5 | 3 | 6.4% |
| 6-11 | 4 | 2.6% |
| 12-17 | 5 | 1.0% |
| 18-23 | 6 | 0.41% |
| 24-29 | 7 | 0.16% |
The threshold **tightens** (gets stricter) with depth, not looser --
`realLoopCost` grows with full remaining depth while `nullProbeCost`
only shrinks mildly via R's slow growth. The alpha-table corner found
above (shipped depth 8-10, raw ~8-13 plies, landing in the R=4-5 rows,
`p* ~= 1-2.6%`) showed predicted FH% of 0.001-0.15% there -- clearing
the math-derived bar by one to two orders of magnitude. Good sign the
corner is real, not just noise, even under a crude model.
## Nested-null-move handling
Raised explicitly: does a null-move search recursing into *another*
null-move search deeper in its own subtree break any of this?
- **Inside the probe's own subtree**: not a problem. `fAvoidNullmove`
is a single shared flag, not stacked per-ply -- it only blocks two
*consecutive* nulls, toggled tightly around the probe call and reset
immediately after. Nested null-moves several plies down inside the
probe are real and representative, exactly as they'd behave in
production. This does mean `nullProbeCost` is a high-variance
per-sample quantity (one lucky nested cutoff can collapse most of a
probe's subtree) -- argues for a large sample floor per bucket
(reuse the ~500-try convention `dual_table_run` already uses)
before trusting any bucket average, not a correctness fix.
- **Inside the "skip branch" realLoopCost measurement**: this is where
it matters. If/when a skip-rule is compiled into `Search()`, nested
nodes inside that measured real-loop subtree will also apply the
same rule recursively -- calibration data collected this way depends
on whatever rule is *already* active at calibration time. Circular.
Resolution (same pattern already used for every other baked table
here): **Pass 1** calibrates with no skip-rule compiled in anywhere
(matches `TEST_NULL`'s existing "structural eligibility only, don't
consult the thing being evaluated" design). Ship from that. **Pass
2**: re-measure/re-splice once live, since nested applications then
change descendant costs too (likely compounding favorably, since a
collapsing-eval signal is autocorrelated down a few plies -- a
hypothesis, not yet verified).
## What's implemented now (2026-10-12)
A single flat, compile-time sweep knob -- not yet the full
depth-bucketed table:
- `search.c:93`: `#define NULLMOVE_SKIP_FH_CEILING_THOUSANDTHS (0)`
-- `0` disables (default/shipped state).
- `search.c`, inside `fTryBetaPruningHere`, opposite-direction check
from the existing static-NM block: when `iEval <= iAlpha`, look up
`_JointTablePredictedFHThousandths(uRawDepthB, 0, GapCpBucket(iAlpha
- iEval), 2, uRawImproveB)` (hang=0, late=2 -- the table's own domain
floor, since collection required `uLegalMoves > 1`, so there's no
literal "move 1" bucket; 2 is the nearest available proxy for "even
the single best-looking move"). `uRawDepthB`/`uRawImproveB` are
reused from the already-computed per-node locals (lines ~551, ~726),
not recomputed. If predicted FH% is below the ceiling, skip calling
`WeShouldTryNullmovePruning` for this node entirely, falling through
to the real move loop.
- `chess.h`: new counter `u64NullMoveSkippedLowFH` (tree counters
struct only, matching `u64QuickNullMoveSuccess`'s precedent -- not
MP-merged, diagnostic-only).
## Empirical sweep results
### bench (6 fixed positions, fast but small)
Baseline (ceiling=0): 2,714,949 nodes. Shallow dip at ceiling 10-30
(~0.4-0.5% fewer nodes), rising above 100, blowing up by 3000-10000.
### ecm_ringers (11 positions, `sd 10`) -- too small, non-monotonic
| ceiling | total nodes | solved/11 |
|---|---|---|
| 0 | 4,293,355 | 3 |
| 10 | 4,435,817 (+3.3%) | 3 |
| 30 | 4,314,830 (+0.5%) | 3 |
| 100 | 4,250,553 (-1.0%) | 3 |
| 300 | 4,089,390 (-4.7%) | 3 |
| 1000 | 4,700,197 (+9.5%) | **2** (lost ECM.783) |
| 3000 | 3,904,085 (-9.1%) | 3 |
Matches this project's own documented caveat: ringers is too small and
chaotically order-sensitive to trust a single run's shape.
### ecm_confident_quick + ecm_hard_quick (90+90 positions, `sd 10`)
Ran as 8 parallel background processes (4 ceilings x 2 suites, 24-core
box, ~idle at the time) using 4 separately-built binaries (ceiling is
a compile-time `#define`, can't be swept at runtime without rebuilding).
| ceiling | combined nodes (180 pos) | delta | combined solved (baseline 79) |
|---|---|---|---|
| 0 | 48,588,231 | -- | 79 |
| 300 | 47,468,723 | -2.3% | 76 (-3) |
| 1000 | 47,783,919 | -1.7% | 77 (-2) |
| 3000 | 45,907,126 | **-5.5%** | **79 (net 0)** |
Per-suite: `ecm_confident_quick` solved count degrades monotonically
(62/60/61/59) as ceiling rises; `ecm_hard_quick` is flat/down through
300-1000 (17/16/16) then jumps to **20** at 3000. The jump is what
makes 300/1000 look worse than their neighbors on both sides --
non-monotonic, not a stable curve.
**Conclusion so far**: no clean single minimum for a flat ceiling --
consistent with (not just predicted by) the depth-dependence argument
above. Ceiling=3000 is the one genuinely encouraging point (real node
savings, net-zero solve cost across 180 positions) but a flat knob
can't properly track a threshold that should move with depth; finding
a better flat number isn't the right next move.
## Future work
1. **Don't keep sweeping the flat knob.** Build the depth-bucketed
version instead: extend `TEST_NULL` to measure `nullProbeCost` and
`realLoopCost` per (depth, gap, improve) bucket (Pass 1, no live
skip-rule anywhere, per the nested-null discussion above), bake a
per-bucket `p*` the same way `dual_table_run`/`splice_dual.py`
already bakes the other tables, re-measure/re-splice once shipped
(Pass 2).
2. If that proves too complex or doesn't pan out, fall back to a
coarser depth-only piecewise ceiling (a handful of depth bands,
each with its own flat threshold derived from this sweep's
per-depth behavior) rather than one global number.
3. Before committing anything: full `ecm.ep_` run at the chosen
setting(s), `precommit_check.sh`, and the usual `head_reference`
comparison protocol.
4. The beta-side (`iEval > iBeta`) mirror of this idea is still
blocked on a resolution-preserving rebake of `g_uBetaFHThousandths`
below its current 34.466% saturation floor -- not worth pursuing
until the alpha-side version proves out.
Sweep artifacts (binaries, logs, stdout) are under
`/tmp/typhoon/nullskip_sweep/`, scratch -- not kept.
|