diff --git a/arche-core/src/bench.rs b/arche-core/src/bench.rs index 910c61b3..9286465c 100644 --- a/arche-core/src/bench.rs +++ b/arche-core/src/bench.rs @@ -571,12 +571,15 @@ mod tests { assert_eq!(column("score"), position.score.to_string()); assert_eq!(column("refused"), position.refused_cutoffs.to_string()); + // a ply deeper than the refusing run: at seven, once the passed pawn + // squares were scored, the test rank's tree met no tainted entry to + // trust let trusting = SearchConfig::with_taint("trust").expect("trust is a policy"); - let trusted = run_suite(&suite, 7, 1 << 20, trusting); + let trusted = run_suite(&suite, 8, 1 << 20, trusting); assert!( trusted .to_string() - .starts_with("bench depth 7 hash 1MB positions 1 taint trust\n") + .starts_with("bench depth 8 hash 1MB positions 1 taint trust\n") ); assert_eq!(trusted.positions[0].refused_cutoffs, 0); assert!(trusted.positions[0].tainted_cutoffs > 0); @@ -612,9 +615,9 @@ mod tests { figures(skipping), ], [ - [177_161, 6_475, 22_519, 437, 0, 181, 0], - [12_948, 1_021, 3_399, 38, 15, 0, 0], - [14_088, 1_084, 3_717, 1, 0, 0, 8], + [182_505, 7_085, 22_788, 535, 0, 287, 0], + [11_884, 914, 3_056, 52, 24, 0, 0], + [15_165, 1_224, 3_823, 3, 0, 0, 39], ], "nodes, cutoffs, stores, tainted, tcuts, refused, skipped" ); @@ -830,24 +833,24 @@ mod tests { assert_eq!( counted, vec![ - ("start", 495_700), - ("italian", 1_121_074), - ("ruy lopez", 366_742), - ("kiwipete", 1_575_476), - ("perft 4", 2_814_759), - ("promotions", 1_983_365), - ("middlegame", 532_079), - ("sharp middlegame", 449_131), - ("bratko kopec 1", 11_714), + ("start", 564_019), + ("italian", 1_137_689), + ("ruy lopez", 378_968), + ("kiwipete", 1_625_682), + ("perft 4", 2_901_384), + ("promotions", 1_643_811), + ("middlegame", 494_644), + ("sharp middlegame", 449_762), + ("bratko kopec 1", 11_568), ("wac 4", 1_516), - ("rook and pawns", 214_156), - ("tarrasch", 238_448), - ("lucena", 361_398), - ("philidor", 357_659), - ("minor endgame", 156_339), + ("rook and pawns", 355_432), + ("tarrasch", 136_354), + ("lucena", 1_403_340), + ("philidor", 237_927), + ("minor endgame", 156_423), ("queen endgame", 337_681), - ("king and pawn", 38_955), - ("trebuchet", 32_344), + ("king and pawn", 45_261), + ("trebuchet", 35_531), ] ); } @@ -881,22 +884,22 @@ mod tests { vec![ ("start", 40_734), ("italian", 181_047), - ("ruy lopez", 155_177), - ("kiwipete", 220_649), - ("perft 4", 172_939), + ("ruy lopez", 155_167), + ("kiwipete", 182_229), + ("perft 4", 179_921), ("promotions", 101_321), - ("middlegame", 159_802), - ("sharp middlegame", 255_709), - ("bratko kopec 1", 6_675), + ("middlegame", 159_814), + ("sharp middlegame", 255_669), + ("bratko kopec 1", 6_654), ("wac 4", 675), - ("rook and pawns", 21_701), - ("tarrasch", 46_932), - ("lucena", 28_842), - ("philidor", 37_043), + ("rook and pawns", 23_516), + ("tarrasch", 32_961), + ("lucena", 49_407), + ("philidor", 38_260), ("minor endgame", 22_523), ("queen endgame", 132_956), - ("king and pawn", 2_843), - ("trebuchet", 1_162), + ("king and pawn", 2_696), + ("trebuchet", 1_007), ] ); } diff --git a/arche-core/src/engine/tests.rs b/arche-core/src/engine/tests.rs index 75d81bcb..443efe91 100644 --- a/arche-core/src/engine/tests.rs +++ b/arche-core/src/engine/tests.rs @@ -1021,11 +1021,14 @@ mod search { // at a ridge of 3e-7, under which a queen and a rook promotion tie // at depth six (573 each) and the two searches break the tie // differently. A tie says nothing about the schedule, so the sharp - // middlegame stands in for it, and every position here answers with - // the same move both ways with the fitted table and the test rank + // middlegame stands in for it. The rook and pawns ending tied the + // same way at depth six once the passed pawn squares were scored, and + // the quiet middlegame stands in for that. Every position here + // answers with the same move both ways with the fitted table and the + // test rank for fen in [ fens::KIWIPETE, - "8/2p5/3p4/KP5r/1R3p1k/8/4P1P1/8 w - - 10 10", + "r4rk1/1pp1qppp/p1np1n2/2b1p1B1/2B1P1b1/P1NP1N2/1PP1QPPP/R4RK1 w - - 0 1", fens::SHARP_MIDDLEGAME, ] { let mut direct = reference(Board::from_fen(fen).unwrap()); diff --git a/arche-core/src/eval/cache.rs b/arche-core/src/eval/cache.rs index 69751259..f4f88ad9 100644 --- a/arche-core/src/eval/cache.rs +++ b/arche-core/src/eval/cache.rs @@ -6,16 +6,16 @@ //! One cache type, not one table: a term names its key, its fold and its //! width, and [`super::Caches`] holds an instance of this per term. -/// One remembered score, under the whole key rather than a tag, since a +/// One remembered answer, under the whole key rather than a tag, since a /// wrong hit would be a silently wrong evaluation. The whole key costs sixteen /// bytes an entry against eight once the alignment is paid. #[derive(Copy, Clone)] -struct Entry { +struct Entry { key: u64, - packed: i32, + value: T, } -/// What one term has already worked out, `SLOTS` scores of it, in an array of +/// What one term has already worked out, `SLOTS` answers of it, in an array of /// that length rather than a slice, so the slot's mask bounds its index and a /// probe carries no bounds check. /// @@ -26,25 +26,36 @@ struct Entry { /// /// Owned by the searcher rather than by the board, because it is scratch and /// not position. -pub(super) struct Cache { - entries: Box<[Entry; SLOTS]>, +/// +/// What it holds is a score by default; the passed pawn squares keep a set +/// of squares in one under the pawn key. +pub(super) struct Cache { + entries: Box<[Entry; SLOTS]>, } -impl Default for Cache { +impl Default for Cache { fn default() -> Self { // an empty entry is key zero holding zero. A pawnless board's pawn - // key is zero and its pawn structure scores zero, so it reads the - // entry correctly; any other key of zero is the same sixty four bit - // coincidence a wrong hit needs anywhere else in the table - let entries = vec![Entry { key: 0, packed: 0 }; Self::SLOTS].into_boxed_slice(); + // key is zero and its pawn structure scores zero (and it has no + // passers), so it reads the entry correctly; any other key of zero is + // the same sixty four bit coincidence a wrong hit needs anywhere else + // in the table + let entries = vec![ + Entry { + key: 0, + value: T::default() + }; + Self::SLOTS + ] + .into_boxed_slice(); Cache { entries: entries.try_into().unwrap_or_else(|_| unreachable!()), } } } -impl Cache { - /// How many scores the table holds. A power of two, so the index is a +impl Cache { + /// How many answers the table holds. A power of two, so the index is a /// mask rather than a remainder. pub(super) const SLOTS: usize = { assert!(SLOTS.is_power_of_two()); @@ -54,24 +65,24 @@ impl Cache { /// What `key` stands for, read back where the table holds it and folded /// where it does not. #[inline] - pub(super) fn get(&mut self, key: u64, fold: impl FnOnce() -> i32) -> i32 { + pub(super) fn get(&mut self, key: u64, fold: impl FnOnce() -> T) -> T { let slot = (key as usize) & (Self::SLOTS - 1); let entry = &mut self.entries[slot]; if entry.key == key { - return entry.packed; + return entry.value; } - let packed = fold(); - *entry = Entry { key, packed }; - packed + let value = fold(); + *entry = Entry { key, value }; + value } /// What the table holds under `key`, and nothing where the slot stands /// for another key. For the tests in [`super`], which read the tables /// rather than the score. #[cfg(test)] - pub(super) fn stored(&self, key: u64) -> Option { + pub(super) fn stored(&self, key: u64) -> Option { let entry = &self.entries[(key as usize) & (Self::SLOTS - 1)]; - (entry.key == key).then_some(entry.packed) + (entry.key == key).then_some(entry.value) } } @@ -102,7 +113,7 @@ mod tests { assert_eq!((shares as usize) & (Cache::::SLOTS - 1), slot); assert_eq!(cache.entries[slot].key, 0, "the slot starts empty"); - assert_eq!(cache.entries[slot].packed, 0, "holding nothing"); + assert_eq!(cache.entries[slot].value, 0, "holding nothing"); assert_eq!(cache.stored(key), None); assert_eq!(cache.stored(0), Some(0), "and a key of zero reads it"); diff --git a/arche-core/src/eval/mod.rs b/arche-core/src/eval/mod.rs index 51c49a35..806e5ca5 100644 --- a/arche-core/src/eval/mod.rs +++ b/arche-core/src/eval/mod.rs @@ -16,6 +16,7 @@ mod cache; pub(crate) mod factors; mod king_attack; mod mobility; +mod passer_squares; mod pawn_structure; mod shelter; mod tempo; @@ -189,6 +190,12 @@ pub(crate) const TERMS: &[Term] = &[ weight: tempo::weight, counts: tempo::counts, }, + Term { + name: "passer_squares", + width: passer_squares::COUNTS, + weight: passer_squares::weight, + counts: passer_squares::counts, + }, ]; /// The widest term, which is how long a buffer the tuner's walk needs to ask @@ -228,6 +235,9 @@ const fn widest() -> usize { trait Memo { /// The shelter and the pawn structure, added. fn tables(&mut self, board: &Position) -> i32; + /// Both sides' passers on the squares [`passer_squares`] reads them on, + /// which the pawns alone decide. + fn passers(&mut self, board: &Position) -> u64; } /// The memo that remembers nothing, which is what [`eval`] hands the sum. @@ -238,6 +248,11 @@ impl Memo for NoMemo { fn tables(&mut self, board: &Position) -> i32 { shelter::fold(board) + pawn_structure::fold(board) } + + #[inline] + fn passers(&mut self, board: &Position) -> u64 { + passer_squares::passers(board) + } } /// What a searcher carries so that the two remembered terms are not computed @@ -249,16 +264,24 @@ impl Memo for NoMemo { /// The pawn table stays behind it: a key of the shelter's alone would /// recompute the pawn structure on every king move, the half of the /// shelter's key that term does not need. Measured on the fitted build. +/// +/// The passed pawn squares read the pieces and are not remembered, but which +/// pawns they read is the pawns' alone, so that set has a table of its own +/// under the pawn key. #[derive(Default)] pub(crate) struct Caches { shelter: ShelterCache, pawns: PawnCache, + passers: PasserCache, } /// The shelter's table, as wide as that term measured it wants. type ShelterCache = Cache<{ 1 << shelter::CACHE_BITS }>; /// The pawn structure's, on its own key and its own measurement. type PawnCache = Cache<{ 1 << pawn_structure::CACHE_BITS }>; +/// The passers the passed pawn squares read, under the same key at the same +/// width as the pawn structure's. +type PasserCache = Cache<{ 1 << pawn_structure::CACHE_BITS }, u64>; impl Memo for Caches { #[inline] @@ -268,6 +291,12 @@ impl Memo for Caches { shelter::fold(board) + pawns.get(board.pawn_key, || pawn_structure::fold(board)) }) } + + #[inline] + fn passers(&mut self, board: &Position) -> u64 { + self.passers + .get(board.pawn_key, || passer_squares::passers(board)) + } } /// The score of the position from the side to move's point of view, with the @@ -294,7 +323,13 @@ fn sum(board: &Position, memo: &mut impl Memo) -> Score { let tables = memo.tables(board); let walk = |color| attack_score::<{ mobility::SCORED_KINDS }, { king_attack::SCORED }>(board, color); - let leaf = walk(Color::White) - walk(Color::Black) + tables + tempo::fold(board); + // read at the leaf: it reads the pieces, which no memo here is keyed on + let passers = if passer_squares::SCORED { + passer_squares::fold(board, memo.passers(board)) + } else { + 0 + }; + let leaf = walk(Color::White) - walk(Color::Black) + tables + tempo::fold(board) + passers; board.eval.score(board.active_color, leaf) } @@ -542,8 +577,8 @@ pub(crate) fn suite_fens() -> Vec { mod evaluate { use super::{ Board, Caches, Memo, PawnCache, ShelterCache, TERMS, TOTAL_PHASE, eval, eval_cached, - factors, king_attack, mobility, pawn_structure, pieces_of, shelter, suite_fens, tempo, - weigh, + factors, king_attack, mobility, passer_squares, pawn_structure, pieces_of, shelter, + suite_fens, tempo, weigh, }; use crate::board::fens; use crate::misc::{Color, File, coordinate_to_index}; @@ -1040,7 +1075,7 @@ mod evaluate { } } - /// The table names the five leaf terms the sum adds. The sum is hand + /// The table names the six leaf terms the sum adds. The sum is hand /// written rather than a walk over the table, so this is the one place /// the two lists are held against each other; a priced term in the table /// and not the sum would fail the tuner's identity, and this says which @@ -1055,11 +1090,12 @@ mod evaluate { "shelter", "pawn_structure", "king_attack", - "tempo" + "tempo", + "passer_squares" ] ); // two queens and a rook against none, a king in each corner and pawns - // of both colours on six files, so that no one of the five folds to + // of both colours on six files, so that no one of the six folds to // nothing. The queen on a4 bears on d7 and e8 of the black king's // ring let board = Board::from_fen("3k4/P4p2/8/3P2p1/Q2P4/PP2p2p/1P6/1Q4KR w - - 0 1").unwrap(); @@ -1069,6 +1105,10 @@ mod evaluate { ("pawn structure", pawn_structure::fold(&board)), ("king attack", king_attack::fold(&board)), ("tempo", tempo::fold(&board)), + ( + "passer squares", + passer_squares::fold(&board, passer_squares::passers(&board)), + ), ] { assert_ne!(term, 0, "{} is level here, so it says nothing", name); } @@ -1076,7 +1116,8 @@ mod evaluate { + shelter::fold(&board) + pawn_structure::fold(&board) + king_attack::fold(&board) - + tempo::fold(&board); + + tempo::fold(&board) + + passer_squares::fold(&board, passer_squares::passers(&board)); assert_eq!(eval(&board), board.eval.score(board.active_color, leaf)); } diff --git a/arche-core/src/eval/passer_squares.rs b/arche-core/src/eval/passer_squares.rs new file mode 100644 index 00000000..b137f841 --- /dev/null +++ b/arche-core/src/eval/passer_squares.rs @@ -0,0 +1,397 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2022-2026 Andrew Wright + +//! What the squares in front of a passed pawn say about it: where the two +//! kings stand from its stop and queening squares, whether the stop is taken +//! or attacked, and whether anything stands between it and the eighth. +//! +//! The structure term prices a passer by its file and rank alone, so that it +//! can sit behind the pawn key. These read the pieces, so they are computed +//! at the leaf. + +use super::pawn_structure; +#[cfg(test)] +use crate::board::Board; +use crate::board::{Position, king_attacks, knight_attacks}; +use crate::magic::MAGIC; +use crate::misc::Color; +use crate::psqt::pack; + +/// The first relative rank the term reads, counted from zero: the fourth. +/// A passer further back is far from queening, and the term was not fitted +/// for it. +const FIRST_RANK: usize = 3; +/// One bucket for each of the relative fourth to seventh. +const BUCKETS: usize = 4; + +/// What each bucket counts, in this order. +const FEATURES: usize = 7; +const OWN_STOP: usize = 0; +const THEIR_STOP: usize = 1; +const OWN_QUEEN: usize = 2; +const THEIR_QUEEN: usize = 3; +const STOP_TAKEN: usize = 4; +const STOP_ATTACKED: usize = 5; +const FREE_PATH: usize = 6; + +/// The counts, by relative rank bucket and then feature. +pub(crate) const COUNTS: usize = BUCKETS * FEATURES; + +/// The squares a white passer is read on, the fourth to the seventh rank, +/// and a black one, the fifth down to the second. +const WHITE_RANKS: u64 = 0x00ff_ffff_ff00_0000; +const BLACK_RANKS: u64 = 0x0000_00ff_ffff_ff00; + +/// What one count is worth, as packed pairs. +/// +/// Fitted 2026-10-14 on 5,026,360 quiet positions from 109,141 games with +/// every other weight held, each weight held to the sign chess expects of it. +/// Without that bound the fit put a blockaded passer on the seventh above a +/// free one in the ending and paid an attacked stop in the midgame, which is +/// what a corpus of quiet positions teaches: a free passer on the seventh with +/// its side to move promotes in quiescence and is not kept. The bound took the +/// held-out gain from 0.000440 to 0.000393. `tune.py` has no such bound, so a +/// refit there gives the unbounded weights back. The king distances are in +/// king steps. +/// +/// On the seventh the stop is the path, so a free path there is exactly a +/// stop not taken, and the split between those two weights is the bound's +/// rather than the corpus's. +static PASSER_SQUARES: [i32; COUNTS] = [ + // fourth + pack(0, -8), + pack(2, 7), + pack(0, 0), + pack(0, 1), + pack(-12, 0), + pack(0, -14), + pack(0, 14), + // fifth + pack(0, -10), + pack(1, 5), + pack(0, 0), + pack(0, 7), + pack(-7, 0), + pack(0, -22), + pack(14, 13), + // sixth + pack(0, -10), + pack(6, 0), + pack(0, 0), + pack(0, 16), + pack(-13, 0), + pack(0, -45), + pack(21, 26), + // seventh, where the stop is the queening square and the two queening + // distances would repeat the stop's + pack(-6, -6), + pack(17, 8), + pack(0, 0), + pack(0, 0), + pack(0, 0), + pack(0, -41), + pack(154, 0), +]; + +/// The weight of one count, as the packed pair, read through +/// [`super::TERMS`] so that a slot names the live weight rather than a copy. +pub(crate) fn weight(index: usize) -> i32 { + PASSER_SQUARES[index] +} + +/// Whether [`super::sum`] takes this term at the leaf: true while any weight +/// is not zero, so weights put back to zero turn it off and nothing is read. +pub(crate) const SCORED: bool = { + let mut index = 0; + let mut any = false; + while index < COUNTS { + any |= PASSER_SQUARES[index] != 0; + index += 1; + } + any +}; + +/// The king distance between two squares: the more of the rank and file +/// steps between them. +const fn steps(a: usize, b: usize) -> u8 { + let files = (a % 8).abs_diff(b % 8); + let ranks = (a / 8).abs_diff(b / 8); + (if files > ranks { files } else { ranks }) as u8 +} + +/// [`steps`] for every pair of squares, so the leaf reads a distance rather +/// than working one out. +static DISTANCE: [[u8; 64]; 64] = { + let mut table = [[0; 64]; 64]; + let mut a = 0; + while a < 64 { + let mut b = 0; + while b < 64 { + table[a][b] = steps(a, b); + b += 1; + } + a += 1; + } + table +}; + +fn distance(a: u8, b: u8) -> i32 { + i32::from(DISTANCE[usize::from(a)][usize::from(b)]) +} + +/// This side's passers on the relative fourth to seventh. +#[inline] +fn read_passers(board: &Position, color: Color) -> u64 { + let ranks = match color { + Color::White => WHITE_RANKS, + Color::Black => BLACK_RANKS, + }; + pawn_structure::passers(board, color) & ranks +} + +/// Both sides' passers on the relative fourth to seventh, in one set: the +/// pawns alone decide it, so [`super::Caches`] keeps it under the pawn key, +/// and the side occupancy tells the two colours apart again. +#[inline] +pub(crate) fn passers(board: &Position) -> u64 { + read_passers(board, Color::White) | read_passers(board, Color::Black) +} + +/// This side's counts, summed over its passers on the fourth to the seventh. +pub(crate) fn counts_of(board: &Position, color: Color) -> [i32; COUNTS] { + let mut counts = [0; COUNTS]; + let other = !color; + let occupied = board.occupied(); + let own_king = board.king_index(color); + let their_king = board.king_index(other); + let mut passers = read_passers(board, color); + while passers != 0 { + let square = passers.trailing_zeros() as u8; + passers &= passers - 1; + let (relative, stop, queen) = match color { + Color::White => (usize::from(square / 8), square + 8, 56 + square % 8), + Color::Black => (7 - usize::from(square / 8), square - 8, square % 8), + }; + let base = (relative - FIRST_RANK) * FEATURES; + counts[base + OWN_STOP] += distance(own_king, stop); + counts[base + THEIR_STOP] += distance(their_king, stop); + if relative < 6 { + counts[base + OWN_QUEEN] += distance(own_king, queen); + counts[base + THEIR_QUEEN] += distance(their_king, queen); + } + counts[base + STOP_TAKEN] += i32::from(occupied & (1u64 << stop) != 0); + counts[base + STOP_ATTACKED] += i32::from(board.square_attacked(stop, other)); + let path = pawn_structure::file_ahead(square, color); + counts[base + FREE_PATH] += i32::from(occupied & path == 0); + } + counts +} + +/// The counts, written into `into`, which is what [`super::TERMS`] hands the +/// tuner's walk. +pub(crate) fn counts(board: &Position, color: Color, into: &mut [i32]) { + into.copy_from_slice(&counts_of(board, color)); +} + +/// Whether `by`'s pieces bear on a passer's stop square. `square_attacked` +/// without its pawn test: a pawn of theirs on a file beside the passer and +/// ahead of it would have stopped it being passed, so none can reach its +/// stop. +#[inline] +fn stop_attacked(board: &Position, stop: u8, by: Color, occupied: u64) -> bool { + let (theirs, _) = board.sides(by); + if knight_attacks(stop) & board.knights() & theirs != 0 + || king_attacks(stop) & board.kings() & theirs != 0 + { + return true; + } + let queens = board.queens(); + let diagonal = (board.bishops() | queens) & theirs; + let straight = (board.rooks() | queens) & theirs; + (diagonal != 0 && MAGIC.get_diagonal_move(stop, occupied) & diagonal != 0) + || (straight != 0 && MAGIC.get_straight_move(stop, occupied) & straight != 0) +} + +/// One side's passers' squares as a packed pair: [`counts_of`] weighed, a +/// passer at a time, so the leaf builds no count array. +#[inline] +fn side_score(board: &Position, color: Color, mut passers: u64, occupied: u64) -> i32 { + let other = !color; + let own_king = board.king_index(color); + let their_king = board.king_index(other); + let mut score = 0; + while passers != 0 { + let square = passers.trailing_zeros() as u8; + passers &= passers - 1; + let (relative, stop, queen) = match color { + Color::White => (usize::from(square / 8), square + 8, 56 + square % 8), + Color::Black => (7 - usize::from(square / 8), square - 8, square % 8), + }; + // the set holds only the fourth to seventh, and the mask says so to + // the bounds checks + let base = ((relative - FIRST_RANK) & (BUCKETS - 1)) * FEATURES; + let weight = |feature: usize| PASSER_SQUARES[base + feature]; + // own king to the queening square is fitted at zero in every bucket, + // so it is not read here, and the other king's is read on the seventh + // too, where its weight is zero; `the_skipped_counts_are_worth_nothing` + // holds both + score += weight(OWN_STOP) * distance(own_king, stop) + + weight(THEIR_STOP) * distance(their_king, stop) + + weight(THEIR_QUEEN) * distance(their_king, queen); + if occupied & (1u64 << stop) != 0 { + score += weight(STOP_TAKEN); + } else if occupied & pawn_structure::file_ahead(square, color) == 0 { + // the stop is on the path, so a taken stop is never a free path + score += weight(FREE_PATH); + } + if stop_attacked(board, stop, other, occupied) { + score += weight(STOP_ATTACKED); + } + } + score +} + +/// White's passers' squares less black's, as a packed pair, from the set +/// [`passers`] gives. Most positions have no passer on the fourth to seventh +/// for either side, and they cost the set and nothing else. +#[inline] +pub(crate) fn fold(board: &Position, passers: u64) -> i32 { + if passers == 0 { + return 0; + } + let occupied = board.occupied(); + let (white, black) = board.sides(Color::White); + side_score(board, Color::White, passers & white, occupied) + - side_score(board, Color::Black, passers & black, occupied) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::eval::weigh; + + fn feature(counts: &[i32; COUNTS], relative: usize, feature: usize) -> i32 { + counts[(relative - FIRST_RANK) * FEATURES + feature] + } + + #[test] + fn a_free_passer_on_the_sixth_counts_its_squares() { + // white pawn e6, white king g1, black king a8: stop e7, queen e8 + let board = Board::from_fen("k7/8/4P3/8/8/8/8/6K1 w - - 0 1").unwrap(); + let white = counts_of(&board, Color::White); + assert_eq!(feature(&white, 5, OWN_STOP), 6); + assert_eq!(feature(&white, 5, THEIR_STOP), 4); + assert_eq!(feature(&white, 5, OWN_QUEEN), 7); + assert_eq!(feature(&white, 5, THEIR_QUEEN), 4); + assert_eq!(feature(&white, 5, STOP_TAKEN), 0); + assert_eq!(feature(&white, 5, STOP_ATTACKED), 0); + assert_eq!(feature(&white, 5, FREE_PATH), 1); + assert_eq!(counts_of(&board, Color::Black), [0; COUNTS]); + } + + #[test] + fn a_blockaded_passer_on_the_seventh_reads_its_stop() { + // white pawn e7, black rook e8: the stop is taken, and the path with + // it. The rook stands on the stop rather than attacking it, and the + // king on g8 does not reach e8, so the stop is not attacked + let board = Board::from_fen("4r1k1/4P3/8/8/8/8/8/6K1 w - - 0 1").unwrap(); + let white = counts_of(&board, Color::White); + assert_eq!(feature(&white, 6, OWN_STOP), 7); + assert_eq!(feature(&white, 6, THEIR_STOP), 2); + assert_eq!(feature(&white, 6, OWN_QUEEN), 0); + assert_eq!(feature(&white, 6, THEIR_QUEEN), 0); + assert_eq!(feature(&white, 6, STOP_TAKEN), 1); + assert_eq!(feature(&white, 6, STOP_ATTACKED), 0); + assert_eq!(feature(&white, 6, FREE_PATH), 0); + } + + #[test] + fn a_black_passer_is_read_down_the_board() { + // black pawn d3 is on its relative sixth; stop d2 attacked by the + // white king on e1 + let board = Board::from_fen("7k/8/8/8/8/3p4/8/4K3 w - - 0 1").unwrap(); + let black = counts_of(&board, Color::Black); + assert_eq!(feature(&black, 5, OWN_STOP), 6); + assert_eq!(feature(&black, 5, THEIR_STOP), 1); + assert_eq!(feature(&black, 5, OWN_QUEEN), 7); + assert_eq!(feature(&black, 5, THEIR_QUEEN), 1); + assert_eq!(feature(&black, 5, STOP_TAKEN), 0); + assert_eq!(feature(&black, 5, STOP_ATTACKED), 1); + assert_eq!(feature(&black, 5, FREE_PATH), 1); + } + + #[test] + fn a_passer_behind_the_fourth_or_on_the_back_rank_is_not_read() { + for fen in [ + "k7/8/8/8/8/4P3/8/6K1 w - - 0 1", + // from_fen accepts a pawn on the eighth; it has no stop + "k3P3/8/8/8/8/8/8/6K1 w - - 0 1", + ] { + let board = Board::from_fen(fen).unwrap(); + assert_eq!(counts_of(&board, Color::White), [0; COUNTS], "{fen}"); + assert_eq!(fold(&board, passers(&board)), 0, "{fen}"); + } + } + + /// The fold is the counts weighed, white less black, including where it + /// returns early. + #[test] + fn the_fold_weighs_white_less_black() { + for fen in [ + "k7/8/4P3/8/8/8/8/6K1 w - - 0 1", + "4r1k1/4P3/8/8/8/8/8/4R1K1 w - - 0 1", + "6k1/5ppp/8/P7/8/8/5PPP/r4RK1 w - - 0 1", + "8/1p3k2/p1p5/P1P1p3/4P1p1/6P1/5K2/8 w - - 0 1", + "r1bq1rk1/pp3ppp/2n5/8/8/2N5/PPP2PP1/R1BQK2R w KQ - 0 1", + // passers of both colours with a king beside a stop, a knight on + // one and a bishop bearing on another + "8/1P4k1/3K4/2n5/5p2/1b6/6p1/8 w - - 0 1", + ] { + let board = Board::from_fen(fen).unwrap(); + let expected = weigh( + &PASSER_SQUARES, + counts_of(&board, Color::White), + counts_of(&board, Color::Black), + ); + assert_eq!(fold(&board, passers(&board)), expected, "{fen}"); + } + } + + /// The leaf skips the own king's distance to the queening square, and + /// reads the other king's on the seventh where [`counts_of`] counts + /// nothing. Both are right only while those weights are zero. + #[test] + fn the_skipped_counts_are_worth_nothing() { + for bucket in 0..BUCKETS { + assert_eq!(PASSER_SQUARES[bucket * FEATURES + OWN_QUEEN], 0, "{bucket}"); + } + assert_eq!(PASSER_SQUARES[(BUCKETS - 1) * FEATURES + THEIR_QUEEN], 0); + } + + #[test] + fn the_distance_table_counts_king_steps() { + assert_eq!(distance(0, 63), 7); + assert_eq!(distance(6, 52), 6); + assert_eq!(distance(62, 52), 2); + assert_eq!(distance(12, 12), 0); + assert_eq!(distance(4, 11), 1); + } + + /// The fitted weights, read as chess: the other king's distance and a + /// free path never count against the passer, and its own king's + /// distance, a taken stop and an attacked one never count for it. + #[test] + fn every_weight_has_the_sign_it_was_fitted_under() { + use crate::psqt::{eg_value, mg_value}; + for (index, weight) in PASSER_SQUARES.iter().enumerate() { + let feature = index % FEATURES; + let favours = matches!(feature, THEIR_STOP | THEIR_QUEEN | FREE_PATH); + for half in [mg_value(*weight), eg_value(*weight)] { + assert!( + if favours { half >= 0 } else { half <= 0 }, + "count {index} has the wrong sign" + ); + } + } + } +} diff --git a/arche-core/src/eval/pawn_structure.rs b/arche-core/src/eval/pawn_structure.rs index 37ce53c3..c8e29569 100644 --- a/arche-core/src/eval/pawn_structure.rs +++ b/arche-core/src/eval/pawn_structure.rs @@ -223,6 +223,31 @@ pub(crate) fn counts_of(board: &Position, color: Color) -> [i32; COUNTS] { counts } +/// This side's passed pawns as a set, by the rule [`counts_of`] counts them +/// on and on every rank: no pawn of theirs ahead on its file or a file beside +/// it, and no pawn of ours ahead on its own file. Set-wise rather than a pawn +/// at a time, for the leaf's [`super::passer_squares`], which cannot read it +/// from the memo. +#[inline] +pub(crate) fn passers(board: &Position, color: Color) -> u64 { + const FILE_A: u64 = 0x0101_0101_0101_0101; + const FILE_H: u64 = FILE_A << 7; + let (ours, theirs) = board.sides(color); + let pawns = board.pawns(); + let (our_pawns, their_pawns) = (pawns & ours, pawns & theirs); + // every square behind one of their pawns, as they move, is a square + // where a pawn of ours has that pawn ahead of it on the same file + let behind = ahead_of(their_pawns, !color); + let stopped = behind | ((behind & !FILE_H) << 1) | ((behind & !FILE_A) >> 1); + our_pawns & !stopped & !ahead_of(our_pawns, !color) +} + +/// The squares in front of a pawn of this colour on its own file. +#[inline] +pub(crate) fn file_ahead(square: u8, color: Color) -> u64 { + MASKS.file_ahead[color as usize][square as usize] +} + /// The eight counts, written into `into`, which is what [`super::TERMS`] hands /// the tuner's walk. pub(crate) fn counts(board: &Position, color: Color, into: &mut [i32]) { @@ -417,6 +442,40 @@ mod tests { } } + /// The set-wise passers agree with the count, rank by rank, on every + /// position the hand counts above use and on a mixed one. + #[test] + fn the_passer_set_agrees_with_the_passed_counts() { + for fen in [ + "4k3/P4p2/8/3P2p1/3P4/PP2p2p/1P6/4K3 w - - 0 1", + "4k3/8/8/3p4/4P3/8/8/4K3 w - - 0 1", + "4k3/8/8/8/3pP3/8/8/4K3 w - - 0 1", + "4k3/8/8/4P3/4P3/8/8/4K3 w - - 0 1", + "4k3/8/8/1p6/P7/8/8/4K3 w - - 0 1", + "4k3/8/8/6p1/7P/8/8/4K3 w - - 0 1", + "r1bq1rk1/pp3ppp/2n5/8/8/2N5/PPP2PP1/R1BQK2R w KQ - 0 1", + "8/1p3k2/p1p5/P1P1p3/4P1p1/6P1/5K2/8 w - - 0 1", + ] { + let board = Board::from_fen(fen).unwrap(); + for color in [Color::White, Color::Black] { + let counts = counts_of(&board, color); + let set = super::passers(&board, color); + for relative in 1..7 { + let rank = match color { + Color::White => relative, + Color::Black => 7 - relative, + }; + let on_rank = (set >> (rank * 8)) & 0xff; + assert_eq!( + on_rank.count_ones() as i32, + counts[relative - 1], + "{fen} {color:?} relative {relative}" + ); + } + } + } + } + /// Black's count of a position is white's count of its reflection, so the /// two colours are read the same way round. #[test] diff --git a/arche-core/src/strategy.rs b/arche-core/src/strategy.rs index 4b5ec8ee..af084440 100644 --- a/arche-core/src/strategy.rs +++ b/arche-core/src/strategy.rs @@ -32,7 +32,7 @@ pub const TABLE_BYTES: usize = 16 * 1024 * 1024; /// How many of the suite's points the search takes at that depth with that /// table. Exact, not a floor: a change that moves it updates this number in /// the same commit. -pub const EXPECTED_POINTS: u32 = 104298; +pub const EXPECTED_POINTS: u32 = 104395; const SUITE: &str = include_str!("../strategy.epd"); diff --git a/arche-core/src/tactics.rs b/arche-core/src/tactics.rs index ec05ba18..dfd2c28f 100644 --- a/arche-core/src/tactics.rs +++ b/arche-core/src/tactics.rs @@ -33,7 +33,7 @@ pub const TABLE_BYTES: usize = 16 * 1024 * 1024; /// is the gate. A change that moves it in either direction updates this /// number in the same commit, and a change that lowers it says what the /// positions were spent on. -pub const EXPECTED_PASSES: usize = 228; +pub const EXPECTED_PASSES: usize = 230; /// The count the suite may not go under, whatever a commit says it meant to /// spend. diff --git a/docs/INSTRUMENTS.md b/docs/INSTRUMENTS.md index 80073d60..844d2b55 100644 --- a/docs/INSTRUMENTS.md +++ b/docs/INSTRUMENTS.md @@ -492,7 +492,7 @@ The line after the header states the layout, so that what reads these rows holds no copy of it: ``` -layout midgame 384 endgame 384 material 6 mobility 4 shelter 7 pawn_structure 8 king_attack 4 tempo 1 +layout midgame 384 endgame 384 material 6 mobility 4 shelter 7 pawn_structure 8 king_attack 4 tempo 1 passer_squares 28 ``` The first three are runs of slots. The names after them are the leaf terms in @@ -523,7 +523,8 @@ evaluation: eval = mat . w_mat + trunc((psqt . w_psqt + mobility . w_mobility + shelter . w_shelter + pawns . w_pawns + king_attack . w_king_attack - + tempo . w_tempo) / 24) + + tempo . w_tempo + + passer_squares . w_passer_squares) / 24) + machine ``` @@ -765,9 +766,10 @@ are read. A hold freezes both halves of the term's taper. A fit of a new term holds everything older: the shelter was fitted under `--hold tables --hold mobility`, the pawn structure under those and `--hold shelter`, the king attack zone under those and `--hold pawn_structure`, and -the tempo under those and `--hold king_attack`. A refit of an older term on a -grown corpus holds every other term instead, so a mobility refit is `--hold -tables --hold shelter --hold pawn_structure --hold king_attack --hold tempo`. +the tempo under those and `--hold king_attack`, and the passer squares under +those and `--hold tempo`. A refit of an older term on a grown corpus holds +every other term instead, so a mobility refit is `--hold tables --hold shelter +--hold pawn_structure --hold king_attack --hold tempo --hold passer_squares`. The holds follow from the one term the run means to move. ## What the table's key signature costs diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 843e70ed..84617879 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -37,12 +37,14 @@ worth doing. at both ends of the taper at every ridge on the grid, likely from the quiet filter, since a position with a passer one square from queening is rarely settled unless the pawn is blockaded or falling. Thirteen of the strategic suite's fifteen themes rose when - that term was fitted; AKPC fell by 112 and 7th Rank by 217. The term leaves out - everything that reads a square rather than a file: whether the square in front of a - passer is occupied or attacked, how far each king stands from the promotion square, - candidate, connected and backward pawns, pawn islands, and the rule of the square. The - first two are the valuable ones, and neither can sit behind a key over the pawns. The - tuner is built ([INSTRUMENTS.md](INSTRUMENTS.md)), so a candidate term is one appended + that term was fitted; AKPC fell by 112 and 7th Rank by 217. A second term, computed at + the leaf, reads the squares in front of a passer on the fourth to seventh: both kings' + distance from its stop and queening squares, whether the stop is taken or attacked, and + whether the way to the eighth is clear. Fitted freely it put a blockaded passer on the + seventh above a free one in the ending, the same quiet filter at work, so it was fitted + with every weight held to the sign chess expects. Candidate, connected and backward + pawns, pawn islands, a rook behind the passer and the rule of the square are still + left out. The tuner is built ([INSTRUMENTS.md](INSTRUMENTS.md)), so a candidate term is one appended column whose held-out loss can be read before there is engine code for it. Fitting on our own games does buy strength: four fits each passed an sprt bounded [0, 10] at 10+0.1, the twelve tables at +54 ±13 over 2,000 games, the first mobility fit at +12 ±8 diff --git a/scripts/tests/test_tune.py b/scripts/tests/test_tune.py index 0ae27b92..f8317a4f 100644 --- a/scripts/tests/test_tune.py +++ b/scripts/tests/test_tune.py @@ -24,7 +24,7 @@ # asked of tune.py, which would agree with itself whatever it said. LAYOUT_LINE = ( "layout midgame 384 endgame 384 material 6 mobility 4 shelter 7 " - "pawn_structure 8 king_attack 4 tempo 1" + "pawn_structure 8 king_attack 4 tempo 1 passer_squares 28" ) LAYOUT = tune.Layout.of(LAYOUT_LINE) @@ -171,17 +171,17 @@ def test_a_header_without_the_drawn_count_is_refused(): tune.parse_terms([header, *lines[1:]]) -@pytest.mark.parametrize("count", [518, 774, 782, 790, 796, 812, 820]) +@pytest.mark.parametrize("count", [518, 774, 782, 790, 796, 812, 820, 822]) def test_a_vector_of_an_earlier_layout_is_refused(tmp_path, count): """The lengths the vector had before the endgame tables, before mobility, before the shelter, before the pawn storm, before the pawn structure, - before the king attack zone and before the tempo. Every slot any of them - names exists in the layout that replaced it, so their numbers would land - on the wrong weights rather than failing to parse. Both doors a vector comes through refuse - them.""" - assert LAYOUT.slots == 822 + before the king attack zone, before the tempo and before the passer + squares. Every slot any of them names exists in the layout that replaced + it, so their numbers would land on the wrong weights rather than failing + to parse. Both doors a vector comes through refuse them.""" + assert LAYOUT.slots == 878 old = [0] * count - with pytest.raises(ValueError, match=f"of {count}, expected 822"): + with pytest.raises(ValueError, match=f"of {count}, expected 878"): tune.parse_terms( [ LAYOUT_LINE, @@ -190,7 +190,7 @@ def test_a_vector_of_an_earlier_layout_is_refused(tmp_path, count): ) written = tmp_path / "fitted.json" written.write_text(json.dumps(old), encoding="utf-8") - with pytest.raises(ValueError, match=f"of {count}, expected 822"): + with pytest.raises(ValueError, match=f"of {count}, expected 878"): tune.read_weights(written, LAYOUT) @@ -731,17 +731,31 @@ def test_the_king_attack_weights_are_priced_too(): def test_the_tempo_weights_are_priced_too(): - """And the last block. The side to move counts one, both colours, at the - larger of the two halves, which here is the midgame one at twenty. The - count is written out rather than read from `BOUNDS`, so a bound of nothing - fails here.""" + """The side to move counts one, both colours, at the larger of the two + halves, which here is the midgame one at twenty. The count is written out + rather than read from `BOUNDS`, so a bound of nothing fails here.""" tempo = LAYOUT.start["tempo"] inside, worst = tune.bounds_hold( np.array(weights({tempo: 20, tempo + 1: 10})), LAYOUT ) assert inside assert worst == 2 * 1 * 20 - huge = weights({slot: 20000 for slot in range(tempo, LAYOUT.slots)}) + huge = weights( + {slot: 20000 for slot in range(tempo, LAYOUT.start["passer_squares"])} + ) + assert not tune.bounds_hold(np.array(huge), LAYOUT)[0] + + +def test_the_passer_squares_weights_are_priced_too(): + """And the last block. A king distance counts up to seven steps for each + of eight passers, both colours: a weight of ten on the first count's + midgame half is charged 2 * 56 * 10. Written out rather than read from + `BOUNDS`.""" + start = LAYOUT.start["passer_squares"] + inside, worst = tune.bounds_hold(np.array(weights({start: 10})), LAYOUT) + assert inside + assert worst == 2 * 56 * 10 + huge = weights({slot: 20000 for slot in range(start, LAYOUT.slots)}) assert not tune.bounds_hold(np.array(huge), LAYOUT)[0] @@ -799,12 +813,22 @@ def test_a_term_is_fitted_with_every_earlier_term_held(): def test_a_refit_holds_the_terms_above_it_as_well_as_the_ones_below(): """A mobility refit holds the tables below it and the shelter, the pawn - structure, the king attack zone and the tempo above, so the mobility + structure, the king attack zone, the tempo and the passer squares above, + so the mobility weights are the only thing that moves. Without the pawn structure hold the pawn weights move too, which is the confound `1b0862a` found the first time a hold was missing.""" refit = tune.frozen_slots( - LAYOUT, False, ["tables", "shelter", "pawn_structure", "king_attack", "tempo"] + LAYOUT, + False, + [ + "tables", + "shelter", + "pawn_structure", + "king_attack", + "tempo", + "passer_squares", + ], ) assert refit[: LAYOUT.start["mobility"]].all() assert not refit[LAYOUT.start["mobility"] : LAYOUT.start["shelter"]].any() diff --git a/scripts/tune.py b/scripts/tune.py index d005cc38..15aa3ff2 100755 --- a/scripts/tune.py +++ b/scripts/tune.py @@ -91,13 +91,15 @@ # can show on the eight squares round a centred king (a knight reaches two, a # bishop two and is given three, a rook four, a queen five and is given six). # A side with two knights shows more, as it does for mobility. Tempo: one, -# the side that has the move. +# the side that has the move. Passer squares: eight passers on one rank, each +# up to seven king steps from a square, or each counting one. BOUNDS = { "mobility": (8, 13, 14, 27), "shelter": (3,) * 7, "pawn_structure": (8,) * 8, "king_attack": (2, 3, 4, 6), "tempo": (1,), + "passer_squares": (56, 56, 56, 56, 8, 8, 8) * 4, } @@ -1484,8 +1486,9 @@ def frozen_slots(layout, free_material, held=()): Each term earns a hold as it is fitted, and nothing is held that the caller did not name. A fit of the newest term names every hold below it, so a shelter fit passes `--hold tables --hold mobility`, a pawn structure fit - adds `--hold shelter`, a king attack fit adds `--hold pawn_structure` and - a tempo fit adds `--hold king_attack`. + adds `--hold shelter`, a king attack fit adds `--hold pawn_structure`, a + tempo fit adds `--hold king_attack` and a passer squares fit adds `--hold + tempo`. Holding the tables but not mobility through a shelter fit refits mobility beside the shelter: `1b0862a` found half of the king safety fit's apparent gain to be that. A refit of an older term holds the newer terms too, which diff --git a/tests/effort_session.rs b/tests/effort_session.rs index f533e680..5f334407 100644 --- a/tests/effort_session.rs +++ b/tests/effort_session.rs @@ -8,14 +8,16 @@ mod report_command; /// A shallow run with a switch off, at a rate that still records plenty: /// the instrument offers an event at every full width node on each side, so -/// ten keeps hundreds of rows at this depth, and the switch is one whose +/// four keeps hundreds of rows at this depth, and the switch is one whose /// rule acts at exactly these depths. /// -/// Depth three, because the saving the test asks for shows there (3,436 -/// events off against 3,415 on). Since the pair term's refit at a ridge of +/// Depth three, because the saving the test asks for shows there (2,784 +/// events off against 2,778 on). Since the pair term's refit at a ridge of /// 3e-7, the run with the rule off offers fewer events than the run with it -/// on at every depth from four to eight. -const ARGUMENTS: [&str; 6] = ["effort", "3", "every", "10", "off", "quiet_futility"]; +/// on at every depth from four to eight. Every four rather than ten since +/// the passed pawn squares were scored: at ten no recorded key was reached by +/// one side alone, and at four two are. +const ARGUMENTS: [&str; 6] = ["effort", "3", "every", "4", "off", "quiet_futility"]; #[test] #[cfg_attr( @@ -27,7 +29,7 @@ fn the_effort_argument_prints_a_header_rows_and_two_summaries() { assert!( printed .header - .starts_with("effort depth 3 every 10 off quiet_futility positions "), + .starts_with("effort depth 3 every 4 off quiet_futility positions "), "header: {}", printed.header ); diff --git a/tests/uci_session.rs b/tests/uci_session.rs index cc4dc846..1e8d78f2 100644 --- a/tests/uci_session.rs +++ b/tests/uci_session.rs @@ -546,15 +546,15 @@ fn a_root_move_that_reaches_beta_is_reported_as_a_floor_and_then_answered_with() fn a_floor_answers_until_the_wider_search_replaces_it() { // what the engine plays when the wider search never finishes. WAC.021 is // answered with d2c3 at depth eight. At depth nine d2h6 reaches beta and - // is reported as a floor at 87,942 nodes, and again at 89,089 and 91,472 - // as the window widens, and the search finishes at 189,094. A budget + // is reported as a floor at 100,380 nodes, and again at 102,809 and + // 105,374 as the window widens, and the search finishes at 151,728. A budget // after d2h6's last floor and inside that is interrupted before anything // beats alpha, so the floor is what is left to answer with; with the // floor not held it answers d2c3, the move the search has just shown // worse let mut s = Session::start(&[]); s.say(&format!("position fen {}", WAC_021)); - s.say("go nodes 100000"); + s.say("go nodes 120000"); let answer = s.wait_for(|l| l.starts_with("bestmove")); let best = move_of(&answer);