summaryrefslogtreecommitdiff
path: root/src/docs/NULLMOVE_SKIP_CALIBRATION.md
blob: 50a050e0e77bcefdbac1775fd5f2c1672ccfb48e (plain)
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.