Skip to main content

depth4_survey/
depth4_survey.rs

1//! One-off survey: exactly solve every canonical, nonterminal depth-4
2//! Quantik position and report the resulting value distribution.
3//!
4//! Enumerates by canonical-key BFS (matching the beam-search multiplicity
5//! technique documented in docs/BEAM_SEARCH.md): each ply, every legal
6//! child is folded onto its canonical representative, and multiplicity
7//! (how many raw 4-ply move sequences land on that representative) is
8//! summed. Terminal (already-won) children are excluded, matching the
9//! "nonterminal" count in the Python worktree's depth4-canonical dataset.
10//!
11//! Solving reuses `bench::reference::solve_position_with_book` so results
12//! land in a real, cross-language-portable SQLite opening book as a
13//! byproduct.
14//!
15//! Usage:
16//!   cargo run --release --example depth4_survey -- \
17//!     --budget-s 5 --limit 0 --db benchmarks/results/depth4.db \
18//!     --out benchmarks/results/depth4-survey.json
19
20use quantik_core::bench::reference::solve_position_with_book;
21use quantik_core::bitboard::Bitboard;
22use quantik_core::game::has_winning_line;
23use quantik_core::moves::{apply_move, generate_legal_moves};
24use quantik_core::opening_book::{OpeningBookConfig, OpeningBookDatabase};
25use quantik_core::state::State;
26use quantik_core::symmetry::SymmetryHandler;
27use std::collections::HashMap;
28use std::time::Instant;
29
30struct Args {
31    budget_s: f64,
32    limit: usize,
33    sample: usize,
34    seed: u64,
35    db: String,
36    out: String,
37}
38
39fn parse_args() -> Args {
40    let mut budget_s = 5.0;
41    let mut limit = 0usize;
42    let mut sample = 0usize;
43    let mut seed = 20260712u64;
44    let mut db = "benchmarks/results/depth4.db".to_string();
45    let mut out = "benchmarks/results/depth4-survey.json".to_string();
46    let mut it = std::env::args().skip(1);
47    while let Some(flag) = it.next() {
48        match flag.as_str() {
49            "--budget-s" => budget_s = it.next().unwrap().parse().unwrap(),
50            "--limit" => limit = it.next().unwrap().parse().unwrap(),
51            "--sample" => sample = it.next().unwrap().parse().unwrap(),
52            "--seed" => seed = it.next().unwrap().parse().unwrap(),
53            "--db" => db = it.next().unwrap(),
54            "--out" => out = it.next().unwrap(),
55            other => panic!("unknown flag {other}"),
56        }
57    }
58    Args {
59        budget_s,
60        limit,
61        sample,
62        seed,
63        db,
64        out,
65    }
66}
67
68/// Canonical-key BFS to depth 4: fold every legal child onto its canonical
69/// representative each ply, excluding wins, summing multiplicity from all
70/// incoming (parent, move) pairs that collapse onto it.
71fn enumerate_depth4() -> Vec<(Bitboard, u64)> {
72    let mut frontier: HashMap<[u8; 16], (Bitboard, u64)> = HashMap::new();
73    frontier.insert(Bitboard::EMPTY.to_le_bytes(), (Bitboard::EMPTY, 1));
74
75    for _ply in 0..4 {
76        let mut next: HashMap<[u8; 16], (Bitboard, u64)> = HashMap::new();
77        for (bb, mult) in frontier.values() {
78            for mv in generate_legal_moves(bb) {
79                let child = apply_move(bb, &mv);
80                if has_winning_line(&child) {
81                    continue;
82                }
83                let canon = SymmetryHandler::find_canonical(&child);
84                let key = canon.to_le_bytes();
85                let entry = next.entry(key).or_insert((canon, 0));
86                entry.1 += mult;
87            }
88        }
89        frontier = next;
90    }
91
92    let mut states: Vec<(Bitboard, u64)> = frontier
93        .into_values()
94        .filter(|(bb, _)| !generate_legal_moves(bb).is_empty())
95        .collect();
96    states.sort_by_key(|(bb, _)| bb.to_le_bytes());
97    states
98}
99
100fn main() {
101    let args = parse_args();
102
103    let started = Instant::now();
104    let mut states = enumerate_depth4();
105    println!(
106        "enumerated {} canonical nonterminal depth-4 states in {:.2}s",
107        states.len(),
108        started.elapsed().as_secs_f64()
109    );
110    if args.sample > 0 && args.sample < states.len() {
111        use rand::prelude::*;
112        let mut rng = StdRng::seed_from_u64(args.seed);
113        states.shuffle(&mut rng);
114        states.truncate(args.sample);
115        states.sort_by_key(|(bb, _)| bb.to_le_bytes());
116        println!(
117            "sampled {} of {} states (seed={})",
118            states.len(),
119            10946,
120            args.seed
121        );
122    } else if args.limit > 0 {
123        states.truncate(args.limit);
124        println!("limited to {} states for this run", states.len());
125    }
126
127    let book = OpeningBookDatabase::open(&OpeningBookConfig {
128        database_path: args.db.clone(),
129        ..Default::default()
130    })
131    .expect("open book");
132
133    let mut rows: Vec<serde_json::Value> = Vec::with_capacity(states.len());
134    let mut solved = 0usize;
135    let mut cutoff = 0usize;
136    let solve_started = Instant::now();
137
138    for (i, (bb, mult)) in states.iter().enumerate() {
139        let t0 = Instant::now();
140        let reference = solve_position_with_book(bb, args.budget_s, Some(&book));
141        let elapsed = t0.elapsed().as_secs_f64();
142        println!(
143            "  [{}/{}] {} solved={} elapsed={:.3}s",
144            i + 1,
145            states.len(),
146            State::new(*bb).to_qfen(),
147            reference.is_some(),
148            elapsed
149        );
150
151        match &reference {
152            Some(r) => {
153                solved += 1;
154                rows.push(serde_json::json!({
155                    "qfen": State::new(*bb).to_qfen(),
156                    "multiplicity": mult,
157                    "value": r["value"],
158                    "nodes": r["nodes"],
159                    "solve_time_s": elapsed,
160                    "solver": r["solver"],
161                }));
162            }
163            None => {
164                cutoff += 1;
165                rows.push(serde_json::json!({
166                    "qfen": State::new(*bb).to_qfen(),
167                    "multiplicity": mult,
168                    "value": null,
169                    "nodes": null,
170                    "solve_time_s": elapsed,
171                    "solver": null,
172                }));
173            }
174        }
175
176        if (i + 1) % 25 == 0 || i + 1 == states.len() {
177            println!(
178                "progress: {}/{} solved={} cutoff={} elapsed_total={:.1}s",
179                i + 1,
180                states.len(),
181                solved,
182                cutoff,
183                solve_started.elapsed().as_secs_f64()
184            );
185            // Flush partial results after every progress line so a killed
186            // run still leaves a readable, if incomplete, artifact — the
187            // SQLite book itself is already durable per-row regardless.
188            let partial = serde_json::json!({
189                "budget_s": args.budget_s,
190                "total_canonical_depth4_states": states.len(),
191                "processed": i + 1,
192                "solved": solved,
193                "cutoff": cutoff,
194                "positions": rows,
195            });
196            std::fs::write(&args.out, serde_json::to_string_pretty(&partial).unwrap())
197                .expect("write partial output");
198        }
199    }
200
201    println!(
202        "done: {} solved, {} cutoff, total solve wall time {:.1}s -> {}",
203        solved,
204        cutoff,
205        solve_started.elapsed().as_secs_f64(),
206        args.out
207    );
208}