Skip to main content

quantik_core/bench/
agreement.rs

1//! Agreement and cost aggregation for benchmark move selections.
2//!
3//! Port of `benchmarks/agreement.py`. Rows are JSON objects so the bundle
4//! schema matches the Python harness.
5
6use 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
13/// Identity of one observation run — `(position_id, engine, config_label,
14/// seed)`, matching Python's `checkpoint.observation_key` tuple order.
15pub type ObservationKey = (String, String, String, Option<u64>);
16
17/// The key an observation row would have (used for checkpoint resume).
18pub 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
27/// One (adapter, position, seed) unit of work, matching Python's
28/// `_agreement_tasks` ordering: outer loop over positions, then adapters,
29/// then that adapter's seeds.
30struct 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
99/// Return one move-observation row per adapter, position, and seed run.
100///
101/// `on_row` is invoked after each completed row, IN TASK ORDER (checkpoint
102/// hook — it returns `Result` so a checkpoint write failure aborts the run
103/// instead of being silently dropped); `skip` rows are not re-run (their
104/// rows must already be accounted for by the caller, e.g. loaded from a
105/// checkpoint).
106///
107/// `workers == 1` runs the task list serially (identical to the original
108/// single-threaded behavior). `workers > 1` builds a rayon pool sized to
109/// `workers` and processes the ordered task list in sequential chunks of
110/// `workers` tasks: each chunk runs in parallel via `par_iter().collect()`
111/// (which preserves task order in the result `Vec`), and its rows are
112/// streamed to `on_row` — in task order — before the next chunk starts.
113/// Because chunks are sequential and each chunk preserves task order, the
114/// produced rows and their order are byte-identical to a `workers == 1` run
115/// regardless of worker count, while still delivering rows to the
116/// checkpoint callback incrementally (at most one chunk of in-flight work
117/// is lost on a crash, instead of the whole phase). An adapter error inside
118/// a chunk fails that chunk (and the run) immediately, but rows from
119/// previously completed chunks have already reached `on_row`.
120pub 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        // Process the ordered task list in sequential chunks of `workers`
153        // tasks: each chunk is computed in parallel, then its rows are
154        // streamed to `on_row` in task order before the next chunk starts.
155        // This keeps checkpoint durability incremental even under
156        // parallelism — a crash loses at most one in-flight chunk instead
157        // of the entire phase (see PR review finding M1).
158        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
171/// Aggregate exact-reference agreement by engine, config label, and phase.
172pub 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
209/// Aggregate measured selection cost by engine and config label.
210pub 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        // Random is stochastic: 3 seeds × 1 position.
311        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    /// workers=2 must produce EXACTLY the same rows, in the same order, as
355    /// workers=1 — the deterministic-ordering contract from the plan.
356    #[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        // Compare everything except measured timing (wall/cpu time are
401        // real durations and will never be bit-identical across runs);
402        // order and every other field — including move choice and hit —
403        // must match exactly.
404        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(&parallel),
418            "workers=2 must match workers=1 exactly (ignoring measured timing)"
419        );
420    }
421
422    /// Stochastic test adapter that counts every `select_raw` invocation in
423    /// a shared atomic counter (so a test can observe how many tasks have
424    /// actually been computed at a given point) and fails for one specific
425    /// seed, so a regression test can force a task deep in the ordered
426    /// task list to error deterministically.
427    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    /// Regression test for M1: under `workers > 1`, rows must reach
458    /// `on_row` chunk by chunk — not only after the ENTIRE task list
459    /// (including tasks the run never gets to) has finished computing.
460    ///
461    /// One position with a single stochastic adapter and 6 seeds produces
462    /// 6 ordered tasks (seed 0..5); with `workers = 2` these split into 3
463    /// sequential chunks of 2 tasks each: `[0,1]`, `[2,3]`, `[4,5]`. The
464    /// adapter fails on seed 4 (the 5th task, in the last chunk), so the
465    /// run must return `Err`, but the first two chunks (seeds 0..3, 4
466    /// rows) must already have reached `on_row` beforehand — that's the
467    /// checkpoint-durability guarantee (see PR review finding M1).
468    ///
469    /// The test also asserts a structural, non-racy fact that pins down
470    /// *why* this holds, not just the end state: at the moment `on_row`
471    /// fires for the very first row, the shared `computed` counter must
472    /// read exactly 2 (only chunk 1's own two tasks have run). Against the
473    /// OLD `pool.install(|| tasks.par_iter().collect())` implementation —
474    /// which computes and collects results for the WHOLE task list inside
475    /// one `pool.install` call before the first `on_row` is ever invoked —
476    /// that counter would already read 6 (every task, including the
477    /// seed-4 failure and the never-delivered seed-5 success) by the time
478    /// `on_row` is first called, deterministically failing this assertion.
479    /// This isn't a timing race: `pool.install(|| ...collect())` cannot
480    /// return control to the caller until every item in the parallel
481    /// iterator has been computed, so the counter value at first delivery
482    /// is a structural property of which implementation is in use, not a
483    /// coincidence of scheduling.
484    #[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}