1pub mod baseline_service;
26pub mod config;
27mod defaults_config;
28pub mod error;
29pub mod fingerprint;
30mod io;
31mod paired;
32mod repair_context;
33mod structured_evidence;
34pub mod validation;
35
36pub use paired::{
37 NoiseDiagnostics, NoiseLevel, PAIRED_SCHEMA_V1, PairedBenchMeta, PairedDiffSummary,
38 PairedRunReceipt, PairedSample, PairedSampleHalf, PairedStats,
39};
40
41pub use defaults_config::*;
42pub use io::{ReadJsonError, read_json_file};
43pub use repair_context::*;
44pub use structured_evidence::*;
45
46pub use validation::{
47 BENCH_NAME_MAX_LEN, BENCH_NAME_PATTERN, ValidationError as BenchNameValidationError,
48 validate_bench_name,
49};
50
51pub use error::{ConfigValidationError, PerfgateError};
52
53use schemars::JsonSchema;
54use serde::{Deserialize, Serialize};
55use std::collections::BTreeMap;
56
57pub const RUN_SCHEMA_V1: &str = "perfgate.run.v1";
58pub const AGGREGATE_SCHEMA_V1: &str = "perfgate.aggregate.v1";
59pub const BASELINE_SCHEMA_V1: &str = "perfgate.baseline.v1";
60pub const COMPARE_SCHEMA_V1: &str = "perfgate.compare.v1";
61pub const PROBE_SCHEMA_V1: &str = "perfgate.probe.v1";
62pub const PROBE_COMPARE_SCHEMA_V1: &str = "perfgate.probe_compare.v1";
63pub const SCENARIO_SCHEMA_V1: &str = "perfgate.scenario.v1";
64pub const TRADEOFF_SCHEMA_V1: &str = "perfgate.tradeoff.v1";
65pub const DECISION_INDEX_SCHEMA_V1: &str = "perfgate.decision_index.v1";
66pub const DECISION_BUNDLE_SCHEMA_V1: &str = "perfgate.decision_bundle.v1";
67pub const REPORT_SCHEMA_V1: &str = "perfgate.report.v1";
68pub const CONFIG_SCHEMA_V1: &str = "perfgate.config.v1";
69pub const RATCHET_SCHEMA_V1: &str = "perfgate.ratchet.v1";
70pub const REPAIR_CONTEXT_SCHEMA_V1: &str = "perfgate.repair_context.v1";
71
72pub const CHECK_ID_BUDGET: &str = "perf.budget";
74pub const CHECK_ID_BASELINE: &str = "perf.baseline";
75pub const CHECK_ID_COMPLEXITY: &str = "perf.complexity";
76pub const CHECK_ID_HOST: &str = "perf.host";
77pub const CHECK_ID_TOOL_RUNTIME: &str = "tool.runtime";
78pub const FINDING_CODE_METRIC_WARN: &str = "metric_warn";
79pub const FINDING_CODE_METRIC_FAIL: &str = "metric_fail";
80pub const FINDING_CODE_BASELINE_MISSING: &str = "missing";
81pub const FINDING_CODE_HOST_MISMATCH: &str = "host_mismatch";
82pub const FINDING_CODE_RUNTIME_ERROR: &str = "runtime_error";
83pub const FINDING_CODE_COMPLEXITY_FAIL: &str = "complexity_fail";
84pub const FINDING_CODE_COMPLEXITY_INCONCLUSIVE: &str = "complexity_inconclusive";
85pub const VERDICT_REASON_NO_BASELINE: &str = "no_baseline";
86pub const VERDICT_REASON_HOST_MISMATCH: &str = "host_mismatch";
87pub const VERDICT_REASON_TOOL_ERROR: &str = "tool_error";
88pub const VERDICT_REASON_TRUNCATED: &str = "truncated";
89pub const VERDICT_REASON_TRADEOFF_RULE_NOT_SATISFIED: &str = "tradeoff_rule_not_satisfied";
90pub const VERDICT_REASON_TRADEOFF_MISSING_REQUIRED_METRIC: &str =
91 "tradeoff_missing_required_metric";
92pub const VERDICT_REASON_TRADEOFF_REVIEW_REQUIRED: &str = "tradeoff_review_required";
93pub const VERDICT_REASON_COMPLEXITY_EXPECTED_EXCEEDED: &str = "complexity_expected_exceeded";
94pub const VERDICT_REASON_COMPLEXITY_FIT_LOW_CONFIDENCE: &str = "complexity_fit_low_confidence";
95pub const VERDICT_REASON_COMPLEXITY_MEASUREMENT_INCOMPLETE: &str =
96 "complexity_measurement_incomplete";
97
98pub const STAGE_CONFIG_PARSE: &str = "config_parse";
100pub const STAGE_BASELINE_RESOLVE: &str = "baseline_resolve";
101pub const STAGE_RUN_COMMAND: &str = "run_command";
102pub const STAGE_WRITE_ARTIFACTS: &str = "write_artifacts";
103
104pub const ERROR_KIND_IO: &str = "io_error";
106pub const ERROR_KIND_PARSE: &str = "parse_error";
107pub const ERROR_KIND_EXEC: &str = "exec_error";
108
109pub const BASELINE_REASON_NO_BASELINE: &str = "no_baseline";
111
112pub const CHECK_ID_TOOL_TRUNCATION: &str = "tool.truncation";
114pub const FINDING_CODE_TRUNCATED: &str = "truncated";
115pub const MAX_FINDINGS_DEFAULT: usize = 100;
116
117pub const SENSOR_REPORT_SCHEMA_V1: &str = "sensor.report.v1";
119
120#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
126#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
127#[serde(rename_all = "snake_case")]
128pub enum CapabilityStatus {
129 Available,
130 Unavailable,
131 Skipped,
132}
133
134#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
136#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
137pub struct Capability {
138 pub status: CapabilityStatus,
139 #[serde(skip_serializing_if = "Option::is_none")]
140 pub reason: Option<String>,
141}
142
143#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
145#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
146pub struct SensorCapabilities {
147 pub baseline: Capability,
148 #[serde(skip_serializing_if = "Option::is_none", default)]
149 pub engine: Option<Capability>,
150}
151
152#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
154#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
155pub struct SensorRunMeta {
156 pub started_at: String,
157 #[serde(skip_serializing_if = "Option::is_none")]
158 pub ended_at: Option<String>,
159 #[serde(skip_serializing_if = "Option::is_none")]
160 pub duration_ms: Option<u64>,
161 pub capabilities: SensorCapabilities,
162}
163
164#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
166#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
167#[serde(rename_all = "snake_case")]
168pub enum SensorVerdictStatus {
169 Pass,
170 Warn,
171 Fail,
172 Skip,
173}
174
175#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
177#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
178pub struct SensorVerdictCounts {
179 pub info: u32,
180 pub warn: u32,
181 pub error: u32,
182}
183
184#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
186#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
187pub struct SensorVerdict {
188 pub status: SensorVerdictStatus,
189 pub counts: SensorVerdictCounts,
190 pub reasons: Vec<String>,
191}
192
193#[derive(Debug, Copy, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
195#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
196#[serde(rename_all = "snake_case")]
197pub enum SensorSeverity {
198 Info,
199 Warn,
200 Error,
201}
202
203#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
205pub struct SensorFinding {
206 pub check_id: String,
207 pub code: String,
208 pub severity: SensorSeverity,
209 pub message: String,
210 #[serde(skip_serializing_if = "Option::is_none")]
211 pub fingerprint: Option<String>,
212 #[serde(skip_serializing_if = "Option::is_none")]
213 pub data: Option<serde_json::Value>,
214}
215
216#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
218#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
219pub struct SensorArtifact {
220 pub path: String,
221 #[serde(rename = "type")]
222 pub artifact_type: String,
223}
224
225#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
233pub struct SensorReport {
234 pub schema: String,
235 pub tool: ToolInfo,
236 pub run: SensorRunMeta,
237 pub verdict: SensorVerdict,
238 pub findings: Vec<SensorFinding>,
239 #[serde(skip_serializing_if = "Vec::is_empty", default)]
240 pub artifacts: Vec<SensorArtifact>,
241 pub data: serde_json::Value,
242}
243
244#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
245#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
246pub struct ToolInfo {
247 pub name: String,
248 pub version: String,
249}
250
251#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
252#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
253pub struct HostInfo {
254 pub os: String,
256
257 pub arch: String,
259
260 #[serde(skip_serializing_if = "Option::is_none", default)]
262 pub cpu_count: Option<u32>,
263
264 #[serde(skip_serializing_if = "Option::is_none", default)]
266 pub memory_bytes: Option<u64>,
267
268 #[serde(skip_serializing_if = "Option::is_none", default)]
271 pub hostname_hash: Option<String>,
272}
273
274#[derive(Debug, Copy, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)]
282#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
283#[serde(rename_all = "snake_case")]
284pub enum HostMismatchPolicy {
285 #[default]
287 Warn,
288 Error,
290 Ignore,
292}
293
294impl HostMismatchPolicy {
295 pub fn as_str(self) -> &'static str {
306 match self {
307 HostMismatchPolicy::Warn => "warn",
308 HostMismatchPolicy::Error => "error",
309 HostMismatchPolicy::Ignore => "ignore",
310 }
311 }
312}
313
314#[derive(Debug, Clone, PartialEq, Eq)]
316pub struct HostMismatchInfo {
317 pub reasons: Vec<String>,
319}
320
321#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
322#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
323pub struct RunMeta {
324 pub id: String,
325 pub started_at: String,
326 pub ended_at: String,
327 pub host: HostInfo,
328}
329
330#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
331#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
332pub struct BenchMeta {
333 pub name: String,
334
335 #[serde(skip_serializing_if = "Option::is_none")]
337 pub cwd: Option<String>,
338
339 pub command: Vec<String>,
341
342 pub repeat: u32,
343 pub warmup: u32,
344
345 #[serde(skip_serializing_if = "Option::is_none")]
346 pub work_units: Option<u64>,
347
348 #[serde(skip_serializing_if = "Option::is_none")]
349 pub timeout_ms: Option<u64>,
350}
351
352#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
353#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
354pub struct Sample {
355 pub wall_ms: u64,
356 pub exit_code: i32,
357
358 #[serde(default)]
359 pub warmup: bool,
360
361 #[serde(default)]
362 pub timed_out: bool,
363
364 #[serde(skip_serializing_if = "Option::is_none", default)]
366 pub cpu_ms: Option<u64>,
367
368 #[serde(skip_serializing_if = "Option::is_none", default)]
370 pub page_faults: Option<u64>,
371
372 #[serde(skip_serializing_if = "Option::is_none", default)]
374 pub ctx_switches: Option<u64>,
375 #[serde(skip_serializing_if = "Option::is_none", default)]
377 pub max_rss_kb: Option<u64>,
378
379 #[serde(skip_serializing_if = "Option::is_none", default)]
381 pub io_read_bytes: Option<u64>,
382
383 #[serde(skip_serializing_if = "Option::is_none", default)]
385 pub io_write_bytes: Option<u64>,
386
387 #[serde(skip_serializing_if = "Option::is_none", default)]
389 pub network_packets: Option<u64>,
390
391 #[serde(skip_serializing_if = "Option::is_none", default)]
393 pub energy_uj: Option<u64>,
394
395 #[serde(skip_serializing_if = "Option::is_none", default)]
397 pub binary_bytes: Option<u64>,
398
399 #[serde(skip_serializing_if = "Option::is_none", default)]
401 pub stdout: Option<String>,
402
403 #[serde(skip_serializing_if = "Option::is_none", default)]
405 pub stderr: Option<String>,
406}
407
408#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
409#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
410pub struct U64Summary {
411 pub median: u64,
412 pub min: u64,
413 pub max: u64,
414 #[serde(skip_serializing_if = "Option::is_none", default)]
415 pub mean: Option<f64>,
416 #[serde(skip_serializing_if = "Option::is_none", default)]
417 pub stddev: Option<f64>,
418}
419
420impl U64Summary {
421 pub fn new(median: u64, min: u64, max: u64) -> Self {
422 Self {
423 median,
424 min,
425 max,
426 mean: None,
427 stddev: None,
428 }
429 }
430
431 pub fn cv(&self) -> Option<f64> {
434 match (self.mean, self.stddev) {
435 (Some(mean), Some(stddev)) if mean > 0.0 => Some(stddev / mean),
436 _ => None,
437 }
438 }
439}
440
441#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
442#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
443pub struct F64Summary {
444 pub median: f64,
445 pub min: f64,
446 pub max: f64,
447 #[serde(skip_serializing_if = "Option::is_none", default)]
448 pub mean: Option<f64>,
449 #[serde(skip_serializing_if = "Option::is_none", default)]
450 pub stddev: Option<f64>,
451}
452
453impl F64Summary {
454 pub fn new(median: f64, min: f64, max: f64) -> Self {
455 Self {
456 median,
457 min,
458 max,
459 mean: None,
460 stddev: None,
461 }
462 }
463
464 pub fn cv(&self) -> Option<f64> {
467 match (self.mean, self.stddev) {
468 (Some(mean), Some(stddev)) if mean > 0.0 => Some(stddev / mean),
469 _ => None,
470 }
471 }
472}
473
474#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
498#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
499pub struct Stats {
500 pub wall_ms: U64Summary,
501
502 #[serde(skip_serializing_if = "Option::is_none", default)]
504 pub cpu_ms: Option<U64Summary>,
505
506 #[serde(skip_serializing_if = "Option::is_none", default)]
508 pub page_faults: Option<U64Summary>,
509
510 #[serde(skip_serializing_if = "Option::is_none", default)]
512 pub ctx_switches: Option<U64Summary>,
513
514 #[serde(skip_serializing_if = "Option::is_none", default)]
515 pub max_rss_kb: Option<U64Summary>,
516
517 #[serde(skip_serializing_if = "Option::is_none", default)]
519 pub io_read_bytes: Option<U64Summary>,
520
521 #[serde(skip_serializing_if = "Option::is_none", default)]
523 pub io_write_bytes: Option<U64Summary>,
524
525 #[serde(skip_serializing_if = "Option::is_none", default)]
527 pub network_packets: Option<U64Summary>,
528
529 #[serde(skip_serializing_if = "Option::is_none", default)]
531 pub energy_uj: Option<U64Summary>,
532
533 #[serde(skip_serializing_if = "Option::is_none", default)]
535 pub binary_bytes: Option<U64Summary>,
536
537 #[serde(skip_serializing_if = "Option::is_none", default)]
538 pub throughput_per_s: Option<F64Summary>,
539}
540
541#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
579#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
580pub struct RunReceipt {
581 pub schema: String,
582 pub tool: ToolInfo,
583 pub run: RunMeta,
584 pub bench: BenchMeta,
585 pub samples: Vec<Sample>,
586 pub stats: Stats,
587}
588
589#[derive(Debug, Copy, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)]
591#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
592#[serde(rename_all = "snake_case")]
593pub enum AggregationPolicy {
594 #[default]
596 All,
597 Majority,
599 Weighted,
601 Quorum,
603 FailIfNOfM,
605}
606
607impl AggregationPolicy {
608 pub fn as_str(self) -> &'static str {
609 match self {
610 AggregationPolicy::All => "all",
611 AggregationPolicy::Majority => "majority",
612 AggregationPolicy::Weighted => "weighted",
613 AggregationPolicy::Quorum => "quorum",
614 AggregationPolicy::FailIfNOfM => "fail_if_n_of_m",
615 }
616 }
617}
618
619#[derive(Debug, Copy, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)]
621#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
622#[serde(rename_all = "snake_case")]
623pub enum AggregateWeightMode {
624 #[default]
626 Configured,
627 InverseVariance,
629}
630
631impl AggregateWeightMode {
632 pub fn as_str(self) -> &'static str {
633 match self {
634 AggregateWeightMode::Configured => "configured",
635 AggregateWeightMode::InverseVariance => "inverse_variance",
636 }
637 }
638}
639
640#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
641#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
642pub struct AggregateRunnerMeta {
643 pub label: String,
645 #[serde(skip_serializing_if = "Option::is_none", default)]
647 pub class: Option<String>,
648 #[serde(skip_serializing_if = "Option::is_none", default)]
650 pub lane: Option<String>,
651 #[serde(skip_serializing_if = "Option::is_none", default)]
653 pub weight: Option<f64>,
654 #[serde(skip_serializing_if = "Option::is_none", default)]
656 pub sample_count: Option<u32>,
657 #[serde(skip_serializing_if = "Option::is_none", default)]
659 pub wall_ms_variance: Option<f64>,
660 #[serde(skip_serializing_if = "Option::is_none", default)]
662 pub effective_weight: Option<f64>,
663 #[serde(skip_serializing_if = "Option::is_none", default)]
665 pub outlier_reason: Option<String>,
666}
667
668#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
669#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
670pub struct AggregateInput {
671 pub source: String,
673 pub run_id: String,
674 pub bench_name: String,
675 pub host: HostInfo,
676 pub runner: AggregateRunnerMeta,
677 pub status: MetricStatus,
679 #[serde(skip_serializing_if = "Vec::is_empty", default)]
681 pub reasons: Vec<String>,
682}
683
684#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
685#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
686pub struct AggregateVerdict {
687 pub status: MetricStatus,
688 pub passed: u32,
689 pub failed: u32,
690 pub total: u32,
691 #[serde(skip_serializing_if = "Option::is_none", default)]
693 pub weighted_pass: Option<f64>,
694 #[serde(skip_serializing_if = "Option::is_none", default)]
696 pub weighted_total: Option<f64>,
697 #[serde(skip_serializing_if = "Option::is_none", default)]
699 pub required: Option<f64>,
700 #[serde(skip_serializing_if = "Option::is_none", default)]
702 pub outlier_runners: Option<u32>,
703 #[serde(skip_serializing_if = "Vec::is_empty", default)]
704 pub reasons: Vec<String>,
705}
706
707#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
708#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
709pub struct FailIfNOfM {
710 pub n: u32,
711 #[serde(skip_serializing_if = "Option::is_none", default)]
712 pub m: Option<u32>,
713}
714
715#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
716#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
717pub struct AggregateReceipt {
718 pub schema: String,
719 pub tool: ToolInfo,
720 pub run: RunMeta,
721 pub benchmark: String,
722 pub policy: AggregationPolicy,
723 #[serde(skip_serializing_if = "Option::is_none", default)]
724 pub quorum: Option<f64>,
725 #[serde(skip_serializing_if = "Option::is_none", default)]
726 pub fail_if: Option<FailIfNOfM>,
727 pub weight_mode: AggregateWeightMode,
728 #[serde(skip_serializing_if = "BTreeMap::is_empty", default)]
729 pub weights: BTreeMap<String, f64>,
730 #[serde(skip_serializing_if = "Option::is_none", default)]
731 pub variance_floor: Option<f64>,
732 pub inputs: Vec<AggregateInput>,
733 pub verdict: AggregateVerdict,
734 #[serde(skip_serializing_if = "Vec::is_empty", default)]
735 pub warnings: Vec<String>,
736}
737
738#[derive(
739 Debug, Copy, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq, PartialOrd, Ord, Hash,
740)]
741#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
742#[serde(rename_all = "snake_case")]
743pub enum Metric {
744 BinaryBytes,
745 CpuMs,
746 CtxSwitches,
747 EnergyUj,
748 IoReadBytes,
749 IoWriteBytes,
750 MaxRssKb,
751 NetworkPackets,
752 PageFaults,
753 ThroughputPerS,
754 WallMs,
755}
756
757impl Metric {
758 pub fn as_str(self) -> &'static str {
769 match self {
770 Metric::BinaryBytes => "binary_bytes",
771 Metric::CpuMs => "cpu_ms",
772 Metric::CtxSwitches => "ctx_switches",
773 Metric::EnergyUj => "energy_uj",
774 Metric::IoReadBytes => "io_read_bytes",
775 Metric::IoWriteBytes => "io_write_bytes",
776 Metric::MaxRssKb => "max_rss_kb",
777 Metric::NetworkPackets => "network_packets",
778 Metric::PageFaults => "page_faults",
779 Metric::ThroughputPerS => "throughput_per_s",
780 Metric::WallMs => "wall_ms",
781 }
782 }
783
784 pub fn parse_key(key: &str) -> Option<Self> {
796 match key {
797 "binary_bytes" => Some(Metric::BinaryBytes),
798 "cpu_ms" => Some(Metric::CpuMs),
799 "ctx_switches" => Some(Metric::CtxSwitches),
800 "energy_uj" => Some(Metric::EnergyUj),
801 "io_read_bytes" => Some(Metric::IoReadBytes),
802 "io_write_bytes" => Some(Metric::IoWriteBytes),
803 "max_rss_kb" => Some(Metric::MaxRssKb),
804 "network_packets" => Some(Metric::NetworkPackets),
805 "page_faults" => Some(Metric::PageFaults),
806 "throughput_per_s" => Some(Metric::ThroughputPerS),
807 "wall_ms" => Some(Metric::WallMs),
808 _ => None,
809 }
810 }
811
812 pub fn default_direction(self) -> Direction {
826 match self {
827 Metric::BinaryBytes => Direction::Lower,
828 Metric::CpuMs => Direction::Lower,
829 Metric::CtxSwitches => Direction::Lower,
830 Metric::EnergyUj => Direction::Lower,
831 Metric::IoReadBytes => Direction::Lower,
832 Metric::IoWriteBytes => Direction::Lower,
833 Metric::MaxRssKb => Direction::Lower,
834 Metric::NetworkPackets => Direction::Lower,
835 Metric::PageFaults => Direction::Lower,
836 Metric::ThroughputPerS => Direction::Higher,
837 Metric::WallMs => Direction::Lower,
838 }
839 }
840
841 pub fn default_warn_factor(self) -> f64 {
842 0.9
844 }
845
846 pub fn display_unit(self) -> &'static str {
858 match self {
859 Metric::BinaryBytes => "bytes",
860 Metric::CpuMs => "ms",
861 Metric::CtxSwitches => "count",
862 Metric::EnergyUj => "uj",
863 Metric::IoReadBytes => "bytes",
864 Metric::IoWriteBytes => "bytes",
865 Metric::MaxRssKb => "KB",
866 Metric::NetworkPackets => "count",
867 Metric::PageFaults => "count",
868 Metric::ThroughputPerS => "/s",
869 Metric::WallMs => "ms",
870 }
871 }
872}
873
874#[derive(Debug, Copy, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)]
875#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
876#[serde(rename_all = "snake_case")]
877pub enum MetricStatistic {
878 #[default]
879 Median,
880 P95,
881}
882
883impl MetricStatistic {
884 pub fn as_str(self) -> &'static str {
895 match self {
896 MetricStatistic::Median => "median",
897 MetricStatistic::P95 => "p95",
898 }
899 }
900}
901
902fn is_default_metric_statistic(stat: &MetricStatistic) -> bool {
903 *stat == MetricStatistic::Median
904}
905
906#[derive(Debug, Copy, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
907#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
908#[serde(rename_all = "snake_case")]
909pub enum Direction {
910 Lower,
911 Higher,
912}
913
914#[derive(Debug, Copy, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
915#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
916pub struct Budget {
917 pub threshold: f64,
919
920 pub warn_threshold: f64,
922 #[serde(skip_serializing_if = "Option::is_none", default)]
925 pub noise_threshold: Option<f64>,
926
927 #[serde(default, skip_serializing_if = "is_default_noise_policy")]
929 pub noise_policy: NoisePolicy,
930
931 pub direction: Direction,
933}
934
935fn is_default_noise_policy(policy: &NoisePolicy) -> bool {
936 *policy == NoisePolicy::Ignore
937}
938
939impl Budget {
940 pub fn new(threshold: f64, warn_threshold: f64, direction: Direction) -> Self {
942 Self {
943 threshold,
944 warn_threshold,
945 noise_threshold: None,
946 noise_policy: NoisePolicy::Ignore,
947 direction,
948 }
949 }
950}
951
952#[derive(Debug, Copy, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
953#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
954#[serde(rename_all = "snake_case")]
955pub enum SignificanceTest {
956 WelchT,
957}
958
959#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
960#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
961pub struct Significance {
962 pub test: SignificanceTest,
963 pub p_value: Option<f64>,
964 pub alpha: f64,
965 pub significant: bool,
966 pub baseline_samples: u32,
967 pub current_samples: u32,
968 #[serde(skip_serializing_if = "Option::is_none")]
969 pub ci_lower: Option<f64>,
970 #[serde(skip_serializing_if = "Option::is_none")]
971 pub ci_upper: Option<f64>,
972}
973
974#[derive(Debug, Copy, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Default)]
976#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
977pub struct SignificancePolicy {
978 pub alpha: Option<f64>,
980 pub min_samples: Option<u32>,
982}
983
984#[derive(Debug, Copy, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)]
985#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
986#[serde(rename_all = "snake_case")]
987pub enum NoisePolicy {
988 #[default]
990 Ignore,
991 Warn,
993 Skip,
995}
996
997impl NoisePolicy {
998 pub fn as_str(self) -> &'static str {
999 match self {
1000 NoisePolicy::Ignore => "ignore",
1001 NoisePolicy::Warn => "warn",
1002 NoisePolicy::Skip => "skip",
1003 }
1004 }
1005}
1006
1007#[derive(Debug, Copy, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
1008#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
1009#[serde(rename_all = "snake_case")]
1010pub enum MetricStatus {
1011 Pass,
1012 Warn,
1013 Fail,
1014 Skip,
1015}
1016
1017impl MetricStatus {
1018 pub fn as_str(self) -> &'static str {
1030 match self {
1031 MetricStatus::Pass => "pass",
1032 MetricStatus::Warn => "warn",
1033 MetricStatus::Fail => "fail",
1034 MetricStatus::Skip => "skip",
1035 }
1036 }
1037}
1038
1039#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
1040#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
1041pub struct Delta {
1042 pub baseline: f64,
1043 pub current: f64,
1044
1045 pub ratio: f64,
1047
1048 pub pct: f64,
1050
1051 pub regression: f64,
1053
1054 #[serde(skip_serializing_if = "Option::is_none")]
1056 pub cv: Option<f64>,
1057
1058 #[serde(skip_serializing_if = "Option::is_none")]
1060 pub noise_threshold: Option<f64>,
1061
1062 #[serde(default, skip_serializing_if = "is_default_metric_statistic")]
1063 pub statistic: MetricStatistic,
1064
1065 #[serde(skip_serializing_if = "Option::is_none")]
1066 pub significance: Option<Significance>,
1067
1068 pub status: MetricStatus,
1069}
1070
1071#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
1072#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
1073pub struct CompareRef {
1074 #[serde(skip_serializing_if = "Option::is_none")]
1075 pub path: Option<String>,
1076
1077 #[serde(skip_serializing_if = "Option::is_none")]
1078 pub run_id: Option<String>,
1079}
1080
1081#[derive(Debug, Copy, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
1082#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
1083#[serde(rename_all = "snake_case")]
1084pub enum VerdictStatus {
1085 Pass,
1086 Warn,
1087 Fail,
1088 Skip,
1089}
1090
1091impl VerdictStatus {
1092 pub fn as_str(self) -> &'static str {
1093 match self {
1094 VerdictStatus::Pass => "pass",
1095 VerdictStatus::Warn => "warn",
1096 VerdictStatus::Fail => "fail",
1097 VerdictStatus::Skip => "skip",
1098 }
1099 }
1100}
1101
1102#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
1103#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
1104pub struct VerdictCounts {
1105 pub pass: u32,
1106 pub warn: u32,
1107 pub fail: u32,
1108 pub skip: u32,
1109}
1110
1111#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
1134#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
1135pub struct Verdict {
1136 pub status: VerdictStatus,
1137 pub counts: VerdictCounts,
1138 pub reasons: Vec<String>,
1139}
1140
1141#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
1170#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
1171pub struct CompareReceipt {
1172 pub schema: String,
1173 pub tool: ToolInfo,
1174
1175 pub bench: BenchMeta,
1176
1177 pub baseline_ref: CompareRef,
1178 pub current_ref: CompareRef,
1179
1180 pub budgets: BTreeMap<Metric, Budget>,
1181 pub deltas: BTreeMap<Metric, Delta>,
1182
1183 pub verdict: Verdict,
1184}
1185
1186#[derive(Debug, Copy, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
1192#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
1193#[serde(rename_all = "snake_case")]
1194pub enum Severity {
1195 Warn,
1196 Fail,
1197}
1198
1199#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
1201#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
1202pub struct FindingData {
1203 #[serde(rename = "metric_name")]
1205 pub metric_name: String,
1206
1207 pub baseline: f64,
1209
1210 pub current: f64,
1212
1213 #[serde(rename = "regression_pct")]
1215 pub regression_pct: f64,
1216
1217 pub threshold: f64,
1219
1220 pub direction: Direction,
1222}
1223
1224#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
1226#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
1227pub struct ReportFinding {
1228 #[serde(rename = "check_id")]
1230 pub check_id: String,
1231
1232 pub code: String,
1234
1235 pub severity: Severity,
1237
1238 pub message: String,
1240
1241 #[serde(skip_serializing_if = "Option::is_none")]
1243 pub data: Option<FindingData>,
1244}
1245
1246#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
1248#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
1249pub struct ReportSummary {
1250 #[serde(rename = "pass_count")]
1252 pub pass_count: u32,
1253
1254 #[serde(rename = "warn_count")]
1256 pub warn_count: u32,
1257
1258 #[serde(rename = "fail_count")]
1260 pub fail_count: u32,
1261
1262 #[serde(rename = "skip_count", default, skip_serializing_if = "is_zero_u32")]
1264 pub skip_count: u32,
1265
1266 #[serde(rename = "total_count")]
1268 pub total_count: u32,
1269}
1270
1271fn is_zero_u32(n: &u32) -> bool {
1272 *n == 0
1273}
1274
1275#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
1277#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
1278#[serde(rename_all = "snake_case")]
1279pub enum ComplexityGateStatus {
1280 Pass,
1281 Fail,
1282 Inconclusive,
1283}
1284
1285#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
1287#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
1288pub struct ComplexityGateResult {
1289 pub status: ComplexityGateStatus,
1290
1291 #[serde(skip_serializing_if = "Option::is_none")]
1292 pub reason: Option<String>,
1293
1294 #[serde(skip_serializing_if = "Option::is_none")]
1295 pub expected: Option<String>,
1296
1297 #[serde(skip_serializing_if = "Option::is_none")]
1298 pub observed: Option<String>,
1299
1300 #[serde(skip_serializing_if = "Option::is_none")]
1301 pub r_squared: Option<f64>,
1302
1303 pub r_squared_threshold: f64,
1304 pub message: String,
1305}
1306
1307#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
1309#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
1310pub struct PerfgateReport {
1311 #[serde(rename = "report_type")]
1313 pub report_type: String,
1314
1315 pub verdict: Verdict,
1317
1318 #[serde(skip_serializing_if = "Option::is_none")]
1320 pub compare: Option<CompareReceipt>,
1321
1322 pub findings: Vec<ReportFinding>,
1324
1325 pub summary: ReportSummary,
1327
1328 #[serde(default, skip_serializing_if = "Option::is_none")]
1330 pub complexity: Option<ComplexityGateResult>,
1331
1332 #[serde(default, skip_serializing_if = "Option::is_none")]
1334 pub profile_path: Option<String>,
1335}
1336
1337#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Default)]
1364#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
1365pub struct ConfigFile {
1366 #[serde(default)]
1367 pub defaults: DefaultsConfig,
1368
1369 #[serde(default)]
1371 pub baseline_server: BaselineServerConfig,
1372
1373 #[serde(default, skip_serializing_if = "DecisionPolicyConfig::is_default")]
1376 pub decision_policy: DecisionPolicyConfig,
1377
1378 #[serde(skip_serializing_if = "Option::is_none", default)]
1380 pub ratchet: Option<RatchetConfig>,
1381
1382 #[serde(default, rename = "bench")]
1383 pub benches: Vec<BenchConfigFile>,
1384
1385 #[serde(default, rename = "scenario")]
1387 pub scenarios: Vec<ScenarioConfigFile>,
1388
1389 #[serde(default, rename = "tradeoff")]
1392 pub tradeoffs: Vec<TradeoffRule>,
1393}
1394
1395impl ConfigFile {
1396 pub fn validate(&self) -> Result<(), String> {
1418 for bench in &self.benches {
1419 validate_bench_name(&bench.name).map_err(|e| e.to_string())?;
1420 }
1421 if self.decision_policy.require_low_noise_for_acceptance
1422 && self.decision_policy.max_cv.is_none()
1423 {
1424 return Err(
1425 "decision_policy.max_cv is required when require_low_noise_for_acceptance is true"
1426 .to_string(),
1427 );
1428 }
1429 if let Some(max_cv) = self.decision_policy.max_cv
1430 && (!max_cv.is_finite() || max_cv < 0.0)
1431 {
1432 return Err("decision_policy.max_cv must be a non-negative finite number".to_string());
1433 }
1434 for scenario in &self.scenarios {
1435 if scenario.name.trim().is_empty() {
1436 return Err("scenario name must not be empty".to_string());
1437 }
1438 if !scenario.weight.is_finite() || scenario.weight <= 0.0 {
1439 return Err(format!(
1440 "scenario '{}' weight must be a positive finite number",
1441 scenario.name
1442 ));
1443 }
1444 if scenario.bench.trim().is_empty() {
1445 return Err(format!(
1446 "scenario '{}' must reference a benchmark",
1447 scenario.name
1448 ));
1449 }
1450 if !self
1451 .benches
1452 .iter()
1453 .any(|bench| bench.name == scenario.bench)
1454 {
1455 return Err(format!(
1456 "scenario '{}' references unknown benchmark '{}'",
1457 scenario.name, scenario.bench
1458 ));
1459 }
1460 let has_probe_inputs =
1461 scenario.probe_baseline.is_some() || scenario.probe_current.is_some();
1462 if has_probe_inputs
1463 && (scenario
1464 .probe_baseline
1465 .as_ref()
1466 .is_none_or(|path| path.trim().is_empty())
1467 || scenario
1468 .probe_current
1469 .as_ref()
1470 .is_none_or(|path| path.trim().is_empty())
1471 || scenario
1472 .probe_compare
1473 .as_ref()
1474 .is_none_or(|path| path.trim().is_empty()))
1475 {
1476 return Err(format!(
1477 "scenario '{}' probe comparison requires probe_baseline, probe_current, and probe_compare",
1478 scenario.name
1479 ));
1480 }
1481 }
1482 for rule in &self.tradeoffs {
1483 if rule.name.trim().is_empty() {
1484 return Err("tradeoff name must not be empty".to_string());
1485 }
1486 if rule.require.is_empty() {
1487 return Err(format!(
1488 "tradeoff '{}' must require at least one compensating metric",
1489 rule.name
1490 ));
1491 }
1492 for requirement in &rule.require {
1493 if requirement
1494 .probe
1495 .as_ref()
1496 .is_some_and(|probe| probe.trim().is_empty())
1497 {
1498 return Err(format!(
1499 "tradeoff '{}' requirement probe must not be empty",
1500 rule.name
1501 ));
1502 }
1503 if !requirement.min_improvement_ratio.is_finite()
1504 || requirement.min_improvement_ratio <= 0.0
1505 {
1506 return Err(format!(
1507 "tradeoff '{}' requirement for '{}' must use a positive finite improvement ratio",
1508 rule.name,
1509 requirement.metric.as_str()
1510 ));
1511 }
1512 }
1513 for allowance in &rule.allow {
1514 if allowance.probe.trim().is_empty() {
1515 return Err(format!(
1516 "tradeoff '{}' allowance probe must not be empty",
1517 rule.name
1518 ));
1519 }
1520 if !allowance.max_regression.is_finite() || allowance.max_regression < 0.0 {
1521 return Err(format!(
1522 "tradeoff '{}' allowance for '{}' must use a non-negative finite max regression",
1523 rule.name,
1524 allowance.metric.as_str()
1525 ));
1526 }
1527 }
1528 }
1529 Ok(())
1530 }
1531}
1532
1533#[derive(Debug, Copy, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)]
1536#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
1537#[serde(rename_all = "snake_case")]
1538pub enum MissingNoisePolicy {
1539 #[default]
1541 NeedsReview,
1542 Accept,
1544}
1545
1546#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Default)]
1548#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
1549pub struct DecisionPolicyConfig {
1550 #[serde(default)]
1552 pub require_low_noise_for_acceptance: bool,
1553
1554 #[serde(skip_serializing_if = "Option::is_none", default)]
1556 pub max_cv: Option<f64>,
1557
1558 #[serde(default)]
1560 pub missing_noise: MissingNoisePolicy,
1561}
1562
1563impl DecisionPolicyConfig {
1564 pub fn is_default(&self) -> bool {
1565 self == &Self::default()
1566 }
1567}
1568
1569#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Default)]
1584#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
1585pub struct BaselineServerConfig {
1586 #[serde(skip_serializing_if = "Option::is_none", default)]
1588 pub url: Option<String>,
1589
1590 #[serde(skip_serializing_if = "Option::is_none", default)]
1592 pub api_key: Option<String>,
1593
1594 #[serde(skip_serializing_if = "Option::is_none", default)]
1596 pub project: Option<String>,
1597
1598 #[serde(default = "default_fallback_to_local")]
1600 pub fallback_to_local: bool,
1601}
1602
1603fn default_fallback_to_local() -> bool {
1604 true
1605}
1606
1607impl BaselineServerConfig {
1608 pub fn is_configured(&self) -> bool {
1610 self.url.is_some() && !self.url.as_ref().unwrap().is_empty()
1611 }
1612
1613 pub fn resolved_url(&self) -> Option<String> {
1615 std::env::var("PERFGATE_SERVER_URL")
1616 .ok()
1617 .or_else(|| self.url.clone())
1618 }
1619
1620 pub fn resolved_api_key(&self) -> Option<String> {
1622 std::env::var("PERFGATE_API_KEY")
1623 .ok()
1624 .or_else(|| self.api_key.clone())
1625 }
1626
1627 pub fn resolved_project(&self) -> Option<String> {
1629 std::env::var("PERFGATE_PROJECT")
1630 .ok()
1631 .or_else(|| self.project.clone())
1632 }
1633}
1634
1635#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
1636#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
1637pub struct BenchConfigFile {
1638 pub name: String,
1639
1640 #[serde(skip_serializing_if = "Option::is_none")]
1641 pub cwd: Option<String>,
1642
1643 #[serde(skip_serializing_if = "Option::is_none")]
1644 pub work: Option<u64>,
1645
1646 #[serde(skip_serializing_if = "Option::is_none")]
1648 pub timeout: Option<String>,
1649
1650 pub command: Vec<String>,
1652
1653 #[serde(skip_serializing_if = "Option::is_none")]
1655 pub repeat: Option<u32>,
1656
1657 #[serde(skip_serializing_if = "Option::is_none")]
1659 pub warmup: Option<u32>,
1660
1661 #[serde(skip_serializing_if = "Option::is_none")]
1662 pub metrics: Option<Vec<Metric>>,
1663
1664 #[serde(skip_serializing_if = "Option::is_none")]
1665 pub budgets: Option<BTreeMap<Metric, BudgetOverride>>,
1666
1667 #[serde(skip_serializing_if = "Option::is_none", default)]
1669 pub scaling: Option<ScalingConfig>,
1670}
1671
1672#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
1678#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
1679pub struct ScenarioConfigFile {
1680 pub name: String,
1681
1682 pub weight: f64,
1684
1685 pub bench: String,
1687
1688 #[serde(skip_serializing_if = "Option::is_none", default)]
1690 pub description: Option<String>,
1691
1692 #[serde(skip_serializing_if = "Option::is_none", default)]
1695 pub compare: Option<String>,
1696
1697 #[serde(skip_serializing_if = "Option::is_none", default)]
1701 pub probe_compare: Option<String>,
1702
1703 #[serde(skip_serializing_if = "Option::is_none", default)]
1707 pub probe_baseline: Option<String>,
1708
1709 #[serde(skip_serializing_if = "Option::is_none", default)]
1713 pub probe_current: Option<String>,
1714}
1715
1716#[derive(Debug, Copy, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)]
1718#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
1719#[serde(rename_all = "snake_case")]
1720pub enum RatchetMode {
1721 #[default]
1723 Threshold,
1724}
1725
1726#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
1728#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
1729pub struct RatchetConfig {
1730 #[serde(default)]
1731 pub enabled: bool,
1732 #[serde(default)]
1733 pub mode: RatchetMode,
1734 #[serde(default = "default_ratchet_min_improvement")]
1735 pub min_improvement: f64,
1736 #[serde(default = "default_ratchet_max_tightening")]
1737 pub max_tightening: f64,
1738 #[serde(default = "default_ratchet_require_significance")]
1739 pub require_significance: bool,
1740 #[serde(default = "default_ratchet_allow_metrics")]
1741 pub allow_metrics: Vec<Metric>,
1742}
1743
1744impl Default for RatchetConfig {
1745 fn default() -> Self {
1746 Self {
1747 enabled: false,
1748 mode: RatchetMode::Threshold,
1749 min_improvement: default_ratchet_min_improvement(),
1750 max_tightening: default_ratchet_max_tightening(),
1751 require_significance: default_ratchet_require_significance(),
1752 allow_metrics: default_ratchet_allow_metrics(),
1753 }
1754 }
1755}
1756
1757fn default_ratchet_min_improvement() -> f64 {
1758 0.05
1759}
1760
1761fn default_ratchet_max_tightening() -> f64 {
1762 0.10
1763}
1764
1765fn default_ratchet_require_significance() -> bool {
1766 true
1767}
1768
1769fn default_ratchet_allow_metrics() -> Vec<Metric> {
1770 vec![Metric::WallMs, Metric::CpuMs]
1771}
1772
1773#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
1775#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
1776pub struct RatchetChange {
1777 pub metric: Metric,
1778 pub field: String,
1779 pub old_value: f64,
1780 pub new_value: f64,
1781 pub reason: String,
1782}
1783
1784#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
1786#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
1787pub struct RatchetReceipt {
1788 pub schema: String,
1789 pub tool: ToolInfo,
1790 pub bench_name: String,
1791 pub compare_path: Option<String>,
1792 pub changes: Vec<RatchetChange>,
1793}
1794
1795#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
1797#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
1798pub struct ScalingConfig {
1799 pub sizes: Vec<u64>,
1801
1802 #[serde(skip_serializing_if = "Option::is_none")]
1804 pub expected: Option<String>,
1805
1806 #[serde(skip_serializing_if = "Option::is_none")]
1808 pub repeat: Option<u32>,
1809
1810 #[serde(skip_serializing_if = "Option::is_none")]
1812 pub r_squared_threshold: Option<f64>,
1813}
1814
1815#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Default)]
1816#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
1817pub struct BudgetOverride {
1818 #[serde(skip_serializing_if = "Option::is_none")]
1819 pub threshold: Option<f64>,
1820
1821 #[serde(skip_serializing_if = "Option::is_none")]
1822 pub direction: Option<Direction>,
1823
1824 #[serde(skip_serializing_if = "Option::is_none")]
1826 pub warn_factor: Option<f64>,
1827
1828 #[serde(skip_serializing_if = "Option::is_none")]
1829 pub noise_threshold: Option<f64>,
1830
1831 #[serde(skip_serializing_if = "Option::is_none")]
1832 pub noise_policy: Option<NoisePolicy>,
1833
1834 #[serde(skip_serializing_if = "Option::is_none")]
1835 pub statistic: Option<MetricStatistic>,
1836}
1837
1838#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
1840#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
1841pub struct TradeoffRequirement {
1842 pub metric: Metric,
1844
1845 #[serde(skip_serializing_if = "Option::is_none", default)]
1848 pub probe: Option<String>,
1849
1850 pub min_improvement_ratio: f64,
1855}
1856
1857#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
1859#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
1860pub struct TradeoffAllowance {
1861 pub metric: Metric,
1863
1864 pub probe: String,
1866
1867 pub max_regression: f64,
1871}
1872
1873#[derive(Debug, Copy, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)]
1875#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
1876#[serde(rename_all = "snake_case")]
1877pub enum TradeoffDowngrade {
1878 #[default]
1879 Warn,
1880 Pass,
1881}
1882
1883#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
1885#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
1886pub struct TradeoffRule {
1887 pub name: String,
1889
1890 pub if_failed: Metric,
1892
1893 #[schemars(length(min = 1))]
1895 pub require: Vec<TradeoffRequirement>,
1896
1897 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1899 pub allow: Vec<TradeoffAllowance>,
1900
1901 #[serde(default)]
1903 pub downgrade_to: TradeoffDowngrade,
1904}
1905
1906#[cfg(test)]
1907mod tests {
1908 use super::*;
1909
1910 #[test]
1911 fn metric_serde_keys_are_snake_case() {
1912 let mut m = BTreeMap::new();
1913 m.insert(Metric::WallMs, Budget::new(0.2, 0.18, Direction::Lower));
1914 let json = serde_json::to_string(&m).unwrap();
1915 assert!(json.contains("\"wall_ms\""));
1916 }
1917
1918 #[test]
1919 fn metric_metadata_and_parsing_are_consistent() {
1920 let cases = [
1921 (
1922 Metric::BinaryBytes,
1923 "binary_bytes",
1924 Direction::Lower,
1925 "bytes",
1926 ),
1927 (Metric::WallMs, "wall_ms", Direction::Lower, "ms"),
1928 (Metric::CpuMs, "cpu_ms", Direction::Lower, "ms"),
1929 (
1930 Metric::CtxSwitches,
1931 "ctx_switches",
1932 Direction::Lower,
1933 "count",
1934 ),
1935 (Metric::MaxRssKb, "max_rss_kb", Direction::Lower, "KB"),
1936 (Metric::PageFaults, "page_faults", Direction::Lower, "count"),
1937 (
1938 Metric::ThroughputPerS,
1939 "throughput_per_s",
1940 Direction::Higher,
1941 "/s",
1942 ),
1943 ];
1944
1945 for (metric, key, direction, unit) in cases {
1946 assert_eq!(metric.as_str(), key);
1947 assert_eq!(Metric::parse_key(key), Some(metric));
1948 assert_eq!(metric.default_direction(), direction);
1949 assert_eq!(metric.display_unit(), unit);
1950 assert!((metric.default_warn_factor() - 0.9).abs() < f64::EPSILON);
1951 }
1952
1953 assert!(Metric::parse_key("unknown").is_none());
1954
1955 assert_eq!(MetricStatistic::Median.as_str(), "median");
1956 assert_eq!(MetricStatistic::P95.as_str(), "p95");
1957 assert!(is_default_metric_statistic(&MetricStatistic::Median));
1958 assert!(!is_default_metric_statistic(&MetricStatistic::P95));
1959 }
1960
1961 #[test]
1962 fn status_and_policy_as_str_values() {
1963 assert_eq!(MetricStatus::Pass.as_str(), "pass");
1964 assert_eq!(MetricStatus::Warn.as_str(), "warn");
1965 assert_eq!(MetricStatus::Fail.as_str(), "fail");
1966
1967 assert_eq!(HostMismatchPolicy::Warn.as_str(), "warn");
1968 assert_eq!(HostMismatchPolicy::Error.as_str(), "error");
1969 assert_eq!(HostMismatchPolicy::Ignore.as_str(), "ignore");
1970 }
1971
1972 #[test]
1974 fn backward_compat_host_info_without_new_fields() {
1975 let json = r#"{"os":"linux","arch":"x86_64"}"#;
1977 let info: HostInfo = serde_json::from_str(json).expect("should parse old format");
1978 assert_eq!(info.os, "linux");
1979 assert_eq!(info.arch, "x86_64");
1980 assert!(info.cpu_count.is_none());
1981 assert!(info.memory_bytes.is_none());
1982 assert!(info.hostname_hash.is_none());
1983 }
1984
1985 #[test]
1986 fn host_info_minimal_json_snapshot() {
1987 let info = HostInfo {
1988 os: "linux".to_string(),
1989 arch: "x86_64".to_string(),
1990 cpu_count: None,
1991 memory_bytes: None,
1992 hostname_hash: None,
1993 };
1994
1995 let value = serde_json::to_value(&info).expect("serialize HostInfo");
1996 insta::assert_json_snapshot!(value, @r###"
1997 {
1998 "arch": "x86_64",
1999 "os": "linux"
2000 }
2001 "###);
2002 }
2003
2004 #[test]
2006 fn host_info_with_new_fields_serializes() {
2007 let info = HostInfo {
2008 os: "linux".to_string(),
2009 arch: "x86_64".to_string(),
2010 cpu_count: Some(8),
2011 memory_bytes: Some(16 * 1024 * 1024 * 1024),
2012 hostname_hash: Some("abc123".to_string()),
2013 };
2014
2015 let json = serde_json::to_string(&info).expect("should serialize");
2016 assert!(json.contains("\"cpu_count\":8"));
2017 assert!(json.contains("\"memory_bytes\":"));
2018 assert!(json.contains("\"hostname_hash\":\"abc123\""));
2019 }
2020
2021 #[test]
2023 fn host_info_omits_none_fields() {
2024 let info = HostInfo {
2025 os: "linux".to_string(),
2026 arch: "x86_64".to_string(),
2027 cpu_count: None,
2028 memory_bytes: None,
2029 hostname_hash: None,
2030 };
2031
2032 let json = serde_json::to_string(&info).expect("should serialize");
2033 assert!(!json.contains("cpu_count"));
2034 assert!(!json.contains("memory_bytes"));
2035 assert!(!json.contains("hostname_hash"));
2036 }
2037
2038 #[test]
2040 fn host_info_round_trip_with_all_fields() {
2041 let original = HostInfo {
2042 os: "macos".to_string(),
2043 arch: "aarch64".to_string(),
2044 cpu_count: Some(10),
2045 memory_bytes: Some(32 * 1024 * 1024 * 1024),
2046 hostname_hash: Some("deadbeef".repeat(8)),
2047 };
2048
2049 let json = serde_json::to_string(&original).expect("should serialize");
2050 let parsed: HostInfo = serde_json::from_str(&json).expect("should deserialize");
2051
2052 assert_eq!(original, parsed);
2053 }
2054
2055 #[test]
2056 fn validate_bench_name_valid() {
2057 assert!(validate_bench_name("my-bench").is_ok());
2058 assert!(validate_bench_name("bench_a").is_ok());
2059 assert!(validate_bench_name("path/to/bench").is_ok());
2060 assert!(validate_bench_name("bench.v2").is_ok());
2061 assert!(validate_bench_name("a").is_ok());
2062 assert!(validate_bench_name("123").is_ok());
2063 }
2064
2065 #[test]
2066 fn validate_bench_name_invalid() {
2067 assert!(validate_bench_name("bench|name").is_err());
2068 assert!(validate_bench_name("").is_err());
2069 assert!(validate_bench_name("bench name").is_err());
2070 assert!(validate_bench_name("bench@name").is_err());
2071 }
2072
2073 #[test]
2074 fn validate_bench_name_path_traversal() {
2075 assert!(validate_bench_name("../bench").is_err());
2076 assert!(validate_bench_name("bench/../x").is_err());
2077 assert!(validate_bench_name("./bench").is_err());
2078 assert!(validate_bench_name("bench/.").is_err());
2079 }
2080
2081 #[test]
2082 fn validate_bench_name_empty_segments() {
2083 assert!(validate_bench_name("/bench").is_err());
2084 assert!(validate_bench_name("bench/").is_err());
2085 assert!(validate_bench_name("bench//x").is_err());
2086 assert!(validate_bench_name("/").is_err());
2087 }
2088
2089 #[test]
2090 fn validate_bench_name_length_cap() {
2091 let name_64 = "a".repeat(BENCH_NAME_MAX_LEN);
2093 assert!(validate_bench_name(&name_64).is_ok());
2094
2095 let name_65 = "a".repeat(BENCH_NAME_MAX_LEN + 1);
2097 assert!(validate_bench_name(&name_65).is_err());
2098 }
2099
2100 #[test]
2101 fn validate_bench_name_case() {
2102 assert!(validate_bench_name("MyBench").is_err());
2103 assert!(validate_bench_name("BENCH").is_err());
2104 assert!(validate_bench_name("benchA").is_err());
2105 }
2106
2107 #[test]
2108 fn config_file_validate_catches_bad_bench_name() {
2109 let config = ConfigFile {
2110 defaults: DefaultsConfig::default(),
2111 baseline_server: BaselineServerConfig::default(),
2112 decision_policy: DecisionPolicyConfig::default(),
2113 tradeoffs: Vec::new(),
2114 ratchet: None,
2115 scenarios: Vec::new(),
2116 benches: vec![BenchConfigFile {
2117 name: "bad|name".to_string(),
2118 cwd: None,
2119 work: None,
2120 timeout: None,
2121 command: vec!["echo".to_string()],
2122 repeat: None,
2123 warmup: None,
2124 metrics: None,
2125 budgets: None,
2126
2127 scaling: None,
2128 }],
2129 };
2130 assert!(config.validate().is_err());
2131 }
2132
2133 #[test]
2134 fn perfgate_error_display_baseline_resolve() {
2135 use crate::error::IoError;
2136 let err = PerfgateError::Io(IoError::BaselineResolve("file not found".to_string()));
2137 assert_eq!(format!("{}", err), "baseline resolve: file not found");
2138 }
2139
2140 #[test]
2141 fn perfgate_error_display_artifact_write() {
2142 use crate::error::IoError;
2143 let err = PerfgateError::Io(IoError::ArtifactWrite("permission denied".to_string()));
2144 assert_eq!(format!("{}", err), "write artifacts: permission denied");
2145 }
2146
2147 #[test]
2148 fn perfgate_error_display_run_command() {
2149 use crate::error::IoError;
2150 let err = PerfgateError::Io(IoError::RunCommand {
2151 command: "echo".to_string(),
2152 reason: "spawn failed".to_string(),
2153 });
2154 assert_eq!(
2155 format!("{}", err),
2156 "failed to execute command \"echo\": spawn failed"
2157 );
2158 }
2159
2160 #[test]
2161 fn sensor_capabilities_backward_compat_without_engine() {
2162 let json = r#"{"baseline":{"status":"available"}}"#;
2163 let caps: SensorCapabilities =
2164 serde_json::from_str(json).expect("should parse without engine");
2165 assert_eq!(caps.baseline.status, CapabilityStatus::Available);
2166 assert!(caps.engine.is_none());
2167 }
2168
2169 #[test]
2170 fn sensor_capabilities_with_engine() {
2171 let caps = SensorCapabilities {
2172 baseline: Capability {
2173 status: CapabilityStatus::Available,
2174 reason: None,
2175 },
2176 engine: Some(Capability {
2177 status: CapabilityStatus::Available,
2178 reason: None,
2179 }),
2180 };
2181 let json = serde_json::to_string(&caps).unwrap();
2182 assert!(json.contains("\"engine\""));
2183 let parsed: SensorCapabilities = serde_json::from_str(&json).unwrap();
2184 assert_eq!(caps, parsed);
2185 }
2186
2187 #[test]
2188 fn sensor_capabilities_engine_omitted_when_none() {
2189 let caps = SensorCapabilities {
2190 baseline: Capability {
2191 status: CapabilityStatus::Available,
2192 reason: None,
2193 },
2194 engine: None,
2195 };
2196 let json = serde_json::to_string(&caps).unwrap();
2197 assert!(!json.contains("engine"));
2198 }
2199
2200 #[test]
2201 fn config_file_validate_passes_good_bench_names() {
2202 let config = ConfigFile {
2203 defaults: DefaultsConfig::default(),
2204 baseline_server: BaselineServerConfig::default(),
2205 decision_policy: DecisionPolicyConfig::default(),
2206 tradeoffs: Vec::new(),
2207 ratchet: None,
2208 scenarios: Vec::new(),
2209 benches: vec![BenchConfigFile {
2210 name: "my-bench".to_string(),
2211 cwd: None,
2212 work: None,
2213 timeout: None,
2214 command: vec!["echo".to_string()],
2215 repeat: None,
2216 warmup: None,
2217 metrics: None,
2218 budgets: None,
2219
2220 scaling: None,
2221 }],
2222 };
2223 assert!(config.validate().is_ok());
2224 }
2225
2226 #[test]
2227 fn config_file_validate_rejects_empty_tradeoff_requirements() {
2228 let config = ConfigFile {
2229 defaults: DefaultsConfig::default(),
2230 baseline_server: BaselineServerConfig::default(),
2231 decision_policy: DecisionPolicyConfig::default(),
2232 tradeoffs: vec![TradeoffRule {
2233 name: "empty".to_string(),
2234 if_failed: Metric::WallMs,
2235 require: Vec::new(),
2236 allow: Vec::new(),
2237 downgrade_to: TradeoffDowngrade::Warn,
2238 }],
2239 ratchet: None,
2240 scenarios: Vec::new(),
2241 benches: vec![BenchConfigFile {
2242 name: "my-bench".to_string(),
2243 cwd: None,
2244 work: None,
2245 timeout: None,
2246 command: vec!["echo".to_string()],
2247 repeat: None,
2248 warmup: None,
2249 metrics: None,
2250 budgets: None,
2251 scaling: None,
2252 }],
2253 };
2254
2255 assert!(config.validate().is_err());
2256 }
2257
2258 #[test]
2259 fn config_file_validate_rejects_invalid_tradeoff_rules() {
2260 let mut config = ConfigFile {
2261 defaults: DefaultsConfig::default(),
2262 baseline_server: BaselineServerConfig::default(),
2263 decision_policy: DecisionPolicyConfig::default(),
2264 tradeoffs: vec![TradeoffRule {
2265 name: "memory_for_speed".to_string(),
2266 if_failed: Metric::MaxRssKb,
2267 require: vec![TradeoffRequirement {
2268 metric: Metric::WallMs,
2269 probe: None,
2270 min_improvement_ratio: 1.10,
2271 }],
2272 allow: Vec::new(),
2273 downgrade_to: TradeoffDowngrade::Warn,
2274 }],
2275 ratchet: None,
2276 scenarios: Vec::new(),
2277 benches: vec![BenchConfigFile {
2278 name: "my-bench".to_string(),
2279 cwd: None,
2280 work: None,
2281 timeout: None,
2282 command: vec!["echo".to_string()],
2283 repeat: None,
2284 warmup: None,
2285 metrics: None,
2286 budgets: None,
2287 scaling: None,
2288 }],
2289 };
2290 assert!(config.validate().is_ok());
2291
2292 config.tradeoffs[0].name = " ".to_string();
2293 assert!(config.validate().unwrap_err().contains("must not be empty"));
2294
2295 config.tradeoffs[0].name = "memory_for_speed".to_string();
2296 config.tradeoffs[0].require[0].probe = Some(" ".to_string());
2297 assert!(config.validate().unwrap_err().contains("must not be empty"));
2298
2299 config.tradeoffs[0].require[0].probe = Some("parser.batch_loop".to_string());
2300 assert!(config.validate().is_ok());
2301
2302 config.tradeoffs[0].require[0].min_improvement_ratio = 0.0;
2303 assert!(config.validate().unwrap_err().contains("positive finite"));
2304
2305 config.tradeoffs[0].require[0].min_improvement_ratio = f64::NAN;
2306 assert!(config.validate().unwrap_err().contains("positive finite"));
2307
2308 config.tradeoffs[0].require[0].min_improvement_ratio = 1.10;
2309 config.tradeoffs[0].allow = vec![TradeoffAllowance {
2310 metric: Metric::WallMs,
2311 probe: " ".to_string(),
2312 max_regression: 0.03,
2313 }];
2314 assert!(config.validate().unwrap_err().contains("must not be empty"));
2315
2316 config.tradeoffs[0].allow[0].probe = "parser.tokenize".to_string();
2317 config.tradeoffs[0].allow[0].max_regression = -0.01;
2318 assert!(
2319 config
2320 .validate()
2321 .unwrap_err()
2322 .contains("non-negative finite")
2323 );
2324
2325 config.tradeoffs[0].allow[0].max_regression = f64::NAN;
2326 assert!(
2327 config
2328 .validate()
2329 .unwrap_err()
2330 .contains("non-negative finite")
2331 );
2332 }
2333
2334 #[test]
2335 fn config_file_validate_rejects_invalid_decision_policy() {
2336 let mut config: ConfigFile = toml::from_str(
2337 r#"
2338[decision_policy]
2339require_low_noise_for_acceptance = true
2340"#,
2341 )
2342 .expect("parse config");
2343
2344 assert!(
2345 config
2346 .validate()
2347 .unwrap_err()
2348 .contains("decision_policy.max_cv is required")
2349 );
2350
2351 config.decision_policy.max_cv = Some(-0.01);
2352 assert!(
2353 config
2354 .validate()
2355 .unwrap_err()
2356 .contains("non-negative finite")
2357 );
2358
2359 config.decision_policy.max_cv = Some(0.10);
2360 assert!(config.validate().is_ok());
2361 }
2362
2363 #[test]
2364 fn config_file_parses_decision_policy() {
2365 let config: ConfigFile = toml::from_str(
2366 r#"
2367[decision_policy]
2368require_low_noise_for_acceptance = true
2369max_cv = 0.10
2370missing_noise = "accept"
2371"#,
2372 )
2373 .expect("parse config");
2374
2375 assert!(config.decision_policy.require_low_noise_for_acceptance);
2376 assert_eq!(config.decision_policy.max_cv, Some(0.10));
2377 assert_eq!(
2378 config.decision_policy.missing_noise,
2379 MissingNoisePolicy::Accept
2380 );
2381 assert!(config.validate().is_ok());
2382 }
2383
2384 #[test]
2385 fn config_file_parses_weighted_scenarios() {
2386 let config: ConfigFile = toml::from_str(
2387 r#"
2388[defaults]
2389threshold = 0.20
2390out_dir = "artifacts/perfgate"
2391
2392[[bench]]
2393name = "large-file"
2394command = ["cargo", "bench", "--bench", "large_file"]
2395
2396[[scenario]]
2397name = "large_file_parse"
2398weight = 0.35
2399bench = "large-file"
2400description = "Parse a large file"
2401probe_compare = "artifacts/perfgate/large-file/probe-compare.json"
2402probe_baseline = "baselines/large-file-probes.json"
2403probe_current = "artifacts/perfgate/large-file/probes.json"
2404"#,
2405 )
2406 .expect("parse config");
2407
2408 assert_eq!(config.scenarios.len(), 1);
2409 assert_eq!(config.scenarios[0].name, "large_file_parse");
2410 assert_eq!(config.scenarios[0].bench, "large-file");
2411 assert_eq!(config.scenarios[0].weight, 0.35);
2412 assert_eq!(
2413 config.scenarios[0].probe_compare.as_deref(),
2414 Some("artifacts/perfgate/large-file/probe-compare.json")
2415 );
2416 assert_eq!(
2417 config.scenarios[0].probe_baseline.as_deref(),
2418 Some("baselines/large-file-probes.json")
2419 );
2420 assert_eq!(
2421 config.scenarios[0].probe_current.as_deref(),
2422 Some("artifacts/perfgate/large-file/probes.json")
2423 );
2424 assert!(config.validate().is_ok());
2425 }
2426
2427 #[test]
2428 fn config_file_validate_rejects_invalid_scenarios() {
2429 let mut config: ConfigFile = toml::from_str(
2430 r#"
2431[[bench]]
2432name = "large-file"
2433command = ["echo", "large"]
2434"#,
2435 )
2436 .expect("parse config");
2437
2438 config.scenarios = vec![ScenarioConfigFile {
2439 name: "unknown".to_string(),
2440 weight: 1.0,
2441 bench: "missing-bench".to_string(),
2442 description: None,
2443 compare: None,
2444 probe_compare: None,
2445 probe_baseline: None,
2446 probe_current: None,
2447 }];
2448 assert!(config.validate().unwrap_err().contains("unknown benchmark"));
2449
2450 config.scenarios[0].bench = "large-file".to_string();
2451 config.scenarios[0].weight = 0.0;
2452 assert!(config.validate().unwrap_err().contains("positive finite"));
2453
2454 config.scenarios[0].weight = 1.0;
2455 config.scenarios[0].name = " ".to_string();
2456 assert!(config.validate().unwrap_err().contains("must not be empty"));
2457
2458 config.scenarios[0].name = "large-file-workload".to_string();
2459 config.scenarios[0].probe_baseline = Some("baselines/probes.json".to_string());
2460 assert!(
2461 config
2462 .validate()
2463 .unwrap_err()
2464 .contains("probe comparison requires")
2465 );
2466
2467 config.scenarios[0].probe_current = Some("artifacts/perfgate/probes.json".to_string());
2468 config.scenarios[0].probe_compare =
2469 Some("artifacts/perfgate/probe-compare.json".to_string());
2470 assert!(config.validate().is_ok());
2471 }
2472
2473 #[test]
2476 fn run_receipt_serde_roundtrip_typical() {
2477 let receipt = RunReceipt {
2478 schema: RUN_SCHEMA_V1.to_string(),
2479 tool: ToolInfo {
2480 name: "perfgate".into(),
2481 version: "1.2.3".into(),
2482 },
2483 run: RunMeta {
2484 id: "abc-123".into(),
2485 started_at: "2024-06-15T10:00:00Z".into(),
2486 ended_at: "2024-06-15T10:00:05Z".into(),
2487 host: HostInfo {
2488 os: "linux".into(),
2489 arch: "x86_64".into(),
2490 cpu_count: Some(8),
2491 memory_bytes: Some(16_000_000_000),
2492 hostname_hash: Some("cafebabe".into()),
2493 },
2494 },
2495 bench: BenchMeta {
2496 name: "my-bench".into(),
2497 cwd: Some("/tmp".into()),
2498 command: vec!["echo".into(), "hello".into()],
2499 repeat: 5,
2500 warmup: 1,
2501 work_units: Some(1000),
2502 timeout_ms: Some(30000),
2503 },
2504 samples: vec![
2505 Sample {
2506 wall_ms: 100,
2507 exit_code: 0,
2508 warmup: true,
2509 timed_out: false,
2510 cpu_ms: Some(80),
2511 page_faults: Some(10),
2512 ctx_switches: Some(5),
2513 max_rss_kb: Some(2048),
2514 io_read_bytes: None,
2515 io_write_bytes: None,
2516 network_packets: None,
2517 energy_uj: None,
2518 binary_bytes: Some(4096),
2519 stdout: Some("ok".into()),
2520 stderr: None,
2521 },
2522 Sample {
2523 wall_ms: 95,
2524 exit_code: 0,
2525 warmup: false,
2526 timed_out: false,
2527 cpu_ms: Some(75),
2528 page_faults: None,
2529 ctx_switches: None,
2530 max_rss_kb: Some(2000),
2531 io_read_bytes: None,
2532 io_write_bytes: None,
2533 network_packets: None,
2534 energy_uj: None,
2535 binary_bytes: None,
2536 stdout: None,
2537 stderr: Some("warn".into()),
2538 },
2539 ],
2540 stats: Stats {
2541 wall_ms: U64Summary::new(95, 90, 100),
2542 cpu_ms: Some(U64Summary::new(75, 70, 80)),
2543 page_faults: Some(U64Summary::new(10, 10, 10)),
2544 ctx_switches: Some(U64Summary::new(5, 5, 5)),
2545 max_rss_kb: Some(U64Summary::new(2048, 2000, 2100)),
2546 io_read_bytes: None,
2547 io_write_bytes: None,
2548 network_packets: None,
2549 energy_uj: None,
2550 binary_bytes: Some(U64Summary::new(4096, 4096, 4096)),
2551 throughput_per_s: Some(F64Summary::new(10.526, 10.0, 11.111)),
2552 },
2553 };
2554 let json = serde_json::to_string(&receipt).unwrap();
2555 let back: RunReceipt = serde_json::from_str(&json).unwrap();
2556 assert_eq!(receipt, back);
2557 }
2558
2559 #[test]
2560 fn run_receipt_serde_roundtrip_edge_empty_samples() {
2561 let receipt = RunReceipt {
2562 schema: RUN_SCHEMA_V1.to_string(),
2563 tool: ToolInfo {
2564 name: "p".into(),
2565 version: "0".into(),
2566 },
2567 run: RunMeta {
2568 id: "".into(),
2569 started_at: "".into(),
2570 ended_at: "".into(),
2571 host: HostInfo {
2572 os: "".into(),
2573 arch: "".into(),
2574 cpu_count: None,
2575 memory_bytes: None,
2576 hostname_hash: None,
2577 },
2578 },
2579 bench: BenchMeta {
2580 name: "b".into(),
2581 cwd: None,
2582 command: vec![],
2583 repeat: 0,
2584 warmup: 0,
2585 work_units: None,
2586 timeout_ms: None,
2587 },
2588 samples: vec![],
2589 stats: Stats {
2590 wall_ms: U64Summary::new(0, 0, 0),
2591 cpu_ms: None,
2592 page_faults: None,
2593 ctx_switches: None,
2594 max_rss_kb: None,
2595 io_read_bytes: None,
2596 io_write_bytes: None,
2597 network_packets: None,
2598 energy_uj: None,
2599 binary_bytes: None,
2600 throughput_per_s: None,
2601 },
2602 };
2603 let json = serde_json::to_string(&receipt).unwrap();
2604 let back: RunReceipt = serde_json::from_str(&json).unwrap();
2605 assert_eq!(receipt, back);
2606 }
2607
2608 #[test]
2609 fn run_receipt_serde_roundtrip_edge_large_values() {
2610 let receipt = RunReceipt {
2611 schema: RUN_SCHEMA_V1.to_string(),
2612 tool: ToolInfo {
2613 name: "perfgate".into(),
2614 version: "99.99.99".into(),
2615 },
2616 run: RunMeta {
2617 id: "max-run".into(),
2618 started_at: "2099-12-31T23:59:59Z".into(),
2619 ended_at: "2099-12-31T23:59:59Z".into(),
2620 host: HostInfo {
2621 os: "linux".into(),
2622 arch: "aarch64".into(),
2623 cpu_count: Some(u32::MAX),
2624 memory_bytes: Some(u64::MAX),
2625 hostname_hash: None,
2626 },
2627 },
2628 bench: BenchMeta {
2629 name: "big".into(),
2630 cwd: None,
2631 command: vec!["run".into()],
2632 repeat: u32::MAX,
2633 warmup: u32::MAX,
2634 work_units: Some(u64::MAX),
2635 timeout_ms: Some(u64::MAX),
2636 },
2637 samples: vec![Sample {
2638 wall_ms: u64::MAX,
2639 exit_code: i32::MIN,
2640 warmup: false,
2641 timed_out: true,
2642 cpu_ms: Some(u64::MAX),
2643 page_faults: Some(u64::MAX),
2644 ctx_switches: Some(u64::MAX),
2645 max_rss_kb: Some(u64::MAX),
2646 io_read_bytes: None,
2647 io_write_bytes: None,
2648 network_packets: None,
2649 energy_uj: None,
2650 binary_bytes: Some(u64::MAX),
2651 stdout: None,
2652 stderr: None,
2653 }],
2654 stats: Stats {
2655 wall_ms: U64Summary::new(u64::MAX, 0, u64::MAX),
2656 cpu_ms: None,
2657 page_faults: None,
2658 ctx_switches: None,
2659 max_rss_kb: None,
2660 io_read_bytes: None,
2661 io_write_bytes: None,
2662 network_packets: None,
2663 energy_uj: None,
2664 binary_bytes: None,
2665 throughput_per_s: Some(F64Summary::new(f64::MAX, 0.0, f64::MAX)),
2666 },
2667 };
2668 let json = serde_json::to_string(&receipt).unwrap();
2669 let back: RunReceipt = serde_json::from_str(&json).unwrap();
2670 assert_eq!(receipt, back);
2671 }
2672
2673 #[test]
2674 fn compare_receipt_serde_roundtrip_typical() {
2675 let mut budgets = BTreeMap::new();
2676 budgets.insert(Metric::WallMs, Budget::new(0.2, 0.18, Direction::Lower));
2677 budgets.insert(Metric::MaxRssKb, Budget::new(0.15, 0.1, Direction::Lower));
2678
2679 let mut deltas = BTreeMap::new();
2680 deltas.insert(
2681 Metric::WallMs,
2682 Delta {
2683 baseline: 1000.0,
2684 current: 1100.0,
2685 ratio: 1.1,
2686 pct: 0.1,
2687 regression: 0.1,
2688 cv: None,
2689 noise_threshold: None,
2690 statistic: MetricStatistic::Median,
2691 significance: None,
2692 status: MetricStatus::Pass,
2693 },
2694 );
2695 deltas.insert(
2696 Metric::MaxRssKb,
2697 Delta {
2698 baseline: 2048.0,
2699 current: 2500.0,
2700 ratio: 1.2207,
2701 pct: 0.2207,
2702 regression: 0.2207,
2703 cv: None,
2704 noise_threshold: None,
2705 statistic: MetricStatistic::Median,
2706 significance: None,
2707 status: MetricStatus::Fail,
2708 },
2709 );
2710
2711 let receipt = CompareReceipt {
2712 schema: COMPARE_SCHEMA_V1.to_string(),
2713 tool: ToolInfo {
2714 name: "perfgate".into(),
2715 version: "1.0.0".into(),
2716 },
2717 bench: BenchMeta {
2718 name: "test".into(),
2719 cwd: None,
2720 command: vec!["echo".into()],
2721 repeat: 5,
2722 warmup: 0,
2723 work_units: None,
2724 timeout_ms: None,
2725 },
2726 baseline_ref: CompareRef {
2727 path: Some("base.json".into()),
2728 run_id: Some("r1".into()),
2729 },
2730 current_ref: CompareRef {
2731 path: Some("cur.json".into()),
2732 run_id: Some("r2".into()),
2733 },
2734 budgets,
2735 deltas,
2736 verdict: Verdict {
2737 status: VerdictStatus::Fail,
2738 counts: VerdictCounts {
2739 pass: 1,
2740 warn: 0,
2741 fail: 1,
2742 skip: 0,
2743 },
2744 reasons: vec!["max_rss_kb_fail".into()],
2745 },
2746 };
2747 let json = serde_json::to_string(&receipt).unwrap();
2748 let back: CompareReceipt = serde_json::from_str(&json).unwrap();
2749 assert_eq!(receipt, back);
2750 }
2751
2752 #[test]
2753 fn compare_receipt_serde_roundtrip_edge_empty_maps() {
2754 let receipt = CompareReceipt {
2755 schema: COMPARE_SCHEMA_V1.to_string(),
2756 tool: ToolInfo {
2757 name: "p".into(),
2758 version: "0".into(),
2759 },
2760 bench: BenchMeta {
2761 name: "b".into(),
2762 cwd: None,
2763 command: vec![],
2764 repeat: 0,
2765 warmup: 0,
2766 work_units: None,
2767 timeout_ms: None,
2768 },
2769 baseline_ref: CompareRef {
2770 path: None,
2771 run_id: None,
2772 },
2773 current_ref: CompareRef {
2774 path: None,
2775 run_id: None,
2776 },
2777 budgets: BTreeMap::new(),
2778 deltas: BTreeMap::new(),
2779 verdict: Verdict {
2780 status: VerdictStatus::Pass,
2781 counts: VerdictCounts {
2782 pass: 0,
2783 warn: 0,
2784 fail: 0,
2785 skip: 0,
2786 },
2787 reasons: vec![],
2788 },
2789 };
2790 let json = serde_json::to_string(&receipt).unwrap();
2791 let back: CompareReceipt = serde_json::from_str(&json).unwrap();
2792 assert_eq!(receipt, back);
2793 }
2794
2795 #[test]
2796 fn report_receipt_serde_roundtrip() {
2797 let report = PerfgateReport {
2798 report_type: REPORT_SCHEMA_V1.to_string(),
2799 verdict: Verdict {
2800 status: VerdictStatus::Warn,
2801 counts: VerdictCounts {
2802 pass: 1,
2803 warn: 1,
2804 fail: 0,
2805 skip: 0,
2806 },
2807 reasons: vec!["wall_ms_warn".into()],
2808 },
2809 compare: None,
2810 findings: vec![ReportFinding {
2811 check_id: CHECK_ID_BUDGET.into(),
2812 code: FINDING_CODE_METRIC_WARN.into(),
2813 severity: Severity::Warn,
2814 message: "Performance regression near threshold for wall_ms".into(),
2815 data: Some(FindingData {
2816 metric_name: "wall_ms".into(),
2817 baseline: 100.0,
2818 current: 119.0,
2819 regression_pct: 0.19,
2820 threshold: 0.2,
2821 direction: Direction::Lower,
2822 }),
2823 }],
2824 summary: ReportSummary {
2825 pass_count: 1,
2826 warn_count: 1,
2827 fail_count: 0,
2828 skip_count: 0,
2829 total_count: 2,
2830 },
2831 complexity: None,
2832 profile_path: None,
2833 };
2834 let json = serde_json::to_string(&report).unwrap();
2835 let back: PerfgateReport = serde_json::from_str(&json).unwrap();
2836 assert_eq!(report, back);
2837 }
2838
2839 #[test]
2840 fn config_file_serde_roundtrip_typical() {
2841 let config = ConfigFile {
2842 defaults: DefaultsConfig {
2843 noise_threshold: None,
2844 noise_policy: None,
2845 repeat: Some(10),
2846 warmup: Some(2),
2847 threshold: Some(0.2),
2848 warn_factor: Some(0.9),
2849 out_dir: Some("artifacts/perfgate".into()),
2850 baseline_dir: Some("baselines".into()),
2851 baseline_pattern: Some("baselines/{bench}.json".into()),
2852 markdown_template: None,
2853 },
2854 baseline_server: BaselineServerConfig::default(),
2855 decision_policy: DecisionPolicyConfig::default(),
2856 tradeoffs: Vec::new(),
2857 ratchet: None,
2858 scenarios: Vec::new(),
2859 benches: vec![BenchConfigFile {
2860 name: "my-bench".into(),
2861 cwd: Some("/home/user/project".into()),
2862 work: Some(1000),
2863 timeout: Some("5s".into()),
2864 command: vec!["cargo".into(), "bench".into()],
2865 repeat: Some(20),
2866 warmup: Some(3),
2867 metrics: Some(vec![Metric::WallMs, Metric::MaxRssKb]),
2868 budgets: Some({
2869 let mut m = BTreeMap::new();
2870 m.insert(
2871 Metric::WallMs,
2872 BudgetOverride {
2873 noise_threshold: None,
2874 noise_policy: None,
2875 threshold: Some(0.15),
2876 direction: Some(Direction::Lower),
2877 warn_factor: Some(0.85),
2878 statistic: Some(MetricStatistic::P95),
2879 },
2880 );
2881 m
2882 }),
2883 scaling: None,
2884 }],
2885 };
2886 let json = serde_json::to_string(&config).unwrap();
2887 let back: ConfigFile = serde_json::from_str(&json).unwrap();
2888 assert_eq!(config, back);
2889 }
2890
2891 #[test]
2892 fn config_file_serde_roundtrip_edge_empty() {
2893 let config = ConfigFile {
2894 defaults: DefaultsConfig::default(),
2895 baseline_server: BaselineServerConfig::default(),
2896 decision_policy: DecisionPolicyConfig::default(),
2897 tradeoffs: Vec::new(),
2898 ratchet: None,
2899 scenarios: Vec::new(),
2900 benches: vec![],
2901 };
2902 let json = serde_json::to_string(&config).unwrap();
2903 let back: ConfigFile = serde_json::from_str(&json).unwrap();
2904 assert_eq!(config, back);
2905 }
2906
2907 #[test]
2908 fn stats_serde_roundtrip_all_fields() {
2909 let stats = Stats {
2910 wall_ms: U64Summary::new(500, 100, 900),
2911 cpu_ms: Some(U64Summary::new(400, 80, 800)),
2912 page_faults: Some(U64Summary::new(50, 10, 100)),
2913 ctx_switches: Some(U64Summary::new(20, 5, 40)),
2914 max_rss_kb: Some(U64Summary::new(4096, 2048, 8192)),
2915 io_read_bytes: Some(U64Summary::new(1000, 500, 1500)),
2916 io_write_bytes: Some(U64Summary::new(500, 200, 800)),
2917 network_packets: Some(U64Summary::new(10, 5, 15)),
2918 energy_uj: None,
2919 binary_bytes: Some(U64Summary::new(1024, 1024, 1024)),
2920 throughput_per_s: Some(F64Summary::new(2.0, 1.111, 10.0)),
2921 };
2922 let json = serde_json::to_string(&stats).unwrap();
2923 let back: Stats = serde_json::from_str(&json).unwrap();
2924 assert_eq!(stats, back);
2925 }
2926
2927 #[test]
2928 fn stats_serde_roundtrip_edge_zeros() {
2929 let stats = Stats {
2930 wall_ms: U64Summary::new(0, 0, 0),
2931 cpu_ms: None,
2932 page_faults: None,
2933 ctx_switches: None,
2934 max_rss_kb: None,
2935 io_read_bytes: None,
2936 io_write_bytes: None,
2937 network_packets: None,
2938 energy_uj: None,
2939 binary_bytes: None,
2940 throughput_per_s: Some(F64Summary::new(0.0, 0.0, 0.0)),
2941 };
2942 let json = serde_json::to_string(&stats).unwrap();
2943 let back: Stats = serde_json::from_str(&json).unwrap();
2944 assert_eq!(stats, back);
2945 }
2946
2947 #[test]
2948 fn backward_compat_run_receipt_missing_host_extensions() {
2949 let json = r#"{
2951 "schema": "perfgate.run.v1",
2952 "tool": {"name": "perfgate", "version": "0.0.1"},
2953 "run": {
2954 "id": "old-run",
2955 "started_at": "2023-06-01T00:00:00Z",
2956 "ended_at": "2023-06-01T00:01:00Z",
2957 "host": {"os": "macos", "arch": "aarch64"}
2958 },
2959 "bench": {
2960 "name": "legacy",
2961 "command": ["./bench"],
2962 "repeat": 1,
2963 "warmup": 0
2964 },
2965 "samples": [{"wall_ms": 50, "exit_code": 0}],
2966 "stats": {
2967 "wall_ms": {"median": 50, "min": 50, "max": 50}
2968 }
2969 }"#;
2970
2971 let receipt: RunReceipt =
2972 serde_json::from_str(json).expect("old format without host extensions");
2973 assert_eq!(receipt.run.host.os, "macos");
2974 assert_eq!(receipt.run.host.arch, "aarch64");
2975 assert!(receipt.run.host.cpu_count.is_none());
2976 assert!(receipt.run.host.memory_bytes.is_none());
2977 assert!(receipt.run.host.hostname_hash.is_none());
2978 assert_eq!(receipt.bench.name, "legacy");
2979 assert_eq!(receipt.samples.len(), 1);
2980 assert!(!receipt.samples[0].warmup);
2981 assert!(!receipt.samples[0].timed_out);
2982 }
2983
2984 #[test]
2985 fn backward_compat_compare_receipt_without_significance() {
2986 let json = r#"{
2987 "schema": "perfgate.compare.v1",
2988 "tool": {"name": "perfgate", "version": "0.0.1"},
2989 "bench": {
2990 "name": "old-cmp",
2991 "command": ["echo"],
2992 "repeat": 3,
2993 "warmup": 0
2994 },
2995 "baseline_ref": {"path": "base.json"},
2996 "current_ref": {"path": "cur.json"},
2997 "budgets": {
2998 "wall_ms": {"threshold": 0.2, "warn_threshold": 0.1, "direction": "lower"}
2999 },
3000 "deltas": {
3001 "wall_ms": {
3002 "baseline": 100.0,
3003 "current": 105.0,
3004 "ratio": 1.05,
3005 "pct": 0.05,
3006 "regression": 0.05,
3007 "status": "pass"
3008 }
3009 },
3010 "verdict": {
3011 "status": "pass",
3012 "counts": {"pass": 1, "warn": 0, "fail": 0, "skip": 0},
3013 "reasons": []
3014 }
3015 }"#;
3016
3017 let receipt: CompareReceipt =
3018 serde_json::from_str(json).expect("compare without significance");
3019 assert_eq!(receipt.deltas.len(), 1);
3020 let delta = receipt.deltas.get(&Metric::WallMs).unwrap();
3021 assert!(delta.significance.is_none());
3022 assert_eq!(delta.statistic, MetricStatistic::Median); assert_eq!(delta.status, MetricStatus::Pass);
3024 }
3025
3026 #[test]
3027 fn backward_compat_unknown_fields_are_ignored() {
3028 let json = r#"{
3029 "schema": "perfgate.run.v1",
3030 "tool": {"name": "perfgate", "version": "0.1.0"},
3031 "run": {
3032 "id": "test",
3033 "started_at": "2024-01-01T00:00:00Z",
3034 "ended_at": "2024-01-01T00:01:00Z",
3035 "host": {"os": "linux", "arch": "x86_64", "future_field": "ignored"}
3036 },
3037 "bench": {
3038 "name": "test",
3039 "command": ["echo"],
3040 "repeat": 1,
3041 "warmup": 0,
3042 "some_new_option": true
3043 },
3044 "samples": [{"wall_ms": 10, "exit_code": 0, "extra_metric": 42}],
3045 "stats": {
3046 "wall_ms": {"median": 10, "min": 10, "max": 10},
3047 "new_metric": {"median": 1, "min": 1, "max": 1}
3048 },
3049 "new_top_level_field": "should be ignored"
3050 }"#;
3051
3052 let receipt: RunReceipt =
3053 serde_json::from_str(json).expect("unknown fields should be ignored");
3054 assert_eq!(receipt.bench.name, "test");
3055 assert_eq!(receipt.samples.len(), 1);
3056 }
3057
3058 #[test]
3059 fn roundtrip_run_receipt_all_optionals_none() {
3060 let receipt = RunReceipt {
3061 schema: RUN_SCHEMA_V1.to_string(),
3062 tool: ToolInfo {
3063 name: "perfgate".into(),
3064 version: "0.1.0".into(),
3065 },
3066 run: RunMeta {
3067 id: "rt".into(),
3068 started_at: "2024-01-01T00:00:00Z".into(),
3069 ended_at: "2024-01-01T00:01:00Z".into(),
3070 host: HostInfo {
3071 os: "linux".into(),
3072 arch: "x86_64".into(),
3073 cpu_count: None,
3074 memory_bytes: None,
3075 hostname_hash: None,
3076 },
3077 },
3078 bench: BenchMeta {
3079 name: "minimal".into(),
3080 cwd: None,
3081 command: vec!["true".into()],
3082 repeat: 1,
3083 warmup: 0,
3084 work_units: None,
3085 timeout_ms: None,
3086 },
3087 samples: vec![Sample {
3088 wall_ms: 1,
3089 exit_code: 0,
3090 warmup: false,
3091 timed_out: false,
3092 cpu_ms: None,
3093 page_faults: None,
3094 ctx_switches: None,
3095 max_rss_kb: None,
3096 io_read_bytes: None,
3097 io_write_bytes: None,
3098 network_packets: None,
3099 energy_uj: None,
3100 binary_bytes: None,
3101 stdout: None,
3102 stderr: None,
3103 }],
3104 stats: Stats {
3105 wall_ms: U64Summary::new(1, 1, 1),
3106 cpu_ms: None,
3107 page_faults: None,
3108 ctx_switches: None,
3109 max_rss_kb: None,
3110 io_read_bytes: None,
3111 io_write_bytes: None,
3112 network_packets: None,
3113 energy_uj: None,
3114 binary_bytes: None,
3115 throughput_per_s: None,
3116 },
3117 };
3118
3119 let json = serde_json::to_string(&receipt).unwrap();
3120 let back: RunReceipt = serde_json::from_str(&json).unwrap();
3121 assert_eq!(receipt, back);
3122
3123 let value: serde_json::Value = serde_json::from_str(&json).unwrap();
3125 let host = &value["run"]["host"];
3126 assert!(host.get("cpu_count").is_none());
3127 assert!(host.get("memory_bytes").is_none());
3128 assert!(host.get("hostname_hash").is_none());
3129 }
3130
3131 #[test]
3132 fn roundtrip_compare_receipt_all_optionals_none() {
3133 let receipt = CompareReceipt {
3134 schema: COMPARE_SCHEMA_V1.to_string(),
3135 tool: ToolInfo {
3136 name: "perfgate".into(),
3137 version: "0.1.0".into(),
3138 },
3139 bench: BenchMeta {
3140 name: "minimal".into(),
3141 cwd: None,
3142 command: vec!["true".into()],
3143 repeat: 1,
3144 warmup: 0,
3145 work_units: None,
3146 timeout_ms: None,
3147 },
3148 baseline_ref: CompareRef {
3149 path: None,
3150 run_id: None,
3151 },
3152 current_ref: CompareRef {
3153 path: None,
3154 run_id: None,
3155 },
3156 budgets: BTreeMap::new(),
3157 deltas: BTreeMap::new(),
3158 verdict: Verdict {
3159 status: VerdictStatus::Pass,
3160 counts: VerdictCounts {
3161 pass: 0,
3162 warn: 0,
3163 fail: 0,
3164 skip: 0,
3165 },
3166 reasons: vec![],
3167 },
3168 };
3169
3170 let json = serde_json::to_string(&receipt).unwrap();
3171 let back: CompareReceipt = serde_json::from_str(&json).unwrap();
3172 assert_eq!(receipt, back);
3173 }
3174
3175 #[test]
3177 fn backward_compat_run_receipt_old_format() {
3178 let json = r#"{
3179 "schema": "perfgate.run.v1",
3180 "tool": {"name": "perfgate", "version": "0.1.0"},
3181 "run": {
3182 "id": "test-id",
3183 "started_at": "2024-01-01T00:00:00Z",
3184 "ended_at": "2024-01-01T00:01:00Z",
3185 "host": {"os": "linux", "arch": "x86_64"}
3186 },
3187 "bench": {
3188 "name": "test",
3189 "command": ["echo", "hello"],
3190 "repeat": 5,
3191 "warmup": 0
3192 },
3193 "samples": [{"wall_ms": 100, "exit_code": 0}],
3194 "stats": {
3195 "wall_ms": {"median": 100, "min": 90, "max": 110}
3196 }
3197 }"#;
3198
3199 let receipt: RunReceipt = serde_json::from_str(json).expect("should parse old format");
3200 assert_eq!(receipt.run.host.os, "linux");
3201 assert_eq!(receipt.run.host.arch, "x86_64");
3202 assert!(receipt.run.host.cpu_count.is_none());
3203 assert!(receipt.run.host.memory_bytes.is_none());
3204 assert!(receipt.run.host.hostname_hash.is_none());
3205 }
3206
3207 #[test]
3212 fn u64_summary_cv_normal_case() {
3213 let s = U64Summary {
3214 median: 100,
3215 min: 80,
3216 max: 120,
3217 mean: Some(100.0),
3218 stddev: Some(10.0),
3219 };
3220 let cv = s.cv().expect("should return Some");
3221 assert!((cv - 0.1).abs() < f64::EPSILON);
3222 }
3223
3224 #[test]
3225 fn u64_summary_cv_zero_mean_returns_none() {
3226 let s = U64Summary {
3227 median: 0,
3228 min: 0,
3229 max: 0,
3230 mean: Some(0.0),
3231 stddev: Some(5.0),
3232 };
3233 assert!(s.cv().is_none());
3234 }
3235
3236 #[test]
3237 fn u64_summary_cv_zero_stddev() {
3238 let s = U64Summary {
3239 median: 100,
3240 min: 100,
3241 max: 100,
3242 mean: Some(100.0),
3243 stddev: Some(0.0),
3244 };
3245 let cv = s.cv().expect("should return Some");
3246 assert!((cv - 0.0).abs() < f64::EPSILON);
3247 }
3248
3249 #[test]
3250 fn u64_summary_cv_missing_mean_returns_none() {
3251 let s = U64Summary {
3252 median: 100,
3253 min: 80,
3254 max: 120,
3255 mean: None,
3256 stddev: Some(10.0),
3257 };
3258 assert!(s.cv().is_none());
3259 }
3260
3261 #[test]
3262 fn u64_summary_cv_missing_stddev_returns_none() {
3263 let s = U64Summary {
3264 median: 100,
3265 min: 80,
3266 max: 120,
3267 mean: Some(100.0),
3268 stddev: None,
3269 };
3270 assert!(s.cv().is_none());
3271 }
3272
3273 #[test]
3278 fn f64_summary_cv_normal_case() {
3279 let s = F64Summary {
3280 median: 50.0,
3281 min: 40.0,
3282 max: 60.0,
3283 mean: Some(50.0),
3284 stddev: Some(5.0),
3285 };
3286 let cv = s.cv().expect("should return Some");
3287 assert!((cv - 0.1).abs() < f64::EPSILON);
3288 }
3289
3290 #[test]
3291 fn f64_summary_cv_zero_mean_returns_none() {
3292 let s = F64Summary {
3293 median: 0.0,
3294 min: 0.0,
3295 max: 0.0,
3296 mean: Some(0.0),
3297 stddev: Some(1.0),
3298 };
3299 assert!(s.cv().is_none());
3300 }
3301
3302 #[test]
3303 fn f64_summary_cv_zero_stddev() {
3304 let s = F64Summary {
3305 median: 50.0,
3306 min: 50.0,
3307 max: 50.0,
3308 mean: Some(50.0),
3309 stddev: Some(0.0),
3310 };
3311 let cv = s.cv().expect("should return Some");
3312 assert!((cv - 0.0).abs() < f64::EPSILON);
3313 }
3314
3315 #[test]
3316 fn f64_summary_cv_missing_fields_returns_none() {
3317 let s = F64Summary::new(50.0, 40.0, 60.0);
3318 assert!(s.cv().is_none());
3319 }
3320}
3321
3322#[cfg(test)]
3323mod property_tests {
3324 use super::*;
3325 use proptest::prelude::*;
3326
3327 fn non_empty_string() -> impl Strategy<Value = String> {
3329 "[a-zA-Z0-9_-]{1,20}".prop_map(|s| s)
3330 }
3331
3332 fn rfc3339_timestamp() -> impl Strategy<Value = String> {
3334 (
3335 2020u32..2030,
3336 1u32..13,
3337 1u32..29,
3338 0u32..24,
3339 0u32..60,
3340 0u32..60,
3341 )
3342 .prop_map(|(year, month, day, hour, min, sec)| {
3343 format!(
3344 "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
3345 year, month, day, hour, min, sec
3346 )
3347 })
3348 }
3349
3350 fn tool_info_strategy() -> impl Strategy<Value = ToolInfo> {
3352 (non_empty_string(), non_empty_string())
3353 .prop_map(|(name, version)| ToolInfo { name, version })
3354 }
3355
3356 fn host_info_strategy() -> impl Strategy<Value = HostInfo> {
3358 (
3359 non_empty_string(),
3360 non_empty_string(),
3361 proptest::option::of(1u32..256),
3362 proptest::option::of(1u64..68719476736), proptest::option::of("[a-f0-9]{64}"), )
3365 .prop_map(
3366 |(os, arch, cpu_count, memory_bytes, hostname_hash)| HostInfo {
3367 os,
3368 arch,
3369 cpu_count,
3370 memory_bytes,
3371 hostname_hash,
3372 },
3373 )
3374 }
3375
3376 fn run_meta_strategy() -> impl Strategy<Value = RunMeta> {
3378 (
3379 non_empty_string(),
3380 rfc3339_timestamp(),
3381 rfc3339_timestamp(),
3382 host_info_strategy(),
3383 )
3384 .prop_map(|(id, started_at, ended_at, host)| RunMeta {
3385 id,
3386 started_at,
3387 ended_at,
3388 host,
3389 })
3390 }
3391
3392 fn bench_meta_strategy() -> impl Strategy<Value = BenchMeta> {
3394 (
3395 non_empty_string(),
3396 proptest::option::of(non_empty_string()),
3397 proptest::collection::vec(non_empty_string(), 1..5),
3398 1u32..100,
3399 0u32..10,
3400 proptest::option::of(1u64..10000),
3401 proptest::option::of(100u64..60000),
3402 )
3403 .prop_map(
3404 |(name, cwd, command, repeat, warmup, work_units, timeout_ms)| BenchMeta {
3405 name,
3406 cwd,
3407 command,
3408 repeat,
3409 warmup,
3410 work_units,
3411 timeout_ms,
3412 },
3413 )
3414 }
3415
3416 fn sample_strategy() -> impl Strategy<Value = Sample> {
3418 (
3419 0u64..100000,
3420 -128i32..128,
3421 any::<bool>(),
3422 any::<bool>(),
3423 proptest::option::of(0u64..1000000), proptest::option::of(0u64..1000000), proptest::option::of(0u64..1000000), proptest::option::of(0u64..1000000), proptest::option::of(0u64..1000000), proptest::option::of(0u64..100000000), proptest::option::of("[a-zA-Z0-9 ]{0,50}"),
3430 proptest::option::of("[a-zA-Z0-9 ]{0,50}"),
3431 )
3432 .prop_map(
3433 |(
3434 wall_ms,
3435 exit_code,
3436 warmup,
3437 timed_out,
3438 cpu_ms,
3439 page_faults,
3440 ctx_switches,
3441 max_rss_kb,
3442 energy_uj,
3443 binary_bytes,
3444 stdout,
3445 stderr,
3446 )| Sample {
3447 wall_ms,
3448 exit_code,
3449 warmup,
3450 timed_out,
3451 cpu_ms,
3452 page_faults,
3453 ctx_switches,
3454 max_rss_kb,
3455 io_read_bytes: None,
3456 io_write_bytes: None,
3457 network_packets: None,
3458 energy_uj,
3459 binary_bytes,
3460 stdout,
3461 stderr,
3462 },
3463 )
3464 }
3465
3466 fn u64_summary_strategy() -> impl Strategy<Value = U64Summary> {
3468 (0u64..1000000, 0u64..1000000, 0u64..1000000).prop_map(|(a, b, c)| {
3469 let mut vals = [a, b, c];
3470 vals.sort();
3471 U64Summary::new(vals[1], vals[0], vals[2])
3472 })
3473 }
3474
3475 fn f64_summary_strategy() -> impl Strategy<Value = F64Summary> {
3477 (0.0f64..1000000.0, 0.0f64..1000000.0, 0.0f64..1000000.0).prop_map(|(a, b, c)| {
3478 let mut vals = [a, b, c];
3479 vals.sort_by(|x, y| x.partial_cmp(y).unwrap());
3480 F64Summary::new(vals[1], vals[0], vals[2])
3481 })
3482 }
3483
3484 fn stats_strategy() -> impl Strategy<Value = Stats> {
3486 (
3487 u64_summary_strategy(),
3488 proptest::option::of(u64_summary_strategy()), proptest::option::of(u64_summary_strategy()), proptest::option::of(u64_summary_strategy()), proptest::option::of(u64_summary_strategy()), proptest::option::of(u64_summary_strategy()), proptest::option::of(u64_summary_strategy()), proptest::option::of(u64_summary_strategy()), proptest::option::of(u64_summary_strategy()), proptest::option::of(u64_summary_strategy()), proptest::option::of(f64_summary_strategy()),
3498 )
3499 .prop_map(
3500 |(
3501 wall_ms,
3502 cpu_ms,
3503 page_faults,
3504 ctx_switches,
3505 max_rss_kb,
3506 io_read_bytes,
3507 io_write_bytes,
3508 network_packets,
3509 energy_uj,
3510 binary_bytes,
3511 throughput_per_s,
3512 )| Stats {
3513 wall_ms,
3514 cpu_ms,
3515 page_faults,
3516 ctx_switches,
3517 max_rss_kb,
3518 io_read_bytes,
3519 io_write_bytes,
3520 network_packets,
3521 energy_uj,
3522 binary_bytes,
3523 throughput_per_s,
3524 },
3525 )
3526 }
3527
3528 fn run_receipt_strategy() -> impl Strategy<Value = RunReceipt> {
3530 (
3531 tool_info_strategy(),
3532 run_meta_strategy(),
3533 bench_meta_strategy(),
3534 proptest::collection::vec(sample_strategy(), 1..10),
3535 stats_strategy(),
3536 )
3537 .prop_map(|(tool, run, bench, samples, stats)| RunReceipt {
3538 schema: RUN_SCHEMA_V1.to_string(),
3539 tool,
3540 run,
3541 bench,
3542 samples,
3543 stats,
3544 })
3545 }
3546
3547 proptest! {
3554 #![proptest_config(ProptestConfig::with_cases(100))]
3555
3556 #[test]
3557 fn run_receipt_serialization_round_trip(receipt in run_receipt_strategy()) {
3558 let json = serde_json::to_string(&receipt)
3560 .expect("RunReceipt should serialize to JSON");
3561
3562 let deserialized: RunReceipt = serde_json::from_str(&json)
3564 .expect("JSON should deserialize back to RunReceipt");
3565
3566 prop_assert_eq!(&receipt.schema, &deserialized.schema);
3568 prop_assert_eq!(&receipt.tool, &deserialized.tool);
3569 prop_assert_eq!(&receipt.run, &deserialized.run);
3570 prop_assert_eq!(&receipt.bench, &deserialized.bench);
3571 prop_assert_eq!(receipt.samples.len(), deserialized.samples.len());
3572
3573 for (orig, deser) in receipt.samples.iter().zip(deserialized.samples.iter()) {
3575 prop_assert_eq!(orig.wall_ms, deser.wall_ms);
3576 prop_assert_eq!(orig.exit_code, deser.exit_code);
3577 prop_assert_eq!(orig.warmup, deser.warmup);
3578 prop_assert_eq!(orig.timed_out, deser.timed_out);
3579 prop_assert_eq!(orig.cpu_ms, deser.cpu_ms);
3580 prop_assert_eq!(orig.page_faults, deser.page_faults);
3581 prop_assert_eq!(orig.ctx_switches, deser.ctx_switches);
3582 prop_assert_eq!(orig.max_rss_kb, deser.max_rss_kb);
3583 prop_assert_eq!(orig.binary_bytes, deser.binary_bytes);
3584 prop_assert_eq!(&orig.stdout, &deser.stdout);
3585 prop_assert_eq!(&orig.stderr, &deser.stderr);
3586 }
3587
3588 prop_assert_eq!(&receipt.stats.wall_ms, &deserialized.stats.wall_ms);
3590 prop_assert_eq!(&receipt.stats.cpu_ms, &deserialized.stats.cpu_ms);
3591 prop_assert_eq!(&receipt.stats.page_faults, &deserialized.stats.page_faults);
3592 prop_assert_eq!(&receipt.stats.ctx_switches, &deserialized.stats.ctx_switches);
3593 prop_assert_eq!(&receipt.stats.max_rss_kb, &deserialized.stats.max_rss_kb);
3594 prop_assert_eq!(&receipt.stats.binary_bytes, &deserialized.stats.binary_bytes);
3595
3596 match (&receipt.stats.throughput_per_s, &deserialized.stats.throughput_per_s) {
3599 (Some(orig), Some(deser)) => {
3600 let rel_tol = |a: f64, b: f64| {
3602 if a == 0.0 && b == 0.0 {
3603 true
3604 } else {
3605 let max_val = a.abs().max(b.abs());
3606 (a - b).abs() / max_val < 1e-10
3607 }
3608 };
3609 prop_assert!(rel_tol(orig.min, deser.min), "min mismatch: {} vs {}", orig.min, deser.min);
3610 prop_assert!(rel_tol(orig.median, deser.median), "median mismatch: {} vs {}", orig.median, deser.median);
3611 prop_assert!(rel_tol(orig.max, deser.max), "max mismatch: {} vs {}", orig.max, deser.max);
3612 }
3613 (None, None) => {}
3614 _ => prop_assert!(false, "throughput_per_s presence mismatch"),
3615 }
3616 }
3617 }
3618
3619 fn compare_ref_strategy() -> impl Strategy<Value = CompareRef> {
3623 (
3624 proptest::option::of(non_empty_string()),
3625 proptest::option::of(non_empty_string()),
3626 )
3627 .prop_map(|(path, run_id)| CompareRef { path, run_id })
3628 }
3629
3630 fn direction_strategy() -> impl Strategy<Value = Direction> {
3632 prop_oneof![Just(Direction::Lower), Just(Direction::Higher),]
3633 }
3634
3635 fn budget_strategy() -> impl Strategy<Value = Budget> {
3637 (0.01f64..1.0, 0.01f64..1.0, direction_strategy()).prop_map(
3638 |(threshold, warn_factor, direction)| {
3639 let warn_threshold = threshold * warn_factor;
3641 Budget {
3642 noise_threshold: None,
3643 noise_policy: NoisePolicy::Ignore,
3644 threshold,
3645 warn_threshold,
3646 direction,
3647 }
3648 },
3649 )
3650 }
3651
3652 fn metric_status_strategy() -> impl Strategy<Value = MetricStatus> {
3654 prop_oneof![
3655 Just(MetricStatus::Pass),
3656 Just(MetricStatus::Warn),
3657 Just(MetricStatus::Fail),
3658 ]
3659 }
3660
3661 fn delta_strategy() -> impl Strategy<Value = Delta> {
3663 (
3664 0.1f64..10000.0, 0.1f64..10000.0, metric_status_strategy(),
3667 )
3668 .prop_map(|(baseline, current, status)| {
3669 let ratio = current / baseline;
3670 let pct = (current - baseline) / baseline;
3671 let regression = if pct > 0.0 { pct } else { 0.0 };
3672 Delta {
3673 baseline,
3674 current,
3675 ratio,
3676 pct,
3677 regression,
3678 cv: None,
3679 noise_threshold: None,
3680 statistic: MetricStatistic::Median,
3681 significance: None,
3682 status,
3683 }
3684 })
3685 }
3686
3687 fn verdict_status_strategy() -> impl Strategy<Value = VerdictStatus> {
3689 prop_oneof![
3690 Just(VerdictStatus::Pass),
3691 Just(VerdictStatus::Warn),
3692 Just(VerdictStatus::Fail),
3693 ]
3694 }
3695
3696 fn verdict_counts_strategy() -> impl Strategy<Value = VerdictCounts> {
3698 (0u32..10, 0u32..10, 0u32..10, 0u32..10).prop_map(|(pass, warn, fail, skip)| {
3699 VerdictCounts {
3700 pass,
3701 warn,
3702 fail,
3703 skip,
3704 }
3705 })
3706 }
3707
3708 fn verdict_strategy() -> impl Strategy<Value = Verdict> {
3710 (
3711 verdict_status_strategy(),
3712 verdict_counts_strategy(),
3713 proptest::collection::vec("[a-zA-Z0-9 ]{1,50}", 0..5),
3714 )
3715 .prop_map(|(status, counts, reasons)| Verdict {
3716 status,
3717 counts,
3718 reasons,
3719 })
3720 }
3721
3722 fn metric_strategy() -> impl Strategy<Value = Metric> {
3724 prop_oneof![
3725 Just(Metric::BinaryBytes),
3726 Just(Metric::CpuMs),
3727 Just(Metric::CtxSwitches),
3728 Just(Metric::MaxRssKb),
3729 Just(Metric::PageFaults),
3730 Just(Metric::ThroughputPerS),
3731 Just(Metric::WallMs),
3732 ]
3733 }
3734
3735 fn budgets_map_strategy() -> impl Strategy<Value = BTreeMap<Metric, Budget>> {
3737 proptest::collection::btree_map(metric_strategy(), budget_strategy(), 0..8)
3738 }
3739
3740 fn deltas_map_strategy() -> impl Strategy<Value = BTreeMap<Metric, Delta>> {
3742 proptest::collection::btree_map(metric_strategy(), delta_strategy(), 0..8)
3743 }
3744
3745 fn compare_receipt_strategy() -> impl Strategy<Value = CompareReceipt> {
3747 (
3748 tool_info_strategy(),
3749 bench_meta_strategy(),
3750 compare_ref_strategy(),
3751 compare_ref_strategy(),
3752 budgets_map_strategy(),
3753 deltas_map_strategy(),
3754 verdict_strategy(),
3755 )
3756 .prop_map(
3757 |(tool, bench, baseline_ref, current_ref, budgets, deltas, verdict)| {
3758 CompareReceipt {
3759 schema: COMPARE_SCHEMA_V1.to_string(),
3760 tool,
3761 bench,
3762 baseline_ref,
3763 current_ref,
3764 budgets,
3765 deltas,
3766 verdict,
3767 }
3768 },
3769 )
3770 }
3771
3772 fn f64_approx_eq(a: f64, b: f64) -> bool {
3774 if a == 0.0 && b == 0.0 {
3775 true
3776 } else {
3777 let max_val = a.abs().max(b.abs());
3778 if max_val == 0.0 {
3779 true
3780 } else {
3781 (a - b).abs() / max_val < 1e-10
3782 }
3783 }
3784 }
3785
3786 proptest! {
3793 #![proptest_config(ProptestConfig::with_cases(100))]
3794
3795 #[test]
3796 fn compare_receipt_serialization_round_trip(receipt in compare_receipt_strategy()) {
3797 let json = serde_json::to_string(&receipt)
3799 .expect("CompareReceipt should serialize to JSON");
3800
3801 let deserialized: CompareReceipt = serde_json::from_str(&json)
3803 .expect("JSON should deserialize back to CompareReceipt");
3804
3805 prop_assert_eq!(&receipt.schema, &deserialized.schema);
3807 prop_assert_eq!(&receipt.tool, &deserialized.tool);
3808 prop_assert_eq!(&receipt.bench, &deserialized.bench);
3809 prop_assert_eq!(&receipt.baseline_ref, &deserialized.baseline_ref);
3810 prop_assert_eq!(&receipt.current_ref, &deserialized.current_ref);
3811 prop_assert_eq!(&receipt.verdict, &deserialized.verdict);
3812
3813 prop_assert_eq!(receipt.budgets.len(), deserialized.budgets.len());
3815 for (metric, orig_budget) in &receipt.budgets {
3816 let deser_budget = deserialized.budgets.get(metric)
3817 .expect("Budget metric should exist in deserialized");
3818 prop_assert!(
3819 f64_approx_eq(orig_budget.threshold, deser_budget.threshold),
3820 "Budget threshold mismatch for {:?}: {} vs {}",
3821 metric, orig_budget.threshold, deser_budget.threshold
3822 );
3823 prop_assert!(
3824 f64_approx_eq(orig_budget.warn_threshold, deser_budget.warn_threshold),
3825 "Budget warn_threshold mismatch for {:?}: {} vs {}",
3826 metric, orig_budget.warn_threshold, deser_budget.warn_threshold
3827 );
3828 prop_assert_eq!(orig_budget.direction, deser_budget.direction);
3829 }
3830
3831 prop_assert_eq!(receipt.deltas.len(), deserialized.deltas.len());
3833 for (metric, orig_delta) in &receipt.deltas {
3834 let deser_delta = deserialized.deltas.get(metric)
3835 .expect("Delta metric should exist in deserialized");
3836 prop_assert!(
3837 f64_approx_eq(orig_delta.baseline, deser_delta.baseline),
3838 "Delta baseline mismatch for {:?}: {} vs {}",
3839 metric, orig_delta.baseline, deser_delta.baseline
3840 );
3841 prop_assert!(
3842 f64_approx_eq(orig_delta.current, deser_delta.current),
3843 "Delta current mismatch for {:?}: {} vs {}",
3844 metric, orig_delta.current, deser_delta.current
3845 );
3846 prop_assert!(
3847 f64_approx_eq(orig_delta.ratio, deser_delta.ratio),
3848 "Delta ratio mismatch for {:?}: {} vs {}",
3849 metric, orig_delta.ratio, deser_delta.ratio
3850 );
3851 prop_assert!(
3852 f64_approx_eq(orig_delta.pct, deser_delta.pct),
3853 "Delta pct mismatch for {:?}: {} vs {}",
3854 metric, orig_delta.pct, deser_delta.pct
3855 );
3856 prop_assert!(
3857 f64_approx_eq(orig_delta.regression, deser_delta.regression),
3858 "Delta regression mismatch for {:?}: {} vs {}",
3859 metric, orig_delta.regression, deser_delta.regression
3860 );
3861 prop_assert_eq!(orig_delta.status, deser_delta.status);
3862 }
3863 }
3864 }
3865
3866 fn budget_override_strategy() -> impl Strategy<Value = BudgetOverride> {
3870 (
3871 proptest::option::of(0.01f64..1.0),
3872 proptest::option::of(direction_strategy()),
3873 proptest::option::of(0.5f64..1.0),
3874 )
3875 .prop_map(|(threshold, direction, warn_factor)| BudgetOverride {
3876 noise_threshold: None,
3877 noise_policy: None,
3878 threshold,
3879 direction,
3880 warn_factor,
3881 statistic: None,
3882 })
3883 }
3884
3885 fn budget_overrides_map_strategy() -> impl Strategy<Value = BTreeMap<Metric, BudgetOverride>> {
3887 proptest::collection::btree_map(metric_strategy(), budget_override_strategy(), 0..4)
3888 }
3889
3890 fn bench_config_file_strategy() -> impl Strategy<Value = BenchConfigFile> {
3892 (
3893 non_empty_string(),
3894 proptest::option::of(non_empty_string()),
3895 proptest::option::of(1u64..10000),
3896 proptest::option::of("[0-9]+[smh]"), proptest::collection::vec(non_empty_string(), 1..5),
3898 proptest::option::of(1u32..100),
3899 proptest::option::of(0u32..10),
3900 proptest::option::of(proptest::collection::vec(metric_strategy(), 1..4)),
3901 proptest::option::of(budget_overrides_map_strategy()),
3902 )
3903 .prop_map(
3904 |(name, cwd, work, timeout, command, repeat, warmup, metrics, budgets)| {
3905 BenchConfigFile {
3906 name,
3907 cwd,
3908 work,
3909 timeout,
3910 command,
3911 repeat,
3912 warmup,
3913 metrics,
3914 budgets,
3915 scaling: None,
3916 }
3917 },
3918 )
3919 }
3920
3921 fn defaults_config_strategy() -> impl Strategy<Value = DefaultsConfig> {
3923 (
3924 proptest::option::of(1u32..100),
3925 proptest::option::of(0u32..10),
3926 proptest::option::of(0.01f64..1.0),
3927 proptest::option::of(0.5f64..1.0),
3928 proptest::option::of(non_empty_string()),
3929 proptest::option::of(non_empty_string()),
3930 proptest::option::of(non_empty_string()),
3931 proptest::option::of(non_empty_string()),
3932 )
3933 .prop_map(
3934 |(
3935 repeat,
3936 warmup,
3937 threshold,
3938 warn_factor,
3939 out_dir,
3940 baseline_dir,
3941 baseline_pattern,
3942 markdown_template,
3943 )| DefaultsConfig {
3944 noise_threshold: None,
3945 noise_policy: None,
3946 repeat,
3947 warmup,
3948 threshold,
3949 warn_factor,
3950 out_dir,
3951 baseline_dir,
3952 baseline_pattern,
3953 markdown_template,
3954 },
3955 )
3956 }
3957
3958 fn baseline_server_config_strategy() -> impl Strategy<Value = BaselineServerConfig> {
3960 (
3961 proptest::option::of(non_empty_string()),
3962 proptest::option::of(non_empty_string()),
3963 proptest::option::of(non_empty_string()),
3964 proptest::bool::ANY,
3965 )
3966 .prop_map(
3967 |(url, api_key, project, fallback_to_local)| BaselineServerConfig {
3968 url,
3969 api_key,
3970 project,
3971 fallback_to_local,
3972 },
3973 )
3974 }
3975
3976 fn config_file_strategy() -> impl Strategy<Value = ConfigFile> {
3978 (
3979 defaults_config_strategy(),
3980 baseline_server_config_strategy(),
3981 proptest::collection::vec(bench_config_file_strategy(), 0..5),
3982 )
3983 .prop_map(|(defaults, baseline_server, benches)| ConfigFile {
3984 defaults,
3985 baseline_server,
3986 decision_policy: DecisionPolicyConfig::default(),
3987 tradeoffs: Vec::new(),
3988 ratchet: None,
3989 scenarios: Vec::new(),
3990 benches,
3991 })
3992 }
3993
3994 proptest! {
4001 #![proptest_config(ProptestConfig::with_cases(100))]
4002
4003 #[test]
4004 fn config_file_json_serialization_round_trip(config in config_file_strategy()) {
4005 let json = serde_json::to_string(&config)
4007 .expect("ConfigFile should serialize to JSON");
4008
4009 let deserialized: ConfigFile = serde_json::from_str(&json)
4011 .expect("JSON should deserialize back to ConfigFile");
4012
4013 prop_assert_eq!(config.defaults.repeat, deserialized.defaults.repeat);
4015 prop_assert_eq!(config.defaults.warmup, deserialized.defaults.warmup);
4016 prop_assert_eq!(&config.defaults.out_dir, &deserialized.defaults.out_dir);
4017 prop_assert_eq!(&config.defaults.baseline_dir, &deserialized.defaults.baseline_dir);
4018 prop_assert_eq!(
4019 &config.defaults.baseline_pattern,
4020 &deserialized.defaults.baseline_pattern
4021 );
4022 prop_assert_eq!(
4023 &config.defaults.markdown_template,
4024 &deserialized.defaults.markdown_template
4025 );
4026
4027 match (config.defaults.threshold, deserialized.defaults.threshold) {
4029 (Some(orig), Some(deser)) => {
4030 prop_assert!(
4031 f64_approx_eq(orig, deser),
4032 "defaults.threshold mismatch: {} vs {}",
4033 orig, deser
4034 );
4035 }
4036 (None, None) => {}
4037 _ => prop_assert!(false, "defaults.threshold presence mismatch"),
4038 }
4039
4040 match (config.defaults.warn_factor, deserialized.defaults.warn_factor) {
4041 (Some(orig), Some(deser)) => {
4042 prop_assert!(
4043 f64_approx_eq(orig, deser),
4044 "defaults.warn_factor mismatch: {} vs {}",
4045 orig, deser
4046 );
4047 }
4048 (None, None) => {}
4049 _ => prop_assert!(false, "defaults.warn_factor presence mismatch"),
4050 }
4051
4052 prop_assert_eq!(config.benches.len(), deserialized.benches.len());
4054 for (orig_bench, deser_bench) in config.benches.iter().zip(deserialized.benches.iter()) {
4055 prop_assert_eq!(&orig_bench.name, &deser_bench.name);
4056 prop_assert_eq!(&orig_bench.cwd, &deser_bench.cwd);
4057 prop_assert_eq!(orig_bench.work, deser_bench.work);
4058 prop_assert_eq!(&orig_bench.timeout, &deser_bench.timeout);
4059 prop_assert_eq!(&orig_bench.command, &deser_bench.command);
4060 prop_assert_eq!(&orig_bench.metrics, &deser_bench.metrics);
4061
4062 match (&orig_bench.budgets, &deser_bench.budgets) {
4064 (Some(orig_budgets), Some(deser_budgets)) => {
4065 prop_assert_eq!(orig_budgets.len(), deser_budgets.len());
4066 for (metric, orig_override) in orig_budgets {
4067 let deser_override = deser_budgets.get(metric)
4068 .expect("BudgetOverride metric should exist in deserialized");
4069
4070 match (orig_override.threshold, deser_override.threshold) {
4072 (Some(orig), Some(deser)) => {
4073 prop_assert!(
4074 f64_approx_eq(orig, deser),
4075 "BudgetOverride threshold mismatch for {:?}: {} vs {}",
4076 metric, orig, deser
4077 );
4078 }
4079 (None, None) => {}
4080 _ => prop_assert!(false, "BudgetOverride threshold presence mismatch for {:?}", metric),
4081 }
4082
4083 prop_assert_eq!(orig_override.direction, deser_override.direction);
4084
4085 match (orig_override.warn_factor, deser_override.warn_factor) {
4087 (Some(orig), Some(deser)) => {
4088 prop_assert!(
4089 f64_approx_eq(orig, deser),
4090 "BudgetOverride warn_factor mismatch for {:?}: {} vs {}",
4091 metric, orig, deser
4092 );
4093 }
4094 (None, None) => {}
4095 _ => prop_assert!(false, "BudgetOverride warn_factor presence mismatch for {:?}", metric),
4096 }
4097 }
4098 }
4099 (None, None) => {}
4100 _ => prop_assert!(false, "bench.budgets presence mismatch"),
4101 }
4102 }
4103 }
4104 }
4105
4106 proptest! {
4113 #![proptest_config(ProptestConfig::with_cases(100))]
4114
4115 #[test]
4116 fn config_file_toml_serialization_round_trip(config in config_file_strategy()) {
4117 let toml_str = toml::to_string(&config)
4119 .expect("ConfigFile should serialize to TOML");
4120
4121 let deserialized: ConfigFile = toml::from_str(&toml_str)
4123 .expect("TOML should deserialize back to ConfigFile");
4124
4125 prop_assert_eq!(config.defaults.repeat, deserialized.defaults.repeat);
4127 prop_assert_eq!(config.defaults.warmup, deserialized.defaults.warmup);
4128 prop_assert_eq!(&config.defaults.out_dir, &deserialized.defaults.out_dir);
4129 prop_assert_eq!(&config.defaults.baseline_dir, &deserialized.defaults.baseline_dir);
4130 prop_assert_eq!(
4131 &config.defaults.baseline_pattern,
4132 &deserialized.defaults.baseline_pattern
4133 );
4134 prop_assert_eq!(
4135 &config.defaults.markdown_template,
4136 &deserialized.defaults.markdown_template
4137 );
4138
4139 match (config.defaults.threshold, deserialized.defaults.threshold) {
4141 (Some(orig), Some(deser)) => {
4142 prop_assert!(
4143 f64_approx_eq(orig, deser),
4144 "defaults.threshold mismatch: {} vs {}",
4145 orig, deser
4146 );
4147 }
4148 (None, None) => {}
4149 _ => prop_assert!(false, "defaults.threshold presence mismatch"),
4150 }
4151
4152 match (config.defaults.warn_factor, deserialized.defaults.warn_factor) {
4153 (Some(orig), Some(deser)) => {
4154 prop_assert!(
4155 f64_approx_eq(orig, deser),
4156 "defaults.warn_factor mismatch: {} vs {}",
4157 orig, deser
4158 );
4159 }
4160 (None, None) => {}
4161 _ => prop_assert!(false, "defaults.warn_factor presence mismatch"),
4162 }
4163
4164 prop_assert_eq!(config.benches.len(), deserialized.benches.len());
4166 for (orig_bench, deser_bench) in config.benches.iter().zip(deserialized.benches.iter()) {
4167 prop_assert_eq!(&orig_bench.name, &deser_bench.name);
4168 prop_assert_eq!(&orig_bench.cwd, &deser_bench.cwd);
4169 prop_assert_eq!(orig_bench.work, deser_bench.work);
4170 prop_assert_eq!(&orig_bench.timeout, &deser_bench.timeout);
4171 prop_assert_eq!(&orig_bench.command, &deser_bench.command);
4172 prop_assert_eq!(&orig_bench.metrics, &deser_bench.metrics);
4173
4174 match (&orig_bench.budgets, &deser_bench.budgets) {
4176 (Some(orig_budgets), Some(deser_budgets)) => {
4177 prop_assert_eq!(orig_budgets.len(), deser_budgets.len());
4178 for (metric, orig_override) in orig_budgets {
4179 let deser_override = deser_budgets.get(metric)
4180 .expect("BudgetOverride metric should exist in deserialized");
4181
4182 match (orig_override.threshold, deser_override.threshold) {
4184 (Some(orig), Some(deser)) => {
4185 prop_assert!(
4186 f64_approx_eq(orig, deser),
4187 "BudgetOverride threshold mismatch for {:?}: {} vs {}",
4188 metric, orig, deser
4189 );
4190 }
4191 (None, None) => {}
4192 _ => prop_assert!(false, "BudgetOverride threshold presence mismatch for {:?}", metric),
4193 }
4194
4195 prop_assert_eq!(orig_override.direction, deser_override.direction);
4196
4197 match (orig_override.warn_factor, deser_override.warn_factor) {
4199 (Some(orig), Some(deser)) => {
4200 prop_assert!(
4201 f64_approx_eq(orig, deser),
4202 "BudgetOverride warn_factor mismatch for {:?}: {} vs {}",
4203 metric, orig, deser
4204 );
4205 }
4206 (None, None) => {}
4207 _ => prop_assert!(false, "BudgetOverride warn_factor presence mismatch for {:?}", metric),
4208 }
4209 }
4210 }
4211 (None, None) => {}
4212 _ => prop_assert!(false, "bench.budgets presence mismatch"),
4213 }
4214 }
4215 }
4216 }
4217
4218 proptest! {
4226 #![proptest_config(ProptestConfig::with_cases(100))]
4227
4228 #[test]
4229 fn bench_config_file_json_serialization_round_trip(bench_config in bench_config_file_strategy()) {
4230 let json = serde_json::to_string(&bench_config)
4232 .expect("BenchConfigFile should serialize to JSON");
4233
4234 let deserialized: BenchConfigFile = serde_json::from_str(&json)
4236 .expect("JSON should deserialize back to BenchConfigFile");
4237
4238 prop_assert_eq!(&bench_config.name, &deserialized.name);
4240 prop_assert_eq!(&bench_config.command, &deserialized.command);
4241
4242 prop_assert_eq!(&bench_config.cwd, &deserialized.cwd);
4244 prop_assert_eq!(bench_config.work, deserialized.work);
4245 prop_assert_eq!(&bench_config.timeout, &deserialized.timeout);
4246 prop_assert_eq!(&bench_config.metrics, &deserialized.metrics);
4247
4248 match (&bench_config.budgets, &deserialized.budgets) {
4250 (Some(orig_budgets), Some(deser_budgets)) => {
4251 prop_assert_eq!(orig_budgets.len(), deser_budgets.len());
4252 for (metric, orig_override) in orig_budgets {
4253 let deser_override = deser_budgets.get(metric)
4254 .expect("BudgetOverride metric should exist in deserialized");
4255
4256 match (orig_override.threshold, deser_override.threshold) {
4258 (Some(orig), Some(deser)) => {
4259 prop_assert!(
4260 f64_approx_eq(orig, deser),
4261 "BudgetOverride threshold mismatch for {:?}: {} vs {}",
4262 metric, orig, deser
4263 );
4264 }
4265 (None, None) => {}
4266 _ => prop_assert!(false, "BudgetOverride threshold presence mismatch for {:?}", metric),
4267 }
4268
4269 prop_assert_eq!(orig_override.direction, deser_override.direction);
4270
4271 match (orig_override.warn_factor, deser_override.warn_factor) {
4273 (Some(orig), Some(deser)) => {
4274 prop_assert!(
4275 f64_approx_eq(orig, deser),
4276 "BudgetOverride warn_factor mismatch for {:?}: {} vs {}",
4277 metric, orig, deser
4278 );
4279 }
4280 (None, None) => {}
4281 _ => prop_assert!(false, "BudgetOverride warn_factor presence mismatch for {:?}", metric),
4282 }
4283 }
4284 }
4285 (None, None) => {}
4286 _ => prop_assert!(false, "budgets presence mismatch"),
4287 }
4288 }
4289 }
4290
4291 proptest! {
4299 #![proptest_config(ProptestConfig::with_cases(100))]
4300
4301 #[test]
4302 fn bench_config_file_toml_serialization_round_trip(bench_config in bench_config_file_strategy()) {
4303 let toml_str = toml::to_string(&bench_config)
4305 .expect("BenchConfigFile should serialize to TOML");
4306
4307 let deserialized: BenchConfigFile = toml::from_str(&toml_str)
4309 .expect("TOML should deserialize back to BenchConfigFile");
4310
4311 prop_assert_eq!(&bench_config.name, &deserialized.name);
4313 prop_assert_eq!(&bench_config.command, &deserialized.command);
4314
4315 prop_assert_eq!(&bench_config.cwd, &deserialized.cwd);
4317 prop_assert_eq!(bench_config.work, deserialized.work);
4318 prop_assert_eq!(&bench_config.timeout, &deserialized.timeout);
4319 prop_assert_eq!(&bench_config.metrics, &deserialized.metrics);
4320
4321 match (&bench_config.budgets, &deserialized.budgets) {
4323 (Some(orig_budgets), Some(deser_budgets)) => {
4324 prop_assert_eq!(orig_budgets.len(), deser_budgets.len());
4325 for (metric, orig_override) in orig_budgets {
4326 let deser_override = deser_budgets.get(metric)
4327 .expect("BudgetOverride metric should exist in deserialized");
4328
4329 match (orig_override.threshold, deser_override.threshold) {
4331 (Some(orig), Some(deser)) => {
4332 prop_assert!(
4333 f64_approx_eq(orig, deser),
4334 "BudgetOverride threshold mismatch for {:?}: {} vs {}",
4335 metric, orig, deser
4336 );
4337 }
4338 (None, None) => {}
4339 _ => prop_assert!(false, "BudgetOverride threshold presence mismatch for {:?}", metric),
4340 }
4341
4342 prop_assert_eq!(orig_override.direction, deser_override.direction);
4343
4344 match (orig_override.warn_factor, deser_override.warn_factor) {
4346 (Some(orig), Some(deser)) => {
4347 prop_assert!(
4348 f64_approx_eq(orig, deser),
4349 "BudgetOverride warn_factor mismatch for {:?}: {} vs {}",
4350 metric, orig, deser
4351 );
4352 }
4353 (None, None) => {}
4354 _ => prop_assert!(false, "BudgetOverride warn_factor presence mismatch for {:?}", metric),
4355 }
4356 }
4357 }
4358 (None, None) => {}
4359 _ => prop_assert!(false, "budgets presence mismatch"),
4360 }
4361 }
4362 }
4363
4364 proptest! {
4371 #![proptest_config(ProptestConfig::with_cases(100))]
4372
4373 #[test]
4374 fn budget_json_serialization_round_trip(budget in budget_strategy()) {
4375 let json = serde_json::to_string(&budget)
4377 .expect("Budget should serialize to JSON");
4378
4379 let deserialized: Budget = serde_json::from_str(&json)
4381 .expect("JSON should deserialize back to Budget");
4382
4383 prop_assert!(
4385 f64_approx_eq(budget.threshold, deserialized.threshold),
4386 "Budget threshold mismatch: {} vs {}",
4387 budget.threshold, deserialized.threshold
4388 );
4389 prop_assert!(
4390 f64_approx_eq(budget.warn_threshold, deserialized.warn_threshold),
4391 "Budget warn_threshold mismatch: {} vs {}",
4392 budget.warn_threshold, deserialized.warn_threshold
4393 );
4394 prop_assert_eq!(budget.direction, deserialized.direction);
4395 }
4396 }
4397
4398 proptest! {
4405 #![proptest_config(ProptestConfig::with_cases(100))]
4406
4407 #[test]
4408 fn budget_override_json_serialization_round_trip(budget_override in budget_override_strategy()) {
4409 let json = serde_json::to_string(&budget_override)
4411 .expect("BudgetOverride should serialize to JSON");
4412
4413 let deserialized: BudgetOverride = serde_json::from_str(&json)
4415 .expect("JSON should deserialize back to BudgetOverride");
4416
4417 match (budget_override.threshold, deserialized.threshold) {
4419 (Some(orig), Some(deser)) => {
4420 prop_assert!(
4421 f64_approx_eq(orig, deser),
4422 "BudgetOverride threshold mismatch: {} vs {}",
4423 orig, deser
4424 );
4425 }
4426 (None, None) => {}
4427 _ => prop_assert!(false, "BudgetOverride threshold presence mismatch"),
4428 }
4429
4430 prop_assert_eq!(budget_override.direction, deserialized.direction);
4432
4433 match (budget_override.warn_factor, deserialized.warn_factor) {
4435 (Some(orig), Some(deser)) => {
4436 prop_assert!(
4437 f64_approx_eq(orig, deser),
4438 "BudgetOverride warn_factor mismatch: {} vs {}",
4439 orig, deser
4440 );
4441 }
4442 (None, None) => {}
4443 _ => prop_assert!(false, "BudgetOverride warn_factor presence mismatch"),
4444 }
4445 }
4446 }
4447
4448 proptest! {
4457 #![proptest_config(ProptestConfig::with_cases(100))]
4458
4459 #[test]
4460 fn budget_threshold_relationship_preserved(budget in budget_strategy()) {
4461 prop_assert!(
4463 budget.warn_threshold <= budget.threshold,
4464 "Budget invariant violated: warn_threshold ({}) should be <= threshold ({})",
4465 budget.warn_threshold, budget.threshold
4466 );
4467
4468 let json = serde_json::to_string(&budget)
4470 .expect("Budget should serialize to JSON");
4471 let deserialized: Budget = serde_json::from_str(&json)
4472 .expect("JSON should deserialize back to Budget");
4473
4474 prop_assert!(
4476 deserialized.warn_threshold <= deserialized.threshold,
4477 "Budget invariant violated after round-trip: warn_threshold ({}) should be <= threshold ({})",
4478 deserialized.warn_threshold, deserialized.threshold
4479 );
4480 }
4481 }
4482
4483 proptest! {
4486 #![proptest_config(ProptestConfig::with_cases(100))]
4487
4488 #[test]
4489 fn host_info_serialization_round_trip(info in host_info_strategy()) {
4490 let json = serde_json::to_string(&info).expect("HostInfo should serialize");
4491 let back: HostInfo = serde_json::from_str(&json).expect("should deserialize");
4492 prop_assert_eq!(info, back);
4493 }
4494
4495 #[test]
4496 fn sample_serialization_round_trip(sample in sample_strategy()) {
4497 let json = serde_json::to_string(&sample).expect("Sample should serialize");
4498 let back: Sample = serde_json::from_str(&json).expect("should deserialize");
4499 prop_assert_eq!(sample, back);
4500 }
4501
4502 #[test]
4503 fn u64_summary_serialization_round_trip(summary in u64_summary_strategy()) {
4504 let json = serde_json::to_string(&summary).expect("U64Summary should serialize");
4505 let back: U64Summary = serde_json::from_str(&json).expect("should deserialize");
4506 prop_assert_eq!(summary, back);
4507 }
4508
4509 #[test]
4510 fn f64_summary_serialization_round_trip(summary in f64_summary_strategy()) {
4511 let json = serde_json::to_string(&summary).expect("F64Summary should serialize");
4512 let back: F64Summary = serde_json::from_str(&json).expect("should deserialize");
4513 prop_assert!(f64_approx_eq(summary.min, back.min));
4514 prop_assert!(f64_approx_eq(summary.median, back.median));
4515 prop_assert!(f64_approx_eq(summary.max, back.max));
4516 }
4517
4518 #[test]
4519 fn stats_serialization_round_trip(stats in stats_strategy()) {
4520 let json = serde_json::to_string(&stats).expect("Stats should serialize");
4521 let back: Stats = serde_json::from_str(&json).expect("should deserialize");
4522 prop_assert_eq!(&stats.wall_ms, &back.wall_ms);
4523 prop_assert_eq!(&stats.cpu_ms, &back.cpu_ms);
4524 prop_assert_eq!(&stats.page_faults, &back.page_faults);
4525 prop_assert_eq!(&stats.ctx_switches, &back.ctx_switches);
4526 prop_assert_eq!(&stats.max_rss_kb, &back.max_rss_kb);
4527 prop_assert_eq!(&stats.binary_bytes, &back.binary_bytes);
4528 match (&stats.throughput_per_s, &back.throughput_per_s) {
4529 (Some(orig), Some(deser)) => {
4530 prop_assert!(f64_approx_eq(orig.min, deser.min));
4531 prop_assert!(f64_approx_eq(orig.median, deser.median));
4532 prop_assert!(f64_approx_eq(orig.max, deser.max));
4533 }
4534 (None, None) => {}
4535 _ => prop_assert!(false, "throughput_per_s presence mismatch"),
4536 }
4537 }
4538
4539 #[test]
4540 fn delta_serialization_round_trip(delta in delta_strategy()) {
4541 let json = serde_json::to_string(&delta).expect("Delta should serialize");
4542 let back: Delta = serde_json::from_str(&json).expect("should deserialize");
4543 prop_assert!(f64_approx_eq(delta.baseline, back.baseline));
4544 prop_assert!(f64_approx_eq(delta.current, back.current));
4545 prop_assert!(f64_approx_eq(delta.ratio, back.ratio));
4546 prop_assert!(f64_approx_eq(delta.pct, back.pct));
4547 prop_assert!(f64_approx_eq(delta.regression, back.regression));
4548 prop_assert_eq!(delta.statistic, back.statistic);
4549 prop_assert_eq!(delta.significance, back.significance);
4550 prop_assert_eq!(delta.status, back.status);
4551 }
4552
4553 #[test]
4554 fn verdict_serialization_round_trip(verdict in verdict_strategy()) {
4555 let json = serde_json::to_string(&verdict).expect("Verdict should serialize");
4556 let back: Verdict = serde_json::from_str(&json).expect("should deserialize");
4557 prop_assert_eq!(verdict, back);
4558 }
4559 }
4560
4561 fn severity_strategy() -> impl Strategy<Value = Severity> {
4564 prop_oneof![Just(Severity::Warn), Just(Severity::Fail),]
4565 }
4566
4567 fn finding_data_strategy() -> impl Strategy<Value = FindingData> {
4568 (
4569 non_empty_string(),
4570 0.1f64..10000.0,
4571 0.1f64..10000.0,
4572 0.0f64..100.0,
4573 0.01f64..1.0,
4574 direction_strategy(),
4575 )
4576 .prop_map(
4577 |(metric_name, baseline, current, regression_pct, threshold, direction)| {
4578 FindingData {
4579 metric_name,
4580 baseline,
4581 current,
4582 regression_pct,
4583 threshold,
4584 direction,
4585 }
4586 },
4587 )
4588 }
4589
4590 fn report_finding_strategy() -> impl Strategy<Value = ReportFinding> {
4591 (
4592 non_empty_string(),
4593 non_empty_string(),
4594 severity_strategy(),
4595 non_empty_string(),
4596 proptest::option::of(finding_data_strategy()),
4597 )
4598 .prop_map(|(check_id, code, severity, message, data)| ReportFinding {
4599 check_id,
4600 code,
4601 severity,
4602 message,
4603 data,
4604 })
4605 }
4606
4607 fn report_summary_strategy() -> impl Strategy<Value = ReportSummary> {
4608 (0u32..100, 0u32..100, 0u32..100, 0u32..100).prop_map(
4609 |(pass_count, warn_count, fail_count, skip_count)| ReportSummary {
4610 pass_count,
4611 warn_count,
4612 fail_count,
4613 skip_count,
4614 total_count: pass_count + warn_count + fail_count + skip_count,
4615 },
4616 )
4617 }
4618
4619 fn perfgate_report_strategy() -> impl Strategy<Value = PerfgateReport> {
4620 (
4621 verdict_strategy(),
4622 proptest::option::of(compare_receipt_strategy()),
4623 proptest::collection::vec(report_finding_strategy(), 0..5),
4624 report_summary_strategy(),
4625 )
4626 .prop_map(|(verdict, compare, findings, summary)| PerfgateReport {
4627 report_type: REPORT_SCHEMA_V1.to_string(),
4628 verdict,
4629 compare,
4630 findings,
4631 summary,
4632 complexity: None,
4633 profile_path: None,
4634 })
4635 }
4636
4637 proptest! {
4638 #![proptest_config(ProptestConfig::with_cases(50))]
4639
4640 #[test]
4641 fn perfgate_report_serialization_round_trip(report in perfgate_report_strategy()) {
4642 let json = serde_json::to_string(&report)
4643 .expect("PerfgateReport should serialize to JSON");
4644 let back: PerfgateReport = serde_json::from_str(&json)
4645 .expect("JSON should deserialize back to PerfgateReport");
4646
4647 prop_assert_eq!(&report.report_type, &back.report_type);
4648 prop_assert_eq!(&report.verdict, &back.verdict);
4649 prop_assert_eq!(&report.summary, &back.summary);
4650 prop_assert_eq!(report.findings.len(), back.findings.len());
4651 for (orig, deser) in report.findings.iter().zip(back.findings.iter()) {
4652 prop_assert_eq!(&orig.check_id, &deser.check_id);
4653 prop_assert_eq!(&orig.code, &deser.code);
4654 prop_assert_eq!(orig.severity, deser.severity);
4655 prop_assert_eq!(&orig.message, &deser.message);
4656 match (&orig.data, &deser.data) {
4657 (Some(o), Some(d)) => {
4658 prop_assert_eq!(&o.metric_name, &d.metric_name);
4659 prop_assert!(f64_approx_eq(o.baseline, d.baseline));
4660 prop_assert!(f64_approx_eq(o.current, d.current));
4661 prop_assert!(f64_approx_eq(o.regression_pct, d.regression_pct));
4662 prop_assert!(f64_approx_eq(o.threshold, d.threshold));
4663 prop_assert_eq!(o.direction, d.direction);
4664 }
4665 (None, None) => {}
4666 _ => prop_assert!(false, "finding data presence mismatch"),
4667 }
4668 }
4669 prop_assert_eq!(report.compare.is_some(), back.compare.is_some());
4672 }
4673 }
4674}
4675
4676#[cfg(test)]
4679mod golden_tests {
4680 use super::*;
4681
4682 const FIXTURE_PASS: &str = include_str!("../../../contracts/fixtures/sensor_report_pass.json");
4683 const FIXTURE_FAIL: &str = include_str!("../../../contracts/fixtures/sensor_report_fail.json");
4684 const FIXTURE_WARN: &str = include_str!("../../../contracts/fixtures/sensor_report_warn.json");
4685 const FIXTURE_NO_BASELINE: &str =
4686 include_str!("../../../contracts/fixtures/sensor_report_no_baseline.json");
4687 const FIXTURE_ERROR: &str =
4688 include_str!("../../../contracts/fixtures/sensor_report_error.json");
4689 const FIXTURE_MULTI_BENCH: &str =
4690 include_str!("../../../contracts/fixtures/sensor_report_multi_bench.json");
4691
4692 #[test]
4693 fn golden_sensor_report_pass() {
4694 let report: SensorReport =
4695 serde_json::from_str(FIXTURE_PASS).expect("fixture should parse");
4696 assert_eq!(report.schema, SENSOR_REPORT_SCHEMA_V1);
4697 assert_eq!(report.tool.name, "perfgate");
4698 assert_eq!(report.verdict.status, SensorVerdictStatus::Pass);
4699 assert_eq!(report.verdict.counts.warn, 0);
4700 assert_eq!(report.verdict.counts.error, 0);
4701 assert!(report.findings.is_empty());
4702 assert_eq!(report.artifacts.len(), 4);
4703
4704 let json = serde_json::to_string(&report).unwrap();
4706 let back: SensorReport = serde_json::from_str(&json).unwrap();
4707 assert_eq!(report, back);
4708 }
4709
4710 #[test]
4711 fn golden_sensor_report_fail() {
4712 let report: SensorReport =
4713 serde_json::from_str(FIXTURE_FAIL).expect("fixture should parse");
4714 assert_eq!(report.schema, SENSOR_REPORT_SCHEMA_V1);
4715 assert_eq!(report.verdict.status, SensorVerdictStatus::Fail);
4716 assert_eq!(report.verdict.counts.error, 1);
4717 assert_eq!(report.verdict.reasons, vec!["wall_ms_fail"]);
4718 assert_eq!(report.findings.len(), 1);
4719 assert_eq!(report.findings[0].check_id, CHECK_ID_BUDGET);
4720 assert_eq!(report.findings[0].code, FINDING_CODE_METRIC_FAIL);
4721 assert_eq!(report.findings[0].severity, SensorSeverity::Error);
4722
4723 let json = serde_json::to_string(&report).unwrap();
4724 let back: SensorReport = serde_json::from_str(&json).unwrap();
4725 assert_eq!(report, back);
4726 }
4727
4728 #[test]
4729 fn golden_sensor_report_warn() {
4730 let report: SensorReport =
4731 serde_json::from_str(FIXTURE_WARN).expect("fixture should parse");
4732 assert_eq!(report.schema, SENSOR_REPORT_SCHEMA_V1);
4733 assert_eq!(report.verdict.status, SensorVerdictStatus::Warn);
4734 assert_eq!(report.verdict.counts.warn, 1);
4735 assert_eq!(report.verdict.reasons, vec!["wall_ms_warn"]);
4736 assert_eq!(report.findings.len(), 1);
4737 assert_eq!(report.findings[0].severity, SensorSeverity::Warn);
4738
4739 let json = serde_json::to_string(&report).unwrap();
4740 let back: SensorReport = serde_json::from_str(&json).unwrap();
4741 assert_eq!(report, back);
4742 }
4743
4744 #[test]
4745 fn golden_sensor_report_no_baseline() {
4746 let report: SensorReport =
4747 serde_json::from_str(FIXTURE_NO_BASELINE).expect("fixture should parse");
4748 assert_eq!(report.schema, SENSOR_REPORT_SCHEMA_V1);
4749 assert_eq!(report.verdict.status, SensorVerdictStatus::Warn);
4750 assert_eq!(report.verdict.reasons, vec!["no_baseline"]);
4751 assert_eq!(
4752 report.run.capabilities.baseline.status,
4753 CapabilityStatus::Unavailable
4754 );
4755 assert_eq!(
4756 report.run.capabilities.baseline.reason.as_deref(),
4757 Some("no_baseline")
4758 );
4759 assert_eq!(report.findings.len(), 1);
4760 assert_eq!(report.findings[0].code, FINDING_CODE_BASELINE_MISSING);
4761
4762 let json = serde_json::to_string(&report).unwrap();
4763 let back: SensorReport = serde_json::from_str(&json).unwrap();
4764 assert_eq!(report, back);
4765 }
4766
4767 #[test]
4768 fn golden_sensor_report_error() {
4769 let report: SensorReport =
4770 serde_json::from_str(FIXTURE_ERROR).expect("fixture should parse");
4771 assert_eq!(report.schema, SENSOR_REPORT_SCHEMA_V1);
4772 assert_eq!(report.verdict.status, SensorVerdictStatus::Fail);
4773 assert_eq!(report.verdict.reasons, vec!["tool_error"]);
4774 assert_eq!(report.findings.len(), 1);
4775 assert_eq!(report.findings[0].check_id, CHECK_ID_TOOL_RUNTIME);
4776 assert_eq!(report.findings[0].code, FINDING_CODE_RUNTIME_ERROR);
4777 assert!(report.artifacts.is_empty());
4778
4779 let json = serde_json::to_string(&report).unwrap();
4780 let back: SensorReport = serde_json::from_str(&json).unwrap();
4781 assert_eq!(report, back);
4782 }
4783
4784 #[test]
4785 fn golden_sensor_report_multi_bench() {
4786 let report: SensorReport =
4787 serde_json::from_str(FIXTURE_MULTI_BENCH).expect("fixture should parse");
4788 assert_eq!(report.schema, SENSOR_REPORT_SCHEMA_V1);
4789 assert_eq!(report.verdict.status, SensorVerdictStatus::Warn);
4790 assert_eq!(report.verdict.counts.warn, 2);
4791 assert_eq!(report.findings.len(), 2);
4792 for finding in &report.findings {
4794 assert_eq!(finding.code, FINDING_CODE_BASELINE_MISSING);
4795 }
4796 assert_eq!(report.artifacts.len(), 5);
4797
4798 let json = serde_json::to_string(&report).unwrap();
4799 let back: SensorReport = serde_json::from_str(&json).unwrap();
4800 assert_eq!(report, back);
4801 }
4802}