Remove duplicated code and tests across the engine, bench unchanged - #373
Conversation
Speed against
|
5759a4f to
b98c586
Compare
…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
b98c586 to
cbb986b
Compare
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_withwrappers, 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 checkon 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 inmake_move.🤖 Generated with Claude Code
https://claude.ai/code/session_016apdB9pbeLzng4mNo87cFm