1use 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
45pub 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 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
114pub 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 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 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 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 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 #[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 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 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 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 #[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 assert_eq!(export_references(&dataset_payload, &db).unwrap(), 0);
379 assert_eq!(db.total_positions().unwrap(), 0);
380
381 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 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}