Skip to content

Remove duplicated code and tests across the engine, bench unchanged - #373

Merged
aywrite merged 10 commits into
masterfrom
claude/arche-simplification-pass-6u0rbd
Oct 5, 2026
Merged

aywrite merged 10 commits into
masterfrom
claude/arche-simplification-pass-6u0rbd

Conversation

@aywrite

@aywrite aywrite commented Oct 4, 2026 •

Copy link
Copy Markdown
Owner

A simplification pass over the whole tree. Ten commits, a refactor and a test commit for each of the board, the evaluation, the protocol, the search and the instruments. The tree is 464 lines shorter (1,598 removed, 1,152 added over 30 files), about two thirds of it tests that were the same test written several times.

The production cuts give one name to an idiom that was written out at several sites: the square behind a pawn, the pawn attack mask inversion, the piece and colour table index, the three table stores, the two ledger rows, the four fold_with wrappers, the instruments' header and position parsing. Every helper on a hot path is inline(always) or a const fn.

Nothing the engine does changes. The bench is 5965973 after every commit, and every instrument's output was diffed byte for byte against a master build. The release, debug, machine-test and baseline target suites pass on the head, and every commit passes cargo check on its own. Each commit body says which tests cover its change and, where a test was merged or deleted, why nothing it asserted was lost.

The first push read +0.19% instructions and -1.0% nps in the speed job. Counting each commit at the bench's depth put the rise in the board commit, and a per function comparison in the full move generator, whose pawn loop the compiler laid out differently once the capture read went through pawn_attacks_from. That one read is written out again with the measurement beside it. Rebased onto ff07eb7, callgrind over the bench counts 9,010,915,912 instructions against master's 9,094,321,693 (-0.9%), most of it in make_move.

🤖 Generated with Claude Code

https://claude.ai/code/session_016apdB9pbeLzng4mNo87cFm

@github-actions

github-actions Bot commented Oct 4, 2026 •

Copy link
Copy Markdown

Speed against ff07eb7c

Measured on AMD EPYC 9V45 96-Core Processor.
Both sides built with rustc 1.98.1 (48a229cea 2026-09-01).

Both sides built and run on this runner in this job, the way
scripts/speed.sh measures a perf commit. Each round runs
both sides on a layout of its own, the same compiled code with
its code and data shuffled and moved, so the 95% interval carries
where the code landed as well as the run. The verdict holds the
interval against a 1% threshold. The default layout, the one a
release ships, is measured after as a diagnostic. 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  change
    1      5581444        6047701   +8.4%
    2      6100725        5873928   -3.7%
    3      6083419        6140182   +0.9%
    4      5874796        6017895   +2.4%
    5      6217404        6190296   -0.4%
    6      6960844        6518294   -6.4%
    7      6941439        6801906   -2.0%
    8      6978660        6195948  -11.2%
    9      7057007        6675214   -5.4%
   10      6720049        6850314   +1.9%
   11      6315544        6093441   -3.5%
   12      6408223        6085715   -5.0%
   13      6988396        6766381   -3.2%
   14      6748029        6943532   +2.9%
   15      6492735        6950642   +7.1%
   16      7485386        7075277   -5.5%
   17      7539486        6716524  -10.9%
   18      7083064        7030114   -0.7%
   19      6497295        7003515   +7.8%
   20      7590277        7117745   -6.2%
   21      7312128        7353895   +0.6%
   22      7695090        7629394   -0.9%
   23      7308491        7581220   +3.7%
   24      7316020        7034590   -3.8%
   25      7637833        7662465   +0.3%
   26      6806221        7176005   +5.4%
   27      7566115        7624168   +0.8%
   28      7676861        7344182   -4.3%
   29      7407934        7425425   +0.2%
   30      7323375        7450741   +1.7%
   31      7472542        7044891   -5.7%
   32      7508212        7704969   +2.6%
   33      6909531        7395464   +7.0%
   34      6087218        6678764   +9.7%
   35      7767577        7318766   -5.8%
   36      7807454        7538771   -3.4%
   37      6727802        6520837   -3.1%
   38      6976791        7319969   +4.9%
   39      7701557        7784604   +1.1%
   40      7696142        7489549   -2.7%

             nodes    time  median nps  faster half
base       5965973  0.85 s     7022702      7497628
candidate  5965973  0.85 s     7032352      7403604
change                           +0.1%        -1.3%

paired change -0.8%, 95% interval -2.4% to +1.0%
diagnostic, on the default layout alone -0.5%, 95% interval -5.8% to +5.8%, 13 rounds

not resolved: the interval reaches past ±1.0% without clearing it, so
more rounds are needed to say either way

Speed: -0.8% (bench nps, 95% interval -2.4% to +1.0%, 40 interleaved rounds over shuffled layouts vs ff07eb7c)

The instructions each side's bench executed, counted under
cachegrind. The count repeats to within a few hundred
instructions, so a small change here is a real one, but it
prices instructions only: cache misses, mispredicted branches
and where the code lands are the speed's to show.

instructions against ff07eb7c, one cachegrind run a side, bench

            instructions    nodes  per node
base       9,098,730,591  5965973    1525.1
candidate  9,011,280,444  5965973    1510.4
change            -0.96%             -0.96%

fewer instructions, beyond the ±0.7% that edits not made for speed have moved the count

@aywrite
aywrite force-pushed the claude/arche-simplification-pass-6u0rbd branch from 5759a4f to b98c586 Compare October 5, 2026 00:45
claude added 10 commits October 5, 2026 01:37
…sks once

The square behind a pawn (the one a double push steps over, and where
the pawn taken en passant stands) was a match on the colour at seven
sites in make, unmake, the swap and the check tests; `behind` names it
once. The pawn attack tables are laid out by the attacked square, so the
squares a pawn attacks are read from the other colour's table, an
inversion spelt out at nine sites and now two methods on `AttackMasks`,
`pawn_attackers_of` and `pawn_attacks_from`. `Piece::table_index` is
the row a piece and colour take in the zobrist, piece square and evaluation
tables, which each of the three computed for itself.

The generator and the pseudo legality check read a square's rank through
`index_to_coordinate`, which also built the file; `rank_of` reads the
rank alone. `move_accumulators` and `relocate_piece_index` repeated the
board half of `place_bare` and `relocate_bare` and now call them. `see`
spelt out `attackers_to`. `Board::key` and `Board::line_ply` had no
caller and go, and the `Default` comment said the same of itself while a
test calls it.

Every helper is inline(always) or a const fn. The one read the
generator's pawn loop makes of the attack table stays written out, with
the measurement beside it: through the helper the compiler laid the loop
out differently and the full generator read 7% more instructions. As it
stands the bench is unchanged at 5965973, and callgrind over the bench
counts 9,012,901,827 instructions against 9,094,321,693 before (-0.9%),
most of the saving in make_move, whose code shrank with the shared bare
moves. The perft suites, the unmake tests and the swap tests cover the
paths.

Bench: 5965973
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016apdB9pbeLzng4mNo87cFm
…nerators

The six perft tests, three over the standard positions and three over the
edge cases, each repeated the loop over the cases and differed only in
which walk they called and the words in the failure. Each module now has
one helper taking the walk and the words, and the six tests are a line
each. The three properties stay three tests: the plain generator, the
evasion mask and the checkers maintenance fail on their own.

The three repetition tests built the same four rook moves; `CYCLE` is
the one constant. The psqt reflection test walks `Piece::PIECES` rather
than its own list of the six.

`each_new_endgame_table_is_written_out_rather_than_aliased` read the
source for the four endgame tables. The test above it refuses a table
whose endgame half matches its midgame half on every square, which is
what an alias or a copy gives, so the source read added no case and
goes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016apdB9pbeLzng4mNo87cFm
…thmetic once

Each of the four leaf terms carried a `fold_with` that handed caller
named weights to `weigh`, with `fold` its one caller passing the live
array, so that a test could fold against weights of its own. The four
tests that did so were one test written four times: the same hand sum
over a trial array, pinning `weigh`'s arithmetic, which is one generic
function. That test now stands once beside `weigh` in eval/mod.rs. Each
term's test keeps its hand counts and holds `fold` to `weigh` over them
against the live weights, which is the part of a fold that is the term's
own (which counts, which weights, white less black). `fold_with` goes.

`Accumulator::recomputed` walked the occupancy by hand and then handed
`pieces_of` to the machine; it walks `pieces_of` and names the colour's
sign once. The static copy of the piece square tables it read goes, as
does the six piece list in `rows()`, which is `Piece::PIECES`. The king
attack term read its weights for a non zero one with a function of its
own; `mobility::scored_kinds` takes the weights and answers both terms.
The factors tests and the shared walk test built the same list of the
four suites' positions, now `suite_fens`, and the term test reads
`pieces_of` rather than restating it. Two comments name
`tune.py::bounds_hold` the way the tuner's own does, and `material`'s
doc names its three readers.

Nothing the evaluation scores changes: `fold` calls `weigh` on the same
arguments, `recomputed` runs under debug assertions alone, and the
compile time constants are the same. The bench is unchanged at 5965973,
and callgrind over the bench counts 9,012,901,827 instructions, the
commit before's count to the instruction.

Bench: 5965973
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016apdB9pbeLzng4mNo87cFm
The info line was built by two format strings that differed only in the
score word. The score word is built first and one format string takes
it. The spin reader scanned the line twice, once for the word and once
for its number; it reads the word once and parses it, with the two
refusals as they were. The panic hook wrote one of two lines depending
on whether the panic had a location; the " at <location>" suffix is
built once and one line carries it.

The epd reader's two early returns with the same refusal are one
condition. The match arms for an unreadable `off` or `kinds` word were
dead: `Params::value` never reads Unreadable, since any word is a word,
and the arm is merged with the bare one. The single-line `use
arche_core::X` blocks in uci.rs and instruments.rs are one braced
import each.

Nothing printed changes. The info line is pinned by
a_report_is_said_as_an_info_line, the spin refusals by
a_hash_value_that_cannot_be_read_leaves_the_table_alone and
a_move_overhead_that_cannot_be_read_leaves_the_one_in_force, the panic
line by a_panic_is_said_where_the_interface_can_read_it, and the epd and
off refusals by the instruments tests and the session tests against the
binary.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016apdB9pbeLzng4mNo87cFm
The instruments tests had one depth, rate and cap test and one unreadable
setting test per sampling reader, with the same rows under four names,
and one suite test per reader that takes a file, with the same body
under four names. Each family is now one test over a table of the
readers. The sampling table maps every reader to its (depth, every, cap)
or its refusal, so the two shared tests read it; the suite table maps
every reader to the depth, file and positions it read, and includes the
forced reader, which had no suite test before. The rows that are one
reader's own stay as their own tests: the residuals policy and its
refusal, the effort switch and budget and their refusals, and the terms
refusals, which differ because terms takes no depth. Every assertion
message names the instrument, so a failure still says which reader
broke. The rows are the union of what each test had, so each reader is
asked everything any of them was.

In the uci tests, the small table engine and the recording session are
built in one place each, a_real_engine_keeps_the_same_promises uses the
uci() fixture it was repeating, and Driven::wait_for_times replaces two
copies of the loop that waited for a second bestmove. Those two waits
now have the thirty second deadline every other wait has rather than
ten; the deadline bounds a hang and asserts nothing.

In the session tests, Session::quit says quit and checks the exit, which
nine tests wrote out, move_of reads the move off a bestmove line, which
four tests wrote out, and the swap test uses line_opens_with in place of
its own copy.

No test is dropped and no property changes hands.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016apdB9pbeLzng4mNo87cFm
… the ledger

Ten places spelt the same thing twice. Each is now written once.

The transposition table's three `record_` methods built the same entry
with a different bound, and `entry()` wrote `NO_EVAL` only for the caller
to overwrite it. `entry()` takes the static evaluation and one private
`record()` serves the three. The probe tested `cuts` three times in a row;
it tests it once and nests the two refusals under it. The empty entry's
move and the ordering's `NOWHERE` were the same hand written null move;
it is `Play::NOWHERE`.

The ledger's skip and scout recorders each spelt out a sixteen field
`reduction::Event`; `Event::recorded` builds it for both. The census's
quiet history closure and the late move denominator's were the same
filter; it is `MoveOrdering::quiet_history`. The gate and `attention()`
built the same `AttentionFeatures`; `model_score` builds it for both, and
one `hist_milli` serves `Features` and the ledger row score. The gate's
plain scout verdict is spelt once, as a closure so the table read stays
on the paths that had it (computed eagerly it cost 0.08% of instructions).

`Decision::Search { reduction: 0, staged: None }` appeared five times and
is `Decision::UNREDUCED`. `ordering_key` forwarded to `keyed` from one
call and is inlined there. `quiescence_value` and `search_root` shared a
prologue, now `begin`.

Nothing the search does changes. The tests in engine/tests.rs,
late_move.rs, transposition.rs and reduction.rs cover the paths touched,
and `node_counts_have_not_moved` pins the tree. The bench is unchanged at
5965973. Callgrind over the bench counts 9,010,901,496 instructions
against the commit before's 9,012,901,827 (-0.02%), one run a side.

Bench: 5965973
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016apdB9pbeLzng4mNo87cFm
… tests

Six tests in engine/tests.rs planted an entry, seven times, with the same five
argument `record_best` call wrapped in an assert. A `seed` helper does it
for them; the quiescence entry test keeps its own call because the depth
of zero is its point.

The four replay label tests in reduction.rs (a fail low and a skipped
move, each harmful and harmless) were the same two fens and the same
assertions under a different `Scout`. One test loops over the two kinds
and names the kind in every assertion message, so a failure still says
which of the four properties broke. The skipped rows zero their cost and
reduction as the recorder does; the replay does not read either.

`a_losing_side_plays_for_the_fifty_move_draw` is deleted. It was the same
fen, the same depth three search and the same assertion as
`the_same_root_one_ply_before_expiry_answers_the_same_way`, which keeps
its note about why the position draws. Both sat at a counter of 99, so
neither tested a boundary the other did not.

Test code only. The bench is unchanged at 5965973.

Bench: 5965973
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016apdB9pbeLzng4mNo87cFm
`File` is declared with the discriminants zero to seven in file order, so
its letter is one addition and its parse from a number or a letter is one
lookup in `VARIANTS`; the three impls spelt out eight arms each. The
promotion piece is `Copy`, and its conversions to a piece and to a letter
now take it by value, so the four callers in make and unmake no longer
borrow it to convert it.

The `File` conversions are reached from fen and move parsing and
printing, none from the search, and the promotion conversions change only
how they take their argument. The bench is unchanged at 5965973.

Bench: 5965973
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016apdB9pbeLzng4mNo87cFm
…share helpers

Six instruments each parsed a suite position with the same panic on a fen
that does not read. `Position::board(instrument)` in bench.rs does it once,
with the same text, and the bench, the recorders, the effort and forced
instruments, the tactical suite and the terms extraction call it.

The strategic suite's two share methods repeated the bench's private
`share` in f64 form. It is now `pub(crate)` and they call it. The u32
values go through u64, which is exact, so the percentages are the same.

The cutoff census, the residuals and the effort instrument each wrote the
opening of their header (name, depth, every, then the cap when it is not the
default and the epd when a suite was named) and each sorted and deduped the
depths their summaries run over. `recorder::write_settings` and
`recorder::depths` carry both. The forced instrument's header states every
setting and stays on its own form; the reduction ledger is left as it was.

In forced.rs the one call of `count_share` is written out and the `dash`
closure folded into its use. In tune.rs `Terms::of` names the colour sign
once instead of matching `Color` twice.

No printed string changes. The header unit tests in each module and the
session tests under tests/ pin the output, and every instrument was run at
a small depth before and after and the output compared byte for byte, with
only the bench's ms and nps columns moving.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016apdB9pbeLzng4mNo87cFm
… hand count helpers

The census, residual and effort recorders each armed the shared fixture
with the same two closures, a reservoir at rate one with the default cap
and a disarm that counts what it kept. The fixture now has a generic form,
`reservoir_leaves_the_search_where_it_was::<T>(depth, config)`, that writes
them once, and the closure form stays for the ledger and the forced
instrument, which arm differently. The configuration is a parameter, so
the effort instrument's baseline test, which repeated the fixture's loop
under a switch off, is one call of the same function.

In tune.rs the tests that hand count a term's coefficients each copied the
slot lookup, the phase guard and the loop over both ends of the taper, and
the four mirrored position tests were one test with different fens. Each is
a helper now (`coefficient`, `phase_off_the_middle`, `both_ends_hold`,
`mirrored_rows_agree`) and the hand counts and their reasons stay in the
tests. The side to move check walks the same positions as
`a_positions_terms_reconstruct_its_evaluation`, so it is a second
assertion in that test's loop rather than a second walk of every_shape().

In residual.rs three tests wrote a `Report` literal that `report_of` builds,
and the suite header test ran two depth two searches with a full replay to
read one word of the header. It now runs one search at depth one with a cap
of nothing, which still carries the name from the run to the header, and
reads the bench shaped header off a made up report. The four made up rows
of the row label test were the rows of the summary test, so their label
assertions are made there, on named rows, before the counts; nothing is
dropped. The bench's second audited run moves into the test that compares
an audited run with a plain one, as one more assertion, which saves a
search. The effort switch test reads its three outcome counts off
`summaries()` instead of counting the rows by hand.

No test is weaker: every assertion that was made is still made, and the
test names docs/INSTRUMENTS.md cites are unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016apdB9pbeLzng4mNo87cFm
@aywrite
aywrite force-pushed the claude/arche-simplification-pass-6u0rbd branch from b98c586 to cbb986b Compare October 5, 2026 01:37
@aywrite
aywrite merged commit d9379f3 into master Oct 5, 2026
21 checks passed
@aywrite
aywrite deleted the claude/arche-simplification-pass-6u0rbd branch October 5, 2026 05:55
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.

2 participants