1use 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
19pub fn move_key(mv: &Move) -> String {
21 format!("{}:{}:{}", mv.player, mv.shape, mv.position)
22}
23
24pub 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
41fn 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
60pub 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 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
125pub 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 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
173pub fn augment_with_references(payload: &mut Value, budget_s: f64) {
177 augment_with_references_with_book(payload, budget_s, None);
178}
179
180pub 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 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 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 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 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}