Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@ search, principal variation search, reverse futility pruning, a null move pass a
reduction. Captures are ordered by what a static exchange evaluation says the swap wins, with
MVV-LVA breaking the ties between the ones it prices alike, and the quiet moves by the ones that
have cut off before. Evaluation is material, piece square tables, piece mobility, the
pawns standing in front of each king, and pawn structure, tapered between a middlegame
and an endgame score. The tables and the three leaf terms were fitted to the engine's
own games; the material values are hand set.
pawns standing in front of each king, pawn structure, and the squares around each king the
enemy pieces attack, tapered between a middlegame and an endgame score. The tables and the
four leaf terms were fitted to the engine's own games; the material values are hand set.

### Background

Expand Down
64 changes: 32 additions & 32 deletions arche-core/src/bench.rs
Original file line number Diff line number Diff line change
Expand Up @@ -759,22 +759,22 @@ mod tests {
assert_eq!(
counted,
vec![
("start", 68_341),
("italian", 150_035),
("ruy lopez", 161_892),
("kiwipete", 243_106),
("perft 4", 110_373),
("promotions", 54_363),
("middlegame", 100_507),
("sharp middlegame", 180_608),
("bratko kopec 1", 571_361),
("wac 4", 1_944_432),
("rook and pawns", 29_021),
("tarrasch", 89_870),
("lucena", 15_468),
("philidor", 61_191),
("minor endgame", 25_367),
("queen endgame", 56_696),
("start", 73_075),
("italian", 132_136),
("ruy lopez", 127_220),
("kiwipete", 275_925),
("perft 4", 122_259),
("promotions", 46_622),
("middlegame", 167_457),
("sharp middlegame", 184_701),
("bratko kopec 1", 671_225),
("wac 4", 1_946_406),
("rook and pawns", 29_112),
("tarrasch", 53_975),
("lucena", 16_701),
("philidor", 60_589),
("minor endgame", 30_918),
("queen endgame", 157_932),
("king and pawn", 3_522),
("trebuchet", 2_834),
]
Expand Down Expand Up @@ -807,22 +807,22 @@ mod tests {
assert_eq!(
counted,
vec![
("start", 28_328),
("italian", 227_641),
("ruy lopez", 120_190),
("kiwipete", 178_402),
("perft 4", 232_961),
("promotions", 95_254),
("middlegame", 167_371),
("sharp middlegame", 277_951),
("bratko kopec 1", 44_544),
("wac 4", 89_394),
("rook and pawns", 24_020),
("tarrasch", 33_182),
("lucena", 21_127),
("philidor", 51_857),
("minor endgame", 34_141),
("queen endgame", 96_523),
("start", 33_729),
("italian", 120_260),
("ruy lopez", 118_515),
("kiwipete", 224_244),
("perft 4", 255_435),
("promotions", 95_210),
("middlegame", 164_325),
("sharp middlegame", 241_920),
("bratko kopec 1", 52_545),
("wac 4", 89_428),
("rook and pawns", 22_615),
("tarrasch", 48_576),
("lucena", 24_290),
("philidor", 29_763),
("minor endgame", 30_627),
("queen endgame", 175_117),
("king and pawn", 1_601),
("trebuchet", 968),
]
Expand Down
181 changes: 121 additions & 60 deletions arche-core/src/eval/king_attack.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,25 +18,80 @@ use crate::psqt::{eg_value, mg_value, pack};

/// How many counts the term is measured in, which is one per piece that
/// carries a weight. The same four as mobility, in the order
/// [`mobility::PIECES`] names them, so a walk that one day takes both readings
/// at once writes each into the slot the other uses.
/// [`mobility::PIECES`] names them, so the walk in `eval/mod.rs` that takes
/// both readings at once writes each into the slot the other uses.
pub(crate) const COUNTS: usize = mobility::PIECES.len();

/// What one attacked square of the enemy king's ring is worth to each of
/// [`mobility::PIECES`], as the packed pairs the taper is read from.
///
/// Unfitted. All eight halves are zero, so [`SCORED`] is false, the leaf does
/// not take the counts at all, and the evaluation is the one the commit below
/// this counted: the same bench, the same two suite totals, and the claim of
/// the commit that added the counts is that they moved none of them. The
/// counts are here for the tuner's rows, which state a coefficient per column
/// whatever weight stands beside it, so a corpus extracted from this build is
/// what a fit prices them on.
/// Fitted 2026-09-16 by `scripts/tune.py` over the whole archived strength
/// run: 310 artifacts and 50,677 games, whose 6,623,970 post-book plies gave
/// 6,227,777 positions and 2,890,773 quiet rows, extracted by `arche terms` at
/// the 820 slot layout. The corpus is sha256 `a0798d51`, built with the sealed
/// pairs named, and those are sha256 `a723887f`. The rows are sha256
/// `6001063b` and the fitted vector is sha256 `f65aa57e`. K was held at 1.0821
/// and the games split by pair: 16,300 pairs and 32,516 games trained, 5,528
/// pairs and 11,030 games chose the ridge, and 3,498 pairs and 6,985 games
/// were sealed. The sealed file names 3,501 pairs over 6,995 games. Three of
/// those pairs reach no row, which accounts for at most six of the ten missing
/// games, and where the other four went is not established. The 768 table
/// entries, the six material values, the eight mobility weights, the fourteen
/// shelter weights and the sixteen pawn structure ones were all held, so these
/// eight are the only thing that moved.
///
/// The fit replaces this block with the provenance one the other three terms
/// carry. `every_term_writes_the_counts_its_width_claims` in the module above
/// names this term as the unfitted one and fails until that happens.
static KING_ATTACK: [i32; COUNTS] = [pack(0, 0); COUNTS];
/// The fit's figure is the sealed group's: a paired difference of -0.000143
/// against a standard error of 0.000059 at a design factor of 3.2. The sealed
/// group was opened once, at 2026-09-16T10:28:31Z, after the vector was frozen
/// and over 417,778 positions that no fit and no ridge choice had read. It
/// scores 0.079520 at zero and 0.079377 at these rounded weights. That is 2.42
/// standard errors from zero, outside its interval.
///
/// The selection group read -0.000395 against a standard error of 0.000052 at
/// the same design factor, scoring 0.085901 at zero and 0.085506 at the fit
/// before it was rounded (0.085496 after). By the rule the fit was registered
/// with, that a disagreement wider than the mobility refit's 1.85 standard
/// errors means the sealed figure is the one quoted, the selection group's
/// -0.000395 overstated the fit.
///
/// Two things in the sealed reading were recorded before any game was played.
/// The first is its size. It is about a third of the selection group's, and
/// about 3.2 standard errors from it, where the mobility refit's two readings
/// were 1.85 apart and the king safety fit's 0.68. The sealed games are the
/// strength runs played since the mobility refit's corpus was built, and most
/// of them are the lazy mobility arms, whose candidates skipped the mobility
/// term at most quiescence stand pats and so reached positions a different
/// evaluation chose. The second is where it pays. By phase the sealed reading
/// is -0.000088 at six pieces or fewer, -0.000414 from seven to twelve and
/// +0.000366 at thirteen or more, so on those games the fullest boards read
/// slightly worse at these weights. The selection group read -0.000261,
/// -0.000811 and +0.000008 over the same buckets, the fullest boards level.
/// Neither group prints a standard error per bucket.
///
/// The ridge is zero, which the grid ranked first. The check that would have
/// overruled it is a largest weight on the column with the least support, and
/// that is not what happened: the largest weight is the rook's 27, and the
/// rook column is the best supported of the four. A midgame coefficient is
/// written in 16.2% of the training rows for the knight, 27.0% for the bishop,
/// 30.5% for the rook and 17.4% for the queen. Little of that is on a full
/// board. A sample of every twentieth of all 2,890,773 rows, across all three
/// groups and reading positions only, finds a rook bearing on the enemy ring
/// from 0.6% of the sides with thirteen or more pieces left, against 21.7% of
/// the sides over the sampled rows. The sample spans every group, so it is a
/// different population from the training row shares above.
///
/// The learning curve is flat from an eighth of the training pairs. Five
/// draws of 2,038 pairs read -0.000346 to -0.000397 against the whole set's
/// -0.000395, so the corpus is not what limits eight weights.
///
/// [`SCORED`] is true at these weights, so the leaf counts the ring at every
/// evaluation. What that costs is in the commit that landed this.
///
/// `bounds_hold` charges one piece of each kind two squares of the ring for a
/// knight, three for a bishop, four for a rook and six for a queen, both sides
/// counted. That puts this term at 584 in the midgame and the whole vector's
/// boardful at 9,762, against the 32,767 a half has to stay inside.
static KING_ATTACK: [i32; COUNTS] = [pack(11, 3), pack(20, -2), pack(27, -3), pack(17, 9)];

/// The weight of one of the four pieces that carries one, as the packed pair.
/// The tuner's seam asks through [`super::TERMS`], so that a slot names the
Expand All @@ -47,13 +102,13 @@ pub(crate) const fn weight(index: usize) -> i32 {

/// Whether [`super::sum`] takes this term at the leaf: true when one of the
/// four [`KING_ATTACK`] weights is not zero at one end of the taper or the
/// other. All eight halves are zero today, so it is false and no leaf asks
/// [`counts_of`] anything.
/// other. The 2026-09-16 fit priced all eight halves, so it is true and every
/// leaf counts the ring for both sides.
///
/// Derived from the weights rather than written out, which is how
/// [`mobility::SCORED_KINDS`] is derived, and it means the fit that prices the
/// columns turns the term on by replacing the weights and editing nothing
/// else.
/// [`mobility::SCORED_KINDS`] is derived. The fit turned the term on by
/// replacing the weights and editing nothing else, and weights put back to
/// zero would turn it off the same way.
///
/// A count at a zero weight is not folded away by the compiler. Multiplying
/// the counts by nothing leaves the walk over the pieces standing, and llvm
Expand Down Expand Up @@ -110,13 +165,17 @@ const fn scored(weights: &[i32; COUNTS]) -> bool {
/// mostly the same pawn one rank on; a king bearing on the other king's ring is
/// the opposition, which the endgame table prices.
///
/// Both [`super::eval`] and the tuner's walk read this, so the identity
/// between them cannot see a wrong count here. What pins it is the hand counts
/// in the tests below, the way the mobility counts are pinned.
/// The tuner's walk reads this, and the evaluation reads the same counts off
/// the walk in `eval/mod.rs` it shares with mobility, which
/// `the_shared_walk_counts_what_each_term_counts_alone` holds to this
/// function. So neither the identity nor that test can see a wrong count
/// here. What pins it is the hand counts in the tests below, the way the
/// mobility counts are pinned.
///
/// Inlined by force, for the reason `mobility::counts_of` gives: the
/// evaluation asks for it twice at every leaf and every quiescence node, and
/// llvm leaves a walk this shape out of line when it is left to itself.
/// Inlined by force, for the reason `mobility::counts_of` gives: when the
/// evaluation read this it asked for it twice at every leaf and every
/// quiescence node, and llvm leaves a walk this shape out of line when it is
/// left to itself.
#[inline(always)]
pub(crate) fn counts_of(board: &Board, color: Color) -> [i32; COUNTS] {
let occupied = board.occupied();
Expand Down Expand Up @@ -156,30 +215,38 @@ pub(crate) fn counts(board: &Board, color: Color, into: &mut [i32]) {
}

/// What white's bearing on the black king stands ahead by, as a packed pair on
/// the scale the piece square pair is on.
/// the scale the piece square pair is on, given each side's counts.
///
/// Nothing at the weights that ship, since all eight halves are zero, and the
/// sum does not call it at all while [`SCORED`] is false. The fold is written
/// now rather than with the fit so that the sum names this term from the
/// commit the counts arrive in, and so that what the fit moves is eight
/// numbers and not the shape of the evaluation.
/// The sum calls it only while [`SCORED`] is true, and does not call
/// [`counts_of`] for the counts. It takes them from the walk in `eval/mod.rs`
/// that reads mobility's counts off the same attack sets, so a piece's attack
/// set is probed once for the two terms rather than once for each.
#[inline]
pub(crate) fn fold_counts(white: [i32; COUNTS], black: [i32; COUNTS]) -> i32 {
weigh(&KING_ATTACK, white, black)
}

/// The fold with the counts taken by [`counts_of`], which is what the sum
/// answered before it shared a walk with mobility. The tests hold the sum to
/// it.
#[cfg(test)]
pub(crate) fn fold(board: &Board) -> i32 {
fold_with(board, &KING_ATTACK)
}

/// The same fold against weights named by the caller.
///
/// Visible to the module above rather than private, which is what the other
/// three leaf terms keep theirs. [`KING_ATTACK`] is zero, so the shipped fold
/// answers nothing on every position and can say nothing about how the four
/// pairs are read, and `the_table_names_the_terms_the_sum_adds` has to read it
/// against weights of its own to say anything at all. The fit puts that back
/// and this goes private again.
#[cfg(test)]
fn fold_with(board: &Board, weights: &[i32; COUNTS]) -> i32 {
weigh(
weights,
counts_of(board, Color::White),
counts_of(board, Color::Black),
)
}

/// White's counts less black's, piece by piece, against `weights`.
#[inline]
pub(super) fn fold_with(board: &Board, weights: &[i32; COUNTS]) -> i32 {
let white = counts_of(board, Color::White);
let black = counts_of(board, Color::Black);
fn weigh(weights: &[i32; COUNTS], white: [i32; COUNTS], black: [i32; COUNTS]) -> i32 {
let mut packed = 0;
for ((weight, white), black) in weights.iter().zip(white).zip(black) {
packed += weight * (white - black);
Expand All @@ -194,12 +261,10 @@ mod tests {
use pretty_assertions::assert_eq;

/// The counts by hand, square by square, because nothing else pins them.
/// The tuner's identity folds a row against the live weights, and `eval`
/// and the walk read this same function, so the two sides of the identity
/// move together whatever it answers. At the zero weights that ship the
/// identity says less than that: the coefficients are multiplied by
/// nothing, so a wrong sign or a wrong half of a pair is invisible to it.
/// These cases are the only check this term has.
/// The tuner's identity folds a row against the live weights, and the
/// tuner reads this function and `eval` reads a walk held to it, so the
/// two sides of the identity move together whatever it answers. These
/// cases are what pins the counts themselves.
///
/// The black king stands on e8 unless the case says otherwise, so its ring
/// is d7, e7, f7, d8 and f8. The white king stands off every line the case
Expand Down Expand Up @@ -322,6 +387,7 @@ mod tests {
] {
let board = Board::from_fen(fen).unwrap();
assert_eq!(counts_of(&board, color), counts, "{}", why);
super::super::evaluate::the_shared_walk_agrees(&board, why);
}
}

Expand Down Expand Up @@ -383,7 +449,7 @@ mod tests {
/// [`counts_of`].
///
/// Black's king on g8 has the ring f7, g7, h7, f8 and h8. White's knight
/// on e6 has f8 and g7. Its bishop on d3 has h7 up the long diagonal. Its
/// on e6 has f8 and g7. Its bishop on d3 has h7, through e4, f5 and g6. Its
/// rook on a7 has d7 and e7 and f7 along the rank, of which f7 alone is in
/// the ring, and then g7 and h7 as well, which is three. Its queen on h6
/// has h7 and h8 up the file and g7 and f8 up the diagonal, which is four.
Expand All @@ -407,12 +473,12 @@ mod tests {

/// What the fold does with weights that are not the shipped ones.
///
/// The shipped ones are zero, so they would say nothing here at all. What
/// the packing does with a pair, and which of the four pieces each pair
/// belongs to, stay invisible until a fit prices the columns. So this
/// hands the fold four pairs that differ from each other at both ends and
/// asserts the packed pair against the arithmetic: white's count less
/// black's, piece by piece, each half of the pair summed on its own.
/// Nothing holds the shipped weights apart from each other, so a pair
/// read into the wrong piece's slot or a half read off the wrong end could
/// land on the right number by accident. So this hands the fold four pairs
/// that differ at both ends and asserts the packed pair against the
/// arithmetic: white's count less black's, piece by piece, each half of the
/// pair summed on its own.
#[test]
fn the_king_attack_fold_reads_white_less_black_piece_by_piece() {
let board = Board::from_fen(BEARING).unwrap();
Expand All @@ -431,11 +497,6 @@ mod tests {
let packed = super::fold_with(&board, &TRIAL);
assert_ne!(midgame, 0, "black less white would answer the same here");
assert_eq!((mg_value(packed), eg_value(packed)), (midgame, endgame));
assert_eq!(
super::fold(&board),
0,
"the shipped weights are not zero, so the bench pin and this term's own doc block are stale"
);
}

/// What the leaf is allowed to leave out, which is the whole of the
Expand All @@ -449,8 +510,8 @@ mod tests {
/// else, read here off the array a second way, and then a weight put back
/// into each half of each column in turn.
///
/// A fit that prices the term flips this to true and the leaf starts
/// counting, with no line here or in the sum to edit.
/// The fit priced the term, so the constant is true. Weights put back to
/// zero would turn it off with no line here or in the sum to edit.
#[test]
fn the_term_is_counted_exactly_when_a_weight_is_not_zero() {
let priced = (0..COUNTS).any(|index| {
Expand Down
Loading
Loading