Skip to main content

micromeasure/
comparison.rs

1// Copyright 2026 Ryan Daum
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Side-effect-free comparison of persisted benchmark evidence.
16
17use crate::{
18    BenchmarkKind, BenchmarkReport, BenchmarkResult, EnergyScope, MeasurementDomain,
19    MemoryBandwidthScope, MetricFormat, PmuCounterProfile, PmuScope, REPORT_SCHEMA_VERSION,
20    SERIES_DOCUMENT_TYPE, SERIES_SCHEMA_VERSION, SeriesReport, SeriesResult, Throughput, Validity,
21    ValidityStatus,
22};
23use serde::{Deserialize, Serialize};
24use sha2::{Digest, Sha256};
25use std::{
26    collections::BTreeMap,
27    error::Error,
28    fmt, fs, io,
29    path::{Path, PathBuf},
30};
31
32/// JSON schema emitted for structured comparison reports.
33pub const COMPARISON_SCHEMA_VERSION: u32 = 1;
34
35/// Options controlling report and report-set comparison.
36///
37/// The default remains conservative: reports must contain exactly the same
38/// result set. CI callers which intentionally selected a baseline may opt into
39/// partial matching with [`ComparisonOptions::allow_partial_result_set`].
40#[derive(Clone, Debug, Default, Eq, PartialEq)]
41#[non_exhaustive]
42pub struct ComparisonOptions {
43    pub allow_partial_result_set: bool,
44    /// Require directory inputs to contain exactly the same suite names.
45    ///
46    /// This option is interpreted by [`crate::compare_report_inputs`].
47    pub require_same_suite_set: bool,
48    pub environment_override: Option<EnvironmentOverride>,
49}
50
51impl ComparisonOptions {
52    pub fn allow_partial_result_set(mut self, allow: bool) -> Self {
53        self.allow_partial_result_set = allow;
54        self
55    }
56
57    pub fn require_same_suite_set(mut self, require: bool) -> Self {
58        self.require_same_suite_set = require;
59        self
60    }
61
62    /// Permit an explicitly justified runner or environment mismatch.
63    ///
64    /// The reason and both original environments are retained in the
65    /// resulting [`ComparisonReport`].
66    pub fn with_environment_override(mut self, reason: impl Into<String>) -> Self {
67        self.environment_override = Some(EnvironmentOverride {
68            reason: reason.into(),
69        });
70        self
71    }
72}
73
74/// Operator-supplied justification for comparing non-identical environments.
75#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
76#[non_exhaustive]
77pub struct EnvironmentOverride {
78    pub reason: String,
79}
80
81/// Complete environment relationship recorded in a comparison artifact.
82#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
83#[non_exhaustive]
84pub struct EnvironmentComparison {
85    pub current_runner_id: String,
86    pub baseline_runner_id: String,
87    pub current_environment: BTreeMap<String, String>,
88    pub baseline_environment: BTreeMap<String, String>,
89    pub exact_match: bool,
90    #[serde(default, skip_serializing_if = "Option::is_none")]
91    pub operator_override: Option<EnvironmentOverride>,
92}
93
94/// The semantic kind of a comparison's primary measurement.
95#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
96#[serde(rename_all = "snake_case")]
97#[non_exhaustive]
98pub enum MeasurementKind {
99    Latency,
100    Throughput,
101    Memory,
102    Occupancy,
103    Custom,
104}
105
106/// Whether larger or smaller primary values represent an improvement.
107#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
108#[serde(rename_all = "snake_case")]
109#[non_exhaustive]
110pub enum MeasurementDirection {
111    Lower,
112    Higher,
113    Informational,
114}
115
116/// Which side of a comparison supplied a value or caused an error.
117#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
118#[serde(rename_all = "snake_case")]
119pub enum ComparisonSide {
120    Current,
121    Baseline,
122}
123
124impl fmt::Display for ComparisonSide {
125    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
126        match self {
127            Self::Current => formatter.write_str("current"),
128            Self::Baseline => formatter.write_str("baseline"),
129        }
130    }
131}
132
133/// Stable semantic identity for one native benchmark case.
134#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
135#[non_exhaustive]
136pub struct BenchmarkCaseIdentity {
137    pub group: String,
138    pub name: String,
139    pub kind: BenchmarkKind,
140    pub throughput: Throughput,
141    pub measurement_domain: MeasurementDomain,
142    /// PMU thread scope. This prevents calling-thread and process-thread
143    /// evidence from being treated as the same benchmark case.
144    #[serde(default)]
145    pub pmu_scope: PmuScope,
146    /// CPU event profile. Full and compact counter evidence is collected under
147    /// different multiplexing conditions and is not comparison-compatible.
148    #[serde(default)]
149    pub pmu_counter_profile: PmuCounterProfile,
150    /// System energy scope. Package-wide RAPL evidence is not equivalent to a
151    /// run that did not collect system energy.
152    #[serde(default)]
153    pub energy_scope: EnergyScope,
154    /// System-wide IMC coverage. Missing, partial, and complete memory
155    /// bandwidth evidence are intentionally different identities.
156    #[serde(default)]
157    pub memory_bandwidth_scope: MemoryBandwidthScope,
158    pub metadata: BTreeMap<String, String>,
159}
160
161impl BenchmarkCaseIdentity {
162    fn from_result(result: &BenchmarkResult) -> Self {
163        Self {
164            group: result.group.clone(),
165            name: result.name.clone(),
166            kind: result.kind,
167            throughput: result.stats.throughput.clone(),
168            measurement_domain: result.stats.measurement_domain,
169            pmu_scope: result.stats.pmu_scope,
170            pmu_counter_profile: PmuCounterProfile::from_measurement_label(
171                &result.stats.measurement_label,
172            ),
173            energy_scope: result.stats.energy_scope,
174            memory_bandwidth_scope: result.stats.memory_bandwidth_scope,
175            metadata: result.metadata.clone(),
176        }
177    }
178}
179
180/// Stable semantic identity for one external series case.
181#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
182#[non_exhaustive]
183pub struct SeriesCaseIdentity {
184    pub group: String,
185    pub name: String,
186    pub measurement: MeasurementKind,
187    pub unit: String,
188    pub direction: MeasurementDirection,
189    pub dimensions: BTreeMap<String, String>,
190}
191
192impl SeriesCaseIdentity {
193    fn from_result(result: &SeriesResult) -> Self {
194        Self {
195            group: result.group.clone(),
196            name: result.name.clone(),
197            measurement: result.measurement,
198            unit: result.unit.clone(),
199            direction: result.direction,
200            dimensions: result.dimensions.clone(),
201        }
202    }
203}
204
205/// Semantic identity for a native benchmark or external series case.
206///
207/// The untagged representation preserves the original native comparison JSON
208/// shape while allowing series identities to carry their declared
209/// measurement semantics and dimensions.
210#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
211#[serde(untagged)]
212#[non_exhaustive]
213pub enum ComparisonCaseIdentity {
214    Native(BenchmarkCaseIdentity),
215    Series(SeriesCaseIdentity),
216}
217
218impl ComparisonCaseIdentity {
219    pub fn group(&self) -> &str {
220        match self {
221            Self::Native(identity) => &identity.group,
222            Self::Series(identity) => &identity.group,
223        }
224    }
225
226    pub fn name(&self) -> &str {
227        match self {
228            Self::Native(identity) => &identity.name,
229            Self::Series(identity) => &identity.name,
230        }
231    }
232}
233
234/// A direction-aware primary value used for comparison.
235#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
236#[non_exhaustive]
237pub struct PrimaryMeasurement {
238    pub measurement: MeasurementKind,
239    pub unit: String,
240    pub direction: MeasurementDirection,
241    /// Chronological observations retained from the evidence document.
242    #[serde(default, skip_serializing_if = "Vec::is_empty")]
243    pub samples: Vec<f64>,
244    /// Median primary value, or `None` when the report did not contain a
245    /// finite comparable value or the result is invalid.
246    pub value: Option<f64>,
247}
248
249/// Stability evidence retained for one side of a matched or unmatched case.
250#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
251#[non_exhaustive]
252pub struct ComparisonStatistics {
253    pub cv_percent: Option<f64>,
254    /// Median absolute deviation in the primary measurement's unit.
255    pub mad: Option<f64>,
256    /// 95th percentile in the primary measurement's unit.
257    #[serde(default)]
258    pub p95: Option<f64>,
259    pub samples: usize,
260    pub outliers: usize,
261}
262
263/// Native-only measurements kept alongside the normalized primary value.
264#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
265#[non_exhaustive]
266pub struct NativeMeasurementProjection {
267    pub mean_throughput_per_sec: Option<f64>,
268    pub median_throughput_per_sec: Option<f64>,
269    pub median_ns_per_op: Option<f64>,
270    pub p95_ns_per_op: Option<f64>,
271    pub mad_ns_per_op: Option<f64>,
272}
273
274/// Measurement evidence for one side of a comparison.
275#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
276#[non_exhaustive]
277pub struct ComparisonCaseSnapshot {
278    pub primary: PrimaryMeasurement,
279    pub statistics: ComparisonStatistics,
280    pub native: NativeMeasurementProjection,
281    #[serde(default)]
282    pub validity: Validity,
283    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
284    pub provenance: BTreeMap<String, String>,
285}
286
287/// A custom metric present in both versions of a matched benchmark.
288#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
289#[non_exhaustive]
290pub struct MetricComparison {
291    pub name: String,
292    pub unit: String,
293    #[serde(default, skip_serializing_if = "String::is_empty")]
294    pub section: String,
295    #[serde(default, skip_serializing_if = "String::is_empty")]
296    pub display_name: String,
297    pub format: MetricFormat,
298    pub current_median: Option<f64>,
299    pub baseline_median: Option<f64>,
300    pub absolute_change: Option<f64>,
301    /// Raw percentage change. Custom native metrics do not yet declare a
302    /// preferred direction, so this is not labelled as an improvement.
303    pub percent_change: Option<f64>,
304}
305
306/// Structured comparison for a benchmark found on both sides.
307#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
308#[non_exhaustive]
309pub struct MatchedBenchmark {
310    pub identity: ComparisonCaseIdentity,
311    pub current: ComparisonCaseSnapshot,
312    pub baseline: ComparisonCaseSnapshot,
313    /// Current minus baseline in the primary measurement's native unit.
314    pub absolute_change: Option<f64>,
315    /// Signed improvement: positive is better for both higher- and
316    /// lower-is-better measurements.
317    pub percent_improvement: Option<f64>,
318    #[serde(default, skip_serializing_if = "Vec::is_empty")]
319    pub stability_warnings: Vec<String>,
320    #[serde(default, skip_serializing_if = "Vec::is_empty")]
321    pub metric_changes: Vec<MetricComparison>,
322}
323
324/// A benchmark present on only one side of a partial comparison.
325#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
326#[non_exhaustive]
327pub struct UnmatchedBenchmark {
328    pub identity: ComparisonCaseIdentity,
329    pub measurement: ComparisonCaseSnapshot,
330}
331
332/// Counts summarizing a structured comparison.
333#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
334#[non_exhaustive]
335pub struct ComparisonSummary {
336    pub matched: usize,
337    pub added: usize,
338    pub removed: usize,
339}
340
341/// Kind of evidence document referenced by a comparison.
342#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
343#[serde(rename_all = "snake_case")]
344#[non_exhaustive]
345pub enum ReportDocumentType {
346    NativeBenchmark,
347    Series,
348}
349
350/// Durable reference to one evidence document.
351#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
352#[non_exhaustive]
353pub struct ReportReference {
354    pub document_type: ReportDocumentType,
355    pub schema_version: u32,
356    pub suite: Option<String>,
357    /// SHA-256 over the exact loaded bytes, or over the normal pretty JSON
358    /// serialization for an in-memory report.
359    pub content_digest: String,
360    pub capture_time: String,
361    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
362    pub source_provenance: BTreeMap<String, String>,
363    /// Invocation-local hint only. It does not participate in identity.
364    #[serde(default, skip_serializing_if = "Option::is_none")]
365    pub display_path: Option<String>,
366}
367
368impl ReportReference {
369    fn native(report: &BenchmarkReport, bytes: &[u8], display_path: Option<&Path>) -> Self {
370        let mut source_provenance = report.context.provenance.clone();
371        if let Some(commit) = report.git_commit.as_deref()
372            && !source_provenance.contains_key("commit")
373        {
374            source_provenance.insert("commit".to_string(), commit.to_string());
375        }
376
377        Self {
378            document_type: ReportDocumentType::NativeBenchmark,
379            schema_version: report.schema_version,
380            suite: report.suite.clone(),
381            content_digest: sha256_digest(bytes),
382            capture_time: report.timestamp.clone(),
383            source_provenance,
384            display_path: display_path.map(|path| path.display().to_string()),
385        }
386    }
387
388    fn series(report: &SeriesReport, bytes: &[u8], display_path: Option<&Path>) -> Self {
389        Self {
390            document_type: ReportDocumentType::Series,
391            schema_version: report.schema_version,
392            suite: Some(report.suite.clone()),
393            content_digest: sha256_digest(bytes),
394            capture_time: report.timestamp.clone(),
395            source_provenance: report.context.provenance.clone(),
396            display_path: display_path.map(|path| path.display().to_string()),
397        }
398    }
399}
400
401/// Loaded evidence plus the reference derived from its original bytes.
402#[derive(Clone, Debug)]
403#[non_exhaustive]
404pub enum ReportDocument {
405    Native {
406        report: BenchmarkReport,
407        reference: ReportReference,
408    },
409    Series {
410        report: SeriesReport,
411        reference: ReportReference,
412    },
413}
414
415impl ReportDocument {
416    /// Load a native benchmark or external series report and retain a digest
417    /// of the exact bytes.
418    pub fn load_from_path(path: impl AsRef<Path>) -> Result<Self, ReportError> {
419        let path = path.as_ref();
420        let bytes = fs::read(path).map_err(|source| ReportError::Io {
421            path: path.to_path_buf(),
422            source,
423        })?;
424        parse_report_document(&bytes, Some(path))
425    }
426
427    /// Wrap an in-memory report using its normal persisted representation.
428    pub fn from_native(report: BenchmarkReport) -> Result<Self, ReportError> {
429        validate_schema(report.schema_version, None)?;
430        let bytes = serde_json::to_vec_pretty(&report)
431            .map_err(|source| ReportError::MalformedReport { path: None, source })?;
432        let reference = ReportReference::native(&report, &bytes, None);
433        Ok(Self::Native { report, reference })
434    }
435
436    /// Wrap an in-memory series report using its normal persisted
437    /// representation.
438    pub fn from_series(report: SeriesReport) -> Result<Self, ReportError> {
439        validate_series_report(&report, None)?;
440        let bytes = serde_json::to_vec_pretty(&report)
441            .map_err(|source| ReportError::MalformedReport { path: None, source })?;
442        let reference = ReportReference::series(&report, &bytes, None);
443        Ok(Self::Series { report, reference })
444    }
445
446    pub fn as_native(&self) -> Option<&BenchmarkReport> {
447        match self {
448            Self::Native { report, .. } => Some(report),
449            Self::Series { .. } => None,
450        }
451    }
452
453    pub fn as_series(&self) -> Option<&SeriesReport> {
454        match self {
455            Self::Native { .. } => None,
456            Self::Series { report, .. } => Some(report),
457        }
458    }
459
460    pub fn reference(&self) -> &ReportReference {
461        match self {
462            Self::Native { reference, .. } | Self::Series { reference, .. } => reference,
463        }
464    }
465
466    /// Validate that this document is usable as one side of a structured
467    /// comparison.
468    ///
469    /// Loading already checks JSON shape and schema version. This additional
470    /// pass checks comparison-level invariants such as suite and runner
471    /// identity, report validity, context semantics, and duplicate case
472    /// identities.
473    pub fn validate_for_comparison(&self) -> Result<(), ComparisonError> {
474        let normalized = normalize_document(self, ComparisonSide::Current)?;
475        validate_report_validity(&normalized.validity, ComparisonSide::Current)?;
476        validate_runner(&normalized.runner_id, ComparisonSide::Current)?;
477        validate_nonempty_result_set(&normalized.cases, ComparisonSide::Current)?;
478        reject_duplicate_identities(&normalized.cases, ComparisonSide::Current)
479    }
480}
481
482/// Serializable, policy-free relationship between two benchmark reports.
483#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
484#[non_exhaustive]
485pub struct ComparisonReport {
486    pub schema_version: u32,
487    pub current: ReportReference,
488    pub baseline: ReportReference,
489    pub suite: String,
490    #[serde(default)]
491    pub environment: EnvironmentComparison,
492    pub matched: Vec<MatchedBenchmark>,
493    pub added: Vec<UnmatchedBenchmark>,
494    pub removed: Vec<UnmatchedBenchmark>,
495    pub summary: ComparisonSummary,
496}
497
498/// Failure to load or interpret a persisted report.
499#[derive(Debug)]
500#[non_exhaustive]
501pub enum ReportError {
502    Io {
503        path: PathBuf,
504        source: io::Error,
505    },
506    MalformedJson {
507        path: Option<PathBuf>,
508        source: serde_json::Error,
509    },
510    MalformedReport {
511        path: Option<PathBuf>,
512        source: serde_json::Error,
513    },
514    InvalidSchemaVersion {
515        path: Option<PathBuf>,
516    },
517    UnsupportedSchema {
518        path: Option<PathBuf>,
519        found: u32,
520        supported: u32,
521    },
522    UnsupportedDocumentType {
523        path: Option<PathBuf>,
524        found: String,
525    },
526    InvalidReport {
527        path: Option<PathBuf>,
528        reason: String,
529    },
530}
531
532impl fmt::Display for ReportError {
533    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
534        match self {
535            Self::Io { path, source } => {
536                write!(
537                    formatter,
538                    "failed to read report {}: {source}",
539                    path.display()
540                )
541            }
542            Self::MalformedJson { path, source } => {
543                write!(formatter, "malformed JSON{}: {source}", path_suffix(path))
544            }
545            Self::MalformedReport { path, source } => {
546                write!(
547                    formatter,
548                    "malformed benchmark report{}: {source}",
549                    path_suffix(path)
550                )
551            }
552            Self::InvalidSchemaVersion { path } => {
553                write!(
554                    formatter,
555                    "benchmark report{} has an invalid schema_version",
556                    path_suffix(path)
557                )
558            }
559            Self::UnsupportedSchema {
560                path,
561                found,
562                supported,
563            } => write!(
564                formatter,
565                "report{} uses schema version {found}, but this version supports {supported}",
566                path_suffix(path)
567            ),
568            Self::UnsupportedDocumentType { path, found } => write!(
569                formatter,
570                "unsupported report document_type {found:?}{}",
571                path_suffix(path)
572            ),
573            Self::InvalidReport { path, reason } => {
574                write!(formatter, "invalid report{}: {reason}", path_suffix(path))
575            }
576        }
577    }
578}
579
580impl Error for ReportError {
581    fn source(&self) -> Option<&(dyn Error + 'static)> {
582        match self {
583            Self::Io { source, .. } => Some(source),
584            Self::MalformedJson { source, .. } | Self::MalformedReport { source, .. } => {
585                Some(source)
586            }
587            Self::InvalidSchemaVersion { .. }
588            | Self::UnsupportedSchema { .. }
589            | Self::UnsupportedDocumentType { .. }
590            | Self::InvalidReport { .. } => None,
591        }
592    }
593}
594
595/// Semantic failure while comparing two otherwise readable reports.
596#[derive(Clone, Debug, Eq, PartialEq)]
597#[non_exhaustive]
598pub enum ComparisonError {
599    UnsupportedSchema {
600        side: ComparisonSide,
601        found: u32,
602        supported: u32,
603    },
604    MissingSuite {
605        side: ComparisonSide,
606    },
607    SuiteMismatch {
608        current: String,
609        baseline: String,
610    },
611    UnknownRunner {
612        side: ComparisonSide,
613    },
614    RunnerMismatch {
615        current: String,
616        baseline: String,
617    },
618    EnvironmentMismatch {
619        current: BTreeMap<String, String>,
620        baseline: BTreeMap<String, String>,
621    },
622    InvalidEnvironmentOverride,
623    DuplicateIdentity {
624        side: ComparisonSide,
625        identity: ComparisonCaseIdentity,
626    },
627    EmptyResultSet {
628        side: ComparisonSide,
629    },
630    InvalidReport {
631        side: ComparisonSide,
632        reason: String,
633    },
634    ResultSetMismatch {
635        added: usize,
636        removed: usize,
637    },
638    ReferenceSerialization {
639        side: ComparisonSide,
640        message: String,
641    },
642}
643
644impl fmt::Display for ComparisonError {
645    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
646        match self {
647            Self::UnsupportedSchema {
648                side,
649                found,
650                supported,
651            } => write!(
652                formatter,
653                "{side} report uses schema version {found}, but this version supports {supported}"
654            ),
655            Self::MissingSuite { side } => write!(formatter, "{side} report has no suite identity"),
656            Self::SuiteMismatch { current, baseline } => write!(
657                formatter,
658                "suite mismatch: current report is {current:?}, baseline report is {baseline:?}"
659            ),
660            Self::UnknownRunner { side } => {
661                write!(formatter, "{side} report has no known runner identity")
662            }
663            Self::RunnerMismatch { current, baseline } => write!(
664                formatter,
665                "runner mismatch: current report is from {current:?}, baseline report is from {baseline:?}"
666            ),
667            Self::EnvironmentMismatch { current, baseline } => write!(
668                formatter,
669                "comparison environment mismatch: current is {current:?}, baseline is {baseline:?}"
670            ),
671            Self::InvalidEnvironmentOverride => {
672                formatter.write_str("environment override reason must not be empty")
673            }
674            Self::DuplicateIdentity { side, identity } => write!(
675                formatter,
676                "{side} report contains duplicate benchmark identity {}/{}",
677                identity.group(),
678                identity.name()
679            ),
680            Self::EmptyResultSet { side } => {
681                write!(formatter, "{side} report contains no benchmark results")
682            }
683            Self::InvalidReport { side, reason } => {
684                write!(formatter, "{side} report is invalid: {reason}")
685            }
686            Self::ResultSetMismatch { added, removed } => write!(
687                formatter,
688                "result sets differ: {added} current-only and {removed} baseline-only benchmarks"
689            ),
690            Self::ReferenceSerialization { side, message } => {
691                write!(formatter, "failed to identify {side} report: {message}")
692            }
693        }
694    }
695}
696
697impl Error for ComparisonError {}
698
699#[derive(Clone, Debug)]
700struct NormalizedReport {
701    suite: String,
702    runner_id: String,
703    environment: BTreeMap<String, String>,
704    validity: Validity,
705    cases: Vec<NormalizedCase>,
706}
707
708#[derive(Clone, Debug)]
709struct NormalizedCase {
710    identity: ComparisonCaseIdentity,
711    primary: PrimaryMeasurement,
712    statistics: ComparisonStatistics,
713    native: NativeMeasurementProjection,
714    validity: Validity,
715    provenance: BTreeMap<String, String>,
716    metrics: Vec<NormalizedMetric>,
717}
718
719#[derive(Clone, Debug)]
720struct NormalizedMetric {
721    name: String,
722    unit: String,
723    section: String,
724    display_name: String,
725    format: MetricFormat,
726    median: Option<f64>,
727}
728
729impl BenchmarkReport {
730    /// Load a native benchmark report with precise schema and parse errors.
731    pub fn load_from_path(path: impl AsRef<Path>) -> Result<Self, ReportError> {
732        let path = path.as_ref();
733        let bytes = fs::read(path).map_err(|source| ReportError::Io {
734            path: path.to_path_buf(),
735            source,
736        })?;
737        parse_native_report(&bytes, Some(path))
738    }
739
740    /// Compare two in-memory native reports without terminal or filesystem
741    /// side effects.
742    pub fn compare(
743        &self,
744        baseline: &BenchmarkReport,
745        options: &ComparisonOptions,
746    ) -> Result<ComparisonReport, ComparisonError> {
747        let current = in_memory_native_document(self, ComparisonSide::Current)?;
748        let baseline = in_memory_native_document(baseline, ComparisonSide::Baseline)?;
749        compare_reports(&current, &baseline, options)
750    }
751}
752
753impl SeriesReport {
754    /// Load an external series report with structural and semantic validation.
755    pub fn load_from_path(path: impl AsRef<Path>) -> Result<Self, ReportError> {
756        let path = path.as_ref();
757        let bytes = fs::read(path).map_err(|source| ReportError::Io {
758            path: path.to_path_buf(),
759            source,
760        })?;
761        parse_series_report(&bytes, Some(path))
762    }
763
764    /// Compare two in-memory external series reports.
765    pub fn compare(
766        &self,
767        baseline: &SeriesReport,
768        options: &ComparisonOptions,
769    ) -> Result<ComparisonReport, ComparisonError> {
770        let current = in_memory_series_document(self, ComparisonSide::Current)?;
771        let baseline = in_memory_series_document(baseline, ComparisonSide::Baseline)?;
772        compare_reports(&current, &baseline, options)
773    }
774}
775
776/// Compare loaded evidence documents without terminal or filesystem effects.
777pub fn compare_reports(
778    current: &ReportDocument,
779    baseline: &ReportDocument,
780    options: &ComparisonOptions,
781) -> Result<ComparisonReport, ComparisonError> {
782    let current_report = normalize_document(current, ComparisonSide::Current)?;
783    let baseline_report = normalize_document(baseline, ComparisonSide::Baseline)?;
784    compare_normalized_reports(
785        current_report,
786        current.reference().clone(),
787        baseline_report,
788        baseline.reference().clone(),
789        options,
790    )
791}
792
793fn compare_normalized_reports(
794    current: NormalizedReport,
795    current_reference: ReportReference,
796    baseline: NormalizedReport,
797    baseline_reference: ReportReference,
798    options: &ComparisonOptions,
799) -> Result<ComparisonReport, ComparisonError> {
800    validate_report_validity(&current.validity, ComparisonSide::Current)?;
801    validate_report_validity(&baseline.validity, ComparisonSide::Baseline)?;
802    validate_nonempty_result_set(&current.cases, ComparisonSide::Current)?;
803    validate_nonempty_result_set(&baseline.cases, ComparisonSide::Baseline)?;
804
805    if current.suite != baseline.suite {
806        return Err(ComparisonError::SuiteMismatch {
807            current: current.suite,
808            baseline: baseline.suite,
809        });
810    }
811
812    validate_runner(&current.runner_id, ComparisonSide::Current)?;
813    validate_runner(&baseline.runner_id, ComparisonSide::Baseline)?;
814
815    if matches!(
816        options.environment_override.as_ref(),
817        Some(environment_override) if environment_override.reason.trim().is_empty()
818    ) {
819        return Err(ComparisonError::InvalidEnvironmentOverride);
820    }
821
822    let runner_matches = current.runner_id == baseline.runner_id;
823    let environment_matches = current.environment == baseline.environment;
824    if !runner_matches && options.environment_override.is_none() {
825        return Err(ComparisonError::RunnerMismatch {
826            current: current.runner_id,
827            baseline: baseline.runner_id,
828        });
829    }
830    if !environment_matches && options.environment_override.is_none() {
831        return Err(ComparisonError::EnvironmentMismatch {
832            current: current.environment,
833            baseline: baseline.environment,
834        });
835    }
836    let environment = EnvironmentComparison {
837        current_runner_id: current.runner_id.clone(),
838        baseline_runner_id: baseline.runner_id.clone(),
839        current_environment: current.environment.clone(),
840        baseline_environment: baseline.environment.clone(),
841        exact_match: runner_matches && environment_matches,
842        operator_override: options.environment_override.clone(),
843    };
844
845    reject_duplicate_identities(&current.cases, ComparisonSide::Current)?;
846    reject_duplicate_identities(&baseline.cases, ComparisonSide::Baseline)?;
847
848    let pairs = pair_normalized_cases(&current.cases, &baseline.cases);
849    let mut matched_current = vec![false; current.cases.len()];
850    let mut matched_baseline = vec![false; baseline.cases.len()];
851    let mut matched = Vec::with_capacity(pairs.len());
852
853    for (current_index, baseline_index) in pairs {
854        matched_current[current_index] = true;
855        matched_baseline[baseline_index] = true;
856        matched.push(matched_benchmark(
857            &current.cases[current_index],
858            &baseline.cases[baseline_index],
859        ));
860    }
861
862    let added: Vec<_> = current
863        .cases
864        .iter()
865        .enumerate()
866        .filter(|(index, _)| !matched_current[*index])
867        .map(|(_, result)| unmatched_benchmark(result))
868        .collect();
869    let removed: Vec<_> = baseline
870        .cases
871        .iter()
872        .enumerate()
873        .filter(|(index, _)| !matched_baseline[*index])
874        .map(|(_, result)| unmatched_benchmark(result))
875        .collect();
876
877    if !options.allow_partial_result_set && (!added.is_empty() || !removed.is_empty()) {
878        return Err(ComparisonError::ResultSetMismatch {
879            added: added.len(),
880            removed: removed.len(),
881        });
882    }
883
884    let summary = ComparisonSummary {
885        matched: matched.len(),
886        added: added.len(),
887        removed: removed.len(),
888    };
889
890    Ok(ComparisonReport {
891        schema_version: COMPARISON_SCHEMA_VERSION,
892        current: current_reference,
893        baseline: baseline_reference,
894        suite: current.suite,
895        environment,
896        matched,
897        added,
898        removed,
899        summary,
900    })
901}
902
903fn validate_report_validity(
904    validity: &Validity,
905    side: ComparisonSide,
906) -> Result<(), ComparisonError> {
907    if validity.status == ValidityStatus::Invalid {
908        return Err(ComparisonError::InvalidReport {
909            side,
910            reason: validity
911                .reason
912                .clone()
913                .unwrap_or_else(|| "no reason supplied".to_string()),
914        });
915    }
916    Ok(())
917}
918
919fn validate_nonempty_result_set(
920    cases: &[NormalizedCase],
921    side: ComparisonSide,
922) -> Result<(), ComparisonError> {
923    if cases.is_empty() {
924        Err(ComparisonError::EmptyResultSet { side })
925    } else {
926        Ok(())
927    }
928}
929
930fn validate_runner(hostname: &str, side: ComparisonSide) -> Result<(), ComparisonError> {
931    if hostname.trim().is_empty() || hostname.eq_ignore_ascii_case("unknown") {
932        return Err(ComparisonError::UnknownRunner { side });
933    }
934    Ok(())
935}
936
937fn effective_runner_id(report: &BenchmarkReport) -> &str {
938    if report.context.runner_id.trim().is_empty() {
939        &report.hostname
940    } else {
941        &report.context.runner_id
942    }
943}
944
945fn normalize_document(
946    document: &ReportDocument,
947    side: ComparisonSide,
948) -> Result<NormalizedReport, ComparisonError> {
949    match document {
950        ReportDocument::Native { report, .. } => normalize_native_report(report, side),
951        ReportDocument::Series { report, .. } => normalize_series_report(report, side),
952    }
953}
954
955fn normalize_native_report(
956    report: &BenchmarkReport,
957    side: ComparisonSide,
958) -> Result<NormalizedReport, ComparisonError> {
959    if report.schema_version != REPORT_SCHEMA_VERSION {
960        return Err(ComparisonError::UnsupportedSchema {
961            side,
962            found: report.schema_version,
963            supported: REPORT_SCHEMA_VERSION,
964        });
965    }
966    report
967        .context
968        .validate()
969        .map_err(|error| ComparisonError::InvalidReport {
970            side,
971            reason: error.to_string(),
972        })?;
973    let suite = report
974        .suite
975        .as_deref()
976        .filter(|suite| !suite.trim().is_empty())
977        .ok_or(ComparisonError::MissingSuite { side })?;
978    Ok(NormalizedReport {
979        suite: suite.to_string(),
980        runner_id: effective_runner_id(report).to_string(),
981        environment: report.context.environment.clone(),
982        validity: Validity::valid(),
983        cases: report.results.iter().map(normalize_native_case).collect(),
984    })
985}
986
987fn normalize_series_report(
988    report: &SeriesReport,
989    side: ComparisonSide,
990) -> Result<NormalizedReport, ComparisonError> {
991    if report.schema_version != SERIES_SCHEMA_VERSION {
992        return Err(ComparisonError::UnsupportedSchema {
993            side,
994            found: report.schema_version,
995            supported: SERIES_SCHEMA_VERSION,
996        });
997    }
998    report
999        .validate()
1000        .map_err(|error| ComparisonError::InvalidReport {
1001            side,
1002            reason: error.to_string(),
1003        })?;
1004    Ok(NormalizedReport {
1005        suite: report.suite.clone(),
1006        runner_id: report.context.runner_id.clone(),
1007        environment: report.context.environment.clone(),
1008        validity: report.validity.clone(),
1009        cases: report.results.iter().map(normalize_series_case).collect(),
1010    })
1011}
1012
1013fn normalize_native_case(result: &BenchmarkResult) -> NormalizedCase {
1014    let median_throughput = median_throughput(result);
1015    let samples: Vec<_> = result
1016        .stats
1017        .sample_throughput_per_sec
1018        .iter()
1019        .copied()
1020        .filter(|sample| sample.is_finite())
1021        .collect();
1022    let throughput_mad = primary_mad(result, median_throughput);
1023    NormalizedCase {
1024        identity: ComparisonCaseIdentity::Native(BenchmarkCaseIdentity::from_result(result)),
1025        primary: PrimaryMeasurement {
1026            measurement: MeasurementKind::Throughput,
1027            unit: format!("{}/s", result.stats.throughput.unit()),
1028            direction: MeasurementDirection::Higher,
1029            samples,
1030            value: finite_nonnegative(median_throughput),
1031        },
1032        statistics: ComparisonStatistics {
1033            cv_percent: finite_nonnegative(result.stats.cv_percent),
1034            mad: throughput_mad,
1035            p95: primary_p95(result),
1036            samples: result.stats.samples,
1037            outliers: primary_outlier_count(result),
1038        },
1039        native: NativeMeasurementProjection {
1040            mean_throughput_per_sec: finite_nonnegative(mean_throughput(result)),
1041            median_throughput_per_sec: finite_nonnegative(median_throughput),
1042            median_ns_per_op: finite_nonnegative(result_median_latency(result)),
1043            p95_ns_per_op: finite_nonnegative(result_p95_latency(result)),
1044            mad_ns_per_op: finite_nonnegative(result_mad_latency(result)),
1045        },
1046        validity: Validity::valid(),
1047        provenance: BTreeMap::new(),
1048        metrics: result
1049            .stats
1050            .metrics
1051            .iter()
1052            .map(|metric| NormalizedMetric {
1053                name: metric.name.clone(),
1054                unit: metric.unit.clone(),
1055                section: metric.section.clone(),
1056                display_name: metric.display_name.clone(),
1057                format: metric.format,
1058                median: finite(metric.median),
1059            })
1060            .collect(),
1061    }
1062}
1063
1064fn normalize_series_case(result: &SeriesResult) -> NormalizedCase {
1065    let mut sorted = result.samples.clone();
1066    sorted.sort_by(f64::total_cmp);
1067    let median = (!sorted.is_empty()).then(|| percentile(&sorted, 0.5));
1068    let p95 = (!sorted.is_empty()).then(|| percentile(&sorted, 0.95));
1069    let mad = median.map(|median| {
1070        let mut deviations: Vec<_> = sorted
1071            .iter()
1072            .map(|sample| (sample - median).abs())
1073            .collect();
1074        deviations.sort_by(f64::total_cmp);
1075        percentile(&deviations, 0.5)
1076    });
1077
1078    NormalizedCase {
1079        identity: ComparisonCaseIdentity::Series(SeriesCaseIdentity::from_result(result)),
1080        primary: PrimaryMeasurement {
1081            measurement: result.measurement,
1082            unit: result.unit.clone(),
1083            direction: result.direction,
1084            samples: result.samples.clone(),
1085            value: result.validity.is_valid().then_some(median).flatten(),
1086        },
1087        statistics: ComparisonStatistics {
1088            cv_percent: coefficient_of_variation_percent(&sorted),
1089            mad,
1090            p95,
1091            samples: sorted.len(),
1092            outliers: tukey_outlier_count(&sorted),
1093        },
1094        native: NativeMeasurementProjection {
1095            mean_throughput_per_sec: None,
1096            median_throughput_per_sec: None,
1097            median_ns_per_op: None,
1098            p95_ns_per_op: None,
1099            mad_ns_per_op: None,
1100        },
1101        validity: result.validity.clone(),
1102        provenance: result.provenance.clone(),
1103        metrics: Vec::new(),
1104    }
1105}
1106
1107fn reject_duplicate_identities(
1108    cases: &[NormalizedCase],
1109    side: ComparisonSide,
1110) -> Result<(), ComparisonError> {
1111    for (index, case) in cases.iter().enumerate() {
1112        if cases[..index]
1113            .iter()
1114            .any(|previous| case.identity == previous.identity)
1115        {
1116            return Err(ComparisonError::DuplicateIdentity {
1117                side,
1118                identity: case.identity.clone(),
1119            });
1120        }
1121    }
1122    Ok(())
1123}
1124
1125fn pair_normalized_cases(
1126    current: &[NormalizedCase],
1127    baseline: &[NormalizedCase],
1128) -> Vec<(usize, usize)> {
1129    let mut matched_baseline = vec![false; baseline.len()];
1130    let mut pairs = Vec::with_capacity(current.len().min(baseline.len()));
1131    for (current_index, current_case) in current.iter().enumerate() {
1132        let Some((baseline_index, _)) =
1133            baseline
1134                .iter()
1135                .enumerate()
1136                .find(|(baseline_index, baseline_case)| {
1137                    !matched_baseline[*baseline_index]
1138                        && current_case.identity == baseline_case.identity
1139                })
1140        else {
1141            continue;
1142        };
1143        matched_baseline[baseline_index] = true;
1144        pairs.push((current_index, baseline_index));
1145    }
1146    pairs
1147}
1148
1149fn matched_benchmark(current: &NormalizedCase, baseline: &NormalizedCase) -> MatchedBenchmark {
1150    let current_snapshot = comparison_snapshot(current);
1151    let baseline_snapshot = comparison_snapshot(baseline);
1152    let current_value = current_snapshot.primary.value;
1153    let baseline_value = baseline_snapshot.primary.value;
1154    let absolute_change = finite_difference(current_value, baseline_value);
1155    let percent_improvement = improvement_percent(
1156        current_value,
1157        baseline_value,
1158        current_snapshot.primary.direction,
1159    );
1160    let mut stability_warnings = Vec::new();
1161    if !current.validity.is_valid() {
1162        stability_warnings.push(format!(
1163            "current result is invalid: {}",
1164            current
1165                .validity
1166                .reason
1167                .as_deref()
1168                .unwrap_or("no reason supplied")
1169        ));
1170    } else if current_value.is_none() {
1171        stability_warnings.push("current primary measurement is not finite".to_string());
1172    }
1173    if !baseline.validity.is_valid() {
1174        stability_warnings.push(format!(
1175            "baseline result is invalid: {}",
1176            baseline
1177                .validity
1178                .reason
1179                .as_deref()
1180                .unwrap_or("no reason supplied")
1181        ));
1182    } else if baseline_value.is_none() {
1183        stability_warnings.push("baseline primary measurement is not finite".to_string());
1184    }
1185
1186    MatchedBenchmark {
1187        identity: current.identity.clone(),
1188        current: current_snapshot,
1189        baseline: baseline_snapshot,
1190        absolute_change,
1191        percent_improvement,
1192        stability_warnings,
1193        metric_changes: compare_metrics(current, baseline),
1194    }
1195}
1196
1197fn unmatched_benchmark(result: &NormalizedCase) -> UnmatchedBenchmark {
1198    UnmatchedBenchmark {
1199        identity: result.identity.clone(),
1200        measurement: comparison_snapshot(result),
1201    }
1202}
1203
1204fn comparison_snapshot(result: &NormalizedCase) -> ComparisonCaseSnapshot {
1205    ComparisonCaseSnapshot {
1206        primary: result.primary.clone(),
1207        statistics: result.statistics.clone(),
1208        native: result.native.clone(),
1209        validity: result.validity.clone(),
1210        provenance: result.provenance.clone(),
1211    }
1212}
1213
1214fn compare_metrics(current: &NormalizedCase, baseline: &NormalizedCase) -> Vec<MetricComparison> {
1215    let mut matched_baseline = vec![false; baseline.metrics.len()];
1216    let mut comparisons = Vec::new();
1217    for current_metric in &current.metrics {
1218        let Some((index, baseline_metric)) =
1219            baseline
1220                .metrics
1221                .iter()
1222                .enumerate()
1223                .find(|(index, candidate)| {
1224                    !matched_baseline[*index]
1225                        && current_metric.name == candidate.name
1226                        && current_metric.unit == candidate.unit
1227                        && current_metric.section == candidate.section
1228                })
1229        else {
1230            continue;
1231        };
1232        matched_baseline[index] = true;
1233        let current_median = current_metric.median;
1234        let baseline_median = baseline_metric.median;
1235        comparisons.push(MetricComparison {
1236            name: current_metric.name.clone(),
1237            unit: current_metric.unit.clone(),
1238            section: current_metric.section.clone(),
1239            display_name: current_metric.display_name.clone(),
1240            format: current_metric.format,
1241            current_median,
1242            baseline_median,
1243            absolute_change: finite_difference(current_median, baseline_median),
1244            percent_change: raw_percent_change(current_median, baseline_median),
1245        });
1246    }
1247    comparisons
1248}
1249
1250fn in_memory_native_document(
1251    report: &BenchmarkReport,
1252    side: ComparisonSide,
1253) -> Result<ReportDocument, ComparisonError> {
1254    if report.schema_version != REPORT_SCHEMA_VERSION {
1255        return Err(ComparisonError::UnsupportedSchema {
1256            side,
1257            found: report.schema_version,
1258            supported: REPORT_SCHEMA_VERSION,
1259        });
1260    }
1261    let bytes = serde_json::to_vec_pretty(report).map_err(|error| {
1262        ComparisonError::ReferenceSerialization {
1263            side,
1264            message: error.to_string(),
1265        }
1266    })?;
1267    Ok(ReportDocument::Native {
1268        report: report.clone(),
1269        reference: ReportReference::native(report, &bytes, None),
1270    })
1271}
1272
1273fn in_memory_series_document(
1274    report: &SeriesReport,
1275    side: ComparisonSide,
1276) -> Result<ReportDocument, ComparisonError> {
1277    if report.schema_version != SERIES_SCHEMA_VERSION {
1278        return Err(ComparisonError::UnsupportedSchema {
1279            side,
1280            found: report.schema_version,
1281            supported: SERIES_SCHEMA_VERSION,
1282        });
1283    }
1284    report
1285        .validate()
1286        .map_err(|error| ComparisonError::InvalidReport {
1287            side,
1288            reason: error.to_string(),
1289        })?;
1290    let bytes = serde_json::to_vec_pretty(report).map_err(|error| {
1291        ComparisonError::ReferenceSerialization {
1292            side,
1293            message: error.to_string(),
1294        }
1295    })?;
1296    Ok(ReportDocument::Series {
1297        report: report.clone(),
1298        reference: ReportReference::series(report, &bytes, None),
1299    })
1300}
1301
1302fn parse_report_document(bytes: &[u8], path: Option<&Path>) -> Result<ReportDocument, ReportError> {
1303    let value: serde_json::Value =
1304        serde_json::from_slice(bytes).map_err(|source| ReportError::MalformedJson {
1305            path: path.map(Path::to_path_buf),
1306            source,
1307        })?;
1308    match value.get("document_type") {
1309        Some(serde_json::Value::String(document_type)) if document_type == SERIES_DOCUMENT_TYPE => {
1310            let report = parse_series_value(value, path)?;
1311            let reference = ReportReference::series(&report, bytes, path);
1312            Ok(ReportDocument::Series { report, reference })
1313        }
1314        Some(serde_json::Value::String(document_type)) => {
1315            Err(ReportError::UnsupportedDocumentType {
1316                path: path.map(Path::to_path_buf),
1317                found: document_type.clone(),
1318            })
1319        }
1320        Some(_) => Err(ReportError::InvalidReport {
1321            path: path.map(Path::to_path_buf),
1322            reason: "document_type must be a string".to_string(),
1323        }),
1324        None => {
1325            let report = parse_native_value(value, path)?;
1326            let reference = ReportReference::native(&report, bytes, path);
1327            Ok(ReportDocument::Native { report, reference })
1328        }
1329    }
1330}
1331
1332fn parse_native_report(bytes: &[u8], path: Option<&Path>) -> Result<BenchmarkReport, ReportError> {
1333    let value: serde_json::Value =
1334        serde_json::from_slice(bytes).map_err(|source| ReportError::MalformedJson {
1335            path: path.map(Path::to_path_buf),
1336            source,
1337        })?;
1338    parse_native_value(value, path)
1339}
1340
1341fn parse_native_value(
1342    value: serde_json::Value,
1343    path: Option<&Path>,
1344) -> Result<BenchmarkReport, ReportError> {
1345    let schema_version = match value.get("schema_version") {
1346        None => REPORT_SCHEMA_VERSION,
1347        Some(value) => {
1348            let Some(version) = value
1349                .as_u64()
1350                .and_then(|version| u32::try_from(version).ok())
1351            else {
1352                return Err(ReportError::InvalidSchemaVersion {
1353                    path: path.map(Path::to_path_buf),
1354                });
1355            };
1356            version
1357        }
1358    };
1359    validate_schema(schema_version, path)?;
1360
1361    serde_json::from_value(value).map_err(|source| ReportError::MalformedReport {
1362        path: path.map(Path::to_path_buf),
1363        source,
1364    })
1365}
1366
1367fn parse_series_report(bytes: &[u8], path: Option<&Path>) -> Result<SeriesReport, ReportError> {
1368    let value: serde_json::Value =
1369        serde_json::from_slice(bytes).map_err(|source| ReportError::MalformedJson {
1370            path: path.map(Path::to_path_buf),
1371            source,
1372        })?;
1373    parse_series_value(value, path)
1374}
1375
1376fn parse_series_value(
1377    value: serde_json::Value,
1378    path: Option<&Path>,
1379) -> Result<SeriesReport, ReportError> {
1380    let document_type = value
1381        .get("document_type")
1382        .and_then(serde_json::Value::as_str)
1383        .ok_or_else(|| ReportError::InvalidReport {
1384            path: path.map(Path::to_path_buf),
1385            reason: format!("document_type must be {SERIES_DOCUMENT_TYPE:?}"),
1386        })?;
1387    if document_type != SERIES_DOCUMENT_TYPE {
1388        return Err(ReportError::UnsupportedDocumentType {
1389            path: path.map(Path::to_path_buf),
1390            found: document_type.to_string(),
1391        });
1392    }
1393    let schema_version = value
1394        .get("schema_version")
1395        .and_then(serde_json::Value::as_u64)
1396        .and_then(|version| u32::try_from(version).ok())
1397        .ok_or_else(|| ReportError::InvalidSchemaVersion {
1398            path: path.map(Path::to_path_buf),
1399        })?;
1400    if schema_version != SERIES_SCHEMA_VERSION {
1401        return Err(ReportError::UnsupportedSchema {
1402            path: path.map(Path::to_path_buf),
1403            found: schema_version,
1404            supported: SERIES_SCHEMA_VERSION,
1405        });
1406    }
1407    let report: SeriesReport =
1408        serde_json::from_value(value).map_err(|source| ReportError::MalformedReport {
1409            path: path.map(Path::to_path_buf),
1410            source,
1411        })?;
1412    validate_series_report(&report, path)?;
1413    Ok(report)
1414}
1415
1416fn validate_series_report(report: &SeriesReport, path: Option<&Path>) -> Result<(), ReportError> {
1417    report
1418        .validate()
1419        .map_err(|error| ReportError::InvalidReport {
1420            path: path.map(Path::to_path_buf),
1421            reason: error.to_string(),
1422        })
1423}
1424
1425fn validate_schema(schema_version: u32, path: Option<&Path>) -> Result<(), ReportError> {
1426    if schema_version != REPORT_SCHEMA_VERSION {
1427        return Err(ReportError::UnsupportedSchema {
1428            path: path.map(Path::to_path_buf),
1429            found: schema_version,
1430            supported: REPORT_SCHEMA_VERSION,
1431        });
1432    }
1433    Ok(())
1434}
1435
1436fn path_suffix(path: &Option<PathBuf>) -> String {
1437    path.as_ref()
1438        .map(|path| format!(" at {}", path.display()))
1439        .unwrap_or_default()
1440}
1441
1442fn sha256_digest(bytes: &[u8]) -> String {
1443    const HEX: &[u8; 16] = b"0123456789abcdef";
1444
1445    let digest = Sha256::digest(bytes);
1446    let mut encoded = String::with_capacity("sha256:".len() + digest.len() * 2);
1447    encoded.push_str("sha256:");
1448    for byte in digest {
1449        encoded.push(HEX[(byte >> 4) as usize] as char);
1450        encoded.push(HEX[(byte & 0x0f) as usize] as char);
1451    }
1452    encoded
1453}
1454
1455fn finite(value: f64) -> Option<f64> {
1456    value.is_finite().then_some(value)
1457}
1458
1459fn finite_nonnegative(value: f64) -> Option<f64> {
1460    (value.is_finite() && value >= 0.0).then_some(value)
1461}
1462
1463fn finite_difference(current: Option<f64>, baseline: Option<f64>) -> Option<f64> {
1464    let difference = current? - baseline?;
1465    finite(difference)
1466}
1467
1468fn raw_percent_change(current: Option<f64>, baseline: Option<f64>) -> Option<f64> {
1469    let current = current?;
1470    let baseline = baseline?;
1471    if baseline.abs() <= f64::EPSILON {
1472        return None;
1473    }
1474    finite(((current - baseline) / baseline) * 100.0)
1475}
1476
1477fn improvement_percent(
1478    current: Option<f64>,
1479    baseline: Option<f64>,
1480    direction: MeasurementDirection,
1481) -> Option<f64> {
1482    let current = current?;
1483    let baseline = baseline?;
1484    if baseline.abs() <= f64::EPSILON {
1485        return None;
1486    }
1487    let ratio = match direction {
1488        MeasurementDirection::Higher => (current - baseline) / baseline.abs(),
1489        MeasurementDirection::Lower => (baseline - current) / baseline.abs(),
1490        MeasurementDirection::Informational => return None,
1491    };
1492    finite(ratio * 100.0)
1493}
1494
1495fn mean_throughput(result: &BenchmarkResult) -> f64 {
1496    if result.stats.sample_throughput_per_sec.is_empty() {
1497        return result.stats.throughput_per_sec;
1498    }
1499    result.stats.sample_throughput_per_sec.iter().sum::<f64>()
1500        / result.stats.sample_throughput_per_sec.len() as f64
1501}
1502
1503fn median_throughput(result: &BenchmarkResult) -> f64 {
1504    if result.stats.median_throughput_per_sec.is_finite()
1505        && result.stats.median_throughput_per_sec > 0.0
1506    {
1507        return result.stats.median_throughput_per_sec;
1508    }
1509    if result.stats.sample_throughput_per_sec.is_empty() {
1510        return result.stats.throughput_per_sec;
1511    }
1512    percentile(&result.stats.sample_throughput_per_sec, 0.5)
1513}
1514
1515fn result_median_latency(result: &BenchmarkResult) -> f64 {
1516    if result.stats.sample_latency_ns_per_op.is_empty() {
1517        return result.stats.median_ns_per_op;
1518    }
1519    percentile(&result.stats.sample_latency_ns_per_op, 0.5)
1520}
1521
1522fn result_p95_latency(result: &BenchmarkResult) -> f64 {
1523    if result.stats.sample_latency_ns_per_op.is_empty() {
1524        return result.stats.p95_ns_per_op;
1525    }
1526    percentile(&result.stats.sample_latency_ns_per_op, 0.95)
1527}
1528
1529fn result_mad_latency(result: &BenchmarkResult) -> f64 {
1530    if result.stats.sample_latency_ns_per_op.is_empty() {
1531        return result.stats.mad_ns_per_op;
1532    }
1533    let median = result_median_latency(result);
1534    let deviations: Vec<_> = result
1535        .stats
1536        .sample_latency_ns_per_op
1537        .iter()
1538        .map(|value| (value - median).abs())
1539        .collect();
1540    percentile(&deviations, 0.5)
1541}
1542
1543fn primary_mad(result: &BenchmarkResult, median: f64) -> Option<f64> {
1544    if result.stats.sample_throughput_per_sec.is_empty() || !median.is_finite() {
1545        return None;
1546    }
1547    let deviations: Vec<_> = result
1548        .stats
1549        .sample_throughput_per_sec
1550        .iter()
1551        .filter(|value| value.is_finite())
1552        .map(|value| (value - median).abs())
1553        .collect();
1554    if deviations.is_empty() {
1555        return None;
1556    }
1557    finite(percentile(&deviations, 0.5))
1558}
1559
1560fn primary_p95(result: &BenchmarkResult) -> Option<f64> {
1561    let samples: Vec<_> = result
1562        .stats
1563        .sample_throughput_per_sec
1564        .iter()
1565        .copied()
1566        .filter(|value| value.is_finite())
1567        .collect();
1568    if samples.is_empty() {
1569        return None;
1570    }
1571    finite(percentile(&samples, 0.95))
1572}
1573
1574fn primary_outlier_count(result: &BenchmarkResult) -> usize {
1575    let samples: Vec<_> = result
1576        .stats
1577        .sample_throughput_per_sec
1578        .iter()
1579        .copied()
1580        .filter(|value| value.is_finite())
1581        .collect();
1582    tukey_outlier_count(&samples)
1583}
1584
1585fn coefficient_of_variation_percent(samples: &[f64]) -> Option<f64> {
1586    if samples.is_empty() {
1587        return None;
1588    }
1589    let mean = samples.iter().sum::<f64>() / samples.len() as f64;
1590    if !mean.is_finite() || mean.abs() <= f64::EPSILON {
1591        return None;
1592    }
1593    let variance = samples
1594        .iter()
1595        .map(|sample| (sample - mean).powi(2))
1596        .sum::<f64>()
1597        / samples.len() as f64;
1598    finite((variance.sqrt() / mean.abs()) * 100.0)
1599}
1600
1601fn tukey_outlier_count(samples: &[f64]) -> usize {
1602    if samples.len() < 4 {
1603        return 0;
1604    }
1605    let mut samples = samples.to_vec();
1606    samples.sort_by(|a, b| a.total_cmp(b));
1607    let q1 = percentile(&samples, 0.25);
1608    let q3 = percentile(&samples, 0.75);
1609    let iqr = q3 - q1;
1610    let lower = q1 - 1.5 * iqr;
1611    let upper = q3 + 1.5 * iqr;
1612    samples
1613        .iter()
1614        .filter(|value| **value < lower || **value > upper)
1615        .count()
1616}
1617
1618fn percentile(values: &[f64], percentile: f64) -> f64 {
1619    if values.is_empty() {
1620        return 0.0;
1621    }
1622    let mut sorted = values.to_vec();
1623    sorted.sort_by(|a, b| a.total_cmp(b));
1624    let percentile = percentile.clamp(0.0, 1.0);
1625    let position = percentile * (sorted.len() - 1) as f64;
1626    let lower = position.floor() as usize;
1627    let upper = position.ceil() as usize;
1628    if lower == upper {
1629        return sorted[lower];
1630    }
1631    let weight = position - lower as f64;
1632    sorted[lower] * (1.0 - weight) + sorted[upper] * weight
1633}
1634
1635pub(crate) fn result_identity_matches(
1636    current: &BenchmarkResult,
1637    previous: &BenchmarkResult,
1638) -> bool {
1639    current.name == previous.name
1640        && current.group == previous.group
1641        && current.kind == previous.kind
1642        && current.metadata == previous.metadata
1643        && current.stats.throughput == previous.stats.throughput
1644        && current.stats.measurement_domain == previous.stats.measurement_domain
1645        && current.stats.pmu_scope == previous.stats.pmu_scope
1646        && PmuCounterProfile::from_measurement_label(&current.stats.measurement_label)
1647            == PmuCounterProfile::from_measurement_label(&previous.stats.measurement_label)
1648        && current.stats.energy_scope == previous.stats.energy_scope
1649        && current.stats.memory_bandwidth_scope == previous.stats.memory_bandwidth_scope
1650}
1651
1652pub(crate) fn pair_results_one_to_one<'current, 'previous>(
1653    current_results: &'current [BenchmarkResult],
1654    previous_results: &'previous [BenchmarkResult],
1655) -> Vec<(&'current BenchmarkResult, &'previous BenchmarkResult)> {
1656    let mut matched_previous = vec![false; previous_results.len()];
1657    let mut pairs = Vec::with_capacity(current_results.len().min(previous_results.len()));
1658    for current in current_results {
1659        let Some((index, previous)) =
1660            previous_results
1661                .iter()
1662                .enumerate()
1663                .find(|(index, previous)| {
1664                    !matched_previous[*index] && result_identity_matches(current, previous)
1665                })
1666        else {
1667            continue;
1668        };
1669        matched_previous[index] = true;
1670        pairs.push((current, previous));
1671    }
1672    pairs
1673}
1674
1675#[cfg(test)]
1676mod tests {
1677    use super::*;
1678    use crate::{BenchmarkStats, MetricSummary, ReportContext};
1679
1680    fn result(group: &str, name: &str, rate: f64) -> BenchmarkResult {
1681        BenchmarkResult {
1682            name: name.to_string(),
1683            group: group.to_string(),
1684            kind: BenchmarkKind::Standard,
1685            execution_index: 0,
1686            stats: BenchmarkStats {
1687                throughput: Throughput::ops(),
1688                throughput_per_sec: rate,
1689                median_throughput_per_sec: rate,
1690                ns_per_op: 1_000_000_000.0 / rate,
1691                median_ns_per_op: 1_000_000_000.0 / rate,
1692                p95_ns_per_op: 1_000_000_000.0 / rate,
1693                mad_ns_per_op: 0.0,
1694                cycles_per_op: 0.0,
1695                instructions_per_op: 0.0,
1696                ipc: 0.0,
1697                cache_references_per_op: 0.0,
1698                l1i_misses_per_op: 0.0,
1699                branches_per_op: 0.0,
1700                branch_miss_rate: 0.0,
1701                branch_misses_per_op: 0.0,
1702                cache_misses_per_op: 0.0,
1703                cache_miss_percent: 0.0,
1704                frontend_stall_cycles_per_op: 0.0,
1705                frontend_stall_percent: 0.0,
1706                backend_stall_cycles_per_op: 0.0,
1707                backend_stall_percent: 0.0,
1708                cv_percent: 1.0,
1709                outlier_count: 0,
1710                samples: 3,
1711                operations: 3,
1712                total_duration_sec: 1.0,
1713                sample_throughput_per_sec: vec![rate, rate, rate],
1714                sample_latency_ns_per_op: vec![
1715                    1_000_000_000.0 / rate,
1716                    1_000_000_000.0 / rate,
1717                    1_000_000_000.0 / rate,
1718                ],
1719                has_cycles: false,
1720                has_instructions: false,
1721                has_cache_references: false,
1722                has_l1i_misses: false,
1723                has_branches: false,
1724                has_branch_misses: false,
1725                has_cache_misses: false,
1726                has_stalled_cycles_frontend: false,
1727                has_stalled_cycles_backend: false,
1728                pmu_time_enabled_ns: 0,
1729                pmu_time_running_ns: 0,
1730                measurement_domain: MeasurementDomain::Cpu,
1731                measurement_label: String::new(),
1732                pmu_scope: PmuScope::CallingThread,
1733                energy_scope: EnergyScope::None,
1734                memory_bandwidth_scope: MemoryBandwidthScope::None,
1735                emits_cpu_diagnostics: true,
1736                metrics: Vec::new(),
1737                sample_metrics: Vec::new(),
1738            },
1739            worker_summaries: Vec::new(),
1740            metadata: BTreeMap::new(),
1741        }
1742    }
1743
1744    fn report(names_and_rates: &[(&str, f64)]) -> BenchmarkReport {
1745        BenchmarkReport {
1746            schema_version: REPORT_SCHEMA_VERSION,
1747            timestamp: "123".to_string(),
1748            hostname: "host-a".to_string(),
1749            suite: Some("suite-a".to_string()),
1750            git_commit: Some("abc123".to_string()),
1751            context: ReportContext::default(),
1752            results: names_and_rates
1753                .iter()
1754                .map(|(name, rate)| result("group", name, *rate))
1755                .collect(),
1756        }
1757    }
1758
1759    #[test]
1760    fn native_comparison_is_direction_aware_and_serializable() {
1761        let current = report(&[("a", 120.0)]);
1762        let baseline = report(&[("a", 100.0)]);
1763        let comparison = current
1764            .compare(&baseline, &ComparisonOptions::default())
1765            .unwrap();
1766
1767        assert_eq!(comparison.summary.matched, 1);
1768        assert_eq!(
1769            comparison.matched[0].current.primary.measurement,
1770            MeasurementKind::Throughput
1771        );
1772        assert_eq!(
1773            comparison.matched[0].current.primary.direction,
1774            MeasurementDirection::Higher
1775        );
1776        assert_eq!(comparison.matched[0].percent_improvement, Some(20.0));
1777        let document = serde_json::to_value(&comparison).unwrap();
1778        assert_eq!(document["schema_version"], COMPARISON_SCHEMA_VERSION);
1779        assert_eq!(document["matched"][0]["percent_improvement"], 20.0);
1780    }
1781
1782    #[test]
1783    fn signed_improvement_respects_measurement_direction() {
1784        assert_eq!(
1785            improvement_percent(Some(80.0), Some(100.0), MeasurementDirection::Lower),
1786            Some(20.0)
1787        );
1788        assert_eq!(
1789            improvement_percent(Some(120.0), Some(100.0), MeasurementDirection::Higher),
1790            Some(20.0)
1791        );
1792        assert_eq!(
1793            improvement_percent(
1794                Some(120.0),
1795                Some(100.0),
1796                MeasurementDirection::Informational
1797            ),
1798            None
1799        );
1800        assert_eq!(
1801            improvement_percent(Some(-5.0), Some(-10.0), MeasurementDirection::Higher),
1802            Some(50.0)
1803        );
1804        assert_eq!(
1805            improvement_percent(Some(-5.0), Some(-10.0), MeasurementDirection::Lower),
1806            Some(-50.0)
1807        );
1808    }
1809
1810    #[test]
1811    fn report_digest_is_lowercase_sha256() {
1812        assert_eq!(
1813            sha256_digest(b""),
1814            "sha256:e3b0c44298fc1c149afbf4c8996fb924\
1815             27ae41e4649b934ca495991b7852b855"
1816        );
1817    }
1818
1819    #[test]
1820    fn empty_native_reports_are_not_comparison_ready() {
1821        let error = report(&[])
1822            .compare(&report(&[("case", 1.0)]), &ComparisonOptions::default())
1823            .unwrap_err();
1824        assert_eq!(
1825            error,
1826            ComparisonError::EmptyResultSet {
1827                side: ComparisonSide::Current
1828            }
1829        );
1830    }
1831
1832    #[test]
1833    fn partial_comparison_reports_added_and_removed_cases() {
1834        let current = report(&[("a", 120.0), ("d", 40.0)]);
1835        let baseline = report(&[("a", 100.0), ("c", 30.0)]);
1836
1837        let strict = current.compare(&baseline, &ComparisonOptions::default());
1838        assert_eq!(
1839            strict.unwrap_err(),
1840            ComparisonError::ResultSetMismatch {
1841                added: 1,
1842                removed: 1
1843            }
1844        );
1845
1846        let comparison = current
1847            .compare(
1848                &baseline,
1849                &ComparisonOptions::default().allow_partial_result_set(true),
1850            )
1851            .unwrap();
1852        assert_eq!(
1853            comparison.summary,
1854            ComparisonSummary {
1855                matched: 1,
1856                added: 1,
1857                removed: 1
1858            }
1859        );
1860        assert_eq!(comparison.added[0].identity.name(), "d");
1861        assert_eq!(comparison.removed[0].identity.name(), "c");
1862    }
1863
1864    #[test]
1865    fn duplicate_identities_are_explicit_errors() {
1866        let current = report(&[("a", 120.0), ("a", 130.0)]);
1867        let baseline = report(&[("a", 100.0)]);
1868        let error = current
1869            .compare(
1870                &baseline,
1871                &ComparisonOptions::default().allow_partial_result_set(true),
1872            )
1873            .unwrap_err();
1874        assert!(matches!(
1875            error,
1876            ComparisonError::DuplicateIdentity {
1877                side: ComparisonSide::Current,
1878                ..
1879            }
1880        ));
1881    }
1882
1883    #[test]
1884    fn identity_changes_are_added_and_removed_not_numeric_matches() {
1885        let mut current = report(&[("a", 120.0)]);
1886        current.results[0]
1887            .metadata
1888            .insert("cache".to_string(), "cold".to_string());
1889        let baseline = report(&[("a", 100.0)]);
1890
1891        let comparison = current
1892            .compare(
1893                &baseline,
1894                &ComparisonOptions::default().allow_partial_result_set(true),
1895            )
1896            .unwrap();
1897        assert_eq!(comparison.summary.matched, 0);
1898        assert_eq!(comparison.summary.added, 1);
1899        assert_eq!(comparison.summary.removed, 1);
1900    }
1901
1902    #[test]
1903    fn pmu_scopes_are_comparison_identity() {
1904        let mut current = report(&[("a", 120.0)]);
1905        let mut baseline = report(&[("a", 100.0)]);
1906        current.results[0].stats.pmu_scope = PmuScope::ProcessThreads;
1907        baseline.results[0].stats.pmu_scope = PmuScope::CallingThread;
1908
1909        let comparison = current
1910            .compare(
1911                &baseline,
1912                &ComparisonOptions::default().allow_partial_result_set(true),
1913            )
1914            .unwrap();
1915        assert_eq!(comparison.summary.matched, 0);
1916        assert_eq!(comparison.summary.added, 1);
1917        assert_eq!(comparison.summary.removed, 1);
1918    }
1919
1920    #[test]
1921    fn pmu_counter_profiles_are_comparison_identity() {
1922        let mut current = report(&[("a", 120.0)]);
1923        let mut baseline = report(&[("a", 100.0)]);
1924        current.results[0].stats.measurement_label = "timing + compact PMU".to_string();
1925        baseline.results[0].stats.measurement_label = "timing + PMU".to_string();
1926
1927        let comparison = current
1928            .compare(
1929                &baseline,
1930                &ComparisonOptions::default().allow_partial_result_set(true),
1931            )
1932            .unwrap();
1933        assert_eq!(comparison.summary.matched, 0);
1934        assert_eq!(comparison.summary.added, 1);
1935        assert_eq!(comparison.summary.removed, 1);
1936    }
1937
1938    #[test]
1939    fn energy_scopes_are_comparison_identity() {
1940        let mut current = report(&[("a", 120.0)]);
1941        let baseline = report(&[("a", 100.0)]);
1942        current.results[0].stats.energy_scope = EnergyScope::RaplPackageDomains;
1943
1944        let comparison = current
1945            .compare(
1946                &baseline,
1947                &ComparisonOptions::default().allow_partial_result_set(true),
1948            )
1949            .unwrap();
1950        assert_eq!(comparison.summary.matched, 0);
1951        assert_eq!(comparison.summary.added, 1);
1952        assert_eq!(comparison.summary.removed, 1);
1953    }
1954
1955    #[test]
1956    fn memory_bandwidth_scopes_are_comparison_identity() {
1957        let mut current = report(&[("a", 120.0)]);
1958        let baseline = report(&[("a", 100.0)]);
1959        current.results[0].stats.memory_bandwidth_scope = MemoryBandwidthScope::SystemComplete;
1960
1961        let comparison = current
1962            .compare(
1963                &baseline,
1964                &ComparisonOptions::default().allow_partial_result_set(true),
1965            )
1966            .unwrap();
1967        assert_eq!(comparison.summary.matched, 0);
1968        assert_eq!(comparison.summary.added, 1);
1969        assert_eq!(comparison.summary.removed, 1);
1970    }
1971
1972    #[test]
1973    fn matching_custom_metrics_are_compared() {
1974        let mut current = report(&[("a", 120.0)]);
1975        let mut baseline = report(&[("a", 100.0)]);
1976        current.results[0].stats.metrics.push(MetricSummary {
1977            name: "power".to_string(),
1978            unit: "W".to_string(),
1979            section: "gpu".to_string(),
1980            display_name: "Power".to_string(),
1981            format: MetricFormat::Number,
1982            mean: 220.0,
1983            median: 220.0,
1984            p95: 220.0,
1985            min: 220.0,
1986            max: 220.0,
1987            samples: 1,
1988        });
1989        baseline.results[0].stats.metrics.push(MetricSummary {
1990            name: "power".to_string(),
1991            unit: "W".to_string(),
1992            section: "gpu".to_string(),
1993            display_name: "Power".to_string(),
1994            format: MetricFormat::Number,
1995            mean: 200.0,
1996            median: 200.0,
1997            p95: 200.0,
1998            min: 200.0,
1999            max: 200.0,
2000            samples: 1,
2001        });
2002
2003        let comparison = current
2004            .compare(&baseline, &ComparisonOptions::default())
2005            .unwrap();
2006        assert_eq!(comparison.matched[0].metric_changes.len(), 1);
2007        assert_eq!(
2008            comparison.matched[0].metric_changes[0].percent_change,
2009            Some(10.0)
2010        );
2011    }
2012
2013    #[test]
2014    fn report_document_digest_uses_exact_loaded_bytes() {
2015        assert_eq!(
2016            sha256_digest(b"abc"),
2017            "sha256:ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
2018        );
2019
2020        let report = report(&[("a", 100.0)]);
2021        let bytes = serde_json::to_vec(&report).unwrap();
2022        let path = std::env::temp_dir().join(format!(
2023            "micromeasure-comparison-report-{}-{}.json",
2024            std::process::id(),
2025            std::time::SystemTime::now()
2026                .duration_since(std::time::UNIX_EPOCH)
2027                .unwrap()
2028                .as_nanos()
2029        ));
2030        fs::write(&path, &bytes).unwrap();
2031
2032        let loaded = ReportDocument::load_from_path(&path).unwrap();
2033        assert_eq!(loaded.reference().content_digest, sha256_digest(&bytes));
2034        assert_eq!(
2035            loaded.reference().display_path.as_deref(),
2036            Some(path.to_string_lossy().as_ref())
2037        );
2038
2039        fs::remove_file(path).unwrap();
2040    }
2041
2042    #[test]
2043    fn loaded_comparison_retains_both_exact_report_references() {
2044        let current_report = report(&[("a", 120.0)]);
2045        let baseline_report = report(&[("a", 100.0)]);
2046        let current_bytes = serde_json::to_vec(&current_report).unwrap();
2047        let baseline_bytes = serde_json::to_vec_pretty(&baseline_report).unwrap();
2048        let directory = std::env::temp_dir().join(format!(
2049            "micromeasure-loaded-comparison-{}-{}",
2050            std::process::id(),
2051            std::time::SystemTime::now()
2052                .duration_since(std::time::UNIX_EPOCH)
2053                .unwrap()
2054                .as_nanos()
2055        ));
2056        fs::create_dir_all(&directory).unwrap();
2057        let current_path = directory.join("current.json");
2058        let baseline_path = directory.join("baseline.json");
2059        fs::write(&current_path, &current_bytes).unwrap();
2060        fs::write(&baseline_path, &baseline_bytes).unwrap();
2061
2062        let current = ReportDocument::load_from_path(&current_path).unwrap();
2063        let baseline = ReportDocument::load_from_path(&baseline_path).unwrap();
2064        let comparison =
2065            compare_reports(&current, &baseline, &ComparisonOptions::default()).unwrap();
2066        assert_eq!(
2067            comparison.current.content_digest,
2068            sha256_digest(&current_bytes)
2069        );
2070        assert_eq!(
2071            comparison.baseline.content_digest,
2072            sha256_digest(&baseline_bytes)
2073        );
2074        assert_eq!(comparison.matched[0].percent_improvement, Some(20.0));
2075
2076        fs::remove_dir_all(directory).unwrap();
2077    }
2078
2079    #[test]
2080    fn loading_distinguishes_malformed_and_unsupported_reports() {
2081        let malformed = br#"{"schema_version":1,"results":"wrong"}"#;
2082        assert!(matches!(
2083            parse_native_report(malformed, None),
2084            Err(ReportError::MalformedReport { .. })
2085        ));
2086
2087        let future = br#"{"schema_version":999}"#;
2088        assert!(matches!(
2089            parse_native_report(future, None),
2090            Err(ReportError::UnsupportedSchema { found: 999, .. })
2091        ));
2092    }
2093
2094    #[test]
2095    fn public_loading_accepts_current_and_legacy_reports() {
2096        let report = report(&[("a", 100.0)]);
2097        let mut legacy = serde_json::to_value(&report).unwrap();
2098        legacy.as_object_mut().unwrap().remove("schema_version");
2099        let directory = std::env::temp_dir().join(format!(
2100            "micromeasure-public-loading-{}-{}",
2101            std::process::id(),
2102            std::time::SystemTime::now()
2103                .duration_since(std::time::UNIX_EPOCH)
2104                .unwrap()
2105                .as_nanos()
2106        ));
2107        fs::create_dir_all(&directory).unwrap();
2108        let current_path = directory.join("current.json");
2109        let legacy_path = directory.join("legacy.json");
2110        fs::write(&current_path, serde_json::to_vec(&report).unwrap()).unwrap();
2111        fs::write(&legacy_path, serde_json::to_vec(&legacy).unwrap()).unwrap();
2112
2113        assert_eq!(
2114            BenchmarkReport::load_from_path(&current_path)
2115                .unwrap()
2116                .schema_version,
2117            REPORT_SCHEMA_VERSION
2118        );
2119        assert_eq!(
2120            BenchmarkReport::load_from_path(&legacy_path)
2121                .unwrap()
2122                .schema_version,
2123            REPORT_SCHEMA_VERSION
2124        );
2125
2126        fs::remove_dir_all(directory).unwrap();
2127    }
2128
2129    #[test]
2130    fn non_comparable_primary_values_remain_structured_and_serializable() {
2131        let mut current = report(&[("a", f64::NAN)]);
2132        current.results[0].stats.median_throughput_per_sec = f64::NAN;
2133        current.results[0].stats.sample_throughput_per_sec.clear();
2134        let baseline = report(&[("a", 0.0)]);
2135
2136        let comparison = current
2137            .compare(&baseline, &ComparisonOptions::default())
2138            .unwrap();
2139        let matched = &comparison.matched[0];
2140        assert_eq!(matched.current.primary.value, None);
2141        assert_eq!(matched.percent_improvement, None);
2142        assert!(!matched.stability_warnings.is_empty());
2143        serde_json::to_vec(&comparison).unwrap();
2144    }
2145
2146    #[test]
2147    fn report_level_mismatches_have_precise_errors() {
2148        let current = report(&[("a", 100.0)]);
2149        let mut baseline = report(&[("a", 100.0)]);
2150        baseline.hostname = "host-b".to_string();
2151        assert_eq!(
2152            current
2153                .compare(&baseline, &ComparisonOptions::default())
2154                .unwrap_err(),
2155            ComparisonError::RunnerMismatch {
2156                current: "host-a".to_string(),
2157                baseline: "host-b".to_string()
2158            }
2159        );
2160
2161        baseline.hostname = "host-a".to_string();
2162        baseline.suite = Some("suite-b".to_string());
2163        assert_eq!(
2164            current
2165                .compare(&baseline, &ComparisonOptions::default())
2166                .unwrap_err(),
2167            ComparisonError::SuiteMismatch {
2168                current: "suite-a".to_string(),
2169                baseline: "suite-b".to_string()
2170            }
2171        );
2172    }
2173
2174    #[test]
2175    fn runner_and_environment_must_match_exactly() {
2176        let mut current = report(&[("a", 100.0)]);
2177        let mut baseline = report(&[("a", 100.0)]);
2178        current.hostname = "ephemeral-current".to_string();
2179        baseline.hostname = "ephemeral-baseline".to_string();
2180        current.context = ReportContext::new("stable-runner")
2181            .with_environment("accelerator", "GB300")
2182            .with_environment("driver", "595.71.05");
2183        baseline.context = current.context.clone();
2184
2185        let comparison = current
2186            .compare(&baseline, &ComparisonOptions::default())
2187            .unwrap();
2188        assert!(comparison.environment.exact_match);
2189        assert_eq!(comparison.environment.current_runner_id, "stable-runner");
2190
2191        baseline
2192            .context
2193            .environment
2194            .insert("driver".to_string(), "595.80.01".to_string());
2195        assert!(matches!(
2196            current
2197                .compare(&baseline, &ComparisonOptions::default())
2198                .unwrap_err(),
2199            ComparisonError::EnvironmentMismatch { .. }
2200        ));
2201    }
2202
2203    #[test]
2204    fn provenance_does_not_affect_compatibility_and_is_referenced() {
2205        let mut current = report(&[("a", 100.0)]);
2206        let mut baseline = report(&[("a", 100.0)]);
2207        current.context.provenance.insert(
2208            "commit".to_string(),
2209            "0123456789abcdef0123456789abcdef01234567".to_string(),
2210        );
2211        baseline
2212            .context
2213            .provenance
2214            .insert("commit".to_string(), "different".to_string());
2215
2216        let comparison = current
2217            .compare(&baseline, &ComparisonOptions::default())
2218            .unwrap();
2219        assert_eq!(
2220            comparison.current.source_provenance["commit"],
2221            "0123456789abcdef0123456789abcdef01234567"
2222        );
2223        assert_eq!(comparison.baseline.source_provenance["commit"], "different");
2224    }
2225
2226    #[test]
2227    fn environment_override_is_explicit_and_auditable() {
2228        let mut current = report(&[("a", 100.0)]);
2229        let mut baseline = report(&[("a", 100.0)]);
2230        current.context = ReportContext::new("runner-a").with_environment("accelerator", "GB300");
2231        baseline.context = ReportContext::new("runner-b").with_environment("accelerator", "B300");
2232
2233        let comparison = current
2234            .compare(
2235                &baseline,
2236                &ComparisonOptions::default()
2237                    .with_environment_override("controlled cross-runner calibration"),
2238            )
2239            .unwrap();
2240        assert!(!comparison.environment.exact_match);
2241        assert_eq!(comparison.environment.current_runner_id, "runner-a");
2242        assert_eq!(comparison.environment.baseline_runner_id, "runner-b");
2243        assert_eq!(
2244            comparison
2245                .environment
2246                .operator_override
2247                .as_ref()
2248                .unwrap()
2249                .reason,
2250            "controlled cross-runner calibration"
2251        );
2252
2253        assert_eq!(
2254            current
2255                .compare(
2256                    &baseline,
2257                    &ComparisonOptions::default().with_environment_override("  ")
2258                )
2259                .unwrap_err(),
2260            ComparisonError::InvalidEnvironmentOverride
2261        );
2262    }
2263
2264    #[test]
2265    fn reports_without_context_remain_comparable_by_hostname() {
2266        let current = report(&[("a", 100.0)]);
2267        let baseline = report(&[("a", 100.0)]);
2268        let mut current_json = serde_json::to_value(current).unwrap();
2269        let mut baseline_json = serde_json::to_value(baseline).unwrap();
2270        current_json.as_object_mut().unwrap().remove("context");
2271        baseline_json.as_object_mut().unwrap().remove("context");
2272        let current: BenchmarkReport = serde_json::from_value(current_json).unwrap();
2273        let baseline: BenchmarkReport = serde_json::from_value(baseline_json).unwrap();
2274
2275        let comparison = current
2276            .compare(&baseline, &ComparisonOptions::default())
2277            .unwrap();
2278        assert_eq!(comparison.environment.current_runner_id, "host-a");
2279        assert!(comparison.environment.exact_match);
2280    }
2281
2282    fn series_fixture(name: &str) -> PathBuf {
2283        Path::new(env!("CARGO_MANIFEST_DIR"))
2284            .join("tests/fixtures/series")
2285            .join(name)
2286    }
2287
2288    fn assert_approximately(actual: Option<f64>, expected: f64) {
2289        let actual = actual.expect("expected a finite statistic");
2290        assert!(
2291            (actual - expected).abs() < 1e-9,
2292            "expected {expected}, found {actual}"
2293        );
2294    }
2295
2296    #[test]
2297    fn python_and_rust_series_fixtures_share_the_comparison_engine() {
2298        let current_path = series_fixture("python-current.json");
2299        let baseline_path = series_fixture("rust-baseline.json");
2300        let current_bytes = fs::read(&current_path).unwrap();
2301        let baseline_bytes = fs::read(&baseline_path).unwrap();
2302        let current = ReportDocument::load_from_path(&current_path).unwrap();
2303        let baseline = ReportDocument::load_from_path(&baseline_path).unwrap();
2304
2305        assert!(current.as_series().is_some());
2306        assert!(current.as_native().is_none());
2307        assert_eq!(
2308            current.reference().document_type,
2309            ReportDocumentType::Series
2310        );
2311        assert_eq!(
2312            current.reference().content_digest,
2313            sha256_digest(&current_bytes)
2314        );
2315        assert_eq!(
2316            baseline.reference().content_digest,
2317            sha256_digest(&baseline_bytes)
2318        );
2319        assert_eq!(
2320            current.reference().source_provenance["commit"],
2321            "current-python-commit"
2322        );
2323
2324        let comparison =
2325            compare_reports(&current, &baseline, &ComparisonOptions::default()).unwrap();
2326        assert_eq!(
2327            comparison.summary,
2328            ComparisonSummary {
2329                matched: 5,
2330                added: 0,
2331                removed: 0,
2332            }
2333        );
2334
2335        let latency = comparison
2336            .matched
2337            .iter()
2338            .find(|case| case.identity.name() == "create")
2339            .unwrap();
2340        assert_eq!(
2341            latency.current.primary.measurement,
2342            MeasurementKind::Latency
2343        );
2344        assert_eq!(latency.current.primary.samples, vec![90.0, 100.0, 110.0]);
2345        assert_eq!(latency.current.primary.value, Some(100.0));
2346        assert_eq!(latency.current.statistics.p95, Some(109.0));
2347        assert_eq!(latency.current.statistics.mad, Some(10.0));
2348        assert_approximately(latency.current.statistics.cv_percent, 8.16496580927726);
2349        assert_approximately(latency.percent_improvement, 9.090909090909092);
2350        assert_eq!(latency.current.provenance["image_digest"], "sha256:current");
2351        assert_eq!(
2352            latency.baseline.provenance["image_digest"],
2353            "sha256:baseline"
2354        );
2355
2356        let invalid = comparison
2357            .matched
2358            .iter()
2359            .find(|case| case.identity.name() == "quality-score")
2360            .unwrap();
2361        assert_eq!(invalid.current.validity.status, ValidityStatus::Invalid);
2362        assert_eq!(invalid.current.primary.value, None);
2363        assert_eq!(invalid.percent_improvement, None);
2364        assert!(
2365            invalid
2366                .stability_warnings
2367                .iter()
2368                .any(|warning| warning.contains("checksum mismatch"))
2369        );
2370    }
2371
2372    #[test]
2373    fn series_measurement_direction_controls_improvement() {
2374        let current =
2375            ReportDocument::load_from_path(series_fixture("python-current.json")).unwrap();
2376        let baseline =
2377            ReportDocument::load_from_path(series_fixture("rust-baseline.json")).unwrap();
2378        let comparison =
2379            compare_reports(&current, &baseline, &ComparisonOptions::default()).unwrap();
2380
2381        let improvement = |name: &str| {
2382            comparison
2383                .matched
2384                .iter()
2385                .find(|case| case.identity.name() == name)
2386                .unwrap()
2387                .percent_improvement
2388        };
2389        assert_approximately(improvement("requests"), 20.0);
2390        assert_approximately(improvement("peak-memory"), 9.523809523809524);
2391        assert_approximately(improvement("occupancy"), 14.084507042253536);
2392        assert_eq!(improvement("quality-score"), None);
2393    }
2394
2395    #[test]
2396    fn series_statistics_are_derived_from_raw_samples() {
2397        let result = SeriesResult::new(
2398            "group",
2399            "case",
2400            MeasurementKind::Latency,
2401            "ms",
2402            MeasurementDirection::Lower,
2403            vec![10.0, 10.0, 10.0, 10.0, 100.0],
2404        );
2405        let current = SeriesReport::new(
2406            "current",
2407            "suite",
2408            ReportContext::new("runner"),
2409            vec![result.clone()],
2410        );
2411        let baseline = SeriesReport::new(
2412            "baseline",
2413            "suite",
2414            ReportContext::new("runner"),
2415            vec![result],
2416        );
2417        let comparison = current
2418            .compare(&baseline, &ComparisonOptions::default())
2419            .unwrap();
2420        let snapshot = &comparison.matched[0].current;
2421
2422        assert_eq!(snapshot.primary.value, Some(10.0));
2423        assert_approximately(snapshot.statistics.p95, 82.0);
2424        assert_eq!(snapshot.statistics.mad, Some(0.0));
2425        assert_eq!(snapshot.statistics.outliers, 1);
2426        assert_eq!(snapshot.statistics.samples, 5);
2427        assert_approximately(snapshot.statistics.cv_percent, 128.57142857142858);
2428    }
2429
2430    #[test]
2431    fn dimensions_affect_series_identity_but_provenance_does_not() {
2432        let current = SeriesReport::load_from_path(series_fixture("python-current.json")).unwrap();
2433        let mut baseline =
2434            SeriesReport::load_from_path(series_fixture("rust-baseline.json")).unwrap();
2435        baseline.results[0]
2436            .provenance
2437            .insert("image_digest".to_string(), "sha256:other".to_string());
2438        assert_eq!(
2439            current
2440                .compare(&baseline, &ComparisonOptions::default())
2441                .unwrap()
2442                .summary
2443                .matched,
2444            5
2445        );
2446
2447        baseline.results[0]
2448            .dimensions
2449            .insert("model".to_string(), "fixture-v2".to_string());
2450        assert_eq!(
2451            current
2452                .compare(&baseline, &ComparisonOptions::default())
2453                .unwrap_err(),
2454            ComparisonError::ResultSetMismatch {
2455                added: 1,
2456                removed: 1,
2457            }
2458        );
2459    }
2460
2461    #[test]
2462    fn measurement_unit_and_direction_participate_in_series_identity() {
2463        let current = SeriesReport::load_from_path(series_fixture("python-current.json")).unwrap();
2464        let baseline = SeriesReport::load_from_path(series_fixture("rust-baseline.json")).unwrap();
2465
2466        for mutate in [
2467            |result: &mut SeriesResult| result.measurement = MeasurementKind::Memory,
2468            |result: &mut SeriesResult| result.unit = "seconds".to_string(),
2469            |result: &mut SeriesResult| result.direction = MeasurementDirection::Higher,
2470        ] {
2471            let mut changed = baseline.clone();
2472            mutate(&mut changed.results[0]);
2473            assert_eq!(
2474                current
2475                    .compare(&changed, &ComparisonOptions::default())
2476                    .unwrap_err(),
2477                ComparisonError::ResultSetMismatch {
2478                    added: 1,
2479                    removed: 1,
2480                }
2481            );
2482        }
2483    }
2484
2485    #[test]
2486    fn invalid_series_report_is_retained_but_not_comparable() {
2487        let current = SeriesReport::load_from_path(series_fixture("python-current.json")).unwrap();
2488        let baseline = SeriesReport::load_from_path(series_fixture("rust-baseline.json")).unwrap();
2489        let current = current.with_validity(Validity::invalid("shared setup failed"));
2490
2491        assert_eq!(
2492            current
2493                .compare(&baseline, &ComparisonOptions::default())
2494                .unwrap_err(),
2495            ComparisonError::InvalidReport {
2496                side: ComparisonSide::Current,
2497                reason: "shared setup failed".to_string(),
2498            }
2499        );
2500    }
2501
2502    #[test]
2503    fn series_loading_distinguishes_structure_schema_and_semantics() {
2504        let missing_validity = br#"{
2505            "document_type":"micromeasure-series",
2506            "schema_version":1,
2507            "timestamp":"now",
2508            "suite":"suite",
2509            "context":{"runner_id":"runner","environment":{},"provenance":{}},
2510            "results":[]
2511        }"#;
2512        assert!(matches!(
2513            parse_series_report(missing_validity, None),
2514            Err(ReportError::MalformedReport { .. })
2515        ));
2516
2517        let future = br#"{"document_type":"micromeasure-series","schema_version":999}"#;
2518        assert!(matches!(
2519            parse_series_report(future, None),
2520            Err(ReportError::UnsupportedSchema { found: 999, .. })
2521        ));
2522
2523        let invalid_reason = br#"{
2524            "document_type":"micromeasure-series",
2525            "schema_version":1,
2526            "timestamp":"now",
2527            "suite":"suite",
2528            "validity":{"status":"invalid"},
2529            "context":{"runner_id":"runner","environment":{},"provenance":{}},
2530            "results":[{
2531                "group":"g","name":"n","measurement":"custom","unit":"u",
2532                "direction":"informational","samples":[],"validity":{"status":"valid"}
2533            }]
2534        }"#;
2535        assert!(matches!(
2536            parse_series_report(invalid_reason, None),
2537            Err(ReportError::InvalidReport { .. })
2538        ));
2539
2540        let unsupported = br#"{"document_type":"other","schema_version":1}"#;
2541        assert!(matches!(
2542            parse_report_document(unsupported, None),
2543            Err(ReportError::UnsupportedDocumentType { .. })
2544        ));
2545    }
2546
2547    #[test]
2548    fn duplicate_series_identities_are_explicit_errors() {
2549        let current = SeriesReport::load_from_path(series_fixture("python-current.json")).unwrap();
2550        let baseline = SeriesReport::load_from_path(series_fixture("rust-baseline.json")).unwrap();
2551        let mut current = current;
2552        current.results.push(current.results[0].clone());
2553
2554        assert!(matches!(
2555            current
2556                .compare(&baseline, &ComparisonOptions::default())
2557                .unwrap_err(),
2558            ComparisonError::DuplicateIdentity {
2559                side: ComparisonSide::Current,
2560                identity: ComparisonCaseIdentity::Series(_),
2561            }
2562        ));
2563    }
2564
2565    #[test]
2566    fn native_and_series_documents_use_shared_partial_matching() {
2567        let native = ReportDocument::from_native(report(&[("create", 100.0)])).unwrap();
2568        let mut series =
2569            SeriesReport::load_from_path(series_fixture("rust-baseline.json")).unwrap();
2570        series.suite = "suite-a".to_string();
2571        series.context = ReportContext::new("host-a");
2572        series.results.truncate(1);
2573        let series = ReportDocument::from_series(series).unwrap();
2574
2575        let comparison = compare_reports(
2576            &native,
2577            &series,
2578            &ComparisonOptions::default().allow_partial_result_set(true),
2579        )
2580        .unwrap();
2581        assert_eq!(
2582            comparison.summary,
2583            ComparisonSummary {
2584                matched: 0,
2585                added: 1,
2586                removed: 1,
2587            }
2588        );
2589        assert_eq!(
2590            comparison.current.document_type,
2591            ReportDocumentType::NativeBenchmark
2592        );
2593        assert_eq!(
2594            comparison.baseline.document_type,
2595            ReportDocumentType::Series
2596        );
2597    }
2598
2599    #[test]
2600    fn older_comparison_documents_default_new_series_fields() {
2601        let comparison = report(&[("a", 120.0)])
2602            .compare(&report(&[("a", 100.0)]), &ComparisonOptions::default())
2603            .unwrap();
2604        let mut document = serde_json::to_value(comparison).unwrap();
2605        let matched = document["matched"].as_array_mut().unwrap();
2606        for side in ["current", "baseline"] {
2607            let snapshot = matched[0][side].as_object_mut().unwrap();
2608            snapshot.remove("validity");
2609            snapshot.remove("provenance");
2610            snapshot["primary"]
2611                .as_object_mut()
2612                .unwrap()
2613                .remove("samples");
2614            snapshot["statistics"]
2615                .as_object_mut()
2616                .unwrap()
2617                .remove("p95");
2618        }
2619
2620        let comparison: ComparisonReport = serde_json::from_value(document).unwrap();
2621        assert_eq!(comparison.matched[0].current.validity, Validity::valid());
2622        assert!(comparison.matched[0].current.primary.samples.is_empty());
2623        assert_eq!(comparison.matched[0].current.statistics.p95, None);
2624    }
2625}