Skip to content

Move the late move reductions and pruning into one module - #265

Merged
aywrite merged 1 commit into
masterfrom
search/late-move-module
Sep 17, 2026
Merged

aywrite merged 1 commit into
masterfrom
search/late-move-module

Conversation

@aywrite

@aywrite aywrite commented Sep 16, 2026 •

Copy link
Copy Markdown
Owner

Pass 6 of the architecture review, candidate F1 (pass 5's E2 re-read). A shape change with no search change: the bench is master's 4102609 on both sides, position by position (3868987 before the rebase over the king attack fit), and the tactics and strategy pins do not move.

The decision about a quiet move ordered late was spread through engine.rs: twenty constants, two predicates with six and nine parameters, a staging field on the engine, and two derivations of the features the attention model reads (with a third in fit_attention.py that had to agree). It is now arche-core/src/late_move.rs. The move loop asks one question, decide(&Search, &mut Node, m, searched), and gets Skip or the plies to scout. Search is three references (board, ordering, config), so the module names what it reads and a test builds one with no engine.

The gate and the reduction ledger now read the features from one function over the same node. The staged field goes: the staging travels as an Option<&Staged> parameter through search_child and windowed, so a reduced move inside a scout cannot consume its parent's staging. The ledger's history_max column becomes the node's held denominator, as the gate always read it, rather than a fresh walk per staging; INSTRUMENTS.md says so and a test holds the cache.

Callgrind on the identical bench: +0.077% instructions, the per-move Node literal against a cheaper parameter, under the clock floor. Tests 475 to 478 debug, 473 to 476 release.

The handoff with the eight grilled decisions is in the planning repo (handoffs/late-move-module.md). One Opus review round, both sides rebuilt: no behavioural defect, nine findings folded into the commit.

🤖 Generated with Claude Code

@aywrite

aywrite commented Sep 16, 2026

Copy link
Copy Markdown
Owner Author

Review round 1 (Opus reviewer, own build directory, both sides rebuilt from git archive exports with tar -m; bench 3868987 at 4b1b80f and at the candidate, position by position; the decision path read line by line against the old reduces and model_gate; mutations on the clamps, the threshold order and the history/denominator swap all caught by tests). No behavioural defect. Nine findings, all folded into the one commit:

  1. The armed instrument path paid an eval at depth-3 nodes the base never paid, because features reached the eval through the same lazy pair as the denominator. The pair is now two lazies; features asks for the denominator alone.
  2. Two comments claimed the features were "read back off the node" and "derived once and carried"; they are derived by a second call of the same function. Both now say the real property: one derivation site, so the two readings cannot diverge.
  3. The ledger's history_max column changed meaning (the node's held denominator, as the gate reads it, rather than a fresh walk per staging) and neither the commit body nor INSTRUMENTS.md said so. Both do now.
  4. Nothing tested that the denominator is held for the node. A test now teaches the history between two readings and asserts the second is unchanged; it fails when the cache is bypassed.
  5. The new test's closing assertion only checked determinism. Removed; the solved-edge assertions above it are what hold the gate to the recorded row.
  6. scripts/tests/test_fit_attention.py still named engine.rs for the clamp. Fixed.
  7. The speed paragraph never said which side was faster. Rewritten with the direction and the callgrind attribution (+3,110,295 Ir, alpha_beta +1.9M for the per-move Node literal, windowed -1.6M for the parameter).
  8. A 79-column line in ARCHITECTURE.md after the rename. Rewrapped.
  9. The implementer's deviation note undercounted the pub(crate) constants (five, not one). Note only; the code was right.

Every number in the commit body was checked against the diff by the reviewer and holds.

@github-actions

github-actions Bot commented Sep 16, 2026 •

Copy link
Copy Markdown
Contributor

Speed against 7991f406

Both sides built and run on this runner in this job, the way
scripts/speed.sh measures a perf commit. The change is between
medians, and the spread beside it is what the change has to be
read against. The node counts are the search's: they move when
the search does, and a speed change leaves them alone.

round     base nps  candidate nps
    1     10733042       10678454
    2     10818945       10717789
    3     10721150       10640870
    4     10721290       10527149
    5     10753720       10637890
    6     10624584       10612574
    7     10689862       10524070
    8     10710849       10600454
    9     10578996       10539481

             nodes    time  median nps  fastest nps
base       4102609  0.38 s    10721150     10818945
candidate  4102609  0.39 s    10612574     10717789
change                           -1.0%        -0.9%

the change is inside the spread, so the medians make no claim; the
fastest column is the steadier comparison when the machine was not
quiet

Speed: -1.0% (bench nps, 9 interleaved rounds vs 7991f406, spread 2.2%)

…module

The decision about a quiet move its ordering put late was spread through
`engine.rs`: twenty constants among the search's other figures, two
predicates with six and nine parameters, a staging field on the engine,
and two derivations of the features the attention model reads, which a
third in the fit's python had to agree with. It is now
`arche-core/src/late_move.rs`, 1164 lines, private to the crate, and
`engine.rs` loses 1161 lines for 188.

What moved, with its comments and its fit provenance as they were: the
five reduction figures with their floors, the thirteen `ATTENTION_*`
weights and the two thresholds, `AttentionFeatures`, `attention_score`,
and the bodies of `reduces` and `model_gate`. The scout stays in
`windowed`, the recorder hooks stay in `engine.rs`, and `census::Table`
stays in `census.rs`, which the module's doc says and why.

The move loop's two calls become one. `decide(&Search, &mut Node, m,
searched)` answers a `Verdict`: `Scout(n)` for a scout n plies shallower,
zero meaning the full search, or `Skip`. `Search` is three references,
the board, the ordering and the config, so the module's interface names
what the decision reads and a test builds one with no engine behind it.
`Node` carries the node's facts and borrows the two dear features. They
are lazy separately: the static evaluation is computed by the first move
the gate scores, since only the score wants it, and the history
denominator by the first move the gate scores or the ledger stages. A
node neither reaches pays for neither, armed or not. The `Gate` enum
goes: the plies say what its three words said, and a move the reduction
never touches is `Scout(0)` where it used to be a separate bool.

The engine's two feature derivations become one. `late_move::features` is
the one place they are derived, in the units the ledger prints; the gate
reads its history fraction off that value and the staging asks the same
function over the same node, so the two readings cannot diverge. The
second history walk in `staged_reduction` goes with it. They used to agree
only because the gate clamped `history` and left `history_max` signed
while the ledger clamped the other one, and the guard on the denominator
made the pair equal; the gate now reads the clamped denominator, which the
guard leaves worth the same. The denominator is also read once for the
node and held, as the gate always held it, so a row records the largest
the score was read against rather than the largest by the time the staging
ran: a child search between two of a node's moves can teach the history,
and INSTRUMENTS.md says which of the two the column is. The fit's python
is the third derivation and stays as it is: it reads printed rows, and
those rows now say what the gate saw.

The `staged` field goes. `search_child` and `windowed` take an
`Option<&reduction::Staged>`, `None` at the two sites that never reduce,
so a reduced move inside a scout cannot consume the staging of the move
being scouted: a parameter is not reachable from a deeper node, where a
field was. The `Recorded::disarm` override that cleared the field goes,
and with nothing left overriding it the provided method goes too.
`search_child` is one argument past clippy's limit and carries the allow;
`ledger_skip` drops the one it had. `census::Table::of` is read once for
the node instead of at the four sites that asked for it.

Fourteen tests stand in the new module, twelve of them moved and two new:
that the gate's threshold edge sits where the recorded row's own score
puts it, and that the denominator is walked once for the node. The two
exemption tests that also ran a search are split, the predicate half
moved and the node-for-node half left beside the search under a name that
says what it measures. The leak-path test goes with the field. `engine.rs`
holds 121 tests where it held 132, and the suite counts 478 in debug
where it counted 475, 476 in release where it counted 473.
`A_CAPTURE_AND_QUIETS` moves to `board::fens`, which both modules read.

No search behaviour changes. The bench is the same 4102609 nodes at depth
seven position by position, rebased over the king attack fit and the
mobility walk that took its counts (the branch benched 3868987 on the
4b1b80f it was cut from), and the tactical and strategic totals do not
move. Five interleaved rounds against 4b1b80f read faster after the
change, +0.8% on the medians and +0.4% on the fastest rounds, on a spread
of 4.2%: inside the spread, so the clock makes no claim either way and
the staging travels as a parameter rather than as a field. Callgrind over
the identical bench prices it at +3,110,295 instructions, 4,024,717,023
against 4,027,827,318, which is +0.077% and well under this box's clock
floor. It lands as +1,906,840 in `alpha_beta`, about half an instruction a
node, which is the `Node` literal the loop writes for each move, against
-1,637,407 in `windowed`, where the parameter costs less than the field's
read and take did.

Bench: 4102609
Elo: not measured
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@aywrite
aywrite force-pushed the search/late-move-module branch from 1f5ed96 to 37fe452 Compare September 17, 2026 04:40
@aywrite
aywrite merged commit f42e6d3 into master Sep 17, 2026
19 of 20 checks passed
@aywrite
aywrite deleted the search/late-move-module branch September 17, 2026 04:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant