summaryrefslogtreecommitdiff
path: root/src/eval_tune/lazy_eval_margins/README.md
blob: 239bb26f6aa3bb67544a434a0fd8520b6c0cdc83 (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
# Lazy/super-lazy eval margin redesign (2026-09-17)

## What changed and why

Original goal: speed up `Eval()` (eval.c) by letting more nodes exit
through the cheap "super lazy" (material-only, runs before `_EvalPawns`)
and "lazy" (material + pawns + passers + bad-trade + bishop-pairs) tiers
instead of paying for full eval, motivated by comparing against Crafty's
much higher NPS.

The old margin construction (still visible via `git show HEAD:src/eval.c`
at commit `4a7ec9a`) was a patchwork: a flat `LAZY_EVAL_BASE_MARGIN` (75)
seed, widened by a `CountKingSafetyDefects`-indexed king term (`_EvalKing`'s
own slow bitboard scan, called *again* just to estimate a margin) plus a
flat residual constant, then `MAX()`'d against a separately-measured
flat floor per material bucket. Super-lazy was a single flat
`SUPER_LAZY_MARGIN_BY_ARMY[8]` lookup, no per-position information at all.
Both had real problems: `CountKingSafetyDefects` is slow despite being
bitboard-based (defeats the point of being in the *cheap* path), and the
`MAX()`-against-a-flat-floor pattern silently overrode the
position-specific estimate in most cases (found and fixed twice this
session, once per tier).

## The new construction

For **both** tiers, at every node, cheap **O(1) table lookups only** (no
`CountKingSafetyDefects`, no attack-table generation):

```
margin_a = MAX0( (king_p_hi[my_bucket]  - king_p_lo[opp_bucket])
                + (resid_p_hi[opp_bucket] - resid_p_lo[my_bucket]) )
margin_b = MAX0( (king_p_hi[opp_bucket] - king_p_lo[my_bucket])
                + (resid_p_hi[my_bucket]  - resid_p_lo[opp_bucket]) )
```

- `margin_a` gates the fail-high check (`score - margin_a > beta`):
  soundness requires assuming the most pessimistic-for-me,
  optimistic-for-opponent outcome for each term.
- `margin_b` gates the fail-low check (`score + margin_b < alpha`):
  the mirror image.
- **King term**: bucketed by the **attacker's** own army scaler
  (`MINU(7, armyScaler/4)`, 8 buckets, 0-31 scaler range) -- danger to a
  king is a function of the attacker's material, not the king-owner's.
  Shared between both tiers (`iKingP2ByAttackerArmyOnly` /
  `iKingP98ByAttackerArmyOnly` in eval.c).
- **Residual term** (mobility/trapped/passers-not-yet-counted/etc.):
  bucketed by **each side's own** army scaler. Two separate tables
  because the two tiers have different baselines to estimate *from*:
  - `iResidualP2/98ByOwnArmy` -- regular lazy's baseline already
    includes pawns/passers/bad-trade/bishop-pairs, so this residual
    only needs to cover what's left (mobility, trapped pieces, etc).
  - `iSuperLazyResidualP2/98ByOwnArmy` -- super-lazy's baseline is pure
    material, so this residual is much wider (includes everything
    regular lazy's own residual excludes).

`_hi`/`_lo` are currently p98/p2 as shipped, but genuinely **per-bucket
tunable** -- see "How to keep tuning" below. The king table has very
little spread above p90 in every bucket (already checked against the
full histogram) and was deliberately left alone throughout tuning;
all of today's percentile-adjustment work only ever touched the two
residual tables.

`iPositional[side]` (the value fed back to QSearch's futility pruning,
`search.c`'s `FUTILITY_BASE_MARGIN_BY_SOURCE[tier] + iPositional[side]`)
is now a **separate**, deliberately different statistic: a **p50**
(median, not tail-bound) typical-value estimate of that side's own
missing terms, matching what full-eval's own `iPositional` actually is
(a real point estimate, not a safety margin). See `iKingP50By...` /
`iResidualP50By...` / `iSuperLazyResidualP50By...` in eval.c. Don't
confuse these with the p98/p2 margin tables -- different statistic,
different purpose (bias correction vs. soundness bound).

## Where the histogram data lives

`eval.c`'s `_PersistRawHistogramsToFile()` (called automatically every
time a `DUMP_RESIDUAL_SAMPLES` build runs `calibrate residual`) writes
**every raw bin** of all three histograms (residual, sl_residual, king)
to `/var/tmp/typhoon/residual_raw_histograms.txt` -- not just the
handful of percentile columns that happen to be printed to the console.
This directory (`src/eval_tune/lazy_eval_margins/`) has copies from the
2026-09-17 "guess #2" 30k-position calibration run
(`residual_raw_histograms_guess2_30k.txt`) plus the reader script
(`hist_percentile.py`) -- **copy the fresh file here after any future
30k run you want to keep**, since `/var/tmp` is scratch and could be
cleared.

To read *any* percentile out of a saved histogram, without re-running
anything:

```sh
python3 hist_percentile.py residual_raw_histograms_guess2_30k.txt          # default spread
python3 hist_percentile.py residual_raw_histograms_guess2_30k.txt 0.93 0.965  # exact percentiles you want
```

## How to keep tuning

1. **Build**: `gmake clean && gmake -j5 GENETIC=1 PERF_COUNTERS=1 MP=1 SIXTYFOUR=1 DUMP_RESIDUAL_SAMPLES=1 CALIBRATE_MARGIN_SAFETY=1`
2. **Run the 30k stratified pool** (both commands in one invocation --
   `calibrate residual` persists the histogram file, `calibrate
   marginsafety` prints the real exceeded rate per **material** bucket
   for both tiers):
   ```sh
   ./typhoon --cpus 1 --hash 256m --logfile /var/tmp/typhoon/run.log \
     --batch --command "force; book name /nonexistent.book.bin; sd 6; \
     script ../tests/eval_calibration_sample_strat30k.ep_; \
     calibrate residual; calibrate marginsafety" \
     > /var/tmp/typhoon/run.stdout 2>&1
   ```
   Takes roughly 20-25 minutes. Run it in the background
   (`run_in_background`/`&`), don't block on it.
3. **Read the exceeded rate per material bucket** from the
   `calibrate marginsafety` output (`exceeded / n`, printed separately
   for "Regular lazy" and "Super lazy", by **material** bucket --
   combined army scaler / 8, a *different* bucketing than the
   percentile tables' per-side scaler/4 buckets -- see the caveat
   below).
4. **Copy the fresh `/var/tmp/typhoon/residual_raw_histograms.txt`**
   into this directory before it can get lost.
5. **Pick new percentiles per per-side bucket** using
   `hist_percentile.py` against the fresh file, and edit
   `iResidualP2/98ByOwnArmy` / `iSuperLazyResidualP2/98ByOwnArmy` in
   eval.c directly (keep each bucket's low/high pair **symmetric** --
   e.g. p95/p5 together, never p98 mixed with p10 in the same bucket).
6. **Rebuild and re-run step 2** to verify the real effect.

### The two gotchas that cost real time this session

- **The 500-position "quick check" sample is unreliable for this
  specific measurement.** It's fine for cheap direction-of-travel
  checks on most things, but exceeded-rate is a rare-event percentage,
  and 500 positions (not even 500 independent draws -- a single game's
  search tree can dominate a material bucket's node count) can be off
  by 2-5x from the true 30k-pool rate. One bucket (super-lazy material
  bucket 1) got tightened based on a 500-position reading of 0.035%,
  then the real 30k number came back at 1.13% -- a dangerous-direction
  miss. **Always confirm with the full 30k pool before trusting a
  number**, especially anything under ~1%.
- **Changing one bucket's margin changes measured behavior in OTHER
  buckets too**, even ones whose own table entries never moved. This
  isn't a bug -- `Eval()`'s exit decisions are real search behavior, so
  widening/narrowing any margin reshapes the actual search tree
  (different alpha/beta bounds propagate differently), which changes
  which exact nodes land in *every* material bucket, not just the one
  you touched. Material bucket 3's exceeded rate moved from 0.21% to
  0.94% to 0.65% to 1.44% across different runs despite its own table
  entries never changing once. Don't chase a bucket that "moved" if you
  didn't touch its inputs -- check what else changed first.
- **Material buckets and per-side buckets are different bucketing
  schemes.** `calibrate marginsafety` reports by **material bucket**
  (`_MaterialBucket`: combined army scaler for both sides, /8). The
  percentile tables you actually edit are indexed by **per-side**
  bucket (`armyScaler/4`, one bucket per side, looked up twice -- once
  as "my bucket", once as "opponent's bucket"). A single material
  bucket (e.g. combined scaler 24-31) is populated by many different
  per-side bucket combinations (3+4, 2+5, 1+6, 0+7...), so there's no
  clean 1:1 way to "fix material bucket 3" by editing "per-side bucket
  3" alone -- expect diffuse, imprecise control, not a dial.

## On the "insane" 2% idea

Structurally straightforward: pick a looser matched percentile pair
(e.g. p90/p10, or go outside the currently-recorded columns using
`hist_percentile.py` against a fresh histogram) for whichever buckets
you want at ~2%, following the same edit-rebuild-reverify loop above.
Two things worth checking once you have it:

1. **NPS gain isn't guaranteed to scale linearly with the exceeded-rate
   relaxation** -- this session found the NPS win from the whole
   redesign was much smaller than expected (see the `git log`/session
   history around `EvalLookForDrawishSituations` getting added to both
   cheap tiers, and `fBareKingEitherSide`'s two unconditional
   `TotalNonPawnCount` calls on every node -- both are new fixed costs
   that eat into any margin-driven exit-rate gain). Worth re-checking
   the `Eval exit breakdown` / new `calibrate exitrate` numbers
   (below) alongside NPS, not just NPS alone.
2. **`FUTILITY_BASE_MARGIN_LAZY`/`SUPERLAZY`** (search.c/chess.h) were
   calibrated against *this* `iPositional` distribution
   (`CALIBRATE_QSEARCH_FUTILITY`, see chess.h's derivation comment near
   those two `#define`s) -- if the eval-exit margins move a lot, especially
   in a direction that changes how often/how confidently each tier
   exits, it's worth re-running that calibration too rather than
   assuming the existing 350/175 values still hold.

## New tooling added this session (always available, no build flag)

- **`calibrate exitrate`** -- prints eval exit rate (% super lazy / %
  lazy / % full) **by material bucket**, not just the flat aggregate
  "Eval exit breakdown" line every search already prints. Complements
  the exceeded-rate data: a bucket with low exit rate AND low exceeded
  rate has real untapped speed headroom; a bucket already near its
  target exceeded rate with a healthy exit rate is well-calibrated.
  Implemented in eval.c (`g_u64SuperLazyEvalsByBucket` /
  `g_u64LazyEvalsByBucket` / `g_u64FullEvalsByBucket`,
  `DumpEvalExitRateByBucket`) and wired into command.c.