1use crate::bench::adapters::{select, EngineAdapter};
7use crate::bench::metrics::{median, percentile, wilson_ci};
8use crate::state::State;
9use rayon::prelude::*;
10use serde_json::{json, Value};
11use std::collections::{BTreeMap, HashSet};
12
13pub type ObservationKey = (String, String, String, Option<u64>);
16
17pub fn observation_key(row: &Value) -> ObservationKey {
19 (
20 row["position_id"].as_str().unwrap_or_default().to_string(),
21 row["engine"].as_str().unwrap_or_default().to_string(),
22 row["config_label"].as_str().unwrap_or_default().to_string(),
23 row["seed"].as_u64(),
24 )
25}
26
27struct AgreementTask<'a> {
31 adapter: &'a dyn EngineAdapter,
32 position: &'a Value,
33 seed: u64,
34}
35
36fn build_agreement_tasks<'a>(
37 adapters: &'a [Box<dyn EngineAdapter>],
38 payload: &'a Value,
39 seeds: &'a [u64],
40 skip: &HashSet<ObservationKey>,
41) -> Result<Vec<AgreementTask<'a>>, String> {
42 let positions = payload["positions"]
43 .as_array()
44 .ok_or("payload has no positions")?;
45 let mut tasks = Vec::new();
46 for position in positions {
47 let position_id = position["id"].as_str().unwrap_or_default();
48 for adapter in adapters {
49 let adapter_seeds: &[u64] = if adapter.stochastic() {
50 seeds
51 } else {
52 &seeds[..1]
53 };
54 for &seed in adapter_seeds {
55 let key: ObservationKey = (
56 position_id.to_string(),
57 adapter.name().to_string(),
58 adapter.config_label(),
59 Some(seed),
60 );
61 if skip.contains(&key) {
62 continue;
63 }
64 tasks.push(AgreementTask {
65 adapter: adapter.as_ref(),
66 position,
67 seed,
68 });
69 }
70 }
71 }
72 Ok(tasks)
73}
74
75fn select_agreement_row(task: &AgreementTask) -> Result<Value, String> {
76 let qfen = task.position["qfen"]
77 .as_str()
78 .ok_or("position missing qfen")?;
79 let bb = State::from_qfen(qfen)?.bb;
80 let reference = task.position.get("reference").filter(|r| !r.is_null());
81 let optimal_moves: Option<HashSet<&str>> = reference.map(|r| {
82 r["optimal_moves"]
83 .as_array()
84 .map(|moves| moves.iter().filter_map(Value::as_str).collect())
85 .unwrap_or_default()
86 });
87 let position_id = task.position["id"].as_str().unwrap_or_default();
88
89 let (_, observation) = select(task.adapter, &bb, position_id, Some(task.seed))?;
90 let mut row = observation.to_json();
91 row["phase"] = task.position["phase"].clone();
92 row["hit"] = match &optimal_moves {
93 Some(optimal) => json!(optimal.contains(observation.mv.as_str())),
94 None => Value::Null,
95 };
96 Ok(row)
97}
98
99pub fn run_agreement(
121 adapters: &[Box<dyn EngineAdapter>],
122 payload: &Value,
123 seeds: &[u64],
124 skip: &HashSet<ObservationKey>,
125 workers: usize,
126 mut on_row: impl FnMut(&Value) -> Result<(), String>,
127) -> Result<Vec<Value>, String> {
128 if seeds.is_empty() {
129 return Err("seeds must be a non-empty ordered list".into());
130 }
131 if workers < 1 {
132 return Err("workers must be at least 1".into());
133 }
134
135 let tasks = build_agreement_tasks(adapters, payload, seeds, skip)?;
136 if tasks.is_empty() {
137 return Ok(Vec::new());
138 }
139
140 let mut rows = Vec::with_capacity(tasks.len());
141 if workers == 1 {
142 for task in &tasks {
143 let row = select_agreement_row(task)?;
144 on_row(&row)?;
145 rows.push(row);
146 }
147 } else {
148 let pool = rayon::ThreadPoolBuilder::new()
149 .num_threads(workers)
150 .build()
151 .map_err(|e| format!("build worker pool: {e}"))?;
152 for chunk in tasks.chunks(workers) {
159 let chunk_rows: Result<Vec<Value>, String> =
160 pool.install(|| chunk.par_iter().map(select_agreement_row).collect());
161 for row in chunk_rows? {
162 on_row(&row)?;
163 rows.push(row);
164 }
165 }
166 }
167
168 Ok(rows)
169}
170
171pub fn aggregate_agreement(rows: &[Value]) -> Vec<Value> {
173 let mut groups: BTreeMap<(String, String, String), Vec<&Value>> = BTreeMap::new();
174 for row in rows {
175 if row["hit"].is_null() {
176 continue;
177 }
178 let key = (
179 row["engine"].as_str().unwrap_or_default().to_string(),
180 row["config_label"].as_str().unwrap_or_default().to_string(),
181 row["phase"].as_str().unwrap_or_default().to_string(),
182 );
183 groups.entry(key).or_default().push(row);
184 }
185
186 groups
187 .into_iter()
188 .map(|((engine, config_label, phase), group)| {
189 let n = group.len() as u64;
190 let hits = group
191 .iter()
192 .filter(|row| row["hit"].as_bool() == Some(true))
193 .count() as u64;
194 let (ci95_low, ci95_high) = wilson_ci(hits, n);
195 json!({
196 "engine": engine,
197 "config_label": config_label,
198 "phase": phase,
199 "n": n,
200 "hits": hits,
201 "agreement": hits as f64 / n as f64,
202 "ci95_low": ci95_low,
203 "ci95_high": ci95_high,
204 })
205 })
206 .collect()
207}
208
209pub fn aggregate_cost(rows: &[Value]) -> Vec<Value> {
211 let mut groups: BTreeMap<(String, String), Vec<&Value>> = BTreeMap::new();
212 for row in rows {
213 let key = (
214 row["engine"].as_str().unwrap_or_default().to_string(),
215 row["config_label"].as_str().unwrap_or_default().to_string(),
216 );
217 groups.entry(key).or_default().push(row);
218 }
219
220 groups
221 .into_iter()
222 .map(|((engine, config_label), group)| {
223 let wall_times: Vec<f64> = group
224 .iter()
225 .filter_map(|row| row["wall_time_s"].as_f64())
226 .collect();
227 let nodes: Vec<f64> = group
228 .iter()
229 .filter_map(|row| row["nodes"].as_u64())
230 .map(|n| n as f64)
231 .collect();
232 let peak_memory: Vec<u64> = group
233 .iter()
234 .filter_map(|row| row["peak_memory_bytes"].as_u64())
235 .collect();
236 json!({
237 "engine": engine,
238 "config_label": config_label,
239 "n": group.len(),
240 "median_time_s": median(&wall_times),
241 "p95_time_s": percentile(&wall_times, 95.0),
242 "median_nodes": if nodes.is_empty() { Value::Null } else { json!(median(&nodes)) },
243 "peak_memory_bytes": peak_memory.iter().max().map_or(Value::Null, |&m| json!(m)),
244 })
245 })
246 .collect()
247}
248
249#[cfg(test)]
250mod tests {
251 use super::*;
252 use crate::bench::adapters::{MinimaxAdapter, RandomAdapter, RawMetrics};
253 use crate::bitboard::Bitboard;
254 use crate::moves::{generate_legal_moves, Move};
255
256 fn fixture_rows() -> Vec<Value> {
257 vec![
258 json!({"engine": "e1", "config_label": "c", "phase": "endgame",
259 "position_id": "p1", "seed": 0, "move": "0:0:0", "hit": true,
260 "wall_time_s": 0.1, "nodes": 100, "peak_memory_bytes": null}),
261 json!({"engine": "e1", "config_label": "c", "phase": "endgame",
262 "position_id": "p1", "seed": 1, "move": "0:0:1", "hit": false,
263 "wall_time_s": 0.3, "nodes": 300, "peak_memory_bytes": null}),
264 json!({"engine": "e1", "config_label": "c", "phase": "opening",
265 "position_id": "p2", "seed": 0, "move": "0:0:0", "hit": null,
266 "wall_time_s": 0.2, "nodes": null, "peak_memory_bytes": 12}),
267 ]
268 }
269
270 #[test]
271 fn aggregate_agreement_excludes_null_hits() {
272 let aggregates = aggregate_agreement(&fixture_rows());
273 assert_eq!(aggregates.len(), 1);
274 let row = &aggregates[0];
275 assert_eq!(row["engine"], json!("e1"));
276 assert_eq!(row["phase"], json!("endgame"));
277 assert_eq!(row["n"], json!(2));
278 assert_eq!(row["hits"], json!(1));
279 assert_eq!(row["agreement"], json!(0.5));
280 assert!(row["ci95_low"].as_f64().unwrap() < 0.5);
281 assert!(row["ci95_high"].as_f64().unwrap() > 0.5);
282 }
283
284 #[test]
285 fn aggregate_cost_medians() {
286 let aggregates = aggregate_cost(&fixture_rows());
287 assert_eq!(aggregates.len(), 1);
288 let row = &aggregates[0];
289 assert_eq!(row["n"], json!(3));
290 assert_eq!(row["median_time_s"], json!(0.2));
291 assert_eq!(row["median_nodes"], json!(200.0));
292 assert_eq!(row["peak_memory_bytes"], json!(12));
293 }
294
295 #[test]
296 fn run_agreement_produces_rows_with_hit_semantics() {
297 let payload = json!({
298 "positions": [
299 {"id": "p0", "qfen": "..../..../..../....", "phase": "opening",
300 "reference": null},
301 ]
302 });
303 let adapters: Vec<Box<dyn EngineAdapter>> = vec![Box::new(RandomAdapter)];
304 let mut streamed = 0;
305 let rows = run_agreement(&adapters, &payload, &[0, 1, 2], &HashSet::new(), 1, |_| {
306 streamed += 1;
307 Ok(())
308 })
309 .unwrap();
310 assert_eq!(rows.len(), 3);
312 assert_eq!(streamed, 3);
313 for row in &rows {
314 assert!(row["hit"].is_null(), "no reference: hit must be null");
315 assert_eq!(row["phase"], json!("opening"));
316 }
317 }
318
319 #[test]
320 fn run_agreement_skip_set_prevents_reruns() {
321 let payload = json!({
322 "positions": [
323 {"id": "p0", "qfen": "..../..../..../....", "phase": "opening",
324 "reference": null},
325 ]
326 });
327 let adapters: Vec<Box<dyn EngineAdapter>> = vec![Box::new(RandomAdapter)];
328 let mut skip = HashSet::new();
329 skip.insert((
330 "p0".to_string(),
331 "random".to_string(),
332 "random".to_string(),
333 Some(0u64),
334 ));
335 let rows = run_agreement(&adapters, &payload, &[0, 1], &skip, 1, |_| Ok(())).unwrap();
336 assert_eq!(rows.len(), 1);
337 assert_eq!(rows[0]["seed"], json!(1));
338 }
339
340 #[test]
341 fn empty_seeds_rejected() {
342 let payload = json!({"positions": []});
343 let adapters: Vec<Box<dyn EngineAdapter>> = vec![];
344 assert!(run_agreement(&adapters, &payload, &[], &HashSet::new(), 1, |_| Ok(())).is_err());
345 }
346
347 #[test]
348 fn workers_zero_rejected() {
349 let payload = json!({"positions": []});
350 let adapters: Vec<Box<dyn EngineAdapter>> = vec![];
351 assert!(run_agreement(&adapters, &payload, &[0], &HashSet::new(), 0, |_| Ok(())).is_err());
352 }
353
354 #[test]
357 fn workers_two_matches_workers_one_exactly() {
358 let payload = json!({
359 "positions": [
360 {"id": "p0", "qfen": "..../..../..../....", "phase": "opening", "reference": null},
361 {"id": "p1", "qfen": "..../..../..../....", "phase": "opening",
362 "reference": {"optimal_moves": ["0:0:0"]}},
363 ]
364 });
365 let adapters_1: Vec<Box<dyn EngineAdapter>> = vec![
366 Box::new(RandomAdapter),
367 Box::new(MinimaxAdapter {
368 max_depth: 2,
369 time_limit_s: Some(0.05),
370 }),
371 ];
372 let adapters_2: Vec<Box<dyn EngineAdapter>> = vec![
373 Box::new(RandomAdapter),
374 Box::new(MinimaxAdapter {
375 max_depth: 2,
376 time_limit_s: Some(0.05),
377 }),
378 ];
379 let seeds = [0u64, 1u64];
380
381 let serial = run_agreement(
382 &adapters_1,
383 &payload,
384 &seeds,
385 &HashSet::new(),
386 1,
387 |_| Ok(()),
388 )
389 .unwrap();
390 let parallel = run_agreement(
391 &adapters_2,
392 &payload,
393 &seeds,
394 &HashSet::new(),
395 2,
396 |_| Ok(()),
397 )
398 .unwrap();
399
400 let strip_timing = |rows: &[Value]| -> Vec<Value> {
405 rows.iter()
406 .map(|row| {
407 let mut row = row.clone();
408 row["wall_time_s"] = json!(null);
409 row["cpu_time_s"] = json!(null);
410 row
411 })
412 .collect()
413 };
414 assert_eq!(serial.len(), parallel.len());
415 assert_eq!(
416 strip_timing(&serial),
417 strip_timing(¶llel),
418 "workers=2 must match workers=1 exactly (ignoring measured timing)"
419 );
420 }
421
422 struct CountingFailAtSeedAdapter {
428 computed: std::sync::Arc<std::sync::atomic::AtomicUsize>,
429 fail_seed: u64,
430 }
431
432 impl EngineAdapter for CountingFailAtSeedAdapter {
433 fn name(&self) -> &'static str {
434 "counting_failtest"
435 }
436 fn stochastic(&self) -> bool {
437 true
438 }
439 fn config_label(&self) -> String {
440 "counting_failtest".into()
441 }
442 fn select_raw(
443 &self,
444 bb: &Bitboard,
445 seed: Option<u64>,
446 ) -> Result<(Move, RawMetrics), String> {
447 self.computed
448 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
449 if seed == Some(self.fail_seed) {
450 return Err("intentional test failure".into());
451 }
452 let moves = generate_legal_moves(bb);
453 Ok((moves[0], RawMetrics::default()))
454 }
455 }
456
457 #[test]
485 fn workers_parallel_streams_completed_chunks_before_later_chunk_error() {
486 let payload = json!({
487 "positions": [
488 {"id": "p0", "qfen": "..../..../..../....", "phase": "opening", "reference": null},
489 ]
490 });
491 let computed = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
492 let adapters: Vec<Box<dyn EngineAdapter>> = vec![Box::new(CountingFailAtSeedAdapter {
493 computed: computed.clone(),
494 fail_seed: 4,
495 })];
496 let seeds = [0u64, 1, 2, 3, 4, 5];
497
498 let mut streamed_rows: Vec<Value> = Vec::new();
499 let mut computed_at_first_delivery = None;
500 let result = run_agreement(&adapters, &payload, &seeds, &HashSet::new(), 2, |row| {
501 if computed_at_first_delivery.is_none() {
502 computed_at_first_delivery =
503 Some(computed.load(std::sync::atomic::Ordering::SeqCst));
504 }
505 streamed_rows.push(row.clone());
506 Ok(())
507 });
508
509 assert!(
510 result.is_err(),
511 "run must fail: task for seed 4 errors inside its chunk"
512 );
513 assert!(
514 streamed_rows.len() >= 4,
515 "rows from chunks completed before the failing chunk must already be \
516 streamed to on_row; got {} rows",
517 streamed_rows.len()
518 );
519 let streamed_seeds: Vec<u64> = streamed_rows
520 .iter()
521 .map(|row| row["seed"].as_u64().unwrap())
522 .collect();
523 assert_eq!(
524 streamed_seeds,
525 vec![0, 1, 2, 3],
526 "exactly the two completed chunks (seeds 0..3) must have reached on_row, \
527 in task order, before the failing chunk aborted the run"
528 );
529 assert_eq!(
530 computed_at_first_delivery,
531 Some(2),
532 "on_row for the first row must fire after only its own chunk (2 tasks) \
533 has been computed, not after the whole task list (this is what an \
534 implementation that collects the entire task list before streaming \
535 any row would violate)"
536 );
537 }
538}