From 195ccf16877a5560a3bf430e873174f041ab6475 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 22:23:09 +0000 Subject: [PATCH] feat(search): Weigh entry age against depth in transposition table replacement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An entry's worth to the replacement policy is now its depth less eight plies for each search since it was stored. In a full bucket the victim is the entry worth least rather than the shallowest. A store is refused by an entry whose worth is above its depth, or by an exact entry of the same position whose worth equals it where the store is not exact. Before this, age counted only at the twelve search cliff, so an entry stored for an earlier move held its slot against a shallower one from this search until then. Stockfish chooses its victim with the same weight. A position's own entry from an earlier search now gives way to a shallower store of the same position once the store reaches its worth, where the deeper old entry used to turn it away. Within one search every entry is worth its depth, so the policy is the old one and the bench is identical. The change shows only across searches. Eight self-play games put the share of stores it can reach at under 0.2% at 256 MB and 10+0.1, and at 11% to 22% at 16 MB, where that many stores are turned away by a deeper entry for another position in a bucket that also holds an older one. It was played in two stages. At 10+0.1 with a 16 MB table, where the policy decides most, it read +19 ±13 over 1,500 games and passed sprt [0, 10]. At 30+0.3 with the default 256 MB it read +4 ±9 over 2,500 games and passed sprt [-10, 0], after one extension past a 2,000 game cap the test reached at an LLR of 2.93 against 2.94. In those games the candidate counted 0.968 to 0.985 of the baseline's nodes a second. That is the tree and not the code: replayed on the same positions at fixed nodes the two run level, and the gap sits in long endgames, where the baseline's old entries answer nodes almost for nothing and the candidate searches two plies deeper in the same time. The age is read in the victim loop: bench games +0.19% instructions, node counts identical. Elo: +4 ±9 (sprt [-10, 0] passed, 2500 games, 30+0.3, vs 31eb33b) Bench: 5965973 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WmzY2g6SZGYszT5dnuQ6tw --- arche-core/src/engine.rs | 8 +-- arche-core/src/engine/tests.rs | 13 ++-- arche-core/src/transposition.rs | 124 ++++++++++++++++++++++++++------ docs/INSTRUMENTS.md | 6 +- 4 files changed, 115 insertions(+), 36 deletions(-) diff --git a/arche-core/src/engine.rs b/arche-core/src/engine.rs index 32d08b01..223bce87 100644 --- a/arche-core/src/engine.rs +++ b/arche-core/src/engine.rs @@ -2968,10 +2968,10 @@ impl AlphaBeta { SearchOutcome::Complete(self.result_for(play, score), bound) } - /// The root's answer to the table, past the taint policy and the depth - /// contest, because the reported line is read back from this slot. - /// Under `Skip` a tainted root answer is stored too, and the rare - /// tainted cutoff it then offers is refused as under `Refuse`. A + /// The root's answer to the table, past the taint policy and the + /// replacement contest, because the reported line is read back from + /// this slot. Under `Skip` a tainted root answer is stored too, and the + /// rare tainted cutoff it then offers is refused as under `Refuse`. A /// ceiling is not stored, so the closest move is never promoted over a /// move it was not shown to beat. fn store_root_answer(&mut self, play: Play, value: Value, depth: u8, bound: ScoreBound) { diff --git a/arche-core/src/engine/tests.rs b/arche-core/src/engine/tests.rs index 39e19e7d..aef840eb 100644 --- a/arche-core/src/engine/tests.rs +++ b/arche-core/src/engine/tests.rs @@ -292,12 +292,13 @@ mod search { #[test] fn the_reported_line_opens_with_the_move_actually_answered() { // A deeper entry for the root position, left by an earlier search of - // it, used to win the depth contest against the root's own store: - // the table then told a line opening with the leftover's move while - // bestmove answered the fresh one, and the two disagreed in front of - // whatever was relaying the search. Here the queen hangs, so a fresh - // search must answer with the capture, while the planted leftover - // claims a quiet king move from a depth no shallow search can beat. + // it, used to win the replacement contest against the root's own + // store: the table then told a line opening with the leftover's move + // while bestmove answered the fresh one, and the two disagreed in + // front of whatever was relaying the search. Here the queen hangs, so + // a fresh search must answer with the capture, while the planted + // leftover claims a quiet king move from a depth no shallow search + // can beat. let game = Board::from_fen("k7/8/8/3q4/8/8/3R4/K7 w - - 0 1").unwrap(); let mut e = engine(game); let quiet = play_named(&e.board, "a1b1"); diff --git a/arche-core/src/transposition.rs b/arche-core/src/transposition.rs index ab8e69ab..957f2100 100644 --- a/arche-core/src/transposition.rs +++ b/arche-core/src/transposition.rs @@ -96,8 +96,8 @@ pub struct SignatureCounters { pub narrow_accepts: [u64; NARROW_WIDTHS.len()], /// Stores whose slice matched a foreign full key, so the store replaced /// another position's entry as this position's. Landed stores only: a - /// store the depth contest turns away after comparing against a foreign - /// entry's depth is a related cost, but nothing was evicted. + /// store the contest in `set` turns away after comparing against a + /// foreign entry's worth is a related cost, but nothing was evicted. pub aliased_evictions: u64, } @@ -294,10 +294,18 @@ const STALE_AFTER_SEARCHES: u8 = 12; /// The generations run from one to this and round again, so generation /// zero reads as empty. Ages are taken modulo this, so an entry from /// thirty one searches ago or more reads as recent again and holds its slot -/// by depth until it ages out again. Nothing wrong is read, since a hit is -/// keyed; a slot is only held longer than it should be. +/// by its worth until it ages out again. Nothing wrong is read, since a hit +/// is keyed; a slot is only held longer than it should be. const GENERATIONS: u8 = 31; +/// The plies of depth an entry is worth less for each search since it was +/// stored. A search on, the game has moved two plies and has usually left +/// the line the entry was searched for, so an entry from this search +/// outranks an older one unless the older is deeper by eight plies a +/// search. Stockfish 16 and its current master choose their victim with +/// the same weight (Stockfish 17 used sixteen). +const AGE_WEIGHT: i16 = 8; + impl Entry { const EMPTY: Entry = Entry { key: 0, @@ -782,6 +790,14 @@ impl TranspositionTable { self.replaceable = replaceable_under(self.generation); } + /// What an entry is worth keeping: its depth, less `AGE_WEIGHT` plies + /// for each search since it was stored. An entry from this search is + /// worth its depth. + #[inline(always)] + fn worth(&self, entry: Entry) -> i16 { + i16::from(entry.depth) - AGE_WEIGHT * i16::from(self.age(entry)) + } + /// How many searches ago an entry was stored. #[inline] fn age(&self, entry: Entry) -> u8 { @@ -908,8 +924,8 @@ impl TranspositionTable { } /// Where in its bucket a position goes: its own entry if it has one, - /// else an empty one, else a stale one, else the shallowest. This and - /// the depth contest in `set` are the whole replacement policy. The + /// else an empty one, else a stale one, else the one worth least. This + /// and the contest in `set` are the whole replacement policy. The /// third field says the slot was found replaceable, so the contest need /// not ask again. #[inline(always)] @@ -922,30 +938,35 @@ impl TranspositionTable { } let bucket = self.bucket(index); let mut victim = 0; + let mut least = i16::MAX; for i in 0..BUCKET { let entry = bucket.entry(i); if self.replaceable(entry) { return (index, i, true); } - if entry.depth < bucket.rest[victim].depth { + let worth = self.worth(entry); + if worth < least { + least = worth; victim = i; } } (index, victim, false) } - /// Store unless the slot holds something worth more. Reports whether - /// the entry landed. + /// Store unless the slot holds something worth more: an entry whose + /// worth is above the new store's depth, or an exact entry of the same + /// position whose worth equals that depth where the new store is not + /// exact. Reports whether the entry landed. #[inline(always)] fn set(&mut self, key: u64, pv: Pv) -> bool { let (index, i, free) = self.slot_for(key); let old = self.bucket(index).entry(i); debug_assert!(!free || self.replaceable(old)); if !free && !self.replaceable(old) { - if pv.depth < old.depth { + if i16::from(pv.depth) < self.worth(old) { return false; } - if pv.depth == old.depth + if i16::from(pv.depth) == self.worth(old) && old.key == Entry::slice(key) && matches!(old.bound(), Bound::Exact) && !matches!(pv.bound, Bound::Exact) @@ -977,7 +998,7 @@ impl TranspositionTable { audit.keys[at] = key; } - /// Store without the depth contest. For the root's end-of-iteration + /// Store without the contest. For the root's end-of-iteration /// entry: it names the move about to be answered with, and the reported /// line is read back from its slot, so an entry a deeper search left /// there earlier in the game must not outrank it. When one did, the @@ -1033,7 +1054,7 @@ impl TranspositionTable { } /// What the three `record_` methods share: the entry, offered to the - /// depth contest. + /// replacement contest. #[inline(always)] fn record( &mut self, @@ -1050,8 +1071,9 @@ impl TranspositionTable { ) } - /// The move the engine is about to answer with, stored past the depth - /// contest for the reason `set_always` gives, so it always lands. + /// The move the engine is about to answer with, stored past the + /// replacement contest for the reason `set_always` gives, so it always + /// lands. #[must_use] pub fn record_answer(&mut self, board: &Board, play: Play, score: Value, depth: u8) -> bool { self.set_always( @@ -1061,9 +1083,9 @@ impl TranspositionTable { true } - /// The move a root iteration failed high on, stored past the depth - /// contest as the floor it is, so the wider re-search orders it first. - /// It always lands. + /// The move a root iteration failed high on, stored past the + /// replacement contest as the floor it is, so the wider re-search orders + /// it first. It always lands. #[must_use] pub fn record_floor_answer( &mut self, @@ -1374,8 +1396,8 @@ mod tests { #[test] fn a_quiescence_entry_does_not_evict_a_searched_entry() { - // quiescence writes at depth zero, so the depth contest is what - // keeps its entries from displacing a searched position's + // quiescence writes at depth zero, so within a search the contest is + // what keeps its entries from displacing a searched position's let mut table = full_bucket(5); table.set(5, new_pv(Bound::Lower, 0)); assert!(table.get(5).is_none()); @@ -1534,7 +1556,8 @@ mod tests { #[test] fn an_entry_from_searches_ago_is_replaced_regardless_of_depth() { - let mut table = full_bucket(8); + // deep enough that its worth holds the slot until the window ends + let mut table = full_bucket(MAX_PLY); for _ in 0..STALE_AFTER_SEARCHES - 1 { table.new_search(); } @@ -1546,6 +1569,61 @@ mod tests { assert_eq!(kept(&table, 1..=4), 3); } + #[test] + fn an_entry_from_the_last_search_is_worth_eight_plies_less() { + let mut table = full_bucket(12); + table.new_search(); + table.set(5, new_pv(Bound::Lower, 3)); + assert!(table.get(5).is_none(), "worth four, deeper than three"); + table.set(5, new_pv(Bound::Lower, 4)); + assert!(table.get(5).is_some(), "worth four, no deeper than four"); + assert_eq!(kept(&table, 1..=4), 3); + } + + #[test] + fn a_positions_own_entry_from_the_last_search_takes_a_shallower_store() { + let mut table = TranspositionTable::with_capacity(4).expect("a table of a few buckets"); + table.set(1, new_pv(Bound::Lower, 10)); + table.set(1, new_pv(Bound::Lower, 9)); + assert_eq!(table.get(1).unwrap().depth, 10, "this search's, kept"); + table.new_search(); + table.set(1, new_pv(Bound::Lower, 2)); + assert_eq!(table.get(1).unwrap().depth, 2, "worth two, replaced"); + } + + #[test] + fn an_exact_entry_from_the_last_search_is_held_at_its_worth() { + let mut table = TranspositionTable::with_capacity(4).expect("a table of a few buckets"); + table.set(1, new_pv(Bound::Exact, 10)); + table.new_search(); + table.set(1, new_pv(Bound::Lower, 2)); + assert!( + matches!(table.get(1).unwrap().bound, Bound::Exact), + "worth two, exact, holds against a bound of two" + ); + table.set(1, new_pv(Bound::Exact, 2)); + assert_eq!(table.get(1).unwrap().depth, 2); + } + + #[test] + fn the_victim_is_the_entry_worth_least_not_the_shallowest() { + let mut table = TranspositionTable::with_capacity(4).expect("a table of a few buckets"); + table.set(1, new_pv(Bound::Exact, 10)); + table.set(2, new_pv(Bound::Exact, 9)); + table.new_search(); + table.set(3, new_pv(Bound::Exact, 3)); + table.set(4, new_pv(Bound::Exact, 3)); + // worth 2, 1, 3 and 3: the depth nine entry from the last search goes + table.set(5, new_pv(Bound::Lower, 3)); + assert!( + table.get(2).is_none(), + "the entry worth least should have gone" + ); + for key in [1, 3, 4, 5] { + assert!(table.get(key).is_some(), "key {key} should be there"); + } + } + #[test] fn the_root_store_takes_a_slot_whatever_the_bucket_holds() { let mut table = full_bucket(20); @@ -1563,11 +1641,11 @@ mod tests { table.new_search(); } for key in 1..=4 { - table.set(key, new_pv(Bound::Exact, 8)); + table.set(key, new_pv(Bound::Exact, MAX_PLY)); } table.new_search(); table.set(5, new_pv(Bound::Lower, 1)); - assert!(table.get(5).is_none(), "one search old, kept by depth"); + assert!(table.get(5).is_none(), "one search old, kept by its worth"); for _ in 0..STALE_AFTER_SEARCHES - 1 { table.new_search(); } diff --git a/docs/INSTRUMENTS.md b/docs/INSTRUMENTS.md index 8c9de43e..0e562bec 100644 --- a/docs/INSTRUMENTS.md +++ b/docs/INSTRUMENTS.md @@ -792,9 +792,9 @@ evictions count stores. A comparison is one live entry a probe really compared its signature against whose full key belonged to another position, so each is one chance in two to the signature's width, and every expectation is drawn from that total. An aliased eviction is a store that landed in a slot the signature -said was its own and replaced another position's entry. A store the depth -contest turned away after comparing itself with a foreign entry is a related -cost but evicted nothing, so it is not counted. +said was its own and replaced another position's entry. A store the +replacement contest turned away after comparing itself with a foreign entry +is a related cost but evicted nothing, so it is not counted. The thirty two bit count cannot say anything on its own: a run of this size expects a few thousandths of a false accept, so a zero is what a working