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