Skip to main content

quantik_core/bench/
book_export.rs

1//! Bulk export of solved benchmark references into the SQLite opening
2//! book, and lookup of stored references to short-circuit repeated
3//! solves.
4//!
5//! The book is keyed by the 18-byte canonical key
6//! ([`crate::state::State::canonical_key`]), which is byte-identical to
7//! the Python implementation's, so the same SQLite file is portable
8//! across languages: a book built by `quantik-core-rust` can be read by
9//! `opening_book.py` and vice versa.
10//!
11//! **Orientation caveat — representative-only, on BOTH reads and writes:**
12//! optimal moves are recorded as `(shape, position)` pairs in a specific
13//! board orientation, but the row is keyed by the canonical key, which is
14//! shared by up to eight symmetric orientations. The book does not (yet)
15//! store which symmetry transform maps the stored orientation to an
16//! arbitrary query, so moves cannot be translated across orientations.
17//! Both directions are therefore restricted to boards that are their own
18//! canonical representative
19//! (`State::new(*bb).canonical_payload() == bb.to_le_bytes()`):
20//!
21//! - **Writes** ([`export_references`], and
22//!   [`crate::opening_book::OpeningBookDatabase::add_solved_position`]
23//!   itself as defense in depth) silently skip any solved position that
24//!   is not its own canonical representative. Without this guard, a row
25//!   written from a rotated orientation would later be served — with
26//!   wrong, possibly illegal moves — to a query on the representative
27//!   board, which passes the read-side check below.
28//! - **Reads** ([`lookup_reference`]) only return a hit when the *query*
29//!   is its own canonical representative; together with the write guard
30//!   this means stored moves are always in exactly the orientation of any
31//!   board they are served for.
32//!
33//! Full orientation tracking (storing the symmetry transform index so
34//! moves can be translated to any queried orientation) is the documented
35//! follow-up that would lift this restriction on both sides.
36
37use crate::bitboard::Bitboard;
38use crate::game::current_player;
39use crate::opening_book::OpeningBookDatabase;
40use crate::state::State;
41use serde_json::{json, Value};
42
43use super::reference::parse_move_key;
44
45/// Upsert every eligible solved reference in `dataset_payload` (a dataset
46/// or bundle JSON artifact with a top-level `positions` array, each entry
47/// carrying `qfen` and an optional `reference`) into `db`. Positions with
48/// a `null` reference are skipped, and — per the module-level orientation
49/// caveat — so are solved positions that are **not their own canonical
50/// representative** (silently: their optimal moves are in their own
51/// orientation, which is the wrong orientation for the canonical key the
52/// row would be stored under). Returns the number of positions actually
53/// inserted.
54///
55/// Idempotent: re-running against the same payload upserts the same rows
56/// (`INSERT OR REPLACE` semantics via
57/// [`crate::opening_book::OpeningBookDatabase::add_solved_position`]), so
58/// the total row count in `positions` does not grow on a rerun.
59pub fn export_references(dataset_payload: &Value, db: &OpeningBookDatabase) -> Result<u64, String> {
60    let positions = dataset_payload
61        .get("positions")
62        .and_then(Value::as_array)
63        .cloned()
64        .unwrap_or_default();
65
66    let mut count = 0u64;
67    for position in &positions {
68        let reference = &position["reference"];
69        if reference.is_null() {
70            continue;
71        }
72
73        let qfen = position["qfen"]
74            .as_str()
75            .ok_or("dataset position missing qfen")?;
76        let state = State::from_qfen(qfen).map_err(|e| format!("parse qfen {qfen:?}: {e}"))?;
77
78        // Write-side orientation guard, symmetric with lookup_reference's
79        // read-side guard: only canonical representatives may be stored.
80        // (add_solved_position enforces this too, as defense in depth.)
81        if state.canonical_payload() != state.bb.to_le_bytes() {
82            continue;
83        }
84
85        let value = reference["value"]
86            .as_i64()
87            .ok_or_else(|| format!("reference for {qfen} missing integer value"))?
88            as i32;
89
90        let optimal_moves = reference["optimal_moves"]
91            .as_array()
92            .ok_or_else(|| format!("reference for {qfen} missing optimal_moves"))?
93            .iter()
94            .map(|v| {
95                let key = v
96                    .as_str()
97                    .ok_or_else(|| format!("optimal_moves entry for {qfen} is not a string"))?;
98                let (_, shape, pos) = parse_move_key(key)?;
99                Ok((shape as i32, pos as i32))
100            })
101            .collect::<Result<Vec<(i32, i32)>, String>>()?;
102
103        let written = db
104            .add_solved_position(&state, value, &optimal_moves)
105            .map_err(|e| format!("add_solved_position for {qfen}: {e}"))?;
106        if written {
107            count += 1;
108        }
109    }
110
111    Ok(count)
112}
113
114/// Probe the book for an exact reference at `bb`, reconstructing the JSON
115/// shape produced by [`crate::bench::reference::solve_position`] (minus a
116/// full principal variation — only the first optimal move is known).
117///
118/// Returns `None` unless (a) a row exists for `bb`'s canonical key with
119/// `solved = true`, and (b) `bb` is its own canonical representative — see
120/// the module doc for why the second condition is required.
121pub fn lookup_reference(bb: &Bitboard, db: &OpeningBookDatabase) -> Option<Value> {
122    let state = State::new(*bb);
123    if state.canonical_payload() != bb.to_le_bytes() {
124        return None;
125    }
126
127    let entry = db.get_position(&state).ok().flatten()?;
128    if !entry.solved {
129        return None;
130    }
131    let value = entry.game_value?;
132    let player = current_player(bb)?;
133
134    let optimal_moves: Vec<String> = entry
135        .best_moves
136        .iter()
137        .map(|&(shape, position)| format!("{player}:{shape}:{position}"))
138        .collect();
139    if optimal_moves.is_empty() {
140        return None;
141    }
142
143    Some(json!({
144        "solved": true,
145        "no_cutoff": true,
146        "value": value,
147        "optimal_moves": optimal_moves,
148        "pv": [optimal_moves[0].clone()],
149        "nodes": 0,
150        "solve_time_s": 0.0,
151        "solver": "opening-book",
152    }))
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158    use crate::bench::reference::{move_key, solve_position};
159    use crate::game::has_winning_line;
160    use crate::moves::{apply_move, generate_legal_moves};
161    use crate::opening_book::OpeningBookConfig;
162    use rand::prelude::*;
163
164    fn temp_db_path(tag: &str) -> String {
165        let id = std::time::SystemTime::now()
166            .duration_since(std::time::UNIX_EPOCH)
167            .unwrap()
168            .as_nanos();
169        std::env::temp_dir()
170            .join(format!("quantik_book_export_{tag}_{id}.db"))
171            .to_string_lossy()
172            .to_string()
173    }
174
175    fn open_book(path: &str) -> OpeningBookDatabase {
176        OpeningBookDatabase::open(&OpeningBookConfig {
177            database_path: path.to_string(),
178            ..Default::default()
179        })
180        .unwrap()
181    }
182
183    fn random_position(seed: u64, plies: usize) -> Bitboard {
184        let mut rng = StdRng::seed_from_u64(seed);
185        'attempt: loop {
186            let mut bb = Bitboard::EMPTY;
187            for _ in 0..plies {
188                let moves = generate_legal_moves(&bb);
189                if moves.is_empty() {
190                    continue 'attempt;
191                }
192                bb = apply_move(&bb, &moves[rng.gen_range(0..moves.len())]);
193                if has_winning_line(&bb) {
194                    continue 'attempt;
195                }
196            }
197            if generate_legal_moves(&bb).is_empty() {
198                continue 'attempt;
199            }
200            return bb;
201        }
202    }
203
204    /// Find a random reachable position that IS its own canonical
205    /// representative (there is always at least one such board per
206    /// equivalence class — the representative itself).
207    fn canonical_representative_position(seed_start: u64, plies: usize) -> Bitboard {
208        (seed_start..)
209            .map(|seed| random_position(seed, plies))
210            .find(|bb| State::new(*bb).canonical_payload() == bb.to_le_bytes())
211            .expect("a canonical-representative position exists among random samples")
212    }
213
214    /// Find a random reachable position that is NOT its own canonical
215    /// representative (the common case: most orientations aren't the
216    /// distinguished representative of their symmetry class).
217    fn non_canonical_position(seed_start: u64, plies: usize) -> Bitboard {
218        (seed_start..)
219            .map(|seed| random_position(seed, plies))
220            .find(|bb| State::new(*bb).canonical_payload() != bb.to_le_bytes())
221            .expect("a non-canonical position exists among random samples")
222    }
223
224    #[test]
225    fn export_and_lookup_roundtrip() {
226        let bb = canonical_representative_position(0, 11);
227        let reference = solve_position(&bb, 60.0).unwrap();
228        let qfen = State::new(bb).to_qfen();
229
230        let dataset_payload = json!({
231            "positions": [
232                {"id": "p0000", "qfen": qfen, "phase": "endgame", "reference": reference},
233            ],
234        });
235
236        let path = temp_db_path("roundtrip");
237        let db = open_book(&path);
238        let inserted = export_references(&dataset_payload, &db).unwrap();
239        assert_eq!(inserted, 1);
240
241        // Same orientation: lookup must succeed and match value/optimal_moves.
242        let looked_up = lookup_reference(&bb, &db).unwrap();
243        assert_eq!(looked_up["value"], reference["value"]);
244        assert_eq!(looked_up["optimal_moves"], reference["optimal_moves"]);
245        assert_eq!(looked_up["solver"], json!("opening-book"));
246        assert_eq!(looked_up["nodes"], json!(0));
247
248        // A non-canonical-representative symmetric variant must return None.
249        let non_canon = non_canonical_position(1000, 11);
250        assert!(lookup_reference(&non_canon, &db).is_none());
251
252        std::fs::remove_file(&path).ok();
253    }
254
255    #[test]
256    fn export_is_idempotent() {
257        let bb = canonical_representative_position(2, 10);
258        let reference = solve_position(&bb, 60.0).unwrap();
259        let qfen = State::new(bb).to_qfen();
260        let dataset_payload = json!({
261            "positions": [
262                {"id": "p0000", "qfen": qfen, "phase": "late_mid", "reference": reference},
263            ],
264        });
265
266        let path = temp_db_path("idempotent");
267        let db = open_book(&path);
268        assert_eq!(export_references(&dataset_payload, &db).unwrap(), 1);
269        assert_eq!(export_references(&dataset_payload, &db).unwrap(), 1);
270        assert_eq!(db.total_positions().unwrap(), 1);
271
272        std::fs::remove_file(&path).ok();
273    }
274
275    #[test]
276    fn positions_without_reference_are_skipped() {
277        let dataset_payload = json!({
278            "positions": [
279                {"id": "p0000", "qfen": "..../..../..../....", "phase": "opening", "reference": Value::Null},
280            ],
281        });
282        let path = temp_db_path("skip-null");
283        let db = open_book(&path);
284        assert_eq!(export_references(&dataset_payload, &db).unwrap(), 0);
285        assert_eq!(db.total_positions().unwrap(), 0);
286        std::fs::remove_file(&path).ok();
287    }
288
289    #[test]
290    fn lookup_returns_none_when_book_is_empty() {
291        let bb = canonical_representative_position(5, 10);
292        let path = temp_db_path("empty");
293        let db = open_book(&path);
294        assert!(lookup_reference(&bb, &db).is_none());
295        std::fs::remove_file(&path).ok();
296    }
297
298    /// Smoke test for the `export-book` CLI path against the golden
299    /// shared dataset (22 solved references). Only solved positions that
300    /// are their own canonical representative are eligible for storage
301    /// (the write-side orientation guard), so the expected insertion
302    /// count is computed from the artifact rather than hardcoded, and the
303    /// exported rows must be exactly that set. A rerun is idempotent (no
304    /// row growth).
305    #[test]
306    fn golden_dataset_export_inserts_exactly_the_representative_solved_set() {
307        let golden = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
308            .join("../../benchmarks/positions-v1.json");
309        let payload = crate::bench::dataset::load(&golden).unwrap();
310
311        let positions = payload["positions"].as_array().unwrap();
312        let (representative, skipped): (Vec<&Value>, Vec<&Value>) = positions
313            .iter()
314            .filter(|p| !p["reference"].is_null())
315            .partition(|p| {
316                let state = State::from_qfen(p["qfen"].as_str().unwrap()).unwrap();
317                state.canonical_payload() == state.bb.to_le_bytes()
318            });
319        // The golden artifact must exercise both sides of the guard.
320        assert!(!representative.is_empty(), "no representative solved refs");
321        assert!(!skipped.is_empty(), "no non-representative solved refs");
322
323        let expected = representative.len() as u64;
324        let path = temp_db_path("golden");
325        let db = open_book(&path);
326        assert_eq!(export_references(&payload, &db).unwrap(), expected);
327        assert_eq!(db.total_positions().unwrap() as u64, expected);
328
329        // Exactly the representative set is present; the rest is absent.
330        for p in &representative {
331            let state = State::from_qfen(p["qfen"].as_str().unwrap()).unwrap();
332            let entry = db.get_position(&state).unwrap().unwrap();
333            assert!(entry.solved);
334            assert_eq!(
335                entry.game_value.unwrap() as i64,
336                p["reference"]["value"].as_i64().unwrap()
337            );
338        }
339        for p in &skipped {
340            let state = State::from_qfen(p["qfen"].as_str().unwrap()).unwrap();
341            assert!(
342                db.get_position(&state).unwrap().is_none(),
343                "non-representative {} must not be stored",
344                p["id"]
345            );
346        }
347
348        // Rerun: same upserts, no row growth.
349        assert_eq!(export_references(&payload, &db).unwrap(), expected);
350        assert_eq!(db.total_positions().unwrap() as u64, expected);
351
352        std::fs::remove_file(&path).ok();
353    }
354
355    /// Regression test for the cross-orientation wrong-hit bug: storing a
356    /// solved position that is NOT its own canonical representative, then
357    /// querying its canonical representative (which passes the read-side
358    /// guard), must return None — nothing may have been written, because
359    /// the stored moves would be in the wrong orientation for that board.
360    /// Before the write-side guard existed, this lookup returned a hit
361    /// whose optimal_moves were wrong (possibly illegal) for the queried
362    /// board.
363    #[test]
364    fn non_canonical_write_never_pollutes_the_representative_lookup() {
365        let bb = non_canonical_position(0, 11);
366        let reference = solve_position(&bb, 60.0).unwrap();
367        let qfen = State::new(bb).to_qfen();
368        let dataset_payload = json!({
369            "positions": [
370                {"id": "p0000", "qfen": qfen, "phase": "endgame", "reference": reference},
371            ],
372        });
373
374        let path = temp_db_path("cross-orientation");
375        let db = open_book(&path);
376
377        // The write must be skipped entirely.
378        assert_eq!(export_references(&dataset_payload, &db).unwrap(), 0);
379        assert_eq!(db.total_positions().unwrap(), 0);
380
381        // The canonical representative of the same class (idempotent:
382        // it IS its own representative, so it passes the read guard) must
383        // find nothing.
384        let canon_bb = Bitboard::from_le_bytes(&State::new(bb).canonical_payload());
385        assert_eq!(
386            State::new(canon_bb).canonical_payload(),
387            canon_bb.to_le_bytes(),
388            "canonicalization is idempotent"
389        );
390        assert!(lookup_reference(&canon_bb, &db).is_none());
391
392        // And so must the original orientation.
393        assert!(lookup_reference(&bb, &db).is_none());
394
395        std::fs::remove_file(&path).ok();
396    }
397
398    #[test]
399    fn optimal_moves_reconstruct_to_valid_move_keys() {
400        let bb = canonical_representative_position(7, 11);
401        let reference = solve_position(&bb, 60.0).unwrap();
402        let qfen = State::new(bb).to_qfen();
403        let dataset_payload = json!({
404            "positions": [
405                {"id": "p0000", "qfen": qfen, "phase": "endgame", "reference": reference},
406            ],
407        });
408
409        let path = temp_db_path("valid-moves");
410        let db = open_book(&path);
411        export_references(&dataset_payload, &db).unwrap();
412        let looked_up = lookup_reference(&bb, &db).unwrap();
413
414        let legal: Vec<String> = generate_legal_moves(&bb).iter().map(move_key).collect();
415        for mv in looked_up["optimal_moves"].as_array().unwrap() {
416            let key = mv.as_str().unwrap();
417            assert!(legal.contains(&key.to_string()), "{key} not legal");
418        }
419
420        std::fs::remove_file(&path).ok();
421    }
422}