Skip to content

Commit eaa660e

Browse files
authored
feat: implement continuation history table (#420)
Changes: - Added a ContinuationTable to Histories and integrate it into move ordering for quiet moves. - Added COUNT constant to Piece. This should replace NumberOf::PIECE_TYPES in the future. - Removed Score::MAX_HISTORY. Since the move picker is phased, the scores don't overlap, so we don't need to make sure that the score ranges don't overlap since they're never really compared directly to each other. ``` Elo | 9.54 +- 5.83 (95%) SPRT | 8.0+0.08s Threads=1 Hash=16MB LLR | 3.00 (-2.94, 2.94) [0.00, 5.00] Games | N: 6268 W: 1880 L: 1708 D: 2680 Penta | [162, 676, 1316, 788, 192] ``` https://openbench.nocturn9x.space/test/7662/ bench: 1229853
1 parent c33c42b commit eaa660e

11 files changed

Lines changed: 395 additions & 99 deletions

File tree

crates/chess/src/pieces.rs

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@
33
// GNU General Public License v3.0 or later
44
// https://www.gnu.org/licenses/gpl-3.0-standalone.html
55

6-
use std::fmt::Display;
6+
use std::{
7+
fmt::Display,
8+
ops::{Index, IndexMut},
9+
};
710

811
use crate::definitions::NumberOf;
912

@@ -57,6 +60,7 @@ pub enum Piece {
5760

5861
impl Piece {
5962
pub const NONE: u32 = 6;
63+
pub const COUNT: usize = 6;
6064

6165
/// Returns `true` if the piece is [`King`].
6266
///
@@ -116,13 +120,17 @@ impl Piece {
116120

117121
/// Returns the short name of the piece as a lowercase character.
118122
pub fn as_char(&self) -> char {
119-
PIECE_SHORT_NAMES[*self as usize].to_ascii_lowercase()
123+
PIECE_SHORT_NAMES[self.index()].to_ascii_lowercase()
120124
}
121125

122126
/// Returns an iterator over all the pieces.
123127
pub fn iter() -> impl Iterator<Item = Piece> {
124128
ALL_PIECES.iter().copied()
125129
}
130+
131+
pub fn index(self) -> usize {
132+
self as usize
133+
}
126134
}
127135

128136
impl Display for Piece {
@@ -168,6 +176,21 @@ impl TryFrom<char> for Piece {
168176
}
169177
}
170178

179+
impl<T> Index<Piece> for [T; Piece::COUNT] {
180+
type Output = T;
181+
182+
#[inline(always)]
183+
fn index(&self, pc: Piece) -> &Self::Output {
184+
&self[pc as usize]
185+
}
186+
}
187+
188+
impl<T> IndexMut<Piece> for [T; Piece::COUNT] {
189+
fn index_mut(&mut self, pc: Piece) -> &mut Self::Output {
190+
&mut self[pc as usize]
191+
}
192+
}
193+
171194
#[cfg(test)]
172195
mod tests {
173196
use super::*;
@@ -267,4 +290,17 @@ mod tests {
267290
assert!(!Piece::Pawn.is_bishop());
268291
assert!(!Piece::Pawn.is_knight());
269292
}
293+
294+
#[test]
295+
fn indexing() {
296+
for pc in Piece::iter() {
297+
assert_eq!(ALL_PIECES[pc], pc);
298+
}
299+
300+
let mut data = ALL_PIECES;
301+
for pc in Piece::iter() {
302+
data[pc] = Piece::try_from((pc as u8 + 2) % Piece::COUNT as u8).unwrap();
303+
assert_ne!(data[pc], pc);
304+
}
305+
}
270306
}

crates/engine/src/history.rs

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,27 +5,59 @@
55

66
//! This module contains all history tables and consolidates them into a Histories object.
77
8-
use chess::{bitboard::Bitboard, moves::Move, side::Side};
8+
use chess::{bitboard::Bitboard, board::Board, moves::Move, side::Side};
99

10-
use crate::{history::quiet_history::QuietHistory, score::LargeScoreType};
10+
use crate::{
11+
history::{continuation_history::ContinuationHistory, quiet_history::QuietHistory},
12+
node::NodeStack,
13+
score::LargeScoreType,
14+
};
1115

16+
pub mod continuation_history;
1217
pub mod quiet_history;
1318
pub mod threat_bucket;
1419
mod types;
20+
mod util;
1521

1622
/// Holds all history tables for the engine.
1723
/// Credit to the author of [hobbes](https://github.com/kelseyde/hobbes-chess-engine) for this setup (kelseyde)
1824
#[derive(Default)]
1925
pub struct Histories {
2026
pub quiet_history: QuietHistory,
27+
pub continuation_history: ContinuationHistory,
2128
}
2229

2330
impl Histories {
24-
pub(crate) fn get(&self, side: Side, mv: Move, threats: Bitboard) -> LargeScoreType {
31+
pub(crate) fn get(
32+
&self,
33+
board: &Board,
34+
node_stack: &NodeStack,
35+
side: Side,
36+
mv: Move,
37+
threats: Bitboard,
38+
ply: usize,
39+
) -> LargeScoreType {
2540
self.quiet_history.get(side, mv, threats)
41+
+ self.continuation_history_score(board, node_stack, &mv, ply)
42+
}
43+
44+
pub(crate) fn continuation_history_score(
45+
&self,
46+
board: &Board,
47+
node_stack: &NodeStack,
48+
mv: &Move,
49+
ply: usize,
50+
) -> i32 {
51+
if let Some((prev_mv, prev_pc)) = node_stack.prev_move(ply) {
52+
let piece = board.piece_type_on_square(mv.from()).unwrap();
53+
self.continuation_history.get(prev_mv, prev_pc, *mv, piece)
54+
} else {
55+
return 0;
56+
}
2657
}
2758

2859
pub fn clear(&mut self) {
2960
self.quiet_history.clear();
61+
self.continuation_history.clear();
3062
}
3163
}
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
// Part of the byte-knight project.
2+
// Author: Paul Tsouchlos (ptsouchlos) (developer.paul.123@gmail.com)
3+
// GNU General Public License v3.0 or later
4+
// https://www.gnu.org/licenses/gpl-3.0-standalone.html
5+
6+
use chess::{moves::Move, pieces::Piece};
7+
8+
use crate::{
9+
history::{types::PieceToHistory, util::gravity},
10+
score::LargeScoreType,
11+
utils::{self, boxed_and_zeroed},
12+
};
13+
14+
pub struct ContinuationHistory {
15+
single_ply_entries: Box<PieceToHistory<PieceToHistory<i32>>>,
16+
}
17+
18+
impl ContinuationHistory {
19+
const MAX: i32 = 16384;
20+
const BONUS_MAX: i32 = Self::MAX / 4;
21+
22+
pub(crate) fn get(&self, prev_mv: Move, prev_pc: Piece, mv: Move, pc: Piece) -> i32 {
23+
self.single_ply_entries[prev_pc.index()][prev_mv.to().index()][pc.index()][mv.to().index()]
24+
}
25+
26+
pub(crate) fn update(
27+
&mut self,
28+
prev_mv: Move,
29+
prev_pc: Piece,
30+
mv: Move,
31+
pc: Piece,
32+
bonus: LargeScoreType,
33+
) {
34+
let bonus = bonus.clamp(-Self::BONUS_MAX, Self::BONUS_MAX);
35+
let entry = &mut self.single_ply_entries[prev_pc.index()][prev_mv.to().index()][pc.index()]
36+
[mv.to().index()];
37+
*entry = gravity(*entry, bonus, Self::MAX);
38+
}
39+
40+
pub(crate) fn clear(&mut self) {
41+
self.single_ply_entries = unsafe { boxed_and_zeroed() };
42+
}
43+
}
44+
45+
impl Default for ContinuationHistory {
46+
fn default() -> Self {
47+
Self {
48+
single_ply_entries: unsafe { utils::boxed_and_zeroed() },
49+
}
50+
}
51+
}
52+
53+
#[cfg(test)]
54+
mod tests {
55+
use chess::{
56+
moves::{Move, MoveFlag},
57+
pieces::Piece,
58+
square::Square,
59+
};
60+
61+
use crate::history::continuation_history::ContinuationHistory;
62+
63+
#[test]
64+
fn clear_table() {
65+
// This test is mostly here to validate that we don't overflow the stack when clearing the table.
66+
let mut cont_hist = ContinuationHistory::default();
67+
let prev_mv = Move::new(Square::B2, Square::B4, MoveFlag::DoublePush);
68+
let mv = Move::new(Square::B4, Square::B5, MoveFlag::Standard);
69+
let bonus = 300;
70+
let pc = Piece::Pawn;
71+
// Update the score
72+
cont_hist.update(prev_mv, pc, mv, pc, bonus);
73+
// Ensure it's non-zero
74+
let score = cont_hist.get(prev_mv, pc, mv, pc);
75+
assert!(score > 0);
76+
77+
// Clear the table
78+
cont_hist.clear();
79+
// Now the score should be 0
80+
let score = cont_hist.get(prev_mv, pc, mv, pc);
81+
assert!(score == 0);
82+
}
83+
84+
#[test]
85+
fn score_never_exceeds_max() {
86+
let mut cont_hist = ContinuationHistory::default();
87+
let prev_mv = Move::new(Square::B2, Square::B4, MoveFlag::DoublePush);
88+
let mv = Move::new(Square::B4, Square::B5, MoveFlag::Standard);
89+
let pc = Piece::Pawn;
90+
91+
// Hammer the same cell with maximal bonuses to try to force it past MAX -
92+
// a saturated entry must never be able to sort above KILLER_BONUS in the move picker
93+
// once combined with quiet history (see move_picker::tests::combined_history_score_never_exceeds_killer_bonus).
94+
for _ in 0..10_000 {
95+
cont_hist.update(prev_mv, pc, mv, pc, i32::MAX);
96+
}
97+
98+
let score = cont_hist.get(prev_mv, pc, mv, pc);
99+
assert!(
100+
score <= ContinuationHistory::MAX,
101+
"saturated continuation history entry ({score}) must not exceed MAX ({})",
102+
ContinuationHistory::MAX
103+
);
104+
105+
// Same check in the negative direction.
106+
for _ in 0..10_000 {
107+
cont_hist.update(prev_mv, pc, mv, pc, i32::MIN);
108+
}
109+
110+
let score = cont_hist.get(prev_mv, pc, mv, pc);
111+
assert!(
112+
score >= -ContinuationHistory::MAX,
113+
"saturated continuation history entry ({score}) must not exceed -MAX ({})",
114+
-ContinuationHistory::MAX
115+
);
116+
}
117+
}

crates/engine/src/history/quiet_history.rs

Lines changed: 2 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ use crate::{
99
history::{
1010
threat_bucket::{ThreatBucket, ThreatIndex},
1111
types::{self, FromToHistory},
12+
util::gravity,
1213
},
1314
score::{LargeScoreType, Score},
1415
};
@@ -49,12 +50,6 @@ impl QuietHistoryEntry {
4950
}
5051
}
5152

52-
/// Applies the standard history "gravity" formula: moves `current` toward `bonus`, weighted by
53-
/// how close `current` already is to `max`, so repeated updates saturate instead of overflowing.
54-
fn gravity(current: LargeScoreType, bonus: LargeScoreType, max: LargeScoreType) -> LargeScoreType {
55-
current + bonus - current * bonus.abs() / max
56-
}
57-
5853
/// History table for all quiet moves, indexed by from-square -> to-square -> per side (butterfly
5954
/// history), with each entry further split into threat buckets (see [`QuietHistoryEntry`]).
6055
pub struct QuietHistory {
@@ -111,7 +106,7 @@ impl Default for QuietHistory {
111106

112107
#[cfg(test)]
113108
mod tests {
114-
use crate::{defs::MAX_DEPTH, score::Score};
109+
use crate::defs::MAX_DEPTH;
115110

116111
use super::{QuietHistory, calculate_bonus_for_depth};
117112
use chess::{bitboard::Bitboard, moves::Move, side::Side, square::Square};
@@ -191,27 +186,6 @@ mod tests {
191186
);
192187
}
193188

194-
#[test]
195-
fn combined_score_never_exceeds_max_history() {
196-
let mut history_table = QuietHistory::new();
197-
let mv = Move::new(Square::B1, Square::A1, chess::moves::MoveFlag::Standard);
198-
let side = Side::Black;
199-
let threats = Bitboard::from(Square::B1) | Bitboard::from(Square::A1);
200-
201-
// Hammer the same cell with maximal bonuses to try to force it past MAX_HISTORY -
202-
// a saturated entry must never be able to sort above KILLER_BONUS in the move picker.
203-
for _ in 0..10_000 {
204-
history_table.update(side, mv, threats, i32::MAX, i32::MAX);
205-
}
206-
207-
let score = history_table.get(side, mv, threats);
208-
assert!(
209-
score <= Score::MAX_HISTORY,
210-
"saturated quiet history entry ({score}) must not exceed MAX_HISTORY ({})",
211-
Score::MAX_HISTORY
212-
);
213-
}
214-
215189
#[test]
216190
fn calculate_bonus_for_any_depth() {
217191
for depth in 1..MAX_DEPTH {

crates/engine/src/history/types.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ use chess::definitions::NumberOf;
99
/// Also known as 'butterfly' history.
1010
pub(crate) type FromToHistory<T> = [[T; NumberOf::SQUARES]; NumberOf::SQUARES];
1111

12+
pub(crate) type PieceToHistory<T> = [[T; NumberOf::SQUARES]; NumberOf::PIECE_TYPES];
13+
1214
pub(crate) fn default_from_to_history<T: Default + Copy>() -> FromToHistory<T> {
1315
[[Default::default(); NumberOf::SQUARES]; NumberOf::SQUARES]
1416
}

crates/engine/src/history/util.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
/// Applies the standard history "gravity" formula: moves `current` toward `bonus`, weighted by
2+
/// how close `current` already is to `max`, so repeated updates saturate instead of overflowing.
3+
pub(crate) fn gravity(current: i32, bonus: i32, max: i32) -> i32 {
4+
current + bonus - current * bonus.abs() / max
5+
}

0 commit comments

Comments
 (0)