1use crate::game::{current_player, has_winning_line};
12use crate::moves::{apply_move, generate_legal_moves};
13use crate::state::State;
14use rand::prelude::*;
15use serde_json::{json, Map, Value};
16use sha2::{Digest, Sha256};
17use std::collections::BTreeMap;
18use std::path::Path;
19
20pub const SCHEMA_VERSION: u64 = 1;
21pub const GENERATOR: &str = "benchmarks.dataset.generate/v1";
22
23pub const PHASES: [(&str, (u32, u32)); 4] = [
25 ("opening", (0, 4)),
26 ("early_mid", (5, 7)),
27 ("late_mid", (8, 11)),
28 ("endgame", (12, 16)),
29];
30
31pub fn phase_of(pieces: u32) -> Result<&'static str, String> {
33 for (phase, (lo, hi)) in PHASES {
34 if (lo..=hi).contains(&pieces) {
35 return Ok(phase);
36 }
37 }
38 Err(format!("no benchmark phase for {pieces} pieces"))
39}
40
41pub fn checksum(payload: &Value) -> String {
47 let mut stripped = payload.as_object().cloned().unwrap_or_default();
48 stripped.remove("checksum");
49 let blob = super::canonical::canonical_json(&Value::Object(stripped));
50 let mut hasher = Sha256::new();
51 hasher.update(blob.as_bytes());
52 format!("{:x}", hasher.finalize())
53}
54
55pub fn save(payload: &Value, path: &Path) -> Result<String, String> {
57 let digest = checksum(payload);
58 let mut output = payload.as_object().cloned().unwrap_or_default();
59 output.insert("checksum".into(), Value::String(digest.clone()));
60 let text = serde_json::to_string_pretty(&Value::Object(output))
61 .map_err(|e| format!("serialize: {e}"))?;
62 if let Some(parent) = path.parent() {
63 std::fs::create_dir_all(parent).map_err(|e| format!("mkdir {parent:?}: {e}"))?;
64 }
65 std::fs::write(path, text + "\n").map_err(|e| format!("write {path:?}: {e}"))?;
66 Ok(digest)
67}
68
69pub fn load(path: &Path) -> Result<Value, String> {
71 let text = std::fs::read_to_string(path).map_err(|e| format!("read {path:?}: {e}"))?;
72 let payload: Value = serde_json::from_str(&text).map_err(|e| format!("parse: {e}"))?;
73 let expected = payload
74 .get("checksum")
75 .and_then(Value::as_str)
76 .map(str::to_string);
77 let actual = checksum(&payload);
78 if expected.as_deref() != Some(actual.as_str()) {
79 return Err(format!(
80 "dataset checksum mismatch: expected {expected:?}, actual {actual}"
81 ));
82 }
83 Ok(payload)
84}
85
86fn random_position(rng: &mut StdRng, plies: u32) -> Option<crate::bitboard::Bitboard> {
89 let mut bb = crate::bitboard::Bitboard::EMPTY;
90 for _ in 0..plies {
91 let moves = generate_legal_moves(&bb);
92 if moves.is_empty() {
93 return None;
94 }
95 bb = apply_move(&bb, &moves[rng.gen_range(0..moves.len())]);
96 if has_winning_line(&bb) {
97 return None;
98 }
99 }
100 if generate_legal_moves(&bb).is_empty() {
101 return None;
102 }
103 Some(bb)
104}
105
106fn position_payload(position_id: usize, bb: &crate::bitboard::Bitboard, phase: &str) -> Value {
107 let pieces = bb.player_piece_count(0) + bb.player_piece_count(1);
108 json!({
109 "id": format!("p{position_id:04}"),
110 "qfen": State::new(*bb).to_qfen(),
111 "phase": phase,
112 "pieces": pieces,
113 "side_to_move": current_player(bb).expect("valid position"),
114 "legal_moves": generate_legal_moves(bb).len(),
115 "reference": Value::Null,
116 })
117}
118
119pub fn generate(requested: &BTreeMap<String, u32>, seed: u64) -> Result<Value, String> {
121 let known: Vec<&str> = PHASES.iter().map(|(name, _)| *name).collect();
122 let unknown: Vec<&String> = requested
123 .keys()
124 .filter(|k| !known.contains(&k.as_str()))
125 .collect();
126 if !unknown.is_empty() {
127 return Err(format!("unknown phase(s): {unknown:?}"));
128 }
129
130 let mut rng = StdRng::seed_from_u64(seed);
131 let mut seen: std::collections::HashSet<[u8; 18]> = std::collections::HashSet::new();
132 let mut positions: Vec<Value> = Vec::new();
133
134 for (phase, (lo, hi)) in PHASES {
135 let want = *requested.get(phase).unwrap_or(&0);
136 let mut found = 0u32;
137 let mut attempts = 0u32;
138 let max_attempts = want * 500;
139
140 while found < want && attempts < max_attempts {
141 attempts += 1;
142 let target_plies = rng.gen_range(lo..=hi.min(15));
143 let Some(bb) = random_position(&mut rng, target_plies) else {
144 continue;
145 };
146 let key = State::new(bb).canonical_key();
147 if !seen.insert(key) {
148 continue;
149 }
150 positions.push(position_payload(positions.len(), &bb, phase));
151 found += 1;
152 }
153 }
154
155 let requested_map: Map<String, Value> = requested
156 .iter()
157 .map(|(k, v)| (k.clone(), json!(v)))
158 .collect();
159
160 Ok(json!({
161 "schema_version": SCHEMA_VERSION,
162 "generator": GENERATOR,
163 "seed": seed,
164 "requested": requested_map,
165 "positions": positions,
166 }))
167}
168
169#[cfg(test)]
170mod tests {
171 use super::*;
172 use crate::game::check_winner;
173 use crate::game::WinStatus;
174
175 fn golden_path() -> std::path::PathBuf {
176 Path::new(env!("CARGO_MANIFEST_DIR")).join("../../benchmarks/positions-v1.json")
177 }
178
179 #[test]
183 fn golden_dataset_checksum_verifies() {
184 let payload = load(&golden_path()).unwrap();
185 assert_eq!(payload["schema_version"], json!(1));
186 assert_eq!(payload["generator"], json!(GENERATOR));
187 assert_eq!(payload["positions"].as_array().unwrap().len(), 36);
188 }
189
190 #[test]
191 fn golden_dataset_positions_are_valid() {
192 let payload = load(&golden_path()).unwrap();
193 for position in payload["positions"].as_array().unwrap() {
194 let qfen = position["qfen"].as_str().unwrap();
195 let state = State::from_qfen(qfen).unwrap();
196 let bb = state.bb;
197 assert_eq!(check_winner(&bb), WinStatus::NoWin, "{qfen} is terminal");
198 let legal = generate_legal_moves(&bb);
199 assert!(!legal.is_empty(), "{qfen} has no legal moves");
200 assert_eq!(
201 position["legal_moves"].as_u64().unwrap() as usize,
202 legal.len(),
203 "{qfen} legal move count"
204 );
205 let pieces = bb.player_piece_count(0) + bb.player_piece_count(1);
206 assert_eq!(position["pieces"].as_u64().unwrap() as u32, pieces);
207 assert_eq!(
208 position["side_to_move"].as_u64().unwrap() as u8,
209 current_player(&bb).unwrap(),
210 "{qfen} side to move"
211 );
212 assert_eq!(
213 position["phase"].as_str().unwrap(),
214 phase_of(pieces).unwrap()
215 );
216 }
217 }
218
219 #[test]
220 fn save_load_roundtrip_and_corruption_detection() {
221 let dir = std::env::temp_dir().join(format!("quantik-bench-{}", std::process::id()));
222 std::fs::create_dir_all(&dir).unwrap();
223 let path = dir.join("tiny.json");
224
225 let requested = BTreeMap::from([("opening".to_string(), 2u32)]);
226 let payload = generate(&requested, 7).unwrap();
227 let digest = save(&payload, &path).unwrap();
228 let loaded = load(&path).unwrap();
229 assert_eq!(loaded["checksum"].as_str().unwrap(), digest);
230
231 let text = std::fs::read_to_string(&path).unwrap();
233 let corrupted = text.replacen("\"pieces\": 3", "\"pieces\": 4", 1);
234 let corrupted = if corrupted == text {
235 text.replacen("\"pieces\": 4", "\"pieces\": 5", 1)
236 } else {
237 corrupted
238 };
239 std::fs::write(&path, corrupted).unwrap();
240 assert!(load(&path).is_err());
241 std::fs::remove_file(&path).ok();
242 }
243
244 #[test]
245 fn generate_is_deterministic_and_deduped() {
246 let requested = BTreeMap::from([
247 ("opening".to_string(), 3u32),
248 ("early_mid".to_string(), 2u32),
249 ]);
250 let a = generate(&requested, 123).unwrap();
251 let b = generate(&requested, 123).unwrap();
252 assert_eq!(a, b);
253
254 let positions = a["positions"].as_array().unwrap();
255 assert_eq!(positions.len(), 5);
256 let mut keys = std::collections::HashSet::new();
257 for position in positions {
258 let state = State::from_qfen(position["qfen"].as_str().unwrap()).unwrap();
259 assert!(keys.insert(state.canonical_key()), "duplicate canonical");
260 let pieces = position["pieces"].as_u64().unwrap() as u32;
261 let phase = position["phase"].as_str().unwrap();
262 assert_eq!(phase_of(pieces).unwrap(), phase);
263 }
264 }
265
266 #[test]
267 fn unknown_phase_rejected() {
268 let requested = BTreeMap::from([("blitz".to_string(), 1u32)]);
269 assert!(generate(&requested, 1).is_err());
270 }
271}