1use 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
32pub const COMPARISON_SCHEMA_VERSION: u32 = 1;
34
35#[derive(Clone, Debug, Default, Eq, PartialEq)]
41#[non_exhaustive]
42pub struct ComparisonOptions {
43 pub allow_partial_result_set: bool,
44 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 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#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
76#[non_exhaustive]
77pub struct EnvironmentOverride {
78 pub reason: String,
79}
80
81#[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#[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#[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#[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#[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 #[serde(default)]
145 pub pmu_scope: PmuScope,
146 #[serde(default)]
149 pub pmu_counter_profile: PmuCounterProfile,
150 #[serde(default)]
153 pub energy_scope: EnergyScope,
154 #[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#[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#[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#[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 #[serde(default, skip_serializing_if = "Vec::is_empty")]
243 pub samples: Vec<f64>,
244 pub value: Option<f64>,
247}
248
249#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
251#[non_exhaustive]
252pub struct ComparisonStatistics {
253 pub cv_percent: Option<f64>,
254 pub mad: Option<f64>,
256 #[serde(default)]
258 pub p95: Option<f64>,
259 pub samples: usize,
260 pub outliers: usize,
261}
262
263#[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#[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#[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 pub percent_change: Option<f64>,
304}
305
306#[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 pub absolute_change: Option<f64>,
315 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#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
326#[non_exhaustive]
327pub struct UnmatchedBenchmark {
328 pub identity: ComparisonCaseIdentity,
329 pub measurement: ComparisonCaseSnapshot,
330}
331
332#[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#[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#[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 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 #[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#[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 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 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 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 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#[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#[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#[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 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 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(¤t, &baseline, options)
750 }
751}
752
753impl SeriesReport {
754 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 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(¤t, &baseline, options)
773 }
774}
775
776pub 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(¤t.validity, ComparisonSide::Current)?;
801 validate_report_validity(&baseline.validity, ComparisonSide::Baseline)?;
802 validate_nonempty_result_set(¤t.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(¤t.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(¤t.cases, ComparisonSide::Current)?;
846 reject_duplicate_identities(&baseline.cases, ComparisonSide::Baseline)?;
847
848 let pairs = pair_normalized_cases(¤t.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 ¤t.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 ¤t.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(¤t.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(¤t_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(¤t_path, ¤t_bytes).unwrap();
2060 fs::write(&baseline_path, &baseline_bytes).unwrap();
2061
2062 let current = ReportDocument::load_from_path(¤t_path).unwrap();
2063 let baseline = ReportDocument::load_from_path(&baseline_path).unwrap();
2064 let comparison =
2065 compare_reports(¤t, &baseline, &ComparisonOptions::default()).unwrap();
2066 assert_eq!(
2067 comparison.current.content_digest,
2068 sha256_digest(¤t_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(¤t_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(¤t_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(¤t_path).unwrap();
2301 let baseline_bytes = fs::read(&baseline_path).unwrap();
2302 let current = ReportDocument::load_from_path(¤t_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(¤t_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(¤t, &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(¤t, &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}