diff --git a/arche-core/src/engine.rs b/arche-core/src/engine.rs index 0aab6085..37ce7005 100644 --- a/arche-core/src/engine.rs +++ b/arche-core/src/engine.rs @@ -1405,7 +1405,9 @@ impl AlphaBeta { /// A node decision just taken, the margin's or the pass's, offered to /// the forced decision instrument. True when it is the decision being - /// inverted, which the caller then does not take. + /// inverted, which the caller then does not take. `gap` is how far the + /// decision's bound cleared beta: the margin's floor or the pass's + /// score, less beta. // cold and out of line behind a bare is_some, for `sample`'s reason #[cold] #[inline(never)] @@ -1416,6 +1418,7 @@ impl AlphaBeta { eval: Score, alpha: Score, beta: Score, + gap: i32, ) -> bool { let address = forced::Address { kind, @@ -1436,6 +1439,7 @@ impl AlphaBeta { features: None, eval_beta: i32::from(eval) - i32::from(beta), alpha_gap: i32::from(alpha) - i32::from(eval), + gap: Some(gap), fen: board.to_fen(), }); false @@ -1495,6 +1499,7 @@ impl AlphaBeta { features: Some(features), eval_beta: eval_beta as i32, alpha_gap: alpha_gap as i32, + gap: None, fen: board.to_fen(), } }); @@ -1505,7 +1510,8 @@ impl AlphaBeta { /// forced decision instrument before it answers for its move. True when /// it is the decision being inverted. The board is the position the /// move left, which is the address, and the node's own evaluation is - /// read by stepping the move back, for kept events alone. + /// read by stepping the move back, for kept events alone. `scout` is + /// the scout's fail soft score, at or under alpha. #[cold] #[inline(never)] fn forced_scout( @@ -1514,6 +1520,7 @@ impl AlphaBeta { depth: u8, alpha: Score, beta: Score, + scout: Score, ) -> bool { let address = forced::Address { kind: forced::Kind::TrustedScout, @@ -1546,6 +1553,7 @@ impl AlphaBeta { features: Some(staged.features), eval_beta: eval_beta as i32, alpha_gap: alpha_gap as i32, + gap: Some(i32::from(alpha) - i32::from(scout)), fen, } }); @@ -2236,7 +2244,14 @@ impl AlphaBeta { } if floor >= beta { if self.forced.is_some() - && self.forced_node(forced::Kind::ReverseFutility, depth, eval, alpha, beta) + && self.forced_node( + forced::Kind::ReverseFutility, + depth, + eval, + alpha, + beta, + i32::from(floor) - i32::from(beta), + ) { margin_inverted = true; } else { @@ -2265,7 +2280,14 @@ impl AlphaBeta { let value = -result?; if value.score >= beta && !(self.forced.is_some() - && self.forced_node(forced::Kind::NullMove, depth, eval, alpha, beta)) + && self.forced_node( + forced::Kind::NullMove, + depth, + eval, + alpha, + beta, + i32::from(value.score) - i32::from(beta), + )) { // a mate found through a pass is not a mate, since the pass // is not a legal move, so the score is held under the window @@ -2378,7 +2400,8 @@ impl AlphaBeta { ); } if scout.score <= alpha - && !(self.forced.is_some() && self.forced_scout(staged, depth, alpha, beta)) + && !(self.forced.is_some() + && self.forced_scout(staged, depth, alpha, beta, scout.score)) { return Ok(scout); } diff --git a/arche-core/src/forced.rs b/arche-core/src/forced.rs index 75d1d78d..82606ff6 100644 --- a/arche-core/src/forced.rs +++ b/arche-core/src/forced.rs @@ -176,6 +176,12 @@ pub struct Event { pub(crate) features: Option, pub eval_beta: i32, pub alpha_gap: i32, + /// How far the decision's own bound cleared its window, in the + /// direction that took the decision: for a trusted scout, the alpha it + /// was read against less its fail soft score; for the null move, the + /// pass's score less beta; for reverse futility, the margin's floor + /// less beta. Zero at the bound. None on a skip, which searches nothing. + pub gap: Option, /// The deciding node's position. pub fen: String, } @@ -396,11 +402,11 @@ pub fn run( /// and what the default answered at each root. /// /// A row is `kind depth index searched generated history history_max -/// killer tt eval_beta alpha_gap answered visits root best_on +/// killer tt eval_beta alpha_gap gap answered visits root best_on /// best_forced score_on score_forced nodes_on nodes_forced fen`, /// whitespace separated with the deciding node's fen last. `root` is the /// root's place in the suite, which its line names. A move decision's own -/// columns print `-` on a node decision. +/// columns print `-` on a node decision, and `gap` prints `-` on a skip. impl fmt::Display for Report { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { writeln!( @@ -435,12 +441,13 @@ impl fmt::Display for Report { }; writeln!( f, - "{} {} {} {} {} {} {} {} {} {} {} {} {} {} {}", + "{} {} {} {} {} {} {} {} {} {} {} {} {} {} {} {}", e.address.kind.word(), e.address.depth, features, e.eval_beta, e.alpha_gap, + e.gap.map_or_else(|| "-".to_string(), |gap| gap.to_string()), row.answered.map_or("-", Answered::word), row.visits, e.root, diff --git a/docs/INSTRUMENTS.md b/docs/INSTRUMENTS.md index e7f21f31..80073d60 100644 --- a/docs/INSTRUMENTS.md +++ b/docs/INSTRUMENTS.md @@ -432,9 +432,13 @@ quiets at once never meets the skips in it. The tests hold an armed search's move, score and node count to an unarmed one's. Each row is `kind depth index searched generated history history_max killer -tt eval_beta alpha_gap answered visits root best_on best_forced score_on -score_forced nodes_on nodes_forced fen`. The move columns are the ledger's, -read at the first visit, and print `-` on a node decision. Every skip at +tt eval_beta alpha_gap gap answered visits root best_on best_forced +score_on score_forced nodes_on nodes_forced fen`. The move columns are the +ledger's, read at the first visit, and print `-` on a node decision. `gap` +is how far the decision's own bound cleared its window, zero at the bound: +for a trusted scout the alpha less its fail soft score, for the null move +the pass's score less beta, and for reverse futility the margin's floor +less beta. A skip searched nothing and prints `-`. Every skip at depth four and up has an `eval_beta` at or under minus the skip's margin by construction. `answered` is, for a node decision, what answered the node once it was inverted. `visits` is how often the forced search met the address, and a diff --git a/tests/forced_session.rs b/tests/forced_session.rs index f05238c3..7e3dc9cd 100644 --- a/tests/forced_session.rs +++ b/tests/forced_session.rs @@ -24,18 +24,24 @@ fn the_forced_argument_prints_a_header_rows_and_a_line_a_kind_and_a_root() { assert!(printed.events() > 0, "header: {}", printed.header); for row in &printed.rows { let words: Vec<&str> = row.split(' ').collect(); - // twenty columns before a fen of six fields - assert_eq!(words.len(), 26, "row: {row}"); + // twenty one columns before a fen of six fields + assert_eq!(words.len(), 27, "row: {row}"); assert!( ["reverse_futility", "null_move", "skip", "trusted_scout"].contains(&words[0]), "row: {row}" ); // depth, visits, root, the two scores and the two node counts - for at in [1, 12, 13, 16, 17, 18, 19] { + for at in [1, 13, 14, 17, 18, 19, 20] { assert!(words[at].parse::().is_ok(), "field {at} of {row}"); } + // every decision but a skip cleared its bound, by zero or more + if words[0] == "skip" { + assert_eq!(words[11], "-", "row: {row}"); + } else { + assert!(words[11].parse::().is_ok(), "row: {row}"); + } // a kept decision the forced search never met is a failed row - assert!(words[12] != "0", "row: {row}"); + assert!(words[13] != "0", "row: {row}"); } let kinds = printed .summary