Skip to main content

candle_graph/
comparison.rs

1//! Fail-closed replicated performance comparisons.
2
3use std::collections::{BTreeMap, BTreeSet};
4use std::path::Path;
5
6use anyhow::{ensure, Context, Result};
7use serde::{Deserialize, Serialize};
8
9use crate::artifact::{verify_bundle, verify_consumed_bundle_files};
10use crate::capability::MeasurementScope;
11use crate::trace::{analyze_health, parse_trace, ComparisonIdentity, TraceDocument};
12
13pub const SCHEMA: &str = "candle-graph/comparison/6";
14pub const MINIMUM_RUNS: usize = 5;
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
17#[serde(rename_all = "snake_case")]
18pub enum ComparisonVerdict {
19    Ineligible,
20    Inconclusive,
21    CandidateFaster,
22    CandidateSlower,
23}
24
25/// Stable, machine-readable cause of comparison ineligibility.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
27#[serde(rename_all = "snake_case")]
28pub enum ComparisonReasonCode {
29    UnverifiedInputs,
30    ReceiptCountMismatch,
31    ReceiptRunIdMismatch,
32    ReceiptDigestInvalid,
33    InsufficientRuns,
34    DuplicateRunIds,
35    NoRuns,
36    CaptureSemanticsMismatch,
37    IncompleteCapture,
38    NotProductionEquivalent,
39    UnsynchronizedDeviceRegion,
40    MeasuredRegionCountInvalid,
41    IdentityMissing,
42    IdentityInvalid,
43    IdentityConditionsDiffer,
44    ImplementationIdMissing,
45    ImplementationIdEmpty,
46    ImplementationIdInconsistent,
47    PairingIncomplete,
48    PairIdsDuplicated,
49    PairSetsMismatch,
50}
51
52/// One ineligibility cause: a stable code plus its human-readable message.
53#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
54pub struct ComparisonReason {
55    pub code: ComparisonReasonCode,
56    pub message: String,
57}
58
59fn reason(code: ComparisonReasonCode, message: impl Into<String>) -> ComparisonReason {
60    ComparisonReason {
61        code,
62        message: message.into(),
63    }
64}
65
66#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
67pub struct SampleStatistics {
68    pub samples_ns: Vec<u64>,
69    pub median_ns: f64,
70    pub p95_ns: f64,
71    pub mad_ns: f64,
72}
73
74#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
75pub struct ConfidenceInterval {
76    pub level: f64,
77    pub lower_delta_ns: f64,
78    pub upper_delta_ns: f64,
79}
80
81#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
82pub struct TensorStatsComparisonRow {
83    pub label: String,
84    pub rms_a: f64,
85    pub rms_b: f64,
86    pub rms_ratio: Option<f64>,
87    pub abs_max_ratio: Option<f64>,
88    pub non_finite_a: u64,
89    pub non_finite_b: u64,
90    /// Events averaged into `rms_a`/`abs_max` for this label (duplicates included).
91    pub samples_a: usize,
92    pub samples_b: usize,
93    /// Cohort runs that contained this label; compare against the cohort run totals to see
94    /// whether an average covers the whole cohort or only part of it.
95    pub runs_a: usize,
96    pub runs_b: usize,
97}
98
99#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
100pub struct TensorStatsComparison {
101    pub baseline_runs: usize,
102    pub candidate_runs: usize,
103    pub matched: Vec<TensorStatsComparisonRow>,
104    pub unmatched_a: Vec<String>,
105    pub unmatched_b: Vec<String>,
106}
107
108/// Trust state of the artifacts supplied to a comparison.
109#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
110#[serde(rename_all = "snake_case")]
111pub enum ComparisonInputVerification {
112    VerifiedBundles,
113    UnverifiedTraces,
114}
115
116/// One content-addressed bundle input verified immediately before comparison.
117#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
118pub struct VerifiedBundleInput {
119    pub run_id: String,
120    pub manifest_sha256: String,
121}
122
123/// Cohort provenance that determines whether a comparison may be eligible.
124#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
125pub struct ComparisonInputs {
126    pub verification: ComparisonInputVerification,
127    pub baseline: Vec<VerifiedBundleInput>,
128    pub candidate: Vec<VerifiedBundleInput>,
129}
130
131#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
132pub struct ReplicatedComparison {
133    pub schema: String,
134    pub metric: String,
135    pub inputs: ComparisonInputs,
136    pub comparable: bool,
137    pub paired: bool,
138    pub verdict: ComparisonVerdict,
139    pub reasons: Vec<ComparisonReason>,
140    pub baseline_implementation_id: Option<String>,
141    pub candidate_implementation_id: Option<String>,
142    pub identity: Option<ComparisonIdentity>,
143    pub baseline: SampleStatistics,
144    pub candidate: SampleStatistics,
145    pub median_delta_ns: f64,
146    pub median_delta_percent: Option<f64>,
147    pub confidence_interval: Option<ConfidenceInterval>,
148    /// Numerical mechanism comparison, independent of timing eligibility.
149    #[serde(default)]
150    pub tensor_stats: TensorStatsComparison,
151}
152
153/// Verify finalized evidence bundles and compare their bound trace documents.
154pub fn compare_verified_bundles<B: AsRef<Path>, C: AsRef<Path>>(
155    baseline: &[B],
156    candidate: &[C],
157) -> Result<ReplicatedComparison> {
158    let (baseline_documents, baseline_inputs) = load_verified_cohort(baseline, "baseline")?;
159    let (candidate_documents, candidate_inputs) = load_verified_cohort(candidate, "candidate")?;
160    Ok(compare_documents(
161        &baseline_documents,
162        &candidate_documents,
163        ComparisonInputs {
164            verification: ComparisonInputVerification::VerifiedBundles,
165            baseline: baseline_inputs,
166            candidate: candidate_inputs,
167        },
168    ))
169}
170
171/// Compare raw trace documents for diagnostics only. This path is always ineligible.
172pub fn compare_unverified_traces(
173    baseline: &[TraceDocument],
174    candidate: &[TraceDocument],
175) -> ReplicatedComparison {
176    compare_documents(
177        baseline,
178        candidate,
179        ComparisonInputs {
180            verification: ComparisonInputVerification::UnverifiedTraces,
181            baseline: Vec::new(),
182            candidate: Vec::new(),
183        },
184    )
185}
186
187fn load_verified_cohort<P: AsRef<Path>>(
188    roots: &[P],
189    cohort: &str,
190) -> Result<(Vec<TraceDocument>, Vec<VerifiedBundleInput>)> {
191    let mut documents = Vec::with_capacity(roots.len());
192    let mut inputs = Vec::with_capacity(roots.len());
193    for (index, root) in roots.iter().enumerate() {
194        let root = root.as_ref();
195        let receipt = verify_bundle(root).with_context(|| {
196            format!("verify {cohort} bundle {} at {}", index + 1, root.display())
197        })?;
198        let document = parse_trace(root.join("trace.jsonl")).with_context(|| {
199            format!(
200                "parse verified {cohort} bundle {} trace at {}",
201                index + 1,
202                root.display()
203            )
204        })?;
205        ensure!(
206            document.run.run_id == receipt.run_id,
207            "verified {cohort} bundle {} manifest run ID {:?} does not match trace run ID {:?}",
208            index + 1,
209            receipt.run_id,
210            document.run.run_id
211        );
212        verify_consumed_bundle_files(root, &receipt, &["trace.jsonl"]).with_context(|| {
213            format!(
214                "post-read verify {cohort} bundle {} trace at {}",
215                index + 1,
216                root.display()
217            )
218        })?;
219        inputs.push(VerifiedBundleInput {
220            run_id: receipt.run_id,
221            manifest_sha256: receipt.manifest_sha256,
222        });
223        documents.push(document);
224    }
225    Ok((documents, inputs))
226}
227
228fn compare_documents(
229    baseline: &[TraceDocument],
230    candidate: &[TraceDocument],
231    inputs: ComparisonInputs,
232) -> ReplicatedComparison {
233    let mut reasons = Vec::new();
234    validate_input_provenance(&inputs, baseline, candidate, &mut reasons);
235    let baseline_samples = measured_samples(baseline, "baseline", &mut reasons);
236    let candidate_samples = measured_samples(candidate, "candidate", &mut reasons);
237    if baseline.len() < MINIMUM_RUNS || candidate.len() < MINIMUM_RUNS {
238        reasons.push(reason(
239            ComparisonReasonCode::InsufficientRuns,
240            format!("at least {MINIMUM_RUNS} independent baseline and candidate runs are required"),
241        ));
242    }
243    require_independent_run_ids(baseline, candidate, &mut reasons);
244    require_consistent_capture_semantics(baseline, candidate, &mut reasons);
245
246    let identity = common_identity(baseline, candidate, &mut reasons);
247    let baseline_implementation_id = cohort_implementation_id(baseline, "baseline", &mut reasons);
248    let candidate_implementation_id =
249        cohort_implementation_id(candidate, "candidate", &mut reasons);
250    let paired_samples = pair_samples(baseline, candidate, &mut reasons);
251    let paired = paired_samples.is_some();
252    let comparable = reasons.is_empty();
253    let baseline_stats = statistics(baseline_samples);
254    let candidate_stats = statistics(candidate_samples);
255    let median_delta_ns = paired_samples.as_ref().map_or_else(
256        || candidate_stats.median_ns - baseline_stats.median_ns,
257        |pairs| {
258            let mut deltas = pairs
259                .iter()
260                .map(|(baseline, candidate)| *candidate as i128 - *baseline as i128)
261                .collect::<Vec<_>>();
262            deltas.sort_unstable();
263            median_i128(&deltas)
264        },
265    );
266    let median_delta_percent = (baseline_stats.median_ns != 0.0)
267        .then_some(median_delta_ns / baseline_stats.median_ns * 100.0);
268    let confidence_interval = comparable.then(|| {
269        let (lower_delta_ns, upper_delta_ns) = bootstrap_delta_ci(
270            &baseline_stats.samples_ns,
271            &candidate_stats.samples_ns,
272            paired_samples.as_deref(),
273        );
274        ConfidenceInterval {
275            level: 0.95,
276            lower_delta_ns,
277            upper_delta_ns,
278        }
279    });
280    let verdict = match &confidence_interval {
281        None => ComparisonVerdict::Ineligible,
282        Some(ci) if ci.upper_delta_ns < 0.0 => ComparisonVerdict::CandidateFaster,
283        Some(ci) if ci.lower_delta_ns > 0.0 => ComparisonVerdict::CandidateSlower,
284        Some(_) => ComparisonVerdict::Inconclusive,
285    };
286    let tensor_stats = compare_tensor_stats(baseline, candidate);
287
288    ReplicatedComparison {
289        schema: SCHEMA.into(),
290        metric: "outer_wall_time_ns".into(),
291        inputs,
292        comparable,
293        paired,
294        verdict,
295        reasons,
296        baseline_implementation_id,
297        candidate_implementation_id,
298        identity,
299        baseline: baseline_stats,
300        candidate: candidate_stats,
301        median_delta_ns,
302        median_delta_percent,
303        confidence_interval,
304        tensor_stats,
305    }
306}
307
308fn compare_tensor_stats(
309    baseline: &[TraceDocument],
310    candidate: &[TraceDocument],
311) -> TensorStatsComparison {
312    #[derive(Default)]
313    struct Aggregate {
314        rms: f64,
315        abs_max: f64,
316        non_finite: u64,
317        samples: usize,
318        runs: usize,
319    }
320
321    fn aggregate(documents: &[TraceDocument]) -> BTreeMap<String, Aggregate> {
322        let mut by_label = BTreeMap::<String, Aggregate>::new();
323        for document in documents {
324            let mut seen = BTreeSet::new();
325            for event in &document.tensor_stats {
326                let entry = by_label.entry(event.label.clone()).or_default();
327                entry.rms += event.rms;
328                entry.abs_max += event.abs_max;
329                entry.non_finite = entry.non_finite.saturating_add(event.non_finite);
330                entry.samples += 1;
331                if seen.insert(event.label.as_str()) {
332                    entry.runs += 1;
333                }
334            }
335        }
336        by_label
337    }
338
339    fn mean(value: f64, samples: usize) -> f64 {
340        if samples == 0 {
341            0.0
342        } else {
343            value / samples as f64
344        }
345    }
346
347    fn ratio(a: f64, b: f64) -> Option<f64> {
348        if a == 0.0 {
349            (b == 0.0).then_some(1.0)
350        } else {
351            Some(b / a)
352        }
353    }
354
355    fn ratio_distance(ratio: Option<f64>) -> f64 {
356        match ratio {
357            Some(value) if value > 0.0 => value.ln().abs(),
358            _ => f64::INFINITY,
359        }
360    }
361
362    let baseline_runs = baseline.len();
363    let candidate_runs = candidate.len();
364    let baseline = aggregate(baseline);
365    let candidate = aggregate(candidate);
366    let mut matched = baseline
367        .iter()
368        .filter_map(|(label, a)| {
369            let b = candidate.get(label)?;
370            let rms_a = mean(a.rms, a.samples);
371            let rms_b = mean(b.rms, b.samples);
372            let abs_max_a = mean(a.abs_max, a.samples);
373            let abs_max_b = mean(b.abs_max, b.samples);
374            Some(TensorStatsComparisonRow {
375                label: label.clone(),
376                rms_a,
377                rms_b,
378                rms_ratio: ratio(rms_a, rms_b),
379                abs_max_ratio: ratio(abs_max_a, abs_max_b),
380                non_finite_a: a.non_finite,
381                non_finite_b: b.non_finite,
382                samples_a: a.samples,
383                samples_b: b.samples,
384                runs_a: a.runs,
385                runs_b: b.runs,
386            })
387        })
388        .collect::<Vec<_>>();
389    matched.sort_by(|a, b| {
390        ratio_distance(b.rms_ratio)
391            .total_cmp(&ratio_distance(a.rms_ratio))
392            .then_with(|| a.label.cmp(&b.label))
393    });
394    TensorStatsComparison {
395        baseline_runs,
396        candidate_runs,
397        matched,
398        unmatched_a: baseline
399            .keys()
400            .filter(|label| !candidate.contains_key(*label))
401            .cloned()
402            .collect(),
403        unmatched_b: candidate
404            .keys()
405            .filter(|label| !baseline.contains_key(*label))
406            .cloned()
407            .collect(),
408    }
409}
410
411fn validate_input_provenance(
412    inputs: &ComparisonInputs,
413    baseline: &[TraceDocument],
414    candidate: &[TraceDocument],
415    reasons: &mut Vec<ComparisonReason>,
416) {
417    match inputs.verification {
418        ComparisonInputVerification::UnverifiedTraces => reasons.push(reason(
419            ComparisonReasonCode::UnverifiedInputs,
420            "unverified raw trace inputs are diagnostic only; finalized verified bundles are required for an eligible comparison",
421        )),
422        ComparisonInputVerification::VerifiedBundles => {
423            validate_verified_cohort(&inputs.baseline, baseline, "baseline", reasons);
424            validate_verified_cohort(&inputs.candidate, candidate, "candidate", reasons);
425        }
426    }
427}
428
429fn validate_verified_cohort(
430    inputs: &[VerifiedBundleInput],
431    documents: &[TraceDocument],
432    cohort: &str,
433    reasons: &mut Vec<ComparisonReason>,
434) {
435    if inputs.len() != documents.len() {
436        reasons.push(reason(
437            ComparisonReasonCode::ReceiptCountMismatch,
438            format!("{cohort} bundle receipts must correspond one-to-one with trace documents"),
439        ));
440        return;
441    }
442    for (index, (input, document)) in inputs.iter().zip(documents).enumerate() {
443        if input.run_id != document.run.run_id {
444            reasons.push(reason(
445                ComparisonReasonCode::ReceiptRunIdMismatch,
446                format!(
447                    "{cohort} bundle {} receipt run ID does not match its trace",
448                    index + 1
449                ),
450            ));
451        }
452        if input.manifest_sha256.len() != 64
453            || !input
454                .manifest_sha256
455                .bytes()
456                .all(|byte| byte.is_ascii_hexdigit())
457        {
458            reasons.push(reason(
459                ComparisonReasonCode::ReceiptDigestInvalid,
460                format!(
461                    "{cohort} bundle {} receipt has an invalid manifest SHA-256",
462                    index + 1
463                ),
464            ));
465        }
466    }
467}
468
469fn require_independent_run_ids(
470    baseline: &[TraceDocument],
471    candidate: &[TraceDocument],
472    reasons: &mut Vec<ComparisonReason>,
473) {
474    let ids = baseline
475        .iter()
476        .chain(candidate)
477        .map(|document| document.run.run_id.as_str())
478        .collect::<Vec<_>>();
479    if ids.iter().copied().collect::<BTreeSet<_>>().len() != ids.len() {
480        reasons.push(reason(
481            ComparisonReasonCode::DuplicateRunIds,
482            "run IDs must be unique across all replicates",
483        ));
484    }
485}
486
487fn require_consistent_capture_semantics(
488    baseline: &[TraceDocument],
489    candidate: &[TraceDocument],
490    reasons: &mut Vec<ComparisonReason>,
491) {
492    let Some(first) = baseline.first().or_else(|| candidate.first()) else {
493        reasons.push(reason(
494            ComparisonReasonCode::NoRuns,
495            "comparison contains no runs",
496        ));
497        return;
498    };
499    if baseline.iter().chain(candidate).any(|document| {
500        document.run.entrypoint != first.run.entrypoint
501            || document.run.phase != first.run.phase
502            || document.run.device != first.run.device
503            || document.run.timing_mode != first.run.timing_mode
504            || document.run.warmup_steps != first.run.warmup_steps
505            || document.run.capture_step != first.run.capture_step
506            || document.run.capture_contract != first.run.capture_contract
507            || document.run.measured_region_device_synchronized
508                != first.run.measured_region_device_synchronized
509    }) {
510        reasons.push(reason(
511            ComparisonReasonCode::CaptureSemanticsMismatch,
512            "entrypoint, phase, device, timing mode, synchronization, warmup, capture step, and capture contract must match",
513        ));
514    }
515}
516
517fn measured_samples(
518    docs: &[TraceDocument],
519    cohort: &str,
520    reasons: &mut Vec<ComparisonReason>,
521) -> Vec<u64> {
522    docs.iter()
523        .enumerate()
524        .map(|(index, doc)| {
525            let health = analyze_health(doc);
526            if !health.structurally_valid || !health.capture_complete {
527                reasons.push(reason(
528                    ComparisonReasonCode::IncompleteCapture,
529                    format!(
530                        "{cohort} run {} is not a complete, structurally valid capture",
531                        index + 1
532                    ),
533                ));
534            }
535            if doc.run.capture_contract.measurement_scope != MeasurementScope::ProductionEquivalent
536            {
537                reasons.push(reason(
538                    ComparisonReasonCode::NotProductionEquivalent,
539                    format!(
540                        "{cohort} run {} is not declared production-equivalent",
541                        index + 1
542                    ),
543                ));
544            }
545            if !doc.run.device.starts_with("cpu") && !doc.run.measured_region_device_synchronized {
546                reasons.push(reason(
547                    ComparisonReasonCode::UnsynchronizedDeviceRegion,
548                    format!(
549                        "{cohort} run {} does not synchronize its measured device region",
550                        index + 1
551                    ),
552                ));
553            }
554            let values = doc
555                .spans
556                .iter()
557                .filter(|span| span.measured && span.closed)
558                .map(|span| span.duration_ns)
559                .collect::<Vec<_>>();
560            if values.len() != 1 {
561                reasons.push(reason(
562                    ComparisonReasonCode::MeasuredRegionCountInvalid,
563                    format!(
564                        "{cohort} run {} does not contain exactly one closed measured region",
565                        index + 1
566                    ),
567                ));
568            }
569            values.into_iter().next().unwrap_or(0)
570        })
571        .collect()
572}
573
574fn common_identity(
575    baseline: &[TraceDocument],
576    candidate: &[TraceDocument],
577    reasons: &mut Vec<ComparisonReason>,
578) -> Option<ComparisonIdentity> {
579    let identities = baseline
580        .iter()
581        .chain(candidate)
582        .map(|doc| doc.run.comparison_identity.as_ref())
583        .collect::<Vec<_>>();
584    let Some(first) = identities.first().copied().flatten() else {
585        reasons.push(reason(
586            ComparisonReasonCode::IdentityMissing,
587            "comparison identity is missing",
588        ));
589        return None;
590    };
591    if identities.iter().any(|identity| identity.is_none()) {
592        reasons.push(reason(
593            ComparisonReasonCode::IdentityMissing,
594            "comparison identity is missing from one or more runs",
595        ));
596        return None;
597    }
598    if let Err(error) = first.validate() {
599        reasons.push(reason(
600            ComparisonReasonCode::IdentityInvalid,
601            format!("comparison identity is invalid: {error}"),
602        ));
603        return None;
604    }
605    if identities
606        .iter()
607        .flatten()
608        .any(|identity| !same_conditions(first, identity))
609    {
610        reasons.push(reason(
611            ComparisonReasonCode::IdentityConditionsDiffer,
612            "workload, model, configuration, data, seed, batch, precision, or device state differs",
613        ));
614        return None;
615    }
616    let mut result = first.clone();
617    result.implementation_id = None;
618    result.pair_id = None;
619    Some(result)
620}
621
622fn cohort_implementation_id(
623    documents: &[TraceDocument],
624    cohort: &str,
625    reasons: &mut Vec<ComparisonReason>,
626) -> Option<String> {
627    let implementation_ids = documents
628        .iter()
629        .map(|document| {
630            document
631                .run
632                .comparison_identity
633                .as_ref()
634                .and_then(|identity| identity.implementation_id.as_deref())
635        })
636        .collect::<Vec<_>>();
637    let Some(first) = implementation_ids.first().copied().flatten() else {
638        reasons.push(reason(
639            ComparisonReasonCode::ImplementationIdMissing,
640            format!("{cohort} implementation ID is missing"),
641        ));
642        return None;
643    };
644    if implementation_ids.iter().any(|identity| identity.is_none()) {
645        reasons.push(reason(
646            ComparisonReasonCode::ImplementationIdMissing,
647            format!("{cohort} implementation ID is missing from one or more runs"),
648        ));
649        return None;
650    }
651    if implementation_ids
652        .iter()
653        .flatten()
654        .any(|identity| identity.trim().is_empty())
655    {
656        reasons.push(reason(
657            ComparisonReasonCode::ImplementationIdEmpty,
658            format!("{cohort} implementation ID must not be empty"),
659        ));
660        return None;
661    }
662    if implementation_ids
663        .iter()
664        .flatten()
665        .any(|identity| *identity != first)
666    {
667        reasons.push(reason(
668            ComparisonReasonCode::ImplementationIdInconsistent,
669            format!("{cohort} implementation ID differs within the cohort"),
670        ));
671        return None;
672    }
673    Some(first.to_owned())
674}
675
676fn same_conditions(left: &ComparisonIdentity, right: &ComparisonIdentity) -> bool {
677    left.workload_id == right.workload_id
678        && left.model_id == right.model_id
679        && left.config_id == right.config_id
680        && left.data_id == right.data_id
681        && left.seed_policy == right.seed_policy
682        && left.physical_batch == right.physical_batch
683        && left.accumulation_steps == right.accumulation_steps
684        && left.precision == right.precision
685        && left.device_state == right.device_state
686}
687
688fn pair_samples(
689    baseline: &[TraceDocument],
690    candidate: &[TraceDocument],
691    reasons: &mut Vec<ComparisonReason>,
692) -> Option<Vec<(u64, u64)>> {
693    let any_pair_id = baseline.iter().chain(candidate).any(|doc| {
694        doc.run
695            .comparison_identity
696            .as_ref()
697            .and_then(|identity| identity.pair_id.as_ref())
698            .is_some()
699    });
700    if !any_pair_id {
701        return None;
702    }
703    let collect = |docs: &[TraceDocument]| -> Result<BTreeMap<String, u64>, ComparisonReason> {
704        let mut values = BTreeMap::new();
705        for doc in docs {
706            let pair = doc
707                .run
708                .comparison_identity
709                .as_ref()
710                .and_then(|identity| identity.pair_id.clone())
711                .ok_or_else(|| {
712                    reason(
713                        ComparisonReasonCode::PairingIncomplete,
714                        "pair IDs must be present on every run when pairing is requested",
715                    )
716                })?;
717            let value = doc
718                .spans
719                .iter()
720                .find(|span| span.measured && span.closed)
721                .ok_or_else(|| {
722                    reason(
723                        ComparisonReasonCode::PairingIncomplete,
724                        "paired runs require one closed measured region",
725                    )
726                })?
727                .duration_ns;
728            if values.insert(pair, value).is_some() {
729                return Err(reason(
730                    ComparisonReasonCode::PairIdsDuplicated,
731                    "pair IDs must be unique within each cohort",
732                ));
733            }
734        }
735        Ok(values)
736    };
737    let left = match collect(baseline) {
738        Ok(values) => values,
739        Err(cause) => {
740            reasons.push(cause);
741            return None;
742        }
743    };
744    let right = match collect(candidate) {
745        Ok(values) => values,
746        Err(cause) => {
747            reasons.push(cause);
748            return None;
749        }
750    };
751    if left.keys().collect::<BTreeSet<_>>() != right.keys().collect::<BTreeSet<_>>() {
752        reasons.push(reason(
753            ComparisonReasonCode::PairSetsMismatch,
754            "baseline and candidate pair-ID sets must match exactly",
755        ));
756        return None;
757    }
758    Some(
759        left.into_iter()
760            .map(|(key, value)| (value, right[&key]))
761            .collect(),
762    )
763}
764
765fn statistics(samples_ns: Vec<u64>) -> SampleStatistics {
766    let median_ns = percentile(&samples_ns, 0.5);
767    let p95_ns = percentile(&samples_ns, 0.95);
768    let deviations = samples_ns
769        .iter()
770        .map(|value| value.abs_diff(median_ns.round() as u64))
771        .collect::<Vec<_>>();
772    SampleStatistics {
773        samples_ns,
774        median_ns,
775        p95_ns,
776        mad_ns: percentile(&deviations, 0.5),
777    }
778}
779
780fn percentile(values: &[u64], quantile: f64) -> f64 {
781    if values.is_empty() {
782        return 0.0;
783    }
784    let mut values = values.to_vec();
785    values.sort_unstable();
786    let position = quantile * (values.len() - 1) as f64;
787    let lower = position.floor() as usize;
788    let upper = position.ceil() as usize;
789    let weight = position - lower as f64;
790    values[lower] as f64 * (1.0 - weight) + values[upper] as f64 * weight
791}
792
793fn bootstrap_delta_ci(
794    baseline: &[u64],
795    candidate: &[u64],
796    pairs: Option<&[(u64, u64)]>,
797) -> (f64, f64) {
798    const ITERATIONS: usize = 10_000;
799    let mut state = 0x4d595df4d0f33173u64;
800    let mut deltas = Vec::with_capacity(ITERATIONS);
801    for _ in 0..ITERATIONS {
802        if let Some(pairs) = pairs {
803            let mut sample = Vec::with_capacity(pairs.len());
804            for _ in 0..pairs.len() {
805                let index = random_index(&mut state, pairs.len());
806                sample.push(pairs[index].1 as i128 - pairs[index].0 as i128);
807            }
808            sample.sort_unstable();
809            deltas.push(median_i128(&sample));
810        } else {
811            let baseline_sample = resample(baseline, &mut state);
812            let candidate_sample = resample(candidate, &mut state);
813            deltas.push(percentile(&candidate_sample, 0.5) - percentile(&baseline_sample, 0.5));
814        }
815    }
816    deltas.sort_by(f64::total_cmp);
817    (deltas[249], deltas[9749])
818}
819
820fn resample(values: &[u64], state: &mut u64) -> Vec<u64> {
821    (0..values.len())
822        .map(|_| values[random_index(state, values.len())])
823        .collect()
824}
825
826fn random_index(state: &mut u64, length: usize) -> usize {
827    *state ^= *state << 13;
828    *state ^= *state >> 7;
829    *state ^= *state << 17;
830    (*state as usize) % length
831}
832
833fn median_i128(values: &[i128]) -> f64 {
834    if values.is_empty() {
835        0.0
836    } else if values.len().is_multiple_of(2) {
837        let upper = values.len() / 2;
838        (values[upper - 1] as f64 + values[upper] as f64) / 2.0
839    } else {
840        values[values.len() / 2] as f64
841    }
842}
843
844#[cfg(test)]
845mod tests {
846    use super::*;
847    use crate::capability::CaptureContract;
848    use crate::trace::{
849        RunOutcome, SpanKind, SpanRecord, TensorStatsEvent, TerminalEvent, TimingMode,
850        TraceRunMeta, SCHEMA as TRACE_SCHEMA,
851    };
852
853    fn run(cohort: &str, index: usize, duration_ns: u64, pair_id: Option<String>) -> TraceDocument {
854        TraceDocument {
855            schema: TRACE_SCHEMA.into(),
856            run: TraceRunMeta {
857                run_id: format!("{cohort}-{index}"),
858                correlation_id: format!("{cohort}-{index}"),
859                entrypoint: "demo::infer".into(),
860                phase: crate::ExecutionPhase::Infer,
861                timestamp: "2026-08-19T00:00:00Z".into(),
862                capture_step: 6,
863                warmup_steps: 5,
864                device: "cpu".into(),
865                measured_region_device_synchronized: false,
866                timing_mode: TimingMode::Host,
867                capture_contract: CaptureContract {
868                    measurement_scope: MeasurementScope::ProductionEquivalent,
869                    ..CaptureContract::default()
870                },
871                comparison_identity: Some(ComparisonIdentity {
872                    implementation_id: Some(cohort.into()),
873                    workload_id: "infer".into(),
874                    model_id: "m1".into(),
875                    config_id: "c1".into(),
876                    data_id: "d1".into(),
877                    seed_policy: "fixed".into(),
878                    physical_batch: 1,
879                    accumulation_steps: 1,
880                    precision: "f32".into(),
881                    device_state: "exclusive".into(),
882                    pair_id,
883                }),
884                tags: Default::default(),
885                candle_version: None,
886            },
887            spans: vec![SpanRecord {
888                id: "root".into(),
889                parent_id: None,
890                name: "infer".into(),
891                kind: SpanKind::Function,
892                measured: true,
893                start_ns: 0,
894                closed: true,
895                duration_ns,
896                step: None,
897            }],
898            ops: vec![],
899            tensors: vec![],
900            tensor_stats: vec![],
901            memory: vec![],
902            device_memory: vec![],
903            device_intervals: vec![],
904            gradients: vec![],
905            edges: vec![],
906            terminal: TerminalEvent {
907                outcome: RunOutcome::Complete,
908                timestamp_ns: duration_ns,
909                reason: None,
910            },
911        }
912    }
913
914    fn compare_test_replicates(
915        baseline: &[TraceDocument],
916        candidate: &[TraceDocument],
917    ) -> ReplicatedComparison {
918        let receipts = |documents: &[TraceDocument]| {
919            documents
920                .iter()
921                .map(|document| VerifiedBundleInput {
922                    run_id: document.run.run_id.clone(),
923                    manifest_sha256: "0".repeat(64),
924                })
925                .collect()
926        };
927        compare_documents(
928            baseline,
929            candidate,
930            ComparisonInputs {
931                verification: ComparisonInputVerification::VerifiedBundles,
932                baseline: receipts(baseline),
933                candidate: receipts(candidate),
934            },
935        )
936    }
937
938    #[test]
939    fn statistics_expose_raw_median_p95_and_mad() {
940        let stats = statistics(vec![10, 11, 12, 13, 100]);
941        assert_eq!(stats.samples_ns, vec![10, 11, 12, 13, 100]);
942        assert_eq!(stats.median_ns, 12.0);
943        assert_eq!(stats.p95_ns, 82.6);
944        assert_eq!(stats.mad_ns, 1.0);
945    }
946
947    #[test]
948    fn tensor_stats_average_every_event_and_expose_sample_and_run_coverage() {
949        let stats = |label: &str, rms: f64, abs_max: f64| TensorStatsEvent {
950            span_id: "s1".into(),
951            label: label.into(),
952            shape: vec![1],
953            dtype: "f32".into(),
954            elements: 1,
955            non_finite: if rms == 100.0 { 1 } else { 0 },
956            rms,
957            abs_max,
958            mean: rms,
959        };
960        let mut baseline = run("base", 0, 100, None);
961        baseline.tensor_stats = vec![
962            stats("stable", 2.0, 4.0),
963            stats("drift", 1.0, 2.0),
964            stats("drift", 100.0, 200.0),
965            stats("only_a", 3.0, 3.0),
966        ];
967        let mut candidate = run("next", 0, 90, None);
968        candidate.tensor_stats = vec![
969            stats("stable", 2.2, 4.4),
970            stats("drift", 4.0, 8.0),
971            stats("only_b", 5.0, 5.0),
972        ];
973
974        let comparison = compare_unverified_traces(&[baseline], &[candidate]);
975        let drift = &comparison.tensor_stats.matched[0];
976        assert_eq!(drift.label, "drift");
977        // Duplicate baseline events are averaged, not discarded after the first occurrence.
978        assert_eq!(drift.rms_a, 50.5);
979        assert_eq!(drift.rms_ratio, Some(4.0 / 50.5));
980        // A non-finite count in a duplicate event is retained.
981        assert_eq!(drift.non_finite_a, 1);
982        assert_eq!(drift.samples_a, 2);
983        assert_eq!(drift.samples_b, 1);
984        assert_eq!(drift.runs_a, 1);
985        assert_eq!(drift.runs_b, 1);
986        assert_eq!(comparison.tensor_stats.baseline_runs, 1);
987        assert_eq!(comparison.tensor_stats.candidate_runs, 1);
988        assert_eq!(comparison.tensor_stats.unmatched_a, vec!["only_a"]);
989        assert_eq!(comparison.tensor_stats.unmatched_b, vec!["only_b"]);
990    }
991
992    #[test]
993    fn out_of_domain_provenance_fails_closed() {
994        let baseline = (0..5)
995            .map(|i| run("base", i, 100 + i as u64, None))
996            .collect::<Vec<_>>();
997        let candidate = (0..5)
998            .map(|i| {
999                let mut document = run("next", i, 90 + i as u64, None);
1000                document.run.capture_step = 0;
1001                document.run.warmup_steps = 0;
1002                document
1003            })
1004            .collect::<Vec<_>>();
1005        let result = compare_test_replicates(&baseline, &candidate);
1006        assert!(!result.comparable);
1007        assert_eq!(result.verdict, ComparisonVerdict::Ineligible);
1008        assert!(result.reasons.iter().any(|reason| {
1009            reason.code == ComparisonReasonCode::IncompleteCapture
1010                && reason
1011                    .message
1012                    .contains("not a complete, structurally valid capture")
1013        }));
1014
1015        let mut zero_batch = baseline.clone();
1016        for document in &mut zero_batch {
1017            document
1018                .run
1019                .comparison_identity
1020                .as_mut()
1021                .unwrap()
1022                .physical_batch = 0;
1023        }
1024        let result = compare_test_replicates(&zero_batch, &zero_batch.clone());
1025        assert!(!result.comparable);
1026        assert_eq!(result.verdict, ComparisonVerdict::Ineligible);
1027    }
1028
1029    #[test]
1030    fn confirms_only_when_replicated_interval_excludes_zero() {
1031        let baseline = [100, 102, 99, 101, 103]
1032            .into_iter()
1033            .enumerate()
1034            .map(|(i, value)| run("base", i, value, None))
1035            .collect::<Vec<_>>();
1036        let candidate = [75, 80, 78, 79, 77]
1037            .into_iter()
1038            .enumerate()
1039            .map(|(i, value)| run("next", i, value, None))
1040            .collect::<Vec<_>>();
1041        let result = compare_test_replicates(&baseline, &candidate);
1042        assert!(result.comparable);
1043        assert_eq!(result.baseline_implementation_id.as_deref(), Some("base"));
1044        assert_eq!(result.candidate_implementation_id.as_deref(), Some("next"));
1045        assert_eq!(
1046            result
1047                .identity
1048                .as_ref()
1049                .and_then(|identity| identity.implementation_id.as_deref()),
1050            None
1051        );
1052        assert_eq!(result.verdict, ComparisonVerdict::CandidateFaster);
1053        assert!(result.confidence_interval.unwrap().upper_delta_ns < 0.0);
1054    }
1055
1056    #[test]
1057    fn fewer_than_five_or_duplicate_runs_fail_closed() {
1058        let baseline = (0..4)
1059            .map(|i| run("base", i, 100, None))
1060            .collect::<Vec<_>>();
1061        let mut candidate = (0..5).map(|i| run("next", i, 90, None)).collect::<Vec<_>>();
1062        candidate[4].run.run_id = candidate[3].run.run_id.clone();
1063        let result = compare_test_replicates(&baseline, &candidate);
1064        assert!(!result.comparable);
1065        assert_eq!(result.verdict, ComparisonVerdict::Ineligible);
1066        assert!(result.reasons.iter().any(|reason| {
1067            reason.code == ComparisonReasonCode::InsufficientRuns
1068                && reason.message.contains("at least 5")
1069        }));
1070        assert!(result.reasons.iter().any(|reason| {
1071            reason.code == ComparisonReasonCode::DuplicateRunIds
1072                && reason.message.contains("unique")
1073        }));
1074    }
1075
1076    #[test]
1077    fn partial_pair_metadata_is_ineligible_instead_of_falling_back() {
1078        let baseline = (0..5)
1079            .map(|i| run("base", i, 100 + i as u64, Some(format!("pair-{i}"))))
1080            .collect::<Vec<_>>();
1081        let mut candidate = (0..5)
1082            .map(|i| run("next", i, 90 + i as u64, Some(format!("pair-{i}"))))
1083            .collect::<Vec<_>>();
1084        candidate[4]
1085            .run
1086            .comparison_identity
1087            .as_mut()
1088            .unwrap()
1089            .pair_id = None;
1090        let result = compare_test_replicates(&baseline, &candidate);
1091        assert!(!result.comparable);
1092        assert!(!result.paired);
1093        assert!(result.reasons.iter().any(|reason| {
1094            reason.code == ComparisonReasonCode::PairingIncomplete
1095                && reason.message.contains("every run")
1096        }));
1097    }
1098
1099    #[test]
1100    fn paired_even_median_averages_the_middle_deltas() {
1101        assert_eq!(median_i128(&[-5, -1, 3, 9]), 1.0);
1102    }
1103
1104    #[test]
1105    fn missing_empty_or_inconsistent_implementation_ids_fail_closed() {
1106        let baseline = (0..5)
1107            .map(|i| run("base", i, 100 + i as u64, None))
1108            .collect::<Vec<_>>();
1109        let candidate = (0..5)
1110            .map(|i| run("next", i, 90 + i as u64, None))
1111            .collect::<Vec<_>>();
1112
1113        for (implementation_id, expected_code, expected_reason) in [
1114            (
1115                None,
1116                ComparisonReasonCode::ImplementationIdMissing,
1117                "missing",
1118            ),
1119            (
1120                Some("   ".to_string()),
1121                ComparisonReasonCode::ImplementationIdEmpty,
1122                "must not be empty",
1123            ),
1124        ] {
1125            let mut invalid = baseline.clone();
1126            invalid[0]
1127                .run
1128                .comparison_identity
1129                .as_mut()
1130                .unwrap()
1131                .implementation_id = implementation_id;
1132            let result = compare_test_replicates(&invalid, &candidate);
1133            assert!(!result.comparable);
1134            assert_eq!(result.verdict, ComparisonVerdict::Ineligible);
1135            assert!(result.reasons.iter().any(|reason| {
1136                reason.code == expected_code && reason.message.contains(expected_reason)
1137            }));
1138        }
1139
1140        let mut inconsistent = candidate.clone();
1141        inconsistent[4]
1142            .run
1143            .comparison_identity
1144            .as_mut()
1145            .unwrap()
1146            .implementation_id = Some("another-build".into());
1147        let result = compare_test_replicates(&baseline, &inconsistent);
1148        assert!(!result.comparable);
1149        assert!(result.reasons.iter().any(|reason| {
1150            reason.code == ComparisonReasonCode::ImplementationIdInconsistent
1151                && reason.message.contains("differs within")
1152        }));
1153    }
1154}