From dcfa2912ce69d8ecf9b278b6e1236d675f2c22a2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 11:10:50 +0000 Subject: [PATCH] feat(eval): Add passed pawn terms that read the stop and queening squares The pawn structure prices a passer by its file and rank alone, so a blockaded passer counts as a free one and neither king's position matters. This adds a leaf term for passers on the fourth to the seventh, by rank: both kings' king step distance from the stop square and from the queening square, whether the stop is taken, whether the other side attacks it, and whether no piece stands between the pawn and the eighth. The weights were fitted on 5,026,360 quiet positions from 109,141 games with every other weight held, over five folds that hold out both games of an opening pair together. Held out loss fell by 0.000440 at 22 standard errors clustered on the pair. That fit read 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, so the weights shipped are a refit with each one held to the sign chess expects: 0.000393 at 19.6 standard errors, and the same at whole centipawns. The term's spread over the held out positions is 21 cp. The passers depend on the pawns alone, so the set is kept in a table of its own under the pawn key, and only the squares are read at each leaf. The term costs 4.83% of the games suite's instructions (1,073.5 to 1,125.3 a node over the same 9,409,127 nodes). Setting every weight to zero turns it off at compile time. The bench grows 7.5%. Two tests needed a new reading of the tree: the rook and pawns ending now ties two moves at depth six, so the aspiration test uses the quiet middlegame, and WAC.021's floor at depth nine now comes after 105,374 nodes, so the budget moves to 120,000. The effort test's sampling goes from one node in ten to one in four, since at ten no sampled key was reached by one side alone. The tactical suite finds 230 rather than 228 and the strategic suite scores 104,395 rather than 104,298. Bench: 11916992 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RY5UTjLftn71Vk2HrJ5iUS --- arche-core/src/bench.rs | 69 ++--- arche-core/src/engine/tests.rs | 9 +- arche-core/src/eval/cache.rs | 53 ++-- arche-core/src/eval/mod.rs | 55 +++- arche-core/src/eval/passer_squares.rs | 397 ++++++++++++++++++++++++++ arche-core/src/eval/pawn_structure.rs | 59 ++++ arche-core/src/strategy.rs | 2 +- arche-core/src/tactics.rs | 2 +- docs/INSTRUMENTS.md | 12 +- docs/ROADMAP.md | 14 +- scripts/tests/test_tune.py | 56 ++-- scripts/tune.py | 9 +- tests/effort_session.rs | 14 +- tests/uci_session.rs | 6 +- 14 files changed, 652 insertions(+), 105 deletions(-) create mode 100644 arche-core/src/eval/passer_squares.rs 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);