summaryrefslogtreecommitdiff
path: root/src/docs/MOVE_ORDERING_IDEAS.md
blob: 5c6593451443727785b2b5700613442360f84f43 (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
214
215
216
217
218
219
220
221
222
223
224
225
226
# Move ordering experiment backlog

Goal: a leaner tree (fewer nodes for the same search quality) via move-ordering
changes, not eval or pruning changes. Methodology: per CLAUDE.md's "Dynamic
move ordering experiments" section -- measure fixed-`sd` node count /
first-move-cutoff-rate on the three curated suites (`ecm_ringers`,
`ecm_confident_quick`, `ecm_hard_quick`) as the primary signal, cross-checked
against a larger `cross_sectional_200k.ep_` sample at fixed `sn` as a tertiary
aggregate sanity check. Avoid adding throwaway per-node instrumentation code
to the tree; prefer compile-time diagnostic swaps (like the old
zero-selection-budget trick) that are never committed, or just the suite-level
before/after comparison.

Status key: TODO / IN PROGRESS / DONE (kept/discarded) / DROPPED (not pursuing)

## Queue

2. **TODO -- Qsearch check generation.** Crafty's `quiesce.c` calls
   `GenerateChecks()` (movgen.c) to extend qsearch's first ply with
   non-capture checking moves (direct + discovered, via attack-set
   bitboard tricks), not just captures. Confirmed (per CLAUDE.md) our
   QSearch is capture-only. This is a search-shape change more than a pure
   ordering change -- re-confirm current QSearch behavior in search.c
   before building, since CLAUDE.md notes may be stale.

5. **TODO -- Countermove fallback key (coarse, empty-slot-only).** Today
   the countermove table is keyed by the *exact* previous move; if that
   exact key's slots are empty/unevidenced, no countermove signal fires at
   all for that node. Add a second, coarser table keyed by
   `[piece-type][to-square]` of the previous move, consulted *only* when
   the exact-match slots don't fire -- a genuine fallback, not a
   competing signal. Different from the earlier "scaled continuation-
   history" experiment (flat across a 256x range), which tested coarse
   keying as an always-active competing signal, conflating key-coarseness
   with "always on." This isolates key-coarseness alone, active only in
   the exact-match's current blind spot.

## Dropped

6. **DROPPED -- Flee-to-safety revival.** `FLEE_BONUS`/the commented-out
   `cEnprise`-match code in `movesup.c`'s `SelectBestWithHistory` (and
   the matching `RETIRED` comment in `generate.c`) was dead -- retired
   after the unconditional GOOD_MOVE-tier version it replaced was shown
   (evidence-calibration, pre-dates this backlog) to perform at the
   plain-leftover baseline for the vast majority of its population.

   Reran with real evidence-bucket resolution (`MOVE_ORDERING_EVIDENCE_
   COLLECTION`, reusing `CM_EVIDENCE_BUCKETS`/`_CMEvidenceBucket` --
   same evidence scale as countermove, no butterfly-only workaround
   needed since this legitimately reuses the ply-1-keyed continuation
   table unlike move-pair/recapture): `evidence >= 10000` hit 56.32%/
   62.84%/68.23% FH (ecm_ringers/ecm_confident_quick/ecm_hard_quick)
   vs. a ~1.35-1.54% unprivileged-leftover baseline -- comparable to
   or exceeding countermove's own calibrated bar. A real signal the
   prior session's cruder zero/nonzero split never isolated.

   Implemented as a `GOOD_MOVE`-tier escape at that threshold, same
   shape as `COUNTERMOVE_TIER_BONUS`. Result: **no measurable effect
   either way** -- 0.00%/-0.05%/0.00% node delta at sd10, zero solve
   diffs on any of the three suites, sn10M depth/cutoff flat. Not a
   magnitude-tuning problem like move-pair/recapture had -- the
   qualifying population (en-prise origin AND evidence >= 10000) was
   just too rare (2,444-9,209 tries across an entire suite run, vs.
   countermove's tens-to-hundreds-of-thousands) for any mechanism
   acting on it to move the aggregate needle. `u64FleeTries/FH` and
   the scoring/precompute code were fully reverted.

4. **DROPPED -- Recapture bonus.** A capture landing on the same square
   the opponent's last move landed on (`mv.cTo == mvLast.cTo`), nudged
   within the winning/even-capture tier (`generate.c`'s `_ScoreAllMoves`,
   `s >= 0` branch) rather than escaping it.

   Built the comparison harness properly this time: `RECAPTURE_BUCKETS`
   (2: SEE<0 losing, SEE>=0 even/winning) plus a parallel
   `u64CaptureBaselineTries/FH` logging *every* capture (not just
   recaptures) at the same SEE-sign buckets, so the recapture rate
   could be read against an apples-to-apples baseline instead of in
   isolation -- exactly the comparison move-pair's harness lacked at
   first. Real signal: at SEE >= 0, recapture FH% ran 64-65% vs. a
   49-52% all-captures baseline, consistent across all three curated
   suites, on samples of 300K-4.4M tries.

   Two magnitudes tried as a same-tier nudge, neither produced a
   tree-size win:
     - `RECAPTURE_BONUS = 50` (first guess, scaled like FLEE_BONUS's
       old magnitude): -0.74%/+5.04%/-0.45% nodes (mixed), but a
       **confirmed real tactical loss** -- ECM.735 (see `../tests/ECM.md`'s
       Legit table: "HEAD's own line is clean and solid"), on
       `ecm_hard_quick` (17->14/90 solved). Root cause, found by
       actually computing the surrounding arithmetic rather than
       guessing: the branch's real MVV/LVA differentiator --
       `PIECE_VALUE_OVER_100(captured) - PIECE_VALUE_OVER_100(mover)`,
       pawn=1/knight,bishop=3/rook=5/queen=9 -- only spans ~112-128
       (a ~16-unit band) across *every* capture combination. 50 is
       over 3x that whole band -- not a tiebreak, a near-dominant
       override capable of vaulting a recapture ahead of an
       objectively higher-MVV/LVA non-recapture capture at the same
       node, which is exactly what cost ECM.735.
     - `RECAPTURE_BONUS = 4` (rescaled inside the ~16-unit band):
       fixed the regression (ECM.735 recovered, 17/90 again; the
       hard_quick loss/gain pair that remained, ECM.491/ECM.443, are
       both independently classified Flaky in `ECM.md` -- net real
       change zero) but still no node-count win: +2.23%/+7.95%/-0.81%,
       worse on `ecm_confident_quick` than the broken 50 value was.
   sn10M depth-reached flat to within noise at both magnitudes
   (14.76 vs. flat-history-only's 14.83), same as every move-pair
   variant.

   Conclusion: same shape as move-pair -- a real, well-calibrated FH%
   edge that MVV/LVA's existing ordering apparently already captures
   most of, with no tried mechanism converting the residual signal
   into a leaner tree. `RECAPTURE_BUCKETS`/`u64RecaptureTries/FH`/
   `u64CaptureBaselineTries/FH` and the scoring-branch code were fully
   reverted; `MOVE_ORDERING_EVIDENCE_COLLECTION` infrastructure kept.

   **Methodology note for next time**: cross-referencing actual solve
   losses/gains against `../tests/ECM.md`'s per-position Legit/Flaky
   classifications (rather than reading raw suite-level solve-count
   deltas) is what separated "real tactical cost" (ECM.735) from "suite
   noise" (ECM.491/443/496) here -- worth doing by default for any
   future candidate's solve-count changes, not just when something
   looks surprising.

3. **DROPPED -- Ply-2 move-pair table.** Crafty maintains a discrete
   2-slot table (`move_pair`, history.c) keyed on the *side's own* move
   two plies back (`curmv[ply-2]`), tried in `next.c` right after the
   countermove phase. Implemented as `mvMovePair` (same shape/indexing
   as `mvCounter`), evidence-gated same as countermove.

   First attempt (borrowed countermove's `g_HistoryCounters +
   g_ContinuationHistory` evidence sum verbatim) measured a complete
   no-op: identical node counts to flat-history-only across all three
   curated suites at sd10, despite the table itself populating and
   predicting fine (55-70% raw hit rate). Root cause: `g_ContinuationHistory`
   is only ever written keyed by the move *one* ply back -- there's no
   table anywhere keyed by two-plies-back, so the borrowed slot read
   unrelated noise. Fixed to use butterfly history (`g_HistoryCounters`)
   alone.

   Built a dedicated evidence-bucket harness (`MOVE_ORDERING_EVIDENCE_
   COLLECTION` build flag, independent of `PERF_COUNTERS` per later
   request; `MP_EVIDENCE_BUCKETS`/`_MPEvidenceBucket`, denser floors
   than countermove's own since butterfly-only evidence is capped at
   `HISTORY_MAX`) and calibrated a real threshold from it: top band
   (evidence >= 6000) hit 53.41%/64.43%/66.23% FH (ecm_ringers/
   ecm_confident_quick/ecm_hard_quick) on large samples (26K/177K/271K
   tries) -- comparable to or exceeding countermove's own calibrated
   bar. A real, strong, well-sampled signal.

   Three mechanisms tried to act on that calibrated signal, none
   produced a tree-size win at sd10 across the three curated suites:
     - GOOD_MOVE-tier escape (same pattern as countermove's own
       promotion): +1.6%/+1.4%/+1.5% nodes -- small but consistent
       regression across all three suites.
     - Same-tier nudge at FLEE_BONUS's old magnitude (100000, a
       dominant override far exceeding the leftover pool's actual
       range): +19.8%/+4.9%/+2.1% nodes -- worse than the tier escape.
       A dominant override apparently inherits the escape's downside
       (jumping ahead of comparably-good moves) without its upside
       (EFP/pruning exemption).
     - Proportionate same-tier nudge (2000, scaled to compete with
       rather than override PSQT+history+continuation): mixed,
       net flat-to-negative (+20.5%/+2.0%/-0.11%) -- `ecm_hard_quick`
       went essentially neutral, but `ecm_ringers` (smallest, noisiest
       suite) got *worse* than every other variant, suggesting it
       reacts badly to this kind of reordering regardless of magnitude.
   sn10M depth-reached on the 30-position cross-sectional sample
   (`/tmp/typhoon/smoke30.ep_`) stayed flat to within noise (14.72-14.83
   vs. flat-history-only's 14.83) across every variant.

   Conclusion: `SelectBestWithHistory`'s existing PSQT+history+
   continuation scoring apparently already captures most of what this
   signal offers; no mechanism tried converts the calibrated FH%
   advantage into fewer nodes. `mvMovePair` and its evidence-bucket
   harness were fully reverted (table, update function, nudge/tier
   code, bucket counters/print block) -- re-derive from git history if
   revisited with a different approach. The general
   `MOVE_ORDERING_EVIDENCE_COLLECTION` infrastructure (flag, `MOI_INC`
   macro, countermove's own bucket harness) was kept -- reusable for
   calibrating the next candidate (recapture, flee-to-safety revival).

## Done

1. **DONE (kept) -- Flat (non-depth-scaled) history increment.** Replaced
   `g_HistoryCounters`/`g_ContinuationHistory`'s old `(depth+1)^2`-scaled
   growth with Crafty's flat/saturating update (`HISTORY_MAX`/
   `HISTORY_UPDATE_SHIFT`, chess.h; `_Increment`/`_DecrementMoveHistoryCounter`
   and continuation counterparts, dynamic.c) -- depth-independent on both
   growth and decay. Measured against a fresh `fbdcb9d` HEAD baseline
   (`head_reference/`, refreshed same session):
     - sd10, curated suites: node count -18.9% (`ecm_confident_quick`),
       -6.6% (`ecm_hard_quick`), +18.9% (`ecm_ringers`, smallest/noisiest --
       first-move cutoff rate barely moved there, 91.918% -> 91.914%, so
       likely a re-search artifact not a real regression); first-move
       cutoff rate up +0.18pp / +0.16pp on the two larger suites.
     - sn10M, 30-position random sample from `cross_sectional_1.ep_`
       (seed 42, `/tmp/typhoon/smoke30.ep_`): avg deepest iteration
       14.52 -> 14.83 ply (+0.31), candidate reached equal-or-deeper depth
       on 27/29 parsed positions, never shallower by more than 1 ply;
       first-move cutoff rate 89.724% -> 89.967%.
   Three independent signals (sd10 node count, sd10/sn10M cutoff rate,
   sn10M depth-reached) all agree in direction. Kept, not yet committed.

## Already covered / not pursuing

- **History malus (decrement on moves tried before the fail-high move).**
  Already implemented -- `UpdateDynamicMoveOrdering` (dynamic.c) calls
  `_DecrementMoveHistoryCounter`/`_DecrementContinuationCounter` for every
  quiet move considered before the one that beat alpha. Not a gap.
- Crafty's `movgen.c` itself (pure move generation -- captures, checks,
  evasions, noncaptures, pin detection) contains no ordering/scoring logic;
  the real ordering machinery is `next.c` (phase FSM) + `history.c` (killer/
  countermove/move-pair/history updates).

## Protocol reminder

- One variable at a time, against a fixed reference build (see
  `head_reference/` convention) -- never "working tree a few hours ago."
- `sd` (fixed depth), not `sn`/`st`, for "does this shrink the tree"
  questions -- matches what all five ideas above are actually asking.
- Read node-count and first-move-cutoff-rate off the three curated suites,
  not solve-count alone (too noisy at 90-ish positions per CLAUDE.md).
- `cross_sectional_200k.ep_` at fixed `sn` as a larger tertiary cross-check,
  not the primary signal, and not on a 100-position sample (too small for
  an aggregate depth-at-fixed-nodes metric) -- use at least a few thousand
  positions if run.