Skip to main content

quantik_core/bench/
reference.rs

1//! Exact game-theoretic references for benchmark positions.
2//!
3//! Port of `benchmarks/reference.py`: a position's reference is the game
4//! value for the side to move plus the complete set of optimal moves,
5//! produced by full-depth minimax and stored only when every child was
6//! solved with no cutoff.
7
8use crate::bench::book_export::lookup_reference;
9use crate::bitboard::Bitboard;
10use crate::game::has_winning_line;
11use crate::minimax::{MinimaxConfig, MinimaxEngine};
12use crate::moves::{apply_move, generate_legal_moves, Move};
13use crate::opening_book::OpeningBookDatabase;
14use crate::state::State;
15use serde_json::{json, Value};
16use std::collections::BTreeMap;
17use std::time::Instant;
18
19/// Stable string identifier for a move: `player:shape:position`.
20pub fn move_key(mv: &Move) -> String {
21    format!("{}:{}:{}", mv.player, mv.shape, mv.position)
22}
23
24/// Parse a move key into `(player, shape, position)`.
25pub fn parse_move_key(key: &str) -> Result<(u8, u8, u8), String> {
26    let parts: Vec<&str> = key.split(':').collect();
27    if parts.len() != 3 {
28        return Err(format!("invalid move key {key:?}"));
29    }
30    let parse = |s: &str| -> Result<u8, String> {
31        s.parse::<u8>()
32            .map_err(|e| format!("move key {key:?}: {e}"))
33    };
34    Ok((parse(parts[0])?, parse(parts[1])?, parse(parts[2])?))
35}
36
37fn remaining_plies(bb: &Bitboard) -> u32 {
38    16 - bb.player_piece_count(0) - bb.player_piece_count(1)
39}
40
41/// Solve one root child exactly within `remaining_budget` seconds.
42/// Returns `(negated score, nodes, child pv keys)` or `None` on cutoff.
43fn score_child(child_bb: &Bitboard, remaining_budget: f64) -> Option<(f64, u64, Vec<String>)> {
44    let mut engine = MinimaxEngine::new(MinimaxConfig {
45        max_depth: 16,
46        time_limit_s: Some(remaining_budget),
47        ..Default::default()
48    });
49    let result = engine.search(&State::new(*child_bb)).ok()?;
50    if result.depth_reached < remaining_plies(child_bb) {
51        return None;
52    }
53    Some((
54        -result.score,
55        result.nodes,
56        result.pv.iter().map(move_key).collect(),
57    ))
58}
59
60/// Return an exact reference for `bb`, or `None` on budget cutoff.
61///
62/// The reference is exact because Quantik never exceeds 16 plies: a
63/// completed iterative-deepening depth at least equal to a child's
64/// remaining plies proves the child was solved to true terminals.
65pub fn solve_position(bb: &Bitboard, budget_s: f64) -> Option<Value> {
66    let started = Instant::now();
67    let legal_moves = generate_legal_moves(bb);
68    if legal_moves.is_empty() {
69        return None;
70    }
71
72    const IMMEDIATE_WIN: f64 = f64::INFINITY;
73    let mut scored: BTreeMap<String, f64> = BTreeMap::new();
74    let mut pvs: BTreeMap<String, Vec<String>> = BTreeMap::new();
75    let mut nodes = 0u64;
76
77    for mv in &legal_moves {
78        let key = move_key(mv);
79        let child_bb = apply_move(bb, mv);
80
81        if has_winning_line(&child_bb) || generate_legal_moves(&child_bb).is_empty() {
82            scored.insert(key.clone(), IMMEDIATE_WIN);
83            pvs.insert(key.clone(), vec![key]);
84            continue;
85        }
86
87        let remaining_budget = budget_s - started.elapsed().as_secs_f64();
88        if remaining_budget <= 0.0 {
89            return None;
90        }
91
92        let (score, child_nodes, child_pv) = score_child(&child_bb, remaining_budget)?;
93        scored.insert(key.clone(), score);
94        let mut pv = vec![key.clone()];
95        pv.extend(child_pv);
96        pvs.insert(key, pv);
97        nodes += child_nodes;
98    }
99
100    let best_score = scored.values().copied().fold(f64::NEG_INFINITY, f64::max);
101    // BTreeMap iteration is already sorted by key.
102    let optimal_moves: Vec<String> = scored
103        .iter()
104        .filter(|(_, &score)| score == best_score)
105        .map(|(key, _)| key.clone())
106        .collect();
107
108    let solve_time_s = (started.elapsed().as_secs_f64() * 1e6).round() / 1e6;
109    Some(json!({
110        "solved": true,
111        "no_cutoff": true,
112        "value": if best_score > 0.0 { 1 } else { -1 },
113        "optimal_moves": optimal_moves,
114        "pv": pvs[&optimal_moves[0]],
115        "nodes": nodes,
116        "solve_time_s": solve_time_s,
117        "solver": format!(
118            "MinimaxEngine(max_depth=16, budget_s={}) quantik-core-rust {}",
119            crate::bench::canonical::python_float_repr(budget_s),
120            env!("CARGO_PKG_VERSION"),
121        ),
122    }))
123}
124
125/// Like [`solve_position`], but first probes `book` (when given) for a
126/// stored solved reference at `bb`'s canonical key, short-circuiting the
127/// minimax solve on a hit. On a fresh solve, the result is written back
128/// through `book` (best-effort — a write failure is silently ignored, the
129/// solve itself already succeeded and is returned regardless).
130///
131/// See [`crate::bench::book_export`] for the orientation caveat: both the
132/// lookup and the write-back apply only when `bb` is its own canonical
133/// representative — the stored moves are in that one orientation and
134/// cannot be translated across symmetries yet.
135pub fn solve_position_with_book(
136    bb: &Bitboard,
137    budget_s: f64,
138    book: Option<&OpeningBookDatabase>,
139) -> Option<Value> {
140    if let Some(db) = book {
141        if let Some(hit) = lookup_reference(bb, db) {
142            return Some(hit);
143        }
144    }
145
146    let reference = solve_position(bb, budget_s)?;
147
148    // Write-side orientation guard, symmetric with lookup_reference's
149    // read-side guard: only canonical representatives may be stored
150    // (add_solved_position enforces this too, as defense in depth).
151    if let Some(db) = book {
152        if State::new(*bb).canonical_payload() == bb.to_le_bytes() {
153            let value = reference["value"].as_i64().unwrap_or(0) as i32;
154            if let Some(optimal_moves) = reference["optimal_moves"].as_array() {
155                let parsed: Result<Vec<(i32, i32)>, String> = optimal_moves
156                    .iter()
157                    .map(|v| {
158                        let key = v.as_str().ok_or("optimal_moves entry is not a string")?;
159                        let (_, shape, pos) = parse_move_key(key)?;
160                        Ok((shape as i32, pos as i32))
161                    })
162                    .collect();
163                if let Ok(optimal_moves) = parsed {
164                    let _ = db.add_solved_position(&State::new(*bb), value, &optimal_moves);
165                }
166            }
167        }
168    }
169
170    Some(reference)
171}
172
173/// Fill reference fields in place; the `opening` phase is skipped (its
174/// positions are too expensive to solve and never contribute to exact
175/// move-agreement figures).
176pub fn augment_with_references(payload: &mut Value, budget_s: f64) {
177    augment_with_references_with_book(payload, budget_s, None);
178}
179
180/// Like [`augment_with_references`], but reads through (and writes back
181/// into) `book` when given — see [`solve_position_with_book`].
182pub fn augment_with_references_with_book(
183    payload: &mut Value,
184    budget_s: f64,
185    book: Option<&OpeningBookDatabase>,
186) {
187    let Some(positions) = payload.get_mut("positions").and_then(Value::as_array_mut) else {
188        return;
189    };
190    for position in positions {
191        if position["phase"] == "opening" {
192            position["reference"] = Value::Null;
193            continue;
194        }
195        let bb = State::from_qfen(position["qfen"].as_str().unwrap_or_default())
196            .map(|s| s.bb)
197            .unwrap_or(Bitboard::EMPTY);
198        position["reference"] =
199            solve_position_with_book(&bb, budget_s, book).unwrap_or(Value::Null);
200    }
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206    use rand::prelude::*;
207
208    fn random_position(seed: u64, plies: usize) -> Bitboard {
209        let mut rng = StdRng::seed_from_u64(seed);
210        'attempt: loop {
211            let mut bb = Bitboard::EMPTY;
212            for _ in 0..plies {
213                let moves = generate_legal_moves(&bb);
214                if moves.is_empty() {
215                    continue 'attempt;
216                }
217                bb = apply_move(&bb, &moves[rng.gen_range(0..moves.len())]);
218                if has_winning_line(&bb) {
219                    continue 'attempt;
220                }
221            }
222            if generate_legal_moves(&bb).is_empty() {
223                continue 'attempt;
224            }
225            return bb;
226        }
227    }
228
229    #[test]
230    fn move_key_roundtrip() {
231        let mv = Move::new(1, 3, 15);
232        assert_eq!(move_key(&mv), "1:3:15");
233        assert_eq!(parse_move_key("1:3:15").unwrap(), (1, 3, 15));
234        assert!(parse_move_key("1:3").is_err());
235        assert!(parse_move_key("a:b:c").is_err());
236    }
237
238    #[test]
239    fn immediate_win_is_optimal() {
240        // Find a deep (cheap to solve exactly) position where the side to
241        // move has an immediate winning reply; the solver must value it +1
242        // and list every immediate win among the optimal moves.
243        let (bb, winning) = (0u64..)
244            .find_map(|seed| {
245                let bb = random_position(seed, 11);
246                let winning: Vec<Move> = generate_legal_moves(&bb)
247                    .into_iter()
248                    .filter(|mv| has_winning_line(&apply_move(&bb, mv)))
249                    .collect();
250                (!winning.is_empty()).then_some((bb, winning))
251            })
252            .unwrap();
253
254        let reference = solve_position(&bb, 60.0).unwrap();
255        assert_eq!(reference["value"], json!(1));
256        let optimal: Vec<&str> = reference["optimal_moves"]
257            .as_array()
258            .unwrap()
259            .iter()
260            .map(|v| v.as_str().unwrap())
261            .collect();
262        for mv in &winning {
263            assert!(optimal.contains(&move_key(mv).as_str()), "{optimal:?}");
264        }
265        assert_eq!(reference["pv"][0], json!(optimal[0]));
266        assert_eq!(reference["solved"], json!(true));
267        assert_eq!(reference["no_cutoff"], json!(true));
268    }
269
270    #[test]
271    fn tiny_budget_returns_none() {
272        let bb = random_position(3, 5);
273        assert!(solve_position(&bb, 1e-9).is_none());
274    }
275
276    #[test]
277    fn solved_reference_selected_moves_verify() {
278        // Deep position: cheap exact solve; every optimal move must be legal.
279        let bb = random_position(11, 10);
280        let reference = solve_position(&bb, 60.0).unwrap();
281        let legal: Vec<String> = generate_legal_moves(&bb).iter().map(move_key).collect();
282        for mv in reference["optimal_moves"].as_array().unwrap() {
283            assert!(legal.contains(&mv.as_str().unwrap().to_string()));
284        }
285        let value = reference["value"].as_i64().unwrap();
286        assert!(value == 1 || value == -1);
287    }
288
289    fn temp_book_path(tag: &str) -> std::path::PathBuf {
290        let id = std::time::SystemTime::now()
291            .duration_since(std::time::UNIX_EPOCH)
292            .unwrap()
293            .as_nanos();
294        std::env::temp_dir().join(format!("quantik_ref_book_{tag}_{id}.db"))
295    }
296
297    fn open_book(path: &std::path::Path) -> crate::opening_book::OpeningBookDatabase {
298        crate::opening_book::OpeningBookDatabase::open(&crate::opening_book::OpeningBookConfig {
299            database_path: path.to_string_lossy().to_string(),
300            ..Default::default()
301        })
302        .unwrap()
303    }
304
305    #[test]
306    fn solve_position_with_book_short_circuits_canonical_positions() {
307        // A canonical-representative position: after the first solve
308        // writes it back, a second lookup must hit the book (solver ==
309        // "opening-book", nodes == 0) instead of re-solving.
310        let bb = (0u64..)
311            .map(|seed| random_position(seed, 11))
312            .find(|bb| State::new(*bb).canonical_payload() == bb.to_le_bytes())
313            .expect("a canonical-representative position exists among random samples");
314
315        let path = temp_book_path("short-circuit");
316        let db = open_book(&path);
317
318        let first = solve_position_with_book(&bb, 60.0, Some(&db)).unwrap();
319        assert_ne!(first["solver"], json!("opening-book"));
320
321        let second = solve_position_with_book(&bb, 60.0, Some(&db)).unwrap();
322        assert_eq!(second["solver"], json!("opening-book"));
323        assert_eq!(second["nodes"], json!(0));
324        assert_eq!(second["value"], first["value"]);
325
326        std::fs::remove_file(&path).ok();
327    }
328
329    #[test]
330    fn solve_position_with_book_never_short_circuits_non_canonical_positions() {
331        // A non-canonical-orientation position: the book guard requires
332        // the QUERY itself to be its own canonical representative, so
333        // lookups here must always fall through to a fresh minimax solve,
334        // even after a write-back.
335        let bb = (0u64..)
336            .map(|seed| random_position(seed, 11))
337            .find(|bb| State::new(*bb).canonical_payload() != bb.to_le_bytes())
338            .expect("a non-canonical position exists among random samples");
339
340        let path = temp_book_path("no-short-circuit");
341        let db = open_book(&path);
342
343        let first = solve_position_with_book(&bb, 60.0, Some(&db)).unwrap();
344        assert_ne!(first["solver"], json!("opening-book"));
345
346        let second = solve_position_with_book(&bb, 60.0, Some(&db)).unwrap();
347        assert_ne!(second["solver"], json!("opening-book"));
348
349        std::fs::remove_file(&path).ok();
350    }
351}