From 1a9838a1ca8918f5f50d935dab34e6916b56ddcb Mon Sep 17 00:00:00 2001 From: Paul Tsouchlos Date: Sat, 25 Jul 2026 18:08:48 -0400 Subject: [PATCH 1/9] feat: allow Piece to be used as an index Allow for `Piece` to be used as an index in fix array types. --- crates/chess/src/pieces.rs | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/crates/chess/src/pieces.rs b/crates/chess/src/pieces.rs index ca49b72b..728ad864 100644 --- a/crates/chess/src/pieces.rs +++ b/crates/chess/src/pieces.rs @@ -3,7 +3,10 @@ // GNU General Public License v3.0 or later // https://www.gnu.org/licenses/gpl-3.0-standalone.html -use std::fmt::Display; +use std::{ + fmt::Display, + ops::{Index, IndexMut}, +}; use crate::definitions::NumberOf; @@ -168,6 +171,20 @@ impl TryFrom for Piece { } } +impl Index for [T; N] { + type Output = T; + + fn index(&self, piece: Piece) -> &Self::Output { + &self[piece as usize] + } +} + +impl IndexMut for [T; N] { + fn index_mut(&mut self, piece: Piece) -> &mut Self::Output { + &mut self[piece as usize] + } +} + #[cfg(test)] mod tests { use super::*; From a07c7dbd0a4f8c54da2251a0da329ca8db07e576 Mon Sep 17 00:00:00 2001 From: Paul Tsouchlos Date: Sat, 25 Jul 2026 18:08:48 -0400 Subject: [PATCH 2/9] feat: add a piece to history type --- crates/engine/src/history/types.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/engine/src/history/types.rs b/crates/engine/src/history/types.rs index b7b0c547..b61b87cc 100644 --- a/crates/engine/src/history/types.rs +++ b/crates/engine/src/history/types.rs @@ -9,6 +9,12 @@ use chess::definitions::NumberOf; /// Also known as 'butterfly' history. pub(crate) type FromToHistory = [[T; NumberOf::SQUARES]; NumberOf::SQUARES]; +pub(crate) type PieceToHistory = [[T; NumberOf::SQUARES]; NumberOf::PIECE_TYPES]; + pub(crate) fn default_from_to_history() -> FromToHistory { [[Default::default(); NumberOf::SQUARES]; NumberOf::SQUARES] } + +pub(crate) fn default_piece_to_history() -> PieceToHistory { + [[Default::default(); NumberOf::SQUARES]; NumberOf::PIECE_TYPES] +} From 0b1b657a87daf6196e0ce0a0556dfedab0bffc08 Mon Sep 17 00:00:00 2001 From: Paul Tsouchlos Date: Sat, 25 Jul 2026 18:08:48 -0400 Subject: [PATCH 3/9] feat: implement piece to quiet hist table This follows the same trend as the from/to table and includes a threat bucket along with a factorizer. --- crates/engine/src/history.rs | 12 +++-- crates/engine/src/history/quiet_history.rs | 55 ++++++++++++++-------- 2 files changed, 44 insertions(+), 23 deletions(-) diff --git a/crates/engine/src/history.rs b/crates/engine/src/history.rs index 3f4aeb6b..8ed8b6e9 100644 --- a/crates/engine/src/history.rs +++ b/crates/engine/src/history.rs @@ -5,7 +5,7 @@ //! This module contains all history tables and consolidates them into a Histories object. -use chess::{bitboard::Bitboard, moves::Move, side::Side}; +use chess::{bitboard::Bitboard, moves::Move, pieces::Piece, side::Side}; use crate::{history::quiet_history::QuietHistory, score::LargeScoreType}; @@ -21,8 +21,14 @@ pub struct Histories { } impl Histories { - pub(crate) fn get(&self, side: Side, mv: Move, threats: Bitboard) -> LargeScoreType { - self.quiet_history.get(side, mv, threats) + pub(crate) fn get( + &self, + side: Side, + mv: Move, + piece: Piece, + threats: Bitboard, + ) -> LargeScoreType { + self.quiet_history.get(side, mv, piece, threats) } pub fn clear(&mut self) { diff --git a/crates/engine/src/history/quiet_history.rs b/crates/engine/src/history/quiet_history.rs index 903ffec6..e96203e9 100644 --- a/crates/engine/src/history/quiet_history.rs +++ b/crates/engine/src/history/quiet_history.rs @@ -3,12 +3,12 @@ // GNU General Public License v3.0 or later // https://www.gnu.org/licenses/gpl-3.0-standalone.html -use chess::{bitboard::Bitboard, definitions::NumberOf, moves::Move, side::Side}; +use chess::{bitboard::Bitboard, definitions::NumberOf, moves::Move, pieces::Piece, side::Side}; use crate::{ history::{ threat_bucket::{ThreatBucket, ThreatIndex}, - types::{self, FromToHistory}, + types::{self, FromToHistory, PieceToHistory}, }, score::{LargeScoreType, Score}, }; @@ -27,13 +27,13 @@ struct QuietHistoryEntry { } impl QuietHistoryEntry { - fn score(&self, threat_index: ThreatIndex) -> LargeScoreType { + fn score(&self, threat_index: &ThreatIndex) -> LargeScoreType { self.factorizer + self.bucket[threat_index.from()][threat_index.to()] } fn update( &mut self, - threat_index: ThreatIndex, + threat_index: &ThreatIndex, bonus: LargeScoreType, factorizer_bonus: LargeScoreType, ) { @@ -59,6 +59,7 @@ fn gravity(current: LargeScoreType, bonus: LargeScoreType, max: LargeScoreType) /// history), with each entry further split into threat buckets (see [`QuietHistoryEntry`]). pub struct QuietHistory { from_to_entries: [FromToHistory; NumberOf::SIDES], + piece_to_entries: [PieceToHistory; NumberOf::SIDES], } /// Safe calculation of the bonus applied to quiet moves that are inserted into the history table. @@ -78,24 +79,35 @@ pub(crate) fn calculate_bonus_for_depth(depth: i16) -> i16 { impl QuietHistory { pub(crate) fn new() -> Self { let from_to_entries = [types::default_from_to_history(); NumberOf::SIDES]; - Self { from_to_entries } + let piece_to_entries = [types::default_piece_to_history(); NumberOf::SIDES]; + Self { + from_to_entries, + piece_to_entries, + } } - pub(crate) fn get(&self, side: Side, mv: Move, threats: Bitboard) -> LargeScoreType { + pub(crate) fn get(&self, side: Side, mv: Move, pc: Piece, threats: Bitboard) -> LargeScoreType { let idx = ThreatIndex::new(&mv, threats); - self.from_to_entries[side][mv.from()][mv.to()].score(idx) + let from_to_score = self.from_to_entries[side][mv.from()][mv.to()].score(&idx); + let piece_to_score = self.piece_to_entries[side][pc][mv.to()].score(&idx); + + // TODO: lerp between the scores with a tunable parameter + from_to_score + piece_to_score } pub(crate) fn update( &mut self, side: Side, mv: Move, + piece: Piece, threats: Bitboard, bonus: LargeScoreType, factorizer_bonus: LargeScoreType, ) { let idx = ThreatIndex::new(&mv, threats); - self.from_to_entries[side][mv.from()][mv.to()].update(idx, bonus, factorizer_bonus); + self.from_to_entries[side][mv.from()][mv.to()].update(&idx, bonus, factorizer_bonus); + + self.piece_to_entries[side][piece][mv.to()].update(&idx, bonus, factorizer_bonus); } pub(crate) fn clear(&mut self) { @@ -114,7 +126,7 @@ mod tests { use crate::{defs::MAX_DEPTH, score::Score}; use super::{QuietHistory, calculate_bonus_for_depth}; - use chess::{bitboard::Bitboard, moves::Move, side::Side, square::Square}; + use chess::{bitboard::Bitboard, moves::Move, pieces::Piece, side::Side, square::Square}; #[test] fn initialize_history_table() { @@ -140,20 +152,21 @@ mod tests { // something worth hardcoding here. let mut history_table = QuietHistory::new(); let mv = Move::new(Square::B1, Square::A1, chess::moves::MoveFlag::Standard); + let piece = Piece::Pawn; let side = Side::Black; let score = 37; let no_threats = Bitboard::default(); - assert_eq!(history_table.get(side, mv, no_threats), 0); - history_table.update(side, mv, no_threats, score, score); - let after_first = history_table.get(side, mv, no_threats); + assert_eq!(history_table.get(side, mv, piece, no_threats), 0); + history_table.update(side, mv, piece, no_threats, score, score); + let after_first = history_table.get(side, mv, piece, no_threats); assert!( after_first > 0, "a positive bonus must raise the score above zero" ); - history_table.update(side, mv, no_threats, score, score); - let after_second = history_table.get(side, mv, no_threats); + history_table.update(side, mv, piece, no_threats, score, score); + let after_second = history_table.get(side, mv, piece, no_threats); assert!( after_second > after_first, "a second positive bonus must raise the score further" @@ -163,6 +176,7 @@ mod tests { #[test] fn threat_buckets_are_independent_of_untouched_buckets() { let mut history_table = QuietHistory::new(); + let piece = Piece::Pawn; let mv = Move::new(Square::B1, Square::A1, chess::moves::MoveFlag::Standard); let side = Side::Black; @@ -171,10 +185,10 @@ mod tests { let both_threatened = Bitboard::from(Square::B1) | Bitboard::from(Square::A1); // Only ever update the "both squares threatened" bucket. - history_table.update(side, mv, both_threatened, 1000, 1000); + history_table.update(side, mv, piece, both_threatened, 1000, 1000); - let untouched = history_table.get(side, mv, no_threats); - let touched = history_table.get(side, mv, both_threatened); + let untouched = history_table.get(side, mv, piece, no_threats); + let touched = history_table.get(side, mv, piece, both_threatened); assert!( untouched > 0, "the factoriser baseline should move on every update, regardless of threat state" @@ -184,7 +198,7 @@ mod tests { "the touched bucket should score higher than a bucket that never saw the bonus" ); - let from_only = history_table.get(side, mv, from_only_threatened); + let from_only = history_table.get(side, mv, piece, from_only_threatened); assert_eq!( from_only, untouched, "a bucket that was never updated should equal the other untouched buckets" @@ -196,15 +210,16 @@ mod tests { let mut history_table = QuietHistory::new(); let mv = Move::new(Square::B1, Square::A1, chess::moves::MoveFlag::Standard); let side = Side::Black; + let piece = Piece::Pawn; let threats = Bitboard::from(Square::B1) | Bitboard::from(Square::A1); // Hammer the same cell with maximal bonuses to try to force it past MAX_HISTORY - // a saturated entry must never be able to sort above KILLER_BONUS in the move picker. for _ in 0..10_000 { - history_table.update(side, mv, threats, i32::MAX, i32::MAX); + history_table.update(side, mv, piece, threats, i32::MAX, i32::MAX); } - let score = history_table.get(side, mv, threats); + let score = history_table.get(side, mv, piece, threats); assert!( score <= Score::MAX_HISTORY, "saturated quiet history entry ({score}) must not exceed MAX_HISTORY ({})", From c626a30bbd10c91f122aefc4b5238495da1b1c33 Mon Sep 17 00:00:00 2001 From: Paul Tsouchlos Date: Sat, 25 Jul 2026 18:08:48 -0400 Subject: [PATCH 4/9] feat: integrate quiet hist changes into search Also integrate the changes into the move picker. bench: 1336999 --- crates/engine/src/move_picker.rs | 6 +++++- crates/engine/src/search.rs | 14 +++++++++----- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/move_picker.rs b/crates/engine/src/move_picker.rs index c3357d26..fcd02ea4 100644 --- a/crates/engine/src/move_picker.rs +++ b/crates/engine/src/move_picker.rs @@ -230,7 +230,7 @@ impl MovePicker { if is_killer { KILLER_BONUS } else { - histories.get(board.side_to_move(), *mv, threats) + histories.get(board.side_to_move(), *mv, piece, threats) } } } @@ -640,9 +640,13 @@ mod tests { let all_moves = move_generation::legal::generate_all_moves(&board); // Give a high history score to the move at index 3 let favored_mv = *all_moves.at(3).unwrap(); + let piece = board + .piece_type_on_square(favored_mv.from()) + .expect("Expected piece on board for move {mv}"); histories.quiet_history.update( board.side_to_move(), favored_mv, + piece, Bitboard::default(), Score::MAX_HISTORY, Score::MAX_HISTORY, diff --git a/crates/engine/src/search.rs b/crates/engine/src/search.rs index 59585c39..1b462c02 100644 --- a/crates/engine/src/search.rs +++ b/crates/engine/src/search.rs @@ -716,6 +716,7 @@ impl<'a, Log: LogLevel> Search<'a, Log> { td.histories.quiet_history.update( board.side_to_move(), mv, + piece, threats, bonus as LargeScoreType, bonus as LargeScoreType, @@ -724,13 +725,14 @@ impl<'a, Log: LogLevel> Search<'a, Log> { // Apply a penalty to all quiets searched so far. // The board is already in the parent state (we already unmade the move) // so it's safe to look up the piece on the board using mv.from(). - for &(prev_mv, _) in picker.searched_quiets() { + for &(prev_mv, prev_pc) in picker.searched_quiets() { if prev_mv == mv { continue; } td.histories.quiet_history.update( board.side_to_move(), prev_mv, + prev_pc, threats, -bonus as LargeScoreType, -bonus as LargeScoreType, @@ -1110,7 +1112,7 @@ mod tests { bitboard::Bitboard, board::Board, moves::{Move, MoveFlag}, - pieces::ALL_PIECES, + pieces::{ALL_PIECES, Piece}, square::Square, }; @@ -1359,9 +1361,11 @@ mod tests { Bitboard::from(to), Bitboard::from(from) | Bitboard::from(to), ] { - let score = td.histories.get(side, mv, threats); - if score > max_history { - max_history = score; + for piece in Piece::iter() { + let score = td.histories.get(side, mv,piece, threats); + if score > max_history { + max_history = score; + } } } } From 7ce35d6857591fadc312d85cea6debc57ac5f4cc Mon Sep 17 00:00:00 2001 From: Paul Tsouchlos Date: Sun, 26 Jul 2026 18:56:41 -0400 Subject: [PATCH 5/9] feat: add math module with lerp and a test --- crates/engine/src/lib.rs | 1 + crates/engine/src/math.rs | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+) create mode 100644 crates/engine/src/math.rs diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index c1651d4a..1a10ec76 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -21,6 +21,7 @@ pub mod history; pub mod killers_table; mod lmr; pub mod log_level; +mod math; mod move_picker; pub(crate) mod node; pub(crate) mod node_types; diff --git a/crates/engine/src/math.rs b/crates/engine/src/math.rs new file mode 100644 index 00000000..0da5d31f --- /dev/null +++ b/crates/engine/src/math.rs @@ -0,0 +1,36 @@ +// Part of the byte-knight project. +// Author: Paul Tsouchlos (ptsouchlos) (developer.paul.123@gmail.com) +// GNU General Public License v3.0 or later +// https://www.gnu.org/licenses/gpl-3.0-standalone.html + +/// Linearly interpolate between a and b using a factor ([0 - 100]). +/// See also https://en.cppreference.com/cpp/numeric/lerp. +/// +/// # Arguments +/// - `a`: The first value. +/// - `b`: The second value. +/// - `factor`: The factor to use to scale between the two values. +pub(crate) fn lerp(a: i32, b: i32, factor: i32) -> i32 { + debug_assert!( + (0..=100).contains(&factor), + "factor must be between 0 and 100: {factor}" + ); + + let a_scale = 100 - factor; + let b_scale = factor; + + ((a * a_scale) + (b * b_scale)) / 100 +} + +#[cfg(test)] +mod tests { + #[test] + fn lerp_validation() { + let a = 20i32; + let b = 40i32; + + let factor = 50; + let result = super::lerp(a, b, factor); + assert_eq!(result, 30); + } +} From 74d1a1deb05419d26dbcd3ccd4a1b68549410d6f Mon Sep 17 00:00:00 2001 From: Paul Tsouchlos Date: Sun, 26 Jul 2026 18:57:04 -0400 Subject: [PATCH 6/9] chore: minor formatting fix --- crates/engine/src/search.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/engine/src/search.rs b/crates/engine/src/search.rs index 1b462c02..1d40d189 100644 --- a/crates/engine/src/search.rs +++ b/crates/engine/src/search.rs @@ -1362,7 +1362,7 @@ mod tests { Bitboard::from(from) | Bitboard::from(to), ] { for piece in Piece::iter() { - let score = td.histories.get(side, mv,piece, threats); + let score = td.histories.get(side, mv, piece, threats); if score > max_history { max_history = score; } From 7b3259bcc323102a58e8fbfa359c13224a63fa42 Mon Sep 17 00:00:00 2001 From: Paul Tsouchlos Date: Sun, 26 Jul 2026 18:57:12 -0400 Subject: [PATCH 7/9] chore: lerp quiet history scores Was running into saturation issues, so now we lerp the output score between the two internal tables in `QuietHistory`; similar to how Hobbes does it. bench: 1302567 --- crates/engine/src/history/quiet_history.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/history/quiet_history.rs b/crates/engine/src/history/quiet_history.rs index e96203e9..94c6e66d 100644 --- a/crates/engine/src/history/quiet_history.rs +++ b/crates/engine/src/history/quiet_history.rs @@ -10,6 +10,7 @@ use crate::{ threat_bucket::{ThreatBucket, ThreatIndex}, types::{self, FromToHistory, PieceToHistory}, }, + math, score::{LargeScoreType, Score}, }; @@ -92,7 +93,7 @@ impl QuietHistory { let piece_to_score = self.piece_to_entries[side][pc][mv.to()].score(&idx); // TODO: lerp between the scores with a tunable parameter - from_to_score + piece_to_score + math::lerp(from_to_score, piece_to_score, 50) } pub(crate) fn update( From 9666b5ae69f7b552132b379195a62be83d2d440c Mon Sep 17 00:00:00 2001 From: Paul Tsouchlos Date: Sun, 26 Jul 2026 19:06:27 -0400 Subject: [PATCH 8/9] chore: make quiet history factor tunable In the future we can run this through SPSA, but for now just lerp 50/50 bench: 1302567 --- crates/engine/src/history/quiet_history.rs | 4 ++-- crates/engine/src/tuneable.rs | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/history/quiet_history.rs b/crates/engine/src/history/quiet_history.rs index 94c6e66d..b24ae478 100644 --- a/crates/engine/src/history/quiet_history.rs +++ b/crates/engine/src/history/quiet_history.rs @@ -12,6 +12,7 @@ use crate::{ }, math, score::{LargeScoreType, Score}, + tuneable::quiet_history_factor, }; /// A single quiet-history cell, split into a threat-agnostic `factoriser` and a `bucket` indexed @@ -92,8 +93,7 @@ impl QuietHistory { let from_to_score = self.from_to_entries[side][mv.from()][mv.to()].score(&idx); let piece_to_score = self.piece_to_entries[side][pc][mv.to()].score(&idx); - // TODO: lerp between the scores with a tunable parameter - math::lerp(from_to_score, piece_to_score, 50) + math::lerp(from_to_score, piece_to_score, quiet_history_factor()) } pub(crate) fn update( diff --git a/crates/engine/src/tuneable.rs b/crates/engine/src/tuneable.rs index 5aaee575..5809e77c 100644 --- a/crates/engine/src/tuneable.rs +++ b/crates/engine/src/tuneable.rs @@ -39,6 +39,7 @@ tunable_params!( razoring_offset = 511, 250, 1000, 10, true; lmr_min_depth = 3, 1, 6, 1, false; lmr_min_moves_seen = 3, 1, 6, 1, false; + quiet_history_factor = 50, 1, 100, 1, true; ); pub(crate) const LMR_OFFSET: f64 = 0.2; From 3bf5519d52391c128f7e2eb1372bf26fb43ee82b Mon Sep 17 00:00:00 2001 From: Paul Tsouchlos Date: Mon, 27 Jul 2026 14:40:08 -0400 Subject: [PATCH 9/9] chore: fix minor formatting issue bench: 1302567 --- crates/engine/src/tuneable.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/engine/src/tuneable.rs b/crates/engine/src/tuneable.rs index 5809e77c..3beea143 100644 --- a/crates/engine/src/tuneable.rs +++ b/crates/engine/src/tuneable.rs @@ -39,7 +39,7 @@ tunable_params!( razoring_offset = 511, 250, 1000, 10, true; lmr_min_depth = 3, 1, 6, 1, false; lmr_min_moves_seen = 3, 1, 6, 1, false; - quiet_history_factor = 50, 1, 100, 1, true; + quiet_history_factor = 50, 1, 100, 1, true; ); pub(crate) const LMR_OFFSET: f64 = 0.2;