Skip to main content

bench_evidence/
bench_evidence.rs

1//! Evidence-interface overhead over one prepared validation snapshot.
2//!
3//! All workflows run from the *same* `PreparedEvidenceValidator` snapshot, so parse,
4//! SHACL-AF inference, normalization, stratification, dataset indexing, and
5//! SPARQL-executor construction are paid once and excluded from every timing.
6//! What the timer brackets is only:
7//!
8//!   A) `validate_conformance()` — target selection + one short-circuiting
9//!      satisfaction test per selected (statement, focus) pair.
10//!   B) `validate_canonical()`   — the same selection, but each pair also
11//!      materializes its applicable evidence polarity (satisfaction trace or
12//!      failure witness). Optional authored-statement progress is excluded.
13//!   C) `find_failures()` followed by `explain_canonical()` for each failed
14//!      pair — the workflow for consumers that only request failures.
15//!
16//! B − A is the cost of complete evidence tracing. C measures whether on-demand
17//! materialization avoids that cost when failures are sparse. Serialization is
18//! timed in a separate loop, since a caller that never leaves the process never
19//! pays it and its allocation traffic must not perturb the validation arms.
20//!
21//! Usage:
22//!   cargo run --release -p shifty-engine --example bench_evidence -- \
23//!       --shapes benchmark/brick/Brick-closure.ttl \
24//!       --models benchmark/brick/models \
25//!       --suite brick --iters 5
26//!
27//! Emits one CSV row per model on stdout; progress and errors on stderr.
28
29use shifty_engine::{
30    CompiledShapes, ConformanceOptions, PreparedEvidenceValidator, SessionData, SessionOptions,
31    ValidationGraphMode, ValidationOptions,
32};
33use std::collections::{HashMap, HashSet};
34use std::fs;
35use std::path::{Path, PathBuf};
36use std::time::{Duration, Instant};
37
38struct Args {
39    shapes: PathBuf,
40    models: PathBuf,
41    suite: String,
42    iters: usize,
43    min_iters: usize,
44    budget_ms: f64,
45    header: bool,
46    no_infer: bool,
47}
48
49#[derive(Clone, Copy)]
50enum Arm {
51    Conformance,
52    FullEvidence,
53    OnDemand,
54}
55
56const ARM_ORDERS: [[Arm; 3]; 3] = [
57    [Arm::Conformance, Arm::FullEvidence, Arm::OnDemand],
58    [Arm::FullEvidence, Arm::OnDemand, Arm::Conformance],
59    [Arm::OnDemand, Arm::Conformance, Arm::FullEvidence],
60];
61
62fn parse_args() -> Args {
63    let mut args = Args {
64        shapes: PathBuf::new(),
65        models: PathBuf::new(),
66        suite: "suite".to_string(),
67        iters: 5,
68        min_iters: 3,
69        budget_ms: 0.0,
70        header: true,
71        no_infer: false,
72    };
73    let mut argv = std::env::args().skip(1);
74    while let Some(flag) = argv.next() {
75        let mut value = || {
76            argv.next()
77                .unwrap_or_else(|| panic!("{flag} needs a value"))
78        };
79        match flag.as_str() {
80            "--shapes" => args.shapes = PathBuf::from(value()),
81            "--models" => args.models = PathBuf::from(value()),
82            "--suite" => args.suite = value(),
83            "--iters" => args.iters = value().parse().expect("--iters must be an integer"),
84            "--budget-ms" => {
85                args.budget_ms = value().parse().expect("--budget-ms must be a number")
86            }
87            "--min-iters" => {
88                args.min_iters = value().parse().expect("--min-iters must be an integer")
89            }
90            "--no-header" => args.header = false,
91            "--no-infer" => args.no_infer = true,
92            other => panic!("unknown flag {other}"),
93        }
94    }
95    assert!(!args.shapes.as_os_str().is_empty(), "--shapes is required");
96    assert!(!args.models.as_os_str().is_empty(), "--models is required");
97    assert!(args.iters > 0, "--iters must be positive");
98    args
99}
100
101const COLUMNS: &str = "suite,model,data_triples,iters,\
102conformance_ms_median,conformance_ms_mad,\
103full_evidence_ms_median,full_evidence_ms_mad,\
104full_overhead_median,full_overhead_mad,\
105failure_discovery_ms_median,failure_discovery_ms_mad,\
106failure_explanation_ms_median,failure_explanation_ms_mad,\
107on_demand_ms_median,on_demand_ms_mad,\
108on_demand_overhead_median,on_demand_overhead_mad,\
109serialize_ms_median,serialize_ms_mad,\
110conforms_conformance,conforms_evidence,conforms_on_demand,\
111evaluated_pairs,pass_pairs,fail_pairs,fail_fraction,\
112authored_pairs,authored_pass_pairs,authored_fail_pairs,\
113statements,\
114evidence_nodes,evidence_nodes_per_authored_pair,\
115evidence_bytes,evidence_bytes_per_authored_pair,\
116full_run_bytes_with_catalog,full_run_bytes_no_catalog,\
117compact_run_bytes_with_catalog,compact_run_bytes_no_catalog,\
118node_occurrences,distinct_nodes,node_redundancy,\
119result_occurrences,distinct_results,result_redundancy,\
120support_occurrences,distinct_support,support_redundancy,support_share,\
121term_occurrences,distinct_terms,term_redundancy,\
122normalized_requests,duplicate_records,divergent_duplicates,\
123canonical_occurrences,shared_payloads,shared_payload_fraction,\
124shared_canonical_occurrences,shared_occurrence_fraction,\
125request_reaches,requests_per_payload,max_payload_requests,\
126distinct_keys,key_redundancy,multi_occurrence_keys,\
127divergent_keys,divergence_fraction,divergent_occurrences,\
128distinct_payloads_per_key,keys_over_payload_cap,both_polarity_addresses,\
129conformance_ms_samples,full_evidence_ms_samples,\
130failure_discovery_ms_samples,failure_explanation_ms_samples,on_demand_ms_samples";
131
132fn main() {
133    let args = parse_args();
134
135    eprintln!("loading shapes {}…", args.shapes.display());
136    let shapes_bytes = fs::read(&args.shapes).expect("cannot read --shapes");
137    let shapes = shifty_parse::load_turtle(&shapes_bytes, None).expect("cannot parse --shapes");
138    let compiled = CompiledShapes::compile(shapes).expect("cannot compile shapes");
139    drop(shapes_bytes);
140
141    if args.header {
142        println!("{COLUMNS}");
143    }
144
145    for model in models_in(&args.models) {
146        let name = model
147            .file_name()
148            .and_then(|name| name.to_str())
149            .unwrap_or("?")
150            .to_string();
151        eprint!("{name}… ");
152
153        // ---- untimed setup: everything common to all workflows --------------
154        let data_bytes = fs::read(&model).expect("cannot read model");
155        let data = shifty_parse::load_turtle(&data_bytes, None).expect("cannot parse model");
156        drop(data_bytes);
157        let data_graph = if args.no_infer {
158            data.graph.clone()
159        } else {
160            compiled
161                .session(
162                    SessionData::Separate(data.graph.clone()),
163                    SessionOptions {
164                        inference: true,
165                        ..SessionOptions::default()
166                    },
167                )
168                .unwrap_or_else(|error| panic!("{name}: inference failed: {error}"))
169                .data()
170                .clone()
171        };
172        let data_triples = data_graph.len();
173
174        let prepared = PreparedEvidenceValidator::with_graphs(
175            &data_graph,
176            &compiled.source().graph,
177            compiled.authored_schema(),
178            ValidationGraphMode::Union,
179        )
180        .unwrap_or_else(|error| panic!("{name}: schema is non-stratifiable: {error}"));
181        let options = ValidationOptions::default();
182        let scan_options = ConformanceOptions::default();
183
184        // ---- timed: validation only -----------------------------------------
185        // One warm-up round, discarded: it faults in the caches all arms share.
186        // Its duration is only consulted when `--budget-ms` is in play.
187        let probe = Instant::now();
188        let _ = prepared.validate_conformance(&scan_options);
189        let _ = prepared.validate_canonical(&options);
190        let (_, warm_failures) = prepared.find_failures(&scan_options);
191        for pair in &warm_failures {
192            std::hint::black_box(prepared.explain_canonical(pair));
193        }
194        let probe_ms = probe.elapsed().as_secs_f64() * 1e3;
195        // Every model gets the same iteration count. `--budget-ms` only caps the
196        // count for a corpus slow enough that a fixed count is impractical; it
197        // is off by default, and the emitted `iters` column records what was used.
198        let iters = if args.budget_ms > 0.0 {
199            ((args.budget_ms / probe_ms.max(f64::MIN_POSITIVE)) as usize)
200                .clamp(args.min_iters.min(args.iters), args.iters)
201        } else {
202            args.iters
203        };
204
205        let mut conformance_samples = Vec::with_capacity(iters);
206        let mut evidence_samples = Vec::with_capacity(iters);
207        let mut discovery_samples = Vec::with_capacity(iters);
208        let mut explanation_samples = Vec::with_capacity(iters);
209        let mut on_demand_samples = Vec::with_capacity(iters);
210        let mut last_run = None;
211        let mut last_conformance = None;
212        let mut last_on_demand_conformance = None;
213        let mut last_failures = Vec::new();
214        let mut last_explanations = Vec::new();
215
216        // Rotate A/B/C so no arm systematically inherits the cache, allocator,
217        // scheduler, or thermal state left by another arm.
218        for iteration in 0..iters {
219            for arm in ARM_ORDERS[iteration % ARM_ORDERS.len()] {
220                match arm {
221                    Arm::Conformance => {
222                        let start = Instant::now();
223                        let conformance_run = prepared.validate_conformance(&scan_options);
224                        conformance_samples.push(start.elapsed());
225                        std::hint::black_box(conformance_run.selected_pairs);
226                        last_conformance = Some(conformance_run);
227                    }
228                    Arm::FullEvidence => {
229                        let start = Instant::now();
230                        let run = prepared.validate_canonical(&options);
231                        evidence_samples.push(start.elapsed());
232                        std::hint::black_box(run.statements.len());
233                        last_run = Some(run);
234                    }
235                    Arm::OnDemand => {
236                        let start = Instant::now();
237                        let (on_demand_conformance, failures) =
238                            prepared.find_failures(&scan_options);
239                        let discovery = start.elapsed();
240
241                        let start = Instant::now();
242                        let explanations: Vec<_> = failures
243                            .iter()
244                            .flat_map(|pair| prepared.explain_canonical(pair))
245                            .collect();
246                        let explanation = start.elapsed();
247
248                        discovery_samples.push(discovery);
249                        explanation_samples.push(explanation);
250                        on_demand_samples.push(discovery + explanation);
251                        std::hint::black_box(explanations.len());
252                        last_on_demand_conformance = Some(on_demand_conformance);
253                        last_failures = failures;
254                        last_explanations = explanations;
255                    }
256                }
257            }
258        }
259
260        // ---- untimed: shape of the evidence produced -------------------------
261        // Reuse the final timed results rather than paying for another run.
262        let conformance = last_conformance.expect("at least one iteration runs");
263        let on_demand_conformance =
264            last_on_demand_conformance.expect("at least one iteration runs");
265        let run = last_run.expect("at least one iteration runs");
266        let authored_pairs: usize = run
267            .statements
268            .iter()
269            .map(|statement| statement.selected_foci.len())
270            .sum();
271        let authored_fail_pairs: usize = run
272            .statements
273            .iter()
274            .flat_map(|statement| &statement.selected_foci)
275            .filter(|focus| focus.status() == shifty_engine::EvaluationStatus::Fail)
276            .count();
277
278        // The three interfaces must describe the same normalized selection and
279        // polarity partition. Compare exact failed identities, not just the
280        // report-wide conformance bit.
281        assert_eq!(
282            conformance, on_demand_conformance,
283            "{name}: on-demand counts"
284        );
285        assert_eq!(
286            conformance.conforms, run.conforms,
287            "{name}: conformance bit"
288        );
289        let full_selected: HashSet<_> = run
290            .statements
291            .iter()
292            .flat_map(|statement| {
293                statement.selected_foci.iter().filter_map(|focus| {
294                    statement
295                        .normalized_statement_id
296                        .map(|id| (id, focus.focus.clone()))
297                })
298            })
299            .collect();
300        let full_failures: HashSet<_> = run
301            .statements
302            .iter()
303            .flat_map(|statement| {
304                statement
305                    .selected_foci
306                    .iter()
307                    .filter(|focus| focus.status() == shifty_engine::EvaluationStatus::Fail)
308                    .filter_map(|focus| {
309                        statement
310                            .normalized_statement_id
311                            .map(|id| (id, focus.focus.clone()))
312                    })
313            })
314            .collect();
315        let discovered_failures: HashSet<_> = last_failures
316            .iter()
317            .map(|pair| (pair.normalized_statement(), pair.focus().clone()))
318            .collect();
319        assert_eq!(
320            full_selected.len(),
321            conformance.selected_pairs,
322            "{name}: selected pairs"
323        );
324        assert_eq!(
325            full_failures.len(),
326            conformance.failed,
327            "{name}: failed pairs"
328        );
329        assert_eq!(
330            full_selected.len() - full_failures.len(),
331            conformance.passed,
332            "{name}: passed pairs"
333        );
334        assert_eq!(
335            full_failures, discovered_failures,
336            "{name}: failed identities"
337        );
338
339        let full_by_authored: HashMap<_, _> = run
340            .statements
341            .iter()
342            .flat_map(|statement| {
343                statement.selected_foci.iter().map(|focus| {
344                    (
345                        (statement.source_statement_id, focus.focus.clone()),
346                        &focus.evidence,
347                    )
348                })
349            })
350            .collect();
351        let mut explained_foci = 0;
352        for statement in &last_explanations {
353            for focus in &statement.selected_foci {
354                explained_foci += 1;
355                assert_eq!(
356                    focus.status(),
357                    shifty_engine::EvaluationStatus::Fail,
358                    "{name}: on-demand explanation polarity"
359                );
360                let key = (statement.source_statement_id, focus.focus.clone());
361                assert_eq!(
362                    &focus.evidence,
363                    *full_by_authored
364                        .get(&key)
365                        .unwrap_or_else(|| panic!("{name}: unexplained full-run pair {key:?}")),
366                    "{name}: full and on-demand evidence differ for {key:?}"
367                );
368            }
369        }
370        assert_eq!(
371            explained_foci, authored_fail_pairs,
372            "{name}: authored explanations"
373        );
374
375        let evidence_nodes = run.walk().len();
376        let evidence_bytes: usize = run
377            .statements
378            .iter()
379            .flat_map(|statement| &statement.selected_foci)
380            .map(|focus| focus.evidence.to_json().expect("evidence serializes").len())
381            .sum();
382        let full_json = run.to_json().expect("evidence run serializes");
383        let full_run_bytes_with_catalog = full_json.len();
384        let mut full_value_no_catalog =
385            serde_json::to_value(&run).expect("evidence run converts to JSON");
386        full_value_no_catalog
387            .as_object_mut()
388            .expect("evidence run is a JSON object")
389            .remove("constraints");
390        let full_run_bytes_no_catalog = serde_json::to_string(&full_value_no_catalog)
391            .expect("catalog-free evidence run serializes")
392            .len();
393
394        // Compare complete runs with the same catalog policy on each side, and
395        // verify both encodings reconstruct the exact typed result.
396        let encoded_with_catalog =
397            shifty_engine::compact(&run, true).expect("compact encoding succeeds");
398        let compact_run_bytes_with_catalog = serde_json::to_string(&encoded_with_catalog)
399            .expect("compact encoding serializes")
400            .len();
401        assert_eq!(
402            shifty_engine::expand(&encoded_with_catalog).expect("compact run expands"),
403            run,
404            "{name}: compact run with catalog is not lossless"
405        );
406        let encoded_no_catalog =
407            shifty_engine::compact(&run, false).expect("compact encoding succeeds");
408        let compact_run_bytes_no_catalog = serde_json::to_string(&encoded_no_catalog)
409            .expect("catalog-free compact encoding serializes")
410            .len();
411        let catalog = serde_json::to_value(&run.constraints).expect("catalog serializes");
412        assert_eq!(
413            shifty_engine::expand_with_catalog(&encoded_no_catalog, catalog)
414                .expect("catalog-free compact run expands"),
415            run,
416            "{name}: compact run without catalog is not lossless"
417        );
418        // Sharing is reported over the evidence alone. The node and term tables
419        // of `encoded` also hold catalog entries, which would understate the
420        // redundancy by adding distinct entries with no evidence occurrences.
421        let sharing = shifty_engine::sharing(&run).expect("sharing measures");
422        let results = shifty_engine::result_sharing(&run);
423
424        // The two passes reach the same judgment nodes by routes that share no
425        // code: `sharing` interns serialized JSON, `result_sharing` walks the
426        // typed evidence. Agreement on both counts is what makes either number
427        // reportable; a mismatch means the node families are misclassified or a
428        // traversal is missing a variant.
429        assert_eq!(
430            sharing.result_occurrences + sharing.support_occurrences,
431            sharing.node_occurrences,
432            "{name}: node families do not partition occurrences"
433        );
434        assert_eq!(
435            sharing.distinct_results + sharing.distinct_support,
436            sharing.distinct_nodes,
437            "{name}: node families do not partition the table"
438        );
439        assert_eq!(
440            sharing.result_occurrences, evidence_nodes,
441            "{name}: interned judgments disagree with the typed walk"
442        );
443        assert_eq!(
444            results.occurrences, evidence_nodes,
445            "{name}: typed sharing pass disagrees with the typed walk"
446        );
447        assert_eq!(
448            results.distinct_payloads, sharing.distinct_results,
449            "{name}: distinct judgments disagree between the two passes"
450        );
451        // Structural equality implies key equality, never the reverse.
452        assert!(
453            results.distinct_keys <= results.distinct_payloads,
454            "{name}: more keys than payloads"
455        );
456        // Requests are the normalized selection the conformance pass counts;
457        // authored records are the source-preserving view over them.
458        assert_eq!(
459            results.normalized_requests, conformance.selected_pairs,
460            "{name}: requests disagree with the conformance selection"
461        );
462        assert_eq!(
463            results.authored_records, authored_pairs,
464            "{name}: authored records disagree with the run"
465        );
466
467        // Serialization is measured separately so its allocation traffic does
468        // not perturb the next validation arm.
469        let mut serialize_samples = Vec::with_capacity(iters);
470        for _ in 0..iters {
471            let start = Instant::now();
472            let json = run.to_json().expect("evidence run serializes");
473            serialize_samples.push(start.elapsed());
474            std::hint::black_box(json.len());
475        }
476
477        // Each ratio compares the corresponding raw samples from one rotated
478        // A/B/C round. The CSV retains those samples for independent checking.
479        let full_ratios: Vec<f64> = conformance_samples
480            .iter()
481            .zip(evidence_samples.iter())
482            .map(|(conformance, evidence)| {
483                evidence.as_secs_f64() / conformance.as_secs_f64().max(f64::MIN_POSITIVE)
484            })
485            .collect();
486        let on_demand_ratios: Vec<f64> = conformance_samples
487            .iter()
488            .zip(on_demand_samples.iter())
489            .map(|(conformance, on_demand)| {
490                on_demand.as_secs_f64() / conformance.as_secs_f64().max(f64::MIN_POSITIVE)
491            })
492            .collect();
493
494        let conformance_ms = summarize_durations(&conformance_samples);
495        let evidence_ms = summarize_durations(&evidence_samples);
496        let discovery_ms = summarize_durations(&discovery_samples);
497        let explanation_ms = summarize_durations(&explanation_samples);
498        let on_demand_ms = summarize_durations(&on_demand_samples);
499        let serialize_ms = summarize_durations(&serialize_samples);
500        let full_ratio = summarize_values(&full_ratios);
501        let on_demand_ratio = summarize_values(&on_demand_ratios);
502        let evaluated_pairs = conformance.selected_pairs.max(1) as f64;
503        let authored_pairs_denominator = authored_pairs.max(1) as f64;
504
505        println!(
506            "{suite},{name},{data_triples},{iters},\
507{c_med:.4},{c_mad:.4},{e_med:.4},{e_mad:.4},{full_ratio:.4},{full_ratio_mad:.4},\
508{find_med:.4},{find_mad:.4},{explain_med:.4},{explain_mad:.4},\
509{ondemand_med:.4},{ondemand_mad:.4},{ondemand_ratio:.4},{ondemand_ratio_mad:.4},\
510{serialize_med:.4},{serialize_mad:.4},\
511{conforms_c},{conforms_e},{conforms_o},\
512{pairs_eval},{pass_pairs},{fail_pairs},{fail_fraction:.6},\
513{authored_pairs},{authored_pass},{authored_fail},\
514{statements},\
515{evidence_nodes},{nodes_per_authored_pair:.3},\
516{evidence_bytes},{bytes_per_authored_pair:.1},\
517{full_with_catalog},{full_no_catalog},{compact_with_catalog},{compact_no_catalog},\
518{node_occurrences},{distinct_nodes},{node_redundancy:.3},\
519{result_occurrences},{distinct_results},{result_redundancy:.3},\
520{support_occurrences},{distinct_support},{support_redundancy:.3},{support_share:.4},\
521{term_occurrences},{distinct_terms},{term_redundancy:.3},\
522{normalized_requests},{duplicate_records},{divergent_duplicates},\
523{canonical_occurrences},{shared_payloads},{shared_payload_fraction:.4},\
524{shared_canonical_occurrences},{shared_occurrence_fraction:.4},\
525{request_reaches},{requests_per_payload:.3},{max_payload_requests},\
526{distinct_keys},{key_redundancy:.3},{multi_occurrence_keys},\
527{divergent_keys},{divergence_fraction:.4},{divergent_occurrences},\
528{distinct_payloads_per_key},{keys_over_payload_cap},{both_polarity_addresses},\
529{conformance_raw},{evidence_raw},{discovery_raw},{explanation_raw},{ondemand_raw}",
530            suite = args.suite,
531            node_occurrences = sharing.node_occurrences,
532            distinct_nodes = sharing.distinct_nodes,
533            node_redundancy = sharing.node_redundancy(),
534            result_occurrences = sharing.result_occurrences,
535            distinct_results = sharing.distinct_results,
536            result_redundancy = sharing.result_redundancy(),
537            support_occurrences = sharing.support_occurrences,
538            distinct_support = sharing.distinct_support,
539            support_redundancy = sharing.support_redundancy(),
540            support_share = sharing.support_share(),
541            term_occurrences = sharing.term_occurrences,
542            distinct_terms = sharing.distinct_terms,
543            term_redundancy = sharing.term_redundancy(),
544            normalized_requests = results.normalized_requests,
545            duplicate_records = results.duplicate_records,
546            divergent_duplicates = results.divergent_duplicates,
547            canonical_occurrences = results.canonical_occurrences,
548            shared_payloads = results.shared_payloads,
549            shared_payload_fraction = results.shared_payload_fraction(),
550            shared_canonical_occurrences = results.shared_canonical_occurrences,
551            shared_occurrence_fraction = results.shared_occurrence_fraction(),
552            request_reaches = results.request_reaches,
553            requests_per_payload = results.requests_per_payload(),
554            max_payload_requests = results.max_payload_requests,
555            distinct_keys = results.distinct_keys,
556            key_redundancy = results.key_redundancy(),
557            multi_occurrence_keys = results.multi_occurrence_keys,
558            divergent_keys = results.divergent_keys,
559            divergence_fraction = results.divergence_fraction(),
560            divergent_occurrences = results.divergent_occurrences,
561            distinct_payloads_per_key = results.distinct_payloads_per_key,
562            keys_over_payload_cap = results.keys_over_payload_cap,
563            both_polarity_addresses = results.both_polarity_addresses,
564            c_med = conformance_ms.median,
565            c_mad = conformance_ms.mad,
566            e_med = evidence_ms.median,
567            e_mad = evidence_ms.mad,
568            full_ratio = full_ratio.median,
569            full_ratio_mad = full_ratio.mad,
570            find_med = discovery_ms.median,
571            find_mad = discovery_ms.mad,
572            explain_med = explanation_ms.median,
573            explain_mad = explanation_ms.mad,
574            ondemand_med = on_demand_ms.median,
575            ondemand_mad = on_demand_ms.mad,
576            ondemand_ratio = on_demand_ratio.median,
577            ondemand_ratio_mad = on_demand_ratio.mad,
578            serialize_med = serialize_ms.median,
579            serialize_mad = serialize_ms.mad,
580            conforms_c = conformance.conforms,
581            conforms_e = run.conforms,
582            conforms_o = on_demand_conformance.conforms,
583            pairs_eval = conformance.selected_pairs,
584            pass_pairs = conformance.passed,
585            fail_pairs = conformance.failed,
586            fail_fraction = conformance.failed as f64 / evaluated_pairs,
587            authored_pass = authored_pairs - authored_fail_pairs,
588            authored_fail = authored_fail_pairs,
589            statements = run.statements.len(),
590            nodes_per_authored_pair = evidence_nodes as f64 / authored_pairs_denominator,
591            bytes_per_authored_pair = evidence_bytes as f64 / authored_pairs_denominator,
592            full_with_catalog = full_run_bytes_with_catalog,
593            full_no_catalog = full_run_bytes_no_catalog,
594            compact_with_catalog = compact_run_bytes_with_catalog,
595            compact_no_catalog = compact_run_bytes_no_catalog,
596            conformance_raw = samples_string(&conformance_samples),
597            evidence_raw = samples_string(&evidence_samples),
598            discovery_raw = samples_string(&discovery_samples),
599            explanation_raw = samples_string(&explanation_samples),
600            ondemand_raw = samples_string(&on_demand_samples),
601        );
602
603        eprintln!(
604            "median: {:.1}ms conformance, {:.1}ms full ({:.2}x), {:.1}ms on-demand ({:.2}x); {} failures / {} pairs; {iters} rounds",
605            conformance_ms.median,
606            evidence_ms.median,
607            full_ratio.median,
608            on_demand_ms.median,
609            on_demand_ratio.median,
610            conformance.failed,
611            conformance.selected_pairs,
612        );
613    }
614}
615
616struct Stats {
617    median: f64,
618    mad: f64,
619}
620
621/// Median and median absolute deviation. Both are directly recomputable from
622/// the raw samples retained in the CSV and resist an occasional descheduled run.
623fn summarize_durations(samples: &[Duration]) -> Stats {
624    let values: Vec<f64> = samples
625        .iter()
626        .map(|duration| duration.as_secs_f64() * 1e3)
627        .collect();
628    summarize_values(&values)
629}
630
631fn summarize_values(samples: &[f64]) -> Stats {
632    let mut values = samples.to_vec();
633    values.sort_by(f64::total_cmp);
634    let median = median_of(&values);
635    let mut deviations: Vec<f64> = values.iter().map(|value| (value - median).abs()).collect();
636    deviations.sort_by(f64::total_cmp);
637    Stats {
638        median,
639        mad: median_of(&deviations),
640    }
641}
642
643fn samples_string(samples: &[Duration]) -> String {
644    samples
645        .iter()
646        .map(|duration| format!("{:.4}", duration.as_secs_f64() * 1e3))
647        .collect::<Vec<_>>()
648        .join(";")
649}
650
651fn median_of(sorted: &[f64]) -> f64 {
652    let n = sorted.len();
653    if n % 2 == 1 {
654        sorted[n / 2]
655    } else {
656        (sorted[n / 2 - 1] + sorted[n / 2]) / 2.0
657    }
658}
659
660/// Models in stable, locale-independent order so repeated runs line up.
661fn models_in(dir: &Path) -> Vec<PathBuf> {
662    let mut models: Vec<PathBuf> = fs::read_dir(dir)
663        .expect("cannot read --models")
664        .filter_map(|entry| entry.ok().map(|entry| entry.path()))
665        .filter(|path| path.extension().is_some_and(|ext| ext == "ttl"))
666        .collect();
667    models.sort();
668    models
669}