Skip to main content

perfgate_types/
lib.rs

1//! Shared types for perfgate.
2//!
3//! Design goal: versioned, explicit, boring.
4//! These structs are used for receipts, PR comments, and (eventually) long-term baselines.
5//!
6//! # Examples
7//!
8//! Round-trip a [`ToolInfo`] through JSON:
9//!
10//! ```
11//! use perfgate_types::ToolInfo;
12//!
13//! let tool = ToolInfo { name: "perfgate".into(), version: "1.0.0".into() };
14//! let json = serde_json::to_string(&tool).unwrap();
15//! let back: ToolInfo = serde_json::from_str(&json).unwrap();
16//! assert_eq!(tool, back);
17//! ```
18//!
19//! # Feature Flags
20//!
21//! - `arbitrary`: Enables `Arbitrary` derive for structure-aware fuzzing with cargo-fuzz.
22
23mod defaults_config;
24mod paired;
25
26pub use paired::{
27    PAIRED_SCHEMA_V1, PairedBenchMeta, PairedDiffSummary, PairedRunReceipt, PairedSample,
28    PairedSampleHalf, PairedStats,
29};
30
31pub use defaults_config::*;
32
33pub use perfgate_validation::{
34    BENCH_NAME_MAX_LEN, BENCH_NAME_PATTERN, ValidationError as BenchNameValidationError,
35    validate_bench_name,
36};
37
38pub use perfgate_error::{ConfigValidationError, PerfgateError};
39
40use schemars::JsonSchema;
41use serde::{Deserialize, Serialize};
42use std::collections::BTreeMap;
43
44pub const RUN_SCHEMA_V1: &str = "perfgate.run.v1";
45pub const BASELINE_SCHEMA_V1: &str = "perfgate.baseline.v1";
46pub const COMPARE_SCHEMA_V1: &str = "perfgate.compare.v1";
47pub const REPORT_SCHEMA_V1: &str = "perfgate.report.v1";
48pub const CONFIG_SCHEMA_V1: &str = "perfgate.config.v1";
49
50// Stable contract identifiers and tokens.
51pub const CHECK_ID_BUDGET: &str = "perf.budget";
52pub const CHECK_ID_BASELINE: &str = "perf.baseline";
53pub const CHECK_ID_HOST: &str = "perf.host";
54pub const CHECK_ID_TOOL_RUNTIME: &str = "tool.runtime";
55pub const FINDING_CODE_METRIC_WARN: &str = "metric_warn";
56pub const FINDING_CODE_METRIC_FAIL: &str = "metric_fail";
57pub const FINDING_CODE_BASELINE_MISSING: &str = "missing";
58pub const FINDING_CODE_HOST_MISMATCH: &str = "host_mismatch";
59pub const FINDING_CODE_RUNTIME_ERROR: &str = "runtime_error";
60pub const VERDICT_REASON_NO_BASELINE: &str = "no_baseline";
61pub const VERDICT_REASON_HOST_MISMATCH: &str = "host_mismatch";
62pub const VERDICT_REASON_TOOL_ERROR: &str = "tool_error";
63pub const VERDICT_REASON_TRUNCATED: &str = "truncated";
64
65// Error classification stages.
66pub const STAGE_CONFIG_PARSE: &str = "config_parse";
67pub const STAGE_BASELINE_RESOLVE: &str = "baseline_resolve";
68pub const STAGE_RUN_COMMAND: &str = "run_command";
69pub const STAGE_WRITE_ARTIFACTS: &str = "write_artifacts";
70
71// Error kind constants.
72pub const ERROR_KIND_IO: &str = "io_error";
73pub const ERROR_KIND_PARSE: &str = "parse_error";
74pub const ERROR_KIND_EXEC: &str = "exec_error";
75
76// Baseline reason tokens.
77pub const BASELINE_REASON_NO_BASELINE: &str = "no_baseline";
78
79// Truncation signaling constants.
80pub const CHECK_ID_TOOL_TRUNCATION: &str = "tool.truncation";
81pub const FINDING_CODE_TRUNCATED: &str = "truncated";
82pub const MAX_FINDINGS_DEFAULT: usize = 100;
83
84// Sensor report schema for cockpit integration
85pub const SENSOR_REPORT_SCHEMA_V1: &str = "sensor.report.v1";
86
87// ----------------------------
88// Sensor report types (sensor.report.v1)
89// ----------------------------
90
91/// Capability status for "No Green By Omission" principle.
92#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
93#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
94#[serde(rename_all = "snake_case")]
95pub enum CapabilityStatus {
96    Available,
97    Unavailable,
98    Skipped,
99}
100
101/// A capability with its status and optional reason.
102#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
103#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
104pub struct Capability {
105    pub status: CapabilityStatus,
106    #[serde(skip_serializing_if = "Option::is_none")]
107    pub reason: Option<String>,
108}
109
110/// Capabilities available to the sensor.
111#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
112#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
113pub struct SensorCapabilities {
114    pub baseline: Capability,
115    #[serde(skip_serializing_if = "Option::is_none", default)]
116    pub engine: Option<Capability>,
117}
118
119/// Run metadata for the sensor report.
120#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
121#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
122pub struct SensorRunMeta {
123    pub started_at: String,
124    #[serde(skip_serializing_if = "Option::is_none")]
125    pub ended_at: Option<String>,
126    #[serde(skip_serializing_if = "Option::is_none")]
127    pub duration_ms: Option<u64>,
128    pub capabilities: SensorCapabilities,
129}
130
131/// Verdict status for the sensor report.
132#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
133#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
134#[serde(rename_all = "snake_case")]
135pub enum SensorVerdictStatus {
136    Pass,
137    Warn,
138    Fail,
139    Skip,
140}
141
142/// Verdict counts for the sensor report.
143#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
144#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
145pub struct SensorVerdictCounts {
146    pub info: u32,
147    pub warn: u32,
148    pub error: u32,
149}
150
151/// Verdict for the sensor report.
152#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
153#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
154pub struct SensorVerdict {
155    pub status: SensorVerdictStatus,
156    pub counts: SensorVerdictCounts,
157    pub reasons: Vec<String>,
158}
159
160/// Severity level for sensor findings (cockpit vocabulary).
161#[derive(Debug, Copy, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
162#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
163#[serde(rename_all = "snake_case")]
164pub enum SensorSeverity {
165    Info,
166    Warn,
167    Error,
168}
169
170/// A finding from the sensor.
171#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
172pub struct SensorFinding {
173    pub check_id: String,
174    pub code: String,
175    pub severity: SensorSeverity,
176    pub message: String,
177    #[serde(skip_serializing_if = "Option::is_none")]
178    pub fingerprint: Option<String>,
179    #[serde(skip_serializing_if = "Option::is_none")]
180    pub data: Option<serde_json::Value>,
181}
182
183/// An artifact produced by the sensor.
184#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
185#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
186pub struct SensorArtifact {
187    pub path: String,
188    #[serde(rename = "type")]
189    pub artifact_type: String,
190}
191
192/// The sensor.report.v1 envelope for cockpit integration.
193///
194/// This wraps PerfgateReport in a cockpit-compatible format with:
195/// - Run metadata including capabilities
196/// - Verdict using cockpit vocabulary (error instead of fail)
197/// - Artifacts list
198/// - Native perfgate data
199#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
200pub struct SensorReport {
201    pub schema: String,
202    pub tool: ToolInfo,
203    pub run: SensorRunMeta,
204    pub verdict: SensorVerdict,
205    pub findings: Vec<SensorFinding>,
206    #[serde(skip_serializing_if = "Vec::is_empty", default)]
207    pub artifacts: Vec<SensorArtifact>,
208    pub data: serde_json::Value,
209}
210
211#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
212#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
213pub struct ToolInfo {
214    pub name: String,
215    pub version: String,
216}
217
218#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
219#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
220pub struct HostInfo {
221    /// Operating system (e.g., "linux", "macos", "windows")
222    pub os: String,
223
224    /// CPU architecture (e.g., "x86_64", "aarch64")
225    pub arch: String,
226
227    /// Number of logical CPUs (best-effort, None if unavailable)
228    #[serde(skip_serializing_if = "Option::is_none", default)]
229    pub cpu_count: Option<u32>,
230
231    /// Total system memory in bytes (best-effort, None if unavailable)
232    #[serde(skip_serializing_if = "Option::is_none", default)]
233    pub memory_bytes: Option<u64>,
234
235    /// Hashed hostname for fingerprinting (opt-in, privacy-preserving).
236    /// When present, this is a SHA-256 hash of the actual hostname.
237    #[serde(skip_serializing_if = "Option::is_none", default)]
238    pub hostname_hash: Option<String>,
239}
240
241/// Policy for handling host mismatches when comparing receipts from different machines.
242///
243/// Host mismatches are detected when:
244/// - Different `os` or `arch`
245/// - Significant difference in `cpu_count` (> 2x)
246/// - Significant difference in `memory_bytes` (> 2x)
247/// - Different `hostname_hash` (if both present)
248#[derive(Debug, Copy, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)]
249#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
250#[serde(rename_all = "snake_case")]
251pub enum HostMismatchPolicy {
252    /// Warn about host mismatch but continue with comparison (default).
253    #[default]
254    Warn,
255    /// Treat host mismatch as an error (exit 1).
256    Error,
257    /// Ignore host mismatches completely (suppress warnings).
258    Ignore,
259}
260
261impl HostMismatchPolicy {
262    /// Returns the string representation of this policy.
263    ///
264    /// # Examples
265    ///
266    /// ```
267    /// use perfgate_types::HostMismatchPolicy;
268    ///
269    /// assert_eq!(HostMismatchPolicy::Warn.as_str(), "warn");
270    /// assert_eq!(HostMismatchPolicy::default().as_str(), "warn");
271    /// ```
272    pub fn as_str(self) -> &'static str {
273        match self {
274            HostMismatchPolicy::Warn => "warn",
275            HostMismatchPolicy::Error => "error",
276            HostMismatchPolicy::Ignore => "ignore",
277        }
278    }
279}
280
281/// Details about a detected host mismatch between baseline and current runs.
282#[derive(Debug, Clone, PartialEq, Eq)]
283pub struct HostMismatchInfo {
284    /// Human-readable description of the mismatch.
285    pub reasons: Vec<String>,
286}
287
288#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
289#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
290pub struct RunMeta {
291    pub id: String,
292    pub started_at: String,
293    pub ended_at: String,
294    pub host: HostInfo,
295}
296
297#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
298#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
299pub struct BenchMeta {
300    pub name: String,
301
302    /// Optional working directory (stringified path).
303    #[serde(skip_serializing_if = "Option::is_none")]
304    pub cwd: Option<String>,
305
306    /// argv vector (no shell parsing).
307    pub command: Vec<String>,
308
309    pub repeat: u32,
310    pub warmup: u32,
311
312    #[serde(skip_serializing_if = "Option::is_none")]
313    pub work_units: Option<u64>,
314
315    #[serde(skip_serializing_if = "Option::is_none")]
316    pub timeout_ms: Option<u64>,
317}
318
319#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
320#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
321pub struct Sample {
322    pub wall_ms: u64,
323    pub exit_code: i32,
324
325    #[serde(default)]
326    pub warmup: bool,
327
328    #[serde(default)]
329    pub timed_out: bool,
330
331    /// CPU time (user + system) in milliseconds (Unix only).
332    #[serde(skip_serializing_if = "Option::is_none", default)]
333    pub cpu_ms: Option<u64>,
334
335    /// Major page faults (Unix only).
336    #[serde(skip_serializing_if = "Option::is_none", default)]
337    pub page_faults: Option<u64>,
338
339    /// Voluntary + involuntary context switches (Unix only).
340    #[serde(skip_serializing_if = "Option::is_none", default)]
341    pub ctx_switches: Option<u64>,
342    /// Peak resident set size in KB.
343    #[serde(skip_serializing_if = "Option::is_none", default)]
344    pub max_rss_kb: Option<u64>,
345
346    /// Bytes read from disk (best-effort).
347    #[serde(skip_serializing_if = "Option::is_none", default)]
348    pub io_read_bytes: Option<u64>,
349
350    /// Bytes written to disk (best-effort).
351    #[serde(skip_serializing_if = "Option::is_none", default)]
352    pub io_write_bytes: Option<u64>,
353
354    /// Total network packets (best-effort).
355    #[serde(skip_serializing_if = "Option::is_none", default)]
356    pub network_packets: Option<u64>,
357
358    /// CPU energy used in microjoules (RAPL on Linux).
359    #[serde(skip_serializing_if = "Option::is_none", default)]
360    pub energy_uj: Option<u64>,
361
362    /// Size of executed binary in bytes (best-effort).
363    #[serde(skip_serializing_if = "Option::is_none", default)]
364    pub binary_bytes: Option<u64>,
365
366    /// Truncated stdout (bytes interpreted as UTF-8 lossily).
367    #[serde(skip_serializing_if = "Option::is_none", default)]
368    pub stdout: Option<String>,
369
370    /// Truncated stderr (bytes interpreted as UTF-8 lossily).
371    #[serde(skip_serializing_if = "Option::is_none", default)]
372    pub stderr: Option<String>,
373}
374
375#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
376#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
377pub struct U64Summary {
378    pub median: u64,
379    pub min: u64,
380    pub max: u64,
381    #[serde(skip_serializing_if = "Option::is_none", default)]
382    pub mean: Option<f64>,
383    #[serde(skip_serializing_if = "Option::is_none", default)]
384    pub stddev: Option<f64>,
385}
386
387impl U64Summary {
388    pub fn new(median: u64, min: u64, max: u64) -> Self {
389        Self {
390            median,
391            min,
392            max,
393            mean: None,
394            stddev: None,
395        }
396    }
397
398    /// Returns the coefficient of variation (stddev / mean).
399    /// Returns None if mean is 0 or stddev/mean are not present.
400    pub fn cv(&self) -> Option<f64> {
401        match (self.mean, self.stddev) {
402            (Some(mean), Some(stddev)) if mean > 0.0 => Some(stddev / mean),
403            _ => None,
404        }
405    }
406}
407
408#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
409#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
410pub struct F64Summary {
411    pub median: f64,
412    pub min: f64,
413    pub max: f64,
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 F64Summary {
421    pub fn new(median: f64, min: f64, max: f64) -> Self {
422        Self {
423            median,
424            min,
425            max,
426            mean: None,
427            stddev: None,
428        }
429    }
430
431    /// Returns the coefficient of variation (stddev / mean).
432    /// Returns None if mean is 0 or stddev/mean are not present.
433    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/// Aggregated statistics for a benchmark run.
442///
443/// # Examples
444///
445/// ```
446/// use perfgate_types::{Stats, U64Summary};
447///
448/// let stats = Stats {
449///     wall_ms: U64Summary::new(100, 90, 120 ),
450///     cpu_ms: None,
451///     page_faults: None,
452///     ctx_switches: None,
453///     max_rss_kb: Some(U64Summary::new(4096, 4000, 4200 )),
454///     io_read_bytes: None,
455///     io_write_bytes: None,
456///     network_packets: None,
457///     energy_uj: None,
458///     binary_bytes: None,
459///     throughput_per_s: None,
460/// };
461/// assert_eq!(stats.wall_ms.median, 100);
462/// assert_eq!(stats.max_rss_kb.unwrap().median, 4096);
463/// ```
464#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
465#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
466pub struct Stats {
467    pub wall_ms: U64Summary,
468
469    /// CPU time (user + system) summary in milliseconds (Unix only).
470    #[serde(skip_serializing_if = "Option::is_none", default)]
471    pub cpu_ms: Option<U64Summary>,
472
473    /// Major page faults summary (Unix only).
474    #[serde(skip_serializing_if = "Option::is_none", default)]
475    pub page_faults: Option<U64Summary>,
476
477    /// Voluntary + involuntary context switches summary (Unix only).
478    #[serde(skip_serializing_if = "Option::is_none", default)]
479    pub ctx_switches: Option<U64Summary>,
480
481    #[serde(skip_serializing_if = "Option::is_none", default)]
482    pub max_rss_kb: Option<U64Summary>,
483
484    /// Bytes read from disk summary (best-effort).
485    #[serde(skip_serializing_if = "Option::is_none", default)]
486    pub io_read_bytes: Option<U64Summary>,
487
488    /// Bytes written to disk summary (best-effort).
489    #[serde(skip_serializing_if = "Option::is_none", default)]
490    pub io_write_bytes: Option<U64Summary>,
491
492    /// Total network packets summary (best-effort).
493    #[serde(skip_serializing_if = "Option::is_none", default)]
494    pub network_packets: Option<U64Summary>,
495
496    /// CPU energy used summary in microjoules (RAPL on Linux).
497    #[serde(skip_serializing_if = "Option::is_none", default)]
498    pub energy_uj: Option<U64Summary>,
499
500    /// Size of executed binary in bytes (best-effort).
501    #[serde(skip_serializing_if = "Option::is_none", default)]
502    pub binary_bytes: Option<U64Summary>,
503
504    #[serde(skip_serializing_if = "Option::is_none", default)]
505    pub throughput_per_s: Option<F64Summary>,
506}
507
508/// A versioned receipt from a single benchmark run (`perfgate.run.v1`).
509///
510/// # Examples
511///
512/// ```
513/// use perfgate_types::*;
514///
515/// let receipt = RunReceipt {
516///     schema: RUN_SCHEMA_V1.to_string(),
517///     tool: ToolInfo { name: "perfgate".into(), version: "0.1.0".into() },
518///     run: RunMeta {
519///         id: "run-1".into(),
520///         started_at: "2024-01-01T00:00:00Z".into(),
521///         ended_at: "2024-01-01T00:00:01Z".into(),
522///         host: HostInfo {
523///             os: "linux".into(), arch: "x86_64".into(),
524///             cpu_count: None, memory_bytes: None, hostname_hash: None,
525///         },
526///     },
527///     bench: BenchMeta {
528///         name: "my-bench".into(), cwd: None,
529///         command: vec!["echo".into(), "hello".into()],
530///         repeat: 3, warmup: 0, work_units: None, timeout_ms: None,
531///     },
532///     samples: vec![],
533///     stats: Stats {
534///         wall_ms: U64Summary::new(100, 90, 120 ),
535///         cpu_ms: None, page_faults: None, ctx_switches: None,
536///         max_rss_kb: None, io_read_bytes: None, io_write_bytes: None,
537///         network_packets: None, energy_uj: None, binary_bytes: None, throughput_per_s: None,
538///     },
539/// };
540///
541/// // Serialize to JSON
542/// let json = serde_json::to_string(&receipt).unwrap();
543/// assert!(json.contains("perfgate.run.v1"));
544/// ```
545#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
546#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
547pub struct RunReceipt {
548    pub schema: String,
549    pub tool: ToolInfo,
550    pub run: RunMeta,
551    pub bench: BenchMeta,
552    pub samples: Vec<Sample>,
553    pub stats: Stats,
554}
555
556#[derive(
557    Debug, Copy, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq, PartialOrd, Ord, Hash,
558)]
559#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
560#[serde(rename_all = "snake_case")]
561pub enum Metric {
562    BinaryBytes,
563    CpuMs,
564    CtxSwitches,
565    EnergyUj,
566    IoReadBytes,
567    IoWriteBytes,
568    MaxRssKb,
569    NetworkPackets,
570    PageFaults,
571    ThroughputPerS,
572    WallMs,
573}
574
575impl Metric {
576    /// Returns the snake_case string key for this metric.
577    ///
578    /// # Examples
579    ///
580    /// ```
581    /// use perfgate_types::Metric;
582    ///
583    /// assert_eq!(Metric::WallMs.as_str(), "wall_ms");
584    /// assert_eq!(Metric::ThroughputPerS.as_str(), "throughput_per_s");
585    /// ```
586    pub fn as_str(self) -> &'static str {
587        match self {
588            Metric::BinaryBytes => "binary_bytes",
589            Metric::CpuMs => "cpu_ms",
590            Metric::CtxSwitches => "ctx_switches",
591            Metric::EnergyUj => "energy_uj",
592            Metric::IoReadBytes => "io_read_bytes",
593            Metric::IoWriteBytes => "io_write_bytes",
594            Metric::MaxRssKb => "max_rss_kb",
595            Metric::NetworkPackets => "network_packets",
596            Metric::PageFaults => "page_faults",
597            Metric::ThroughputPerS => "throughput_per_s",
598            Metric::WallMs => "wall_ms",
599        }
600    }
601
602    /// Parses a snake_case string key into a [`Metric`], returning `None` for unknown keys.
603    ///
604    /// # Examples
605    ///
606    /// ```
607    /// use perfgate_types::Metric;
608    ///
609    /// assert_eq!(Metric::parse_key("wall_ms"), Some(Metric::WallMs));
610    /// assert_eq!(Metric::parse_key("max_rss_kb"), Some(Metric::MaxRssKb));
611    /// assert_eq!(Metric::parse_key("unknown"), None);
612    /// ```
613    pub fn parse_key(key: &str) -> Option<Self> {
614        match key {
615            "binary_bytes" => Some(Metric::BinaryBytes),
616            "cpu_ms" => Some(Metric::CpuMs),
617            "ctx_switches" => Some(Metric::CtxSwitches),
618            "energy_uj" => Some(Metric::EnergyUj),
619            "io_read_bytes" => Some(Metric::IoReadBytes),
620            "io_write_bytes" => Some(Metric::IoWriteBytes),
621            "max_rss_kb" => Some(Metric::MaxRssKb),
622            "network_packets" => Some(Metric::NetworkPackets),
623            "page_faults" => Some(Metric::PageFaults),
624            "throughput_per_s" => Some(Metric::ThroughputPerS),
625            "wall_ms" => Some(Metric::WallMs),
626            _ => None,
627        }
628    }
629
630    /// Returns the default comparison direction for this metric.
631    ///
632    /// Most metrics use [`Direction::Lower`] (lower is better), except
633    /// throughput which uses [`Direction::Higher`].
634    ///
635    /// # Examples
636    ///
637    /// ```
638    /// use perfgate_types::{Metric, Direction};
639    ///
640    /// assert_eq!(Metric::WallMs.default_direction(), Direction::Lower);
641    /// assert_eq!(Metric::ThroughputPerS.default_direction(), Direction::Higher);
642    /// ```
643    pub fn default_direction(self) -> Direction {
644        match self {
645            Metric::BinaryBytes => Direction::Lower,
646            Metric::CpuMs => Direction::Lower,
647            Metric::CtxSwitches => Direction::Lower,
648            Metric::EnergyUj => Direction::Lower,
649            Metric::IoReadBytes => Direction::Lower,
650            Metric::IoWriteBytes => Direction::Lower,
651            Metric::MaxRssKb => Direction::Lower,
652            Metric::NetworkPackets => Direction::Lower,
653            Metric::PageFaults => Direction::Lower,
654            Metric::ThroughputPerS => Direction::Higher,
655            Metric::WallMs => Direction::Lower,
656        }
657    }
658
659    pub fn default_warn_factor(self) -> f64 {
660        // Near-budget warnings are useful in PRs, but they should not fail by default.
661        0.9
662    }
663
664    /// Returns the human-readable display unit for this metric.
665    ///
666    /// # Examples
667    ///
668    /// ```
669    /// use perfgate_types::Metric;
670    ///
671    /// assert_eq!(Metric::WallMs.display_unit(), "ms");
672    /// assert_eq!(Metric::MaxRssKb.display_unit(), "KB");
673    /// assert_eq!(Metric::ThroughputPerS.display_unit(), "/s");
674    /// ```
675    pub fn display_unit(self) -> &'static str {
676        match self {
677            Metric::BinaryBytes => "bytes",
678            Metric::CpuMs => "ms",
679            Metric::CtxSwitches => "count",
680            Metric::EnergyUj => "uj",
681            Metric::IoReadBytes => "bytes",
682            Metric::IoWriteBytes => "bytes",
683            Metric::MaxRssKb => "KB",
684            Metric::NetworkPackets => "count",
685            Metric::PageFaults => "count",
686            Metric::ThroughputPerS => "/s",
687            Metric::WallMs => "ms",
688        }
689    }
690}
691
692#[derive(Debug, Copy, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)]
693#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
694#[serde(rename_all = "snake_case")]
695pub enum MetricStatistic {
696    #[default]
697    Median,
698    P95,
699}
700
701impl MetricStatistic {
702    /// Returns the string representation of this statistic.
703    ///
704    /// # Examples
705    ///
706    /// ```
707    /// use perfgate_types::MetricStatistic;
708    ///
709    /// assert_eq!(MetricStatistic::Median.as_str(), "median");
710    /// assert_eq!(MetricStatistic::P95.as_str(), "p95");
711    /// ```
712    pub fn as_str(self) -> &'static str {
713        match self {
714            MetricStatistic::Median => "median",
715            MetricStatistic::P95 => "p95",
716        }
717    }
718}
719
720fn is_default_metric_statistic(stat: &MetricStatistic) -> bool {
721    *stat == MetricStatistic::Median
722}
723
724#[derive(Debug, Copy, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
725#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
726#[serde(rename_all = "snake_case")]
727pub enum Direction {
728    Lower,
729    Higher,
730}
731
732#[derive(Debug, Copy, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
733#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
734pub struct Budget {
735    /// Fail threshold, as a fraction (0.20 = 20% regression allowed).
736    pub threshold: f64,
737
738    /// Warn threshold, as a fraction.
739    pub warn_threshold: f64,
740    /// Noise threshold (coefficient of variation).
741    /// If CV exceeds this, the metric is considered flaky/noisy.
742    #[serde(skip_serializing_if = "Option::is_none", default)]
743    pub noise_threshold: Option<f64>,
744
745    /// Policy for handling noisy metrics.
746    #[serde(default, skip_serializing_if = "is_default_noise_policy")]
747    pub noise_policy: NoisePolicy,
748
749    /// Regression direction.
750    pub direction: Direction,
751}
752
753fn is_default_noise_policy(policy: &NoisePolicy) -> bool {
754    *policy == NoisePolicy::Ignore
755}
756
757impl Budget {
758    /// Creates a new budget with defaults for noise_threshold.
759    pub fn new(threshold: f64, warn_threshold: f64, direction: Direction) -> Self {
760        Self {
761            threshold,
762            warn_threshold,
763            noise_threshold: None,
764            noise_policy: NoisePolicy::Ignore,
765            direction,
766        }
767    }
768}
769
770#[derive(Debug, Copy, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
771#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
772#[serde(rename_all = "snake_case")]
773pub enum SignificanceTest {
774    WelchT,
775}
776
777#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
778#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
779pub struct Significance {
780    pub test: SignificanceTest,
781    pub p_value: Option<f64>,
782    pub alpha: f64,
783    pub significant: bool,
784    pub baseline_samples: u32,
785    pub current_samples: u32,
786    #[serde(skip_serializing_if = "Option::is_none")]
787    pub ci_lower: Option<f64>,
788    #[serde(skip_serializing_if = "Option::is_none")]
789    pub ci_upper: Option<f64>,
790}
791
792/// Policy for statistical significance testing.
793#[derive(Debug, Copy, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Default)]
794#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
795pub struct SignificancePolicy {
796    /// Significance level (e.g. 0.05).
797    pub alpha: Option<f64>,
798    /// Minimum number of samples required for a significant result.
799    pub min_samples: Option<u32>,
800}
801
802#[derive(Debug, Copy, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)]
803#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
804#[serde(rename_all = "snake_case")]
805pub enum NoisePolicy {
806    /// No change to status based on noise.
807    #[default]
808    Ignore,
809    /// Escalate Pass to Warn, and demote Fail to Warn.
810    Warn,
811    /// Demote Pass and Fail to Skip.
812    Skip,
813}
814
815impl NoisePolicy {
816    pub fn as_str(self) -> &'static str {
817        match self {
818            NoisePolicy::Ignore => "ignore",
819            NoisePolicy::Warn => "warn",
820            NoisePolicy::Skip => "skip",
821        }
822    }
823}
824
825#[derive(Debug, Copy, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
826#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
827#[serde(rename_all = "snake_case")]
828pub enum MetricStatus {
829    Pass,
830    Warn,
831    Fail,
832    Skip,
833}
834
835impl MetricStatus {
836    /// Returns the string representation of this status.
837    ///
838    /// # Examples
839    ///
840    /// ```
841    /// use perfgate_types::MetricStatus;
842    ///
843    /// assert_eq!(MetricStatus::Pass.as_str(), "pass");
844    /// assert_eq!(MetricStatus::Warn.as_str(), "warn");
845    /// assert_eq!(MetricStatus::Fail.as_str(), "fail");
846    /// ```
847    pub fn as_str(self) -> &'static str {
848        match self {
849            MetricStatus::Pass => "pass",
850            MetricStatus::Warn => "warn",
851            MetricStatus::Fail => "fail",
852            MetricStatus::Skip => "skip",
853        }
854    }
855}
856
857#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
858#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
859pub struct Delta {
860    pub baseline: f64,
861    pub current: f64,
862
863    /// current / baseline
864    pub ratio: f64,
865
866    /// (current - baseline) / baseline
867    pub pct: f64,
868
869    /// Positive regression amount, normalized as a fraction.
870    pub regression: f64,
871
872    /// Coefficient of variation for the current run.
873    #[serde(skip_serializing_if = "Option::is_none")]
874    pub cv: Option<f64>,
875
876    /// Noise threshold used for this comparison.
877    #[serde(skip_serializing_if = "Option::is_none")]
878    pub noise_threshold: Option<f64>,
879
880    #[serde(default, skip_serializing_if = "is_default_metric_statistic")]
881    pub statistic: MetricStatistic,
882
883    #[serde(skip_serializing_if = "Option::is_none")]
884    pub significance: Option<Significance>,
885
886    pub status: MetricStatus,
887}
888
889#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
890#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
891pub struct CompareRef {
892    #[serde(skip_serializing_if = "Option::is_none")]
893    pub path: Option<String>,
894
895    #[serde(skip_serializing_if = "Option::is_none")]
896    pub run_id: Option<String>,
897}
898
899#[derive(Debug, Copy, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
900#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
901#[serde(rename_all = "snake_case")]
902pub enum VerdictStatus {
903    Pass,
904    Warn,
905    Fail,
906    Skip,
907}
908
909impl VerdictStatus {
910    pub fn as_str(self) -> &'static str {
911        match self {
912            VerdictStatus::Pass => "pass",
913            VerdictStatus::Warn => "warn",
914            VerdictStatus::Fail => "fail",
915            VerdictStatus::Skip => "skip",
916        }
917    }
918}
919
920#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
921#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
922pub struct VerdictCounts {
923    pub pass: u32,
924    pub warn: u32,
925    pub fail: u32,
926    pub skip: u32,
927}
928
929/// Overall verdict for a comparison, with pass/warn/fail counts.
930///
931/// # Examples
932///
933/// ```
934/// use perfgate_types::{Verdict, VerdictStatus, VerdictCounts};
935///
936/// let verdict = Verdict {
937///     status: VerdictStatus::Pass,
938///     counts: VerdictCounts { pass: 2, warn: 0, fail: 0, skip: 0 },
939///     reasons: vec![],
940/// };
941/// assert_eq!(verdict.status, VerdictStatus::Pass);
942///
943/// let failing = Verdict {
944///     status: VerdictStatus::Fail,
945///     counts: VerdictCounts { pass: 1, warn: 0, fail: 1, skip: 0 },
946///     reasons: vec!["wall_ms.fail".into()],
947/// };
948/// assert_eq!(failing.status, VerdictStatus::Fail);
949/// assert!(!failing.reasons.is_empty());
950/// ```
951#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
952#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
953pub struct Verdict {
954    pub status: VerdictStatus,
955    pub counts: VerdictCounts,
956    pub reasons: Vec<String>,
957}
958
959/// A versioned receipt comparing baseline vs current (`perfgate.compare.v1`).
960///
961/// # Examples
962///
963/// ```
964/// use perfgate_types::*;
965/// use std::collections::BTreeMap;
966///
967/// let receipt = CompareReceipt {
968///     schema: COMPARE_SCHEMA_V1.to_string(),
969///     tool: ToolInfo { name: "perfgate".into(), version: "0.1.0".into() },
970///     bench: BenchMeta {
971///         name: "my-bench".into(), cwd: None,
972///         command: vec!["echo".into()], repeat: 5, warmup: 0,
973///         work_units: None, timeout_ms: None,
974///     },
975///     baseline_ref: CompareRef { path: Some("base.json".into()), run_id: None },
976///     current_ref: CompareRef { path: Some("cur.json".into()), run_id: None },
977///     budgets: BTreeMap::new(),
978///     deltas: BTreeMap::new(),
979///     verdict: Verdict {
980///         status: VerdictStatus::Pass,
981///         counts: VerdictCounts { pass: 0, warn: 0, fail: 0, skip: 0 },
982///         reasons: vec![],
983///     },
984/// };
985/// assert_eq!(receipt.schema, "perfgate.compare.v1");
986/// ```
987#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
988#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
989pub struct CompareReceipt {
990    pub schema: String,
991    pub tool: ToolInfo,
992
993    pub bench: BenchMeta,
994
995    pub baseline_ref: CompareRef,
996    pub current_ref: CompareRef,
997
998    pub budgets: BTreeMap<Metric, Budget>,
999    pub deltas: BTreeMap<Metric, Delta>,
1000
1001    pub verdict: Verdict,
1002}
1003
1004// ----------------------------
1005// Report types (perfgate.report.v1)
1006// ----------------------------
1007
1008/// Severity level for a finding.
1009#[derive(Debug, Copy, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
1010#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
1011#[serde(rename_all = "snake_case")]
1012pub enum Severity {
1013    Warn,
1014    Fail,
1015}
1016
1017/// Data associated with a metric finding.
1018#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
1019#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
1020pub struct FindingData {
1021    /// Name of the metric (e.g., "wall_ms", "max_rss_kb").
1022    #[serde(rename = "metric_name")]
1023    pub metric_name: String,
1024
1025    /// Baseline value.
1026    pub baseline: f64,
1027
1028    /// Current value.
1029    pub current: f64,
1030
1031    /// Regression percentage (positive means regression).
1032    #[serde(rename = "regression_pct")]
1033    pub regression_pct: f64,
1034
1035    /// Threshold that was exceeded (as a fraction, e.g., 0.20 for 20%).
1036    pub threshold: f64,
1037
1038    /// Whether lower is better or higher is better.
1039    pub direction: Direction,
1040}
1041
1042/// A single finding from the performance check.
1043#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
1044#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
1045pub struct ReportFinding {
1046    /// Unique identifier for the check type (e.g., "perf.budget", "perf.baseline").
1047    #[serde(rename = "check_id")]
1048    pub check_id: String,
1049
1050    /// Machine-readable code for the finding (e.g., "metric_warn", "metric_fail", "missing").
1051    pub code: String,
1052
1053    /// Severity level (warn or fail).
1054    pub severity: Severity,
1055
1056    /// Human-readable message describing the finding.
1057    pub message: String,
1058
1059    /// Structured data about the finding (present for metric findings, absent for structural findings).
1060    #[serde(skip_serializing_if = "Option::is_none")]
1061    pub data: Option<FindingData>,
1062}
1063
1064/// Summary counts and key metrics for the report.
1065#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
1066#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
1067pub struct ReportSummary {
1068    /// Number of metrics that passed.
1069    #[serde(rename = "pass_count")]
1070    pub pass_count: u32,
1071
1072    /// Number of metrics that warned.
1073    #[serde(rename = "warn_count")]
1074    pub warn_count: u32,
1075
1076    /// Number of metrics that failed.
1077    #[serde(rename = "fail_count")]
1078    pub fail_count: u32,
1079
1080    /// Number of metrics that were skipped.
1081    #[serde(rename = "skip_count", default, skip_serializing_if = "is_zero_u32")]
1082    pub skip_count: u32,
1083
1084    /// Total number of metrics checked.
1085    #[serde(rename = "total_count")]
1086    pub total_count: u32,
1087}
1088
1089fn is_zero_u32(n: &u32) -> bool {
1090    *n == 0
1091}
1092
1093/// A performance report wrapping compare results in a cockpit-compatible envelope.
1094#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
1095#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
1096pub struct PerfgateReport {
1097    /// Schema identifier, always "perfgate.report.v1".
1098    #[serde(rename = "report_type")]
1099    pub report_type: String,
1100
1101    /// Overall verdict for the report.
1102    pub verdict: Verdict,
1103
1104    /// The full compare receipt (absent when baseline is missing).
1105    #[serde(skip_serializing_if = "Option::is_none")]
1106    pub compare: Option<CompareReceipt>,
1107
1108    /// List of findings (warnings and failures).
1109    pub findings: Vec<ReportFinding>,
1110
1111    /// Summary counts.
1112    pub summary: ReportSummary,
1113}
1114
1115// ----------------------------
1116// Optional config file schema
1117// ----------------------------
1118
1119/// Top-level configuration file (`perfgate.toml`).
1120///
1121/// # Examples
1122///
1123/// ```
1124/// use perfgate_types::ConfigFile;
1125///
1126/// let toml_str = r#"
1127/// [defaults]
1128/// repeat = 5
1129/// threshold = 0.20
1130///
1131/// [[bench]]
1132/// name = "my-bench"
1133/// command = ["echo", "hello"]
1134/// "#;
1135///
1136/// let config: ConfigFile = toml::from_str(toml_str).unwrap();
1137/// assert_eq!(config.benches.len(), 1);
1138/// assert_eq!(config.benches[0].name, "my-bench");
1139/// assert_eq!(config.defaults.repeat, Some(5));
1140/// ```
1141#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Default)]
1142#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
1143pub struct ConfigFile {
1144    #[serde(default)]
1145    pub defaults: DefaultsConfig,
1146
1147    /// Optional baseline server configuration for centralized baseline management.
1148    #[serde(default)]
1149    pub baseline_server: BaselineServerConfig,
1150
1151    #[serde(default, rename = "bench")]
1152    pub benches: Vec<BenchConfigFile>,
1153}
1154
1155impl ConfigFile {
1156    /// Validate all bench names in this config. Returns an error if any name is invalid.
1157    ///
1158    /// # Examples
1159    ///
1160    /// ```
1161    /// use perfgate_types::ConfigFile;
1162    ///
1163    /// let good: ConfigFile = toml::from_str(r#"
1164    /// [[bench]]
1165    /// name = "valid-bench"
1166    /// command = ["echo"]
1167    /// "#).unwrap();
1168    /// assert!(good.validate().is_ok());
1169    ///
1170    /// let bad: ConfigFile = toml::from_str(r#"
1171    /// [[bench]]
1172    /// name = "INVALID|name"
1173    /// command = ["echo"]
1174    /// "#).unwrap();
1175    /// assert!(bad.validate().is_err());
1176    /// ```
1177    pub fn validate(&self) -> Result<(), String> {
1178        for bench in &self.benches {
1179            validate_bench_name(&bench.name).map_err(|e| e.to_string())?;
1180        }
1181        Ok(())
1182    }
1183}
1184
1185/// Configuration for the baseline server connection.
1186///
1187/// When configured, the CLI can use a centralized baseline server
1188/// for storing and retrieving baselines instead of local files.
1189///
1190/// # Examples
1191///
1192/// ```toml
1193/// [baseline_server]
1194/// url = "http://localhost:3000/api/v1"
1195/// api_key = "pg_live_xxx"
1196/// project = "my-project"
1197/// fallback_to_local = true
1198/// ```
1199#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Default)]
1200#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
1201pub struct BaselineServerConfig {
1202    /// URL of the baseline server (e.g., "http://localhost:3000/api/v1").
1203    #[serde(skip_serializing_if = "Option::is_none", default)]
1204    pub url: Option<String>,
1205
1206    /// API key for authentication.
1207    #[serde(skip_serializing_if = "Option::is_none", default)]
1208    pub api_key: Option<String>,
1209
1210    /// Project name for multi-tenancy.
1211    #[serde(skip_serializing_if = "Option::is_none", default)]
1212    pub project: Option<String>,
1213
1214    /// Fall back to local storage when server is unavailable.
1215    #[serde(default = "default_fallback_to_local")]
1216    pub fallback_to_local: bool,
1217}
1218
1219fn default_fallback_to_local() -> bool {
1220    true
1221}
1222
1223impl BaselineServerConfig {
1224    /// Returns true if server is configured (has a URL).
1225    pub fn is_configured(&self) -> bool {
1226        self.url.is_some() && !self.url.as_ref().unwrap().is_empty()
1227    }
1228
1229    /// Resolves the server URL from config or environment variable.
1230    pub fn resolved_url(&self) -> Option<String> {
1231        std::env::var("PERFGATE_SERVER_URL")
1232            .ok()
1233            .or_else(|| self.url.clone())
1234    }
1235
1236    /// Resolves the API key from config or environment variable.
1237    pub fn resolved_api_key(&self) -> Option<String> {
1238        std::env::var("PERFGATE_API_KEY")
1239            .ok()
1240            .or_else(|| self.api_key.clone())
1241    }
1242
1243    /// Resolves the project name from config or environment variable.
1244    pub fn resolved_project(&self) -> Option<String> {
1245        std::env::var("PERFGATE_PROJECT")
1246            .ok()
1247            .or_else(|| self.project.clone())
1248    }
1249}
1250
1251#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
1252#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
1253pub struct BenchConfigFile {
1254    pub name: String,
1255
1256    #[serde(skip_serializing_if = "Option::is_none")]
1257    pub cwd: Option<String>,
1258
1259    #[serde(skip_serializing_if = "Option::is_none")]
1260    pub work: Option<u64>,
1261
1262    /// Duration string parseable by humantime, e.g. "2s".
1263    #[serde(skip_serializing_if = "Option::is_none")]
1264    pub timeout: Option<String>,
1265
1266    /// argv vector (no shell parsing).
1267    pub command: Vec<String>,
1268
1269    /// Number of measured samples (overrides defaults.repeat).
1270    #[serde(skip_serializing_if = "Option::is_none")]
1271    pub repeat: Option<u32>,
1272
1273    /// Warmup samples excluded from stats (overrides defaults.warmup).
1274    #[serde(skip_serializing_if = "Option::is_none")]
1275    pub warmup: Option<u32>,
1276
1277    #[serde(skip_serializing_if = "Option::is_none")]
1278    pub metrics: Option<Vec<Metric>>,
1279
1280    #[serde(skip_serializing_if = "Option::is_none")]
1281    pub budgets: Option<BTreeMap<Metric, BudgetOverride>>,
1282}
1283
1284#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Default)]
1285#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
1286pub struct BudgetOverride {
1287    #[serde(skip_serializing_if = "Option::is_none")]
1288    pub threshold: Option<f64>,
1289
1290    #[serde(skip_serializing_if = "Option::is_none")]
1291    pub direction: Option<Direction>,
1292
1293    /// Warn fraction (warn_threshold = threshold * warn_factor).
1294    #[serde(skip_serializing_if = "Option::is_none")]
1295    pub warn_factor: Option<f64>,
1296
1297    #[serde(skip_serializing_if = "Option::is_none")]
1298    pub noise_threshold: Option<f64>,
1299
1300    #[serde(skip_serializing_if = "Option::is_none")]
1301    pub noise_policy: Option<NoisePolicy>,
1302
1303    #[serde(skip_serializing_if = "Option::is_none")]
1304    pub statistic: Option<MetricStatistic>,
1305}
1306
1307#[cfg(test)]
1308mod tests {
1309    use super::*;
1310
1311    #[test]
1312    fn metric_serde_keys_are_snake_case() {
1313        let mut m = BTreeMap::new();
1314        m.insert(Metric::WallMs, Budget::new(0.2, 0.18, Direction::Lower));
1315        let json = serde_json::to_string(&m).unwrap();
1316        assert!(json.contains("\"wall_ms\""));
1317    }
1318
1319    #[test]
1320    fn metric_metadata_and_parsing_are_consistent() {
1321        let cases = [
1322            (
1323                Metric::BinaryBytes,
1324                "binary_bytes",
1325                Direction::Lower,
1326                "bytes",
1327            ),
1328            (Metric::WallMs, "wall_ms", Direction::Lower, "ms"),
1329            (Metric::CpuMs, "cpu_ms", Direction::Lower, "ms"),
1330            (
1331                Metric::CtxSwitches,
1332                "ctx_switches",
1333                Direction::Lower,
1334                "count",
1335            ),
1336            (Metric::MaxRssKb, "max_rss_kb", Direction::Lower, "KB"),
1337            (Metric::PageFaults, "page_faults", Direction::Lower, "count"),
1338            (
1339                Metric::ThroughputPerS,
1340                "throughput_per_s",
1341                Direction::Higher,
1342                "/s",
1343            ),
1344        ];
1345
1346        for (metric, key, direction, unit) in cases {
1347            assert_eq!(metric.as_str(), key);
1348            assert_eq!(Metric::parse_key(key), Some(metric));
1349            assert_eq!(metric.default_direction(), direction);
1350            assert_eq!(metric.display_unit(), unit);
1351            assert!((metric.default_warn_factor() - 0.9).abs() < f64::EPSILON);
1352        }
1353
1354        assert!(Metric::parse_key("unknown").is_none());
1355
1356        assert_eq!(MetricStatistic::Median.as_str(), "median");
1357        assert_eq!(MetricStatistic::P95.as_str(), "p95");
1358        assert!(is_default_metric_statistic(&MetricStatistic::Median));
1359        assert!(!is_default_metric_statistic(&MetricStatistic::P95));
1360    }
1361
1362    #[test]
1363    fn status_and_policy_as_str_values() {
1364        assert_eq!(MetricStatus::Pass.as_str(), "pass");
1365        assert_eq!(MetricStatus::Warn.as_str(), "warn");
1366        assert_eq!(MetricStatus::Fail.as_str(), "fail");
1367
1368        assert_eq!(HostMismatchPolicy::Warn.as_str(), "warn");
1369        assert_eq!(HostMismatchPolicy::Error.as_str(), "error");
1370        assert_eq!(HostMismatchPolicy::Ignore.as_str(), "ignore");
1371    }
1372
1373    /// Test backward compatibility: receipts without new host fields still parse
1374    #[test]
1375    fn backward_compat_host_info_without_new_fields() {
1376        // Old format with only os and arch
1377        let json = r#"{"os":"linux","arch":"x86_64"}"#;
1378        let info: HostInfo = serde_json::from_str(json).expect("should parse old format");
1379        assert_eq!(info.os, "linux");
1380        assert_eq!(info.arch, "x86_64");
1381        assert!(info.cpu_count.is_none());
1382        assert!(info.memory_bytes.is_none());
1383        assert!(info.hostname_hash.is_none());
1384    }
1385
1386    #[test]
1387    fn host_info_minimal_json_snapshot() {
1388        let info = HostInfo {
1389            os: "linux".to_string(),
1390            arch: "x86_64".to_string(),
1391            cpu_count: None,
1392            memory_bytes: None,
1393            hostname_hash: None,
1394        };
1395
1396        let value = serde_json::to_value(&info).expect("serialize HostInfo");
1397        insta::assert_json_snapshot!(value, @r###"
1398        {
1399          "arch": "x86_64",
1400          "os": "linux"
1401        }
1402        "###);
1403    }
1404
1405    /// Test that new fields are serialized when present
1406    #[test]
1407    fn host_info_with_new_fields_serializes() {
1408        let info = HostInfo {
1409            os: "linux".to_string(),
1410            arch: "x86_64".to_string(),
1411            cpu_count: Some(8),
1412            memory_bytes: Some(16 * 1024 * 1024 * 1024),
1413            hostname_hash: Some("abc123".to_string()),
1414        };
1415
1416        let json = serde_json::to_string(&info).expect("should serialize");
1417        assert!(json.contains("\"cpu_count\":8"));
1418        assert!(json.contains("\"memory_bytes\":"));
1419        assert!(json.contains("\"hostname_hash\":\"abc123\""));
1420    }
1421
1422    /// Test that new fields are omitted when None (skip_serializing_if)
1423    #[test]
1424    fn host_info_omits_none_fields() {
1425        let info = HostInfo {
1426            os: "linux".to_string(),
1427            arch: "x86_64".to_string(),
1428            cpu_count: None,
1429            memory_bytes: None,
1430            hostname_hash: None,
1431        };
1432
1433        let json = serde_json::to_string(&info).expect("should serialize");
1434        assert!(!json.contains("cpu_count"));
1435        assert!(!json.contains("memory_bytes"));
1436        assert!(!json.contains("hostname_hash"));
1437    }
1438
1439    /// Test round-trip serialization with all fields
1440    #[test]
1441    fn host_info_round_trip_with_all_fields() {
1442        let original = HostInfo {
1443            os: "macos".to_string(),
1444            arch: "aarch64".to_string(),
1445            cpu_count: Some(10),
1446            memory_bytes: Some(32 * 1024 * 1024 * 1024),
1447            hostname_hash: Some("deadbeef".repeat(8)),
1448        };
1449
1450        let json = serde_json::to_string(&original).expect("should serialize");
1451        let parsed: HostInfo = serde_json::from_str(&json).expect("should deserialize");
1452
1453        assert_eq!(original, parsed);
1454    }
1455
1456    #[test]
1457    fn validate_bench_name_valid() {
1458        assert!(validate_bench_name("my-bench").is_ok());
1459        assert!(validate_bench_name("bench_a").is_ok());
1460        assert!(validate_bench_name("path/to/bench").is_ok());
1461        assert!(validate_bench_name("bench.v2").is_ok());
1462        assert!(validate_bench_name("a").is_ok());
1463        assert!(validate_bench_name("123").is_ok());
1464    }
1465
1466    #[test]
1467    fn validate_bench_name_invalid() {
1468        assert!(validate_bench_name("bench|name").is_err());
1469        assert!(validate_bench_name("").is_err());
1470        assert!(validate_bench_name("bench name").is_err());
1471        assert!(validate_bench_name("bench@name").is_err());
1472    }
1473
1474    #[test]
1475    fn validate_bench_name_path_traversal() {
1476        assert!(validate_bench_name("../bench").is_err());
1477        assert!(validate_bench_name("bench/../x").is_err());
1478        assert!(validate_bench_name("./bench").is_err());
1479        assert!(validate_bench_name("bench/.").is_err());
1480    }
1481
1482    #[test]
1483    fn validate_bench_name_empty_segments() {
1484        assert!(validate_bench_name("/bench").is_err());
1485        assert!(validate_bench_name("bench/").is_err());
1486        assert!(validate_bench_name("bench//x").is_err());
1487        assert!(validate_bench_name("/").is_err());
1488    }
1489
1490    #[test]
1491    fn validate_bench_name_length_cap() {
1492        // Exactly 64 chars -> ok
1493        let name_64 = "a".repeat(BENCH_NAME_MAX_LEN);
1494        assert!(validate_bench_name(&name_64).is_ok());
1495
1496        // 65 chars -> rejected
1497        let name_65 = "a".repeat(BENCH_NAME_MAX_LEN + 1);
1498        assert!(validate_bench_name(&name_65).is_err());
1499    }
1500
1501    #[test]
1502    fn validate_bench_name_case() {
1503        assert!(validate_bench_name("MyBench").is_err());
1504        assert!(validate_bench_name("BENCH").is_err());
1505        assert!(validate_bench_name("benchA").is_err());
1506    }
1507
1508    #[test]
1509    fn config_file_validate_catches_bad_bench_name() {
1510        let config = ConfigFile {
1511            defaults: DefaultsConfig::default(),
1512            baseline_server: BaselineServerConfig::default(),
1513            benches: vec![BenchConfigFile {
1514                name: "bad|name".to_string(),
1515                cwd: None,
1516                work: None,
1517                timeout: None,
1518                command: vec!["echo".to_string()],
1519                repeat: None,
1520                warmup: None,
1521                metrics: None,
1522                budgets: None,
1523            }],
1524        };
1525        assert!(config.validate().is_err());
1526    }
1527
1528    #[test]
1529    fn perfgate_error_display_baseline_resolve() {
1530        use perfgate_error::IoError;
1531        let err = PerfgateError::Io(IoError::BaselineResolve("file not found".to_string()));
1532        assert_eq!(format!("{}", err), "baseline resolve: file not found");
1533    }
1534
1535    #[test]
1536    fn perfgate_error_display_artifact_write() {
1537        use perfgate_error::IoError;
1538        let err = PerfgateError::Io(IoError::ArtifactWrite("permission denied".to_string()));
1539        assert_eq!(format!("{}", err), "write artifacts: permission denied");
1540    }
1541
1542    #[test]
1543    fn perfgate_error_display_run_command() {
1544        use perfgate_error::IoError;
1545        let err = PerfgateError::Io(IoError::RunCommand {
1546            command: "echo".to_string(),
1547            reason: "spawn failed".to_string(),
1548        });
1549        assert_eq!(
1550            format!("{}", err),
1551            "failed to execute command \"echo\": spawn failed"
1552        );
1553    }
1554
1555    #[test]
1556    fn sensor_capabilities_backward_compat_without_engine() {
1557        let json = r#"{"baseline":{"status":"available"}}"#;
1558        let caps: SensorCapabilities =
1559            serde_json::from_str(json).expect("should parse without engine");
1560        assert_eq!(caps.baseline.status, CapabilityStatus::Available);
1561        assert!(caps.engine.is_none());
1562    }
1563
1564    #[test]
1565    fn sensor_capabilities_with_engine() {
1566        let caps = SensorCapabilities {
1567            baseline: Capability {
1568                status: CapabilityStatus::Available,
1569                reason: None,
1570            },
1571            engine: Some(Capability {
1572                status: CapabilityStatus::Available,
1573                reason: None,
1574            }),
1575        };
1576        let json = serde_json::to_string(&caps).unwrap();
1577        assert!(json.contains("\"engine\""));
1578        let parsed: SensorCapabilities = serde_json::from_str(&json).unwrap();
1579        assert_eq!(caps, parsed);
1580    }
1581
1582    #[test]
1583    fn sensor_capabilities_engine_omitted_when_none() {
1584        let caps = SensorCapabilities {
1585            baseline: Capability {
1586                status: CapabilityStatus::Available,
1587                reason: None,
1588            },
1589            engine: None,
1590        };
1591        let json = serde_json::to_string(&caps).unwrap();
1592        assert!(!json.contains("engine"));
1593    }
1594
1595    #[test]
1596    fn config_file_validate_passes_good_bench_names() {
1597        let config = ConfigFile {
1598            defaults: DefaultsConfig::default(),
1599            baseline_server: BaselineServerConfig::default(),
1600            benches: vec![BenchConfigFile {
1601                name: "my-bench".to_string(),
1602                cwd: None,
1603                work: None,
1604                timeout: None,
1605                command: vec!["echo".to_string()],
1606                repeat: None,
1607                warmup: None,
1608                metrics: None,
1609                budgets: None,
1610            }],
1611        };
1612        assert!(config.validate().is_ok());
1613    }
1614
1615    // ---- Serde round-trip unit tests ----
1616
1617    #[test]
1618    fn run_receipt_serde_roundtrip_typical() {
1619        let receipt = RunReceipt {
1620            schema: RUN_SCHEMA_V1.to_string(),
1621            tool: ToolInfo {
1622                name: "perfgate".into(),
1623                version: "1.2.3".into(),
1624            },
1625            run: RunMeta {
1626                id: "abc-123".into(),
1627                started_at: "2024-06-15T10:00:00Z".into(),
1628                ended_at: "2024-06-15T10:00:05Z".into(),
1629                host: HostInfo {
1630                    os: "linux".into(),
1631                    arch: "x86_64".into(),
1632                    cpu_count: Some(8),
1633                    memory_bytes: Some(16_000_000_000),
1634                    hostname_hash: Some("cafebabe".into()),
1635                },
1636            },
1637            bench: BenchMeta {
1638                name: "my-bench".into(),
1639                cwd: Some("/tmp".into()),
1640                command: vec!["echo".into(), "hello".into()],
1641                repeat: 5,
1642                warmup: 1,
1643                work_units: Some(1000),
1644                timeout_ms: Some(30000),
1645            },
1646            samples: vec![
1647                Sample {
1648                    wall_ms: 100,
1649                    exit_code: 0,
1650                    warmup: true,
1651                    timed_out: false,
1652                    cpu_ms: Some(80),
1653                    page_faults: Some(10),
1654                    ctx_switches: Some(5),
1655                    max_rss_kb: Some(2048),
1656                    io_read_bytes: None,
1657                    io_write_bytes: None,
1658                    network_packets: None,
1659                    energy_uj: None,
1660                    binary_bytes: Some(4096),
1661                    stdout: Some("ok".into()),
1662                    stderr: None,
1663                },
1664                Sample {
1665                    wall_ms: 95,
1666                    exit_code: 0,
1667                    warmup: false,
1668                    timed_out: false,
1669                    cpu_ms: Some(75),
1670                    page_faults: None,
1671                    ctx_switches: None,
1672                    max_rss_kb: Some(2000),
1673                    io_read_bytes: None,
1674                    io_write_bytes: None,
1675                    network_packets: None,
1676                    energy_uj: None,
1677                    binary_bytes: None,
1678                    stdout: None,
1679                    stderr: Some("warn".into()),
1680                },
1681            ],
1682            stats: Stats {
1683                wall_ms: U64Summary::new(95, 90, 100),
1684                cpu_ms: Some(U64Summary::new(75, 70, 80)),
1685                page_faults: Some(U64Summary::new(10, 10, 10)),
1686                ctx_switches: Some(U64Summary::new(5, 5, 5)),
1687                max_rss_kb: Some(U64Summary::new(2048, 2000, 2100)),
1688                io_read_bytes: None,
1689                io_write_bytes: None,
1690                network_packets: None,
1691                energy_uj: None,
1692                binary_bytes: Some(U64Summary::new(4096, 4096, 4096)),
1693                throughput_per_s: Some(F64Summary::new(10.526, 10.0, 11.111)),
1694            },
1695        };
1696        let json = serde_json::to_string(&receipt).unwrap();
1697        let back: RunReceipt = serde_json::from_str(&json).unwrap();
1698        assert_eq!(receipt, back);
1699    }
1700
1701    #[test]
1702    fn run_receipt_serde_roundtrip_edge_empty_samples() {
1703        let receipt = RunReceipt {
1704            schema: RUN_SCHEMA_V1.to_string(),
1705            tool: ToolInfo {
1706                name: "p".into(),
1707                version: "0".into(),
1708            },
1709            run: RunMeta {
1710                id: "".into(),
1711                started_at: "".into(),
1712                ended_at: "".into(),
1713                host: HostInfo {
1714                    os: "".into(),
1715                    arch: "".into(),
1716                    cpu_count: None,
1717                    memory_bytes: None,
1718                    hostname_hash: None,
1719                },
1720            },
1721            bench: BenchMeta {
1722                name: "b".into(),
1723                cwd: None,
1724                command: vec![],
1725                repeat: 0,
1726                warmup: 0,
1727                work_units: None,
1728                timeout_ms: None,
1729            },
1730            samples: vec![],
1731            stats: Stats {
1732                wall_ms: U64Summary::new(0, 0, 0),
1733                cpu_ms: None,
1734                page_faults: None,
1735                ctx_switches: None,
1736                max_rss_kb: None,
1737                io_read_bytes: None,
1738                io_write_bytes: None,
1739                network_packets: None,
1740                energy_uj: None,
1741                binary_bytes: None,
1742                throughput_per_s: None,
1743            },
1744        };
1745        let json = serde_json::to_string(&receipt).unwrap();
1746        let back: RunReceipt = serde_json::from_str(&json).unwrap();
1747        assert_eq!(receipt, back);
1748    }
1749
1750    #[test]
1751    fn run_receipt_serde_roundtrip_edge_large_values() {
1752        let receipt = RunReceipt {
1753            schema: RUN_SCHEMA_V1.to_string(),
1754            tool: ToolInfo {
1755                name: "perfgate".into(),
1756                version: "99.99.99".into(),
1757            },
1758            run: RunMeta {
1759                id: "max-run".into(),
1760                started_at: "2099-12-31T23:59:59Z".into(),
1761                ended_at: "2099-12-31T23:59:59Z".into(),
1762                host: HostInfo {
1763                    os: "linux".into(),
1764                    arch: "aarch64".into(),
1765                    cpu_count: Some(u32::MAX),
1766                    memory_bytes: Some(u64::MAX),
1767                    hostname_hash: None,
1768                },
1769            },
1770            bench: BenchMeta {
1771                name: "big".into(),
1772                cwd: None,
1773                command: vec!["run".into()],
1774                repeat: u32::MAX,
1775                warmup: u32::MAX,
1776                work_units: Some(u64::MAX),
1777                timeout_ms: Some(u64::MAX),
1778            },
1779            samples: vec![Sample {
1780                wall_ms: u64::MAX,
1781                exit_code: i32::MIN,
1782                warmup: false,
1783                timed_out: true,
1784                cpu_ms: Some(u64::MAX),
1785                page_faults: Some(u64::MAX),
1786                ctx_switches: Some(u64::MAX),
1787                max_rss_kb: Some(u64::MAX),
1788                io_read_bytes: None,
1789                io_write_bytes: None,
1790                network_packets: None,
1791                energy_uj: None,
1792                binary_bytes: Some(u64::MAX),
1793                stdout: None,
1794                stderr: None,
1795            }],
1796            stats: Stats {
1797                wall_ms: U64Summary::new(u64::MAX, 0, u64::MAX),
1798                cpu_ms: None,
1799                page_faults: None,
1800                ctx_switches: None,
1801                max_rss_kb: None,
1802                io_read_bytes: None,
1803                io_write_bytes: None,
1804                network_packets: None,
1805                energy_uj: None,
1806                binary_bytes: None,
1807                throughput_per_s: Some(F64Summary::new(f64::MAX, 0.0, f64::MAX)),
1808            },
1809        };
1810        let json = serde_json::to_string(&receipt).unwrap();
1811        let back: RunReceipt = serde_json::from_str(&json).unwrap();
1812        assert_eq!(receipt, back);
1813    }
1814
1815    #[test]
1816    fn compare_receipt_serde_roundtrip_typical() {
1817        let mut budgets = BTreeMap::new();
1818        budgets.insert(Metric::WallMs, Budget::new(0.2, 0.18, Direction::Lower));
1819        budgets.insert(Metric::MaxRssKb, Budget::new(0.15, 0.1, Direction::Lower));
1820
1821        let mut deltas = BTreeMap::new();
1822        deltas.insert(
1823            Metric::WallMs,
1824            Delta {
1825                baseline: 1000.0,
1826                current: 1100.0,
1827                ratio: 1.1,
1828                pct: 0.1,
1829                regression: 0.1,
1830                cv: None,
1831                noise_threshold: None,
1832                statistic: MetricStatistic::Median,
1833                significance: None,
1834                status: MetricStatus::Pass,
1835            },
1836        );
1837        deltas.insert(
1838            Metric::MaxRssKb,
1839            Delta {
1840                baseline: 2048.0,
1841                current: 2500.0,
1842                ratio: 1.2207,
1843                pct: 0.2207,
1844                regression: 0.2207,
1845                cv: None,
1846                noise_threshold: None,
1847                statistic: MetricStatistic::Median,
1848                significance: None,
1849                status: MetricStatus::Fail,
1850            },
1851        );
1852
1853        let receipt = CompareReceipt {
1854            schema: COMPARE_SCHEMA_V1.to_string(),
1855            tool: ToolInfo {
1856                name: "perfgate".into(),
1857                version: "1.0.0".into(),
1858            },
1859            bench: BenchMeta {
1860                name: "test".into(),
1861                cwd: None,
1862                command: vec!["echo".into()],
1863                repeat: 5,
1864                warmup: 0,
1865                work_units: None,
1866                timeout_ms: None,
1867            },
1868            baseline_ref: CompareRef {
1869                path: Some("base.json".into()),
1870                run_id: Some("r1".into()),
1871            },
1872            current_ref: CompareRef {
1873                path: Some("cur.json".into()),
1874                run_id: Some("r2".into()),
1875            },
1876            budgets,
1877            deltas,
1878            verdict: Verdict {
1879                status: VerdictStatus::Fail,
1880                counts: VerdictCounts {
1881                    pass: 1,
1882                    warn: 0,
1883                    fail: 1,
1884                    skip: 0,
1885                },
1886                reasons: vec!["max_rss_kb_fail".into()],
1887            },
1888        };
1889        let json = serde_json::to_string(&receipt).unwrap();
1890        let back: CompareReceipt = serde_json::from_str(&json).unwrap();
1891        assert_eq!(receipt, back);
1892    }
1893
1894    #[test]
1895    fn compare_receipt_serde_roundtrip_edge_empty_maps() {
1896        let receipt = CompareReceipt {
1897            schema: COMPARE_SCHEMA_V1.to_string(),
1898            tool: ToolInfo {
1899                name: "p".into(),
1900                version: "0".into(),
1901            },
1902            bench: BenchMeta {
1903                name: "b".into(),
1904                cwd: None,
1905                command: vec![],
1906                repeat: 0,
1907                warmup: 0,
1908                work_units: None,
1909                timeout_ms: None,
1910            },
1911            baseline_ref: CompareRef {
1912                path: None,
1913                run_id: None,
1914            },
1915            current_ref: CompareRef {
1916                path: None,
1917                run_id: None,
1918            },
1919            budgets: BTreeMap::new(),
1920            deltas: BTreeMap::new(),
1921            verdict: Verdict {
1922                status: VerdictStatus::Pass,
1923                counts: VerdictCounts {
1924                    pass: 0,
1925                    warn: 0,
1926                    fail: 0,
1927                    skip: 0,
1928                },
1929                reasons: vec![],
1930            },
1931        };
1932        let json = serde_json::to_string(&receipt).unwrap();
1933        let back: CompareReceipt = serde_json::from_str(&json).unwrap();
1934        assert_eq!(receipt, back);
1935    }
1936
1937    #[test]
1938    fn report_receipt_serde_roundtrip() {
1939        let report = PerfgateReport {
1940            report_type: REPORT_SCHEMA_V1.to_string(),
1941            verdict: Verdict {
1942                status: VerdictStatus::Warn,
1943                counts: VerdictCounts {
1944                    pass: 1,
1945                    warn: 1,
1946                    fail: 0,
1947                    skip: 0,
1948                },
1949                reasons: vec!["wall_ms_warn".into()],
1950            },
1951            compare: None,
1952            findings: vec![ReportFinding {
1953                check_id: CHECK_ID_BUDGET.into(),
1954                code: FINDING_CODE_METRIC_WARN.into(),
1955                severity: Severity::Warn,
1956                message: "Performance regression near threshold for wall_ms".into(),
1957                data: Some(FindingData {
1958                    metric_name: "wall_ms".into(),
1959                    baseline: 100.0,
1960                    current: 119.0,
1961                    regression_pct: 0.19,
1962                    threshold: 0.2,
1963                    direction: Direction::Lower,
1964                }),
1965            }],
1966            summary: ReportSummary {
1967                pass_count: 1,
1968                warn_count: 1,
1969                fail_count: 0,
1970                skip_count: 0,
1971                total_count: 2,
1972            },
1973        };
1974        let json = serde_json::to_string(&report).unwrap();
1975        let back: PerfgateReport = serde_json::from_str(&json).unwrap();
1976        assert_eq!(report, back);
1977    }
1978
1979    #[test]
1980    fn config_file_serde_roundtrip_typical() {
1981        let config = ConfigFile {
1982            defaults: DefaultsConfig {
1983                noise_threshold: None,
1984                noise_policy: None,
1985                repeat: Some(10),
1986                warmup: Some(2),
1987                threshold: Some(0.2),
1988                warn_factor: Some(0.9),
1989                out_dir: Some("artifacts/perfgate".into()),
1990                baseline_dir: Some("baselines".into()),
1991                baseline_pattern: Some("baselines/{bench}.json".into()),
1992                markdown_template: None,
1993            },
1994            baseline_server: BaselineServerConfig::default(),
1995            benches: vec![BenchConfigFile {
1996                name: "my-bench".into(),
1997                cwd: Some("/home/user/project".into()),
1998                work: Some(1000),
1999                timeout: Some("5s".into()),
2000                command: vec!["cargo".into(), "bench".into()],
2001                repeat: Some(20),
2002                warmup: Some(3),
2003                metrics: Some(vec![Metric::WallMs, Metric::MaxRssKb]),
2004                budgets: Some({
2005                    let mut m = BTreeMap::new();
2006                    m.insert(
2007                        Metric::WallMs,
2008                        BudgetOverride {
2009                            noise_threshold: None,
2010                            noise_policy: None,
2011                            threshold: Some(0.15),
2012                            direction: Some(Direction::Lower),
2013                            warn_factor: Some(0.85),
2014                            statistic: Some(MetricStatistic::P95),
2015                        },
2016                    );
2017                    m
2018                }),
2019            }],
2020        };
2021        let json = serde_json::to_string(&config).unwrap();
2022        let back: ConfigFile = serde_json::from_str(&json).unwrap();
2023        assert_eq!(config, back);
2024    }
2025
2026    #[test]
2027    fn config_file_serde_roundtrip_edge_empty() {
2028        let config = ConfigFile {
2029            defaults: DefaultsConfig::default(),
2030            baseline_server: BaselineServerConfig::default(),
2031            benches: vec![],
2032        };
2033        let json = serde_json::to_string(&config).unwrap();
2034        let back: ConfigFile = serde_json::from_str(&json).unwrap();
2035        assert_eq!(config, back);
2036    }
2037
2038    #[test]
2039    fn stats_serde_roundtrip_all_fields() {
2040        let stats = Stats {
2041            wall_ms: U64Summary::new(500, 100, 900),
2042            cpu_ms: Some(U64Summary::new(400, 80, 800)),
2043            page_faults: Some(U64Summary::new(50, 10, 100)),
2044            ctx_switches: Some(U64Summary::new(20, 5, 40)),
2045            max_rss_kb: Some(U64Summary::new(4096, 2048, 8192)),
2046            io_read_bytes: Some(U64Summary::new(1000, 500, 1500)),
2047            io_write_bytes: Some(U64Summary::new(500, 200, 800)),
2048            network_packets: Some(U64Summary::new(10, 5, 15)),
2049            energy_uj: None,
2050            binary_bytes: Some(U64Summary::new(1024, 1024, 1024)),
2051            throughput_per_s: Some(F64Summary::new(2.0, 1.111, 10.0)),
2052        };
2053        let json = serde_json::to_string(&stats).unwrap();
2054        let back: Stats = serde_json::from_str(&json).unwrap();
2055        assert_eq!(stats, back);
2056    }
2057
2058    #[test]
2059    fn stats_serde_roundtrip_edge_zeros() {
2060        let stats = Stats {
2061            wall_ms: U64Summary::new(0, 0, 0),
2062            cpu_ms: None,
2063            page_faults: None,
2064            ctx_switches: None,
2065            max_rss_kb: None,
2066            io_read_bytes: None,
2067            io_write_bytes: None,
2068            network_packets: None,
2069            energy_uj: None,
2070            binary_bytes: None,
2071            throughput_per_s: Some(F64Summary::new(0.0, 0.0, 0.0)),
2072        };
2073        let json = serde_json::to_string(&stats).unwrap();
2074        let back: Stats = serde_json::from_str(&json).unwrap();
2075        assert_eq!(stats, back);
2076    }
2077
2078    #[test]
2079    fn backward_compat_run_receipt_missing_host_extensions() {
2080        // Old format: host has only os/arch, no cpu_count/memory_bytes/hostname_hash
2081        let json = r#"{
2082            "schema": "perfgate.run.v1",
2083            "tool": {"name": "perfgate", "version": "0.0.1"},
2084            "run": {
2085                "id": "old-run",
2086                "started_at": "2023-06-01T00:00:00Z",
2087                "ended_at": "2023-06-01T00:01:00Z",
2088                "host": {"os": "macos", "arch": "aarch64"}
2089            },
2090            "bench": {
2091                "name": "legacy",
2092                "command": ["./bench"],
2093                "repeat": 1,
2094                "warmup": 0
2095            },
2096            "samples": [{"wall_ms": 50, "exit_code": 0}],
2097            "stats": {
2098                "wall_ms": {"median": 50, "min": 50, "max": 50}
2099            }
2100        }"#;
2101
2102        let receipt: RunReceipt =
2103            serde_json::from_str(json).expect("old format without host extensions");
2104        assert_eq!(receipt.run.host.os, "macos");
2105        assert_eq!(receipt.run.host.arch, "aarch64");
2106        assert!(receipt.run.host.cpu_count.is_none());
2107        assert!(receipt.run.host.memory_bytes.is_none());
2108        assert!(receipt.run.host.hostname_hash.is_none());
2109        assert_eq!(receipt.bench.name, "legacy");
2110        assert_eq!(receipt.samples.len(), 1);
2111        assert!(!receipt.samples[0].warmup);
2112        assert!(!receipt.samples[0].timed_out);
2113    }
2114
2115    #[test]
2116    fn backward_compat_compare_receipt_without_significance() {
2117        let json = r#"{
2118            "schema": "perfgate.compare.v1",
2119            "tool": {"name": "perfgate", "version": "0.0.1"},
2120            "bench": {
2121                "name": "old-cmp",
2122                "command": ["echo"],
2123                "repeat": 3,
2124                "warmup": 0
2125            },
2126            "baseline_ref": {"path": "base.json"},
2127            "current_ref": {"path": "cur.json"},
2128            "budgets": {
2129                "wall_ms": {"threshold": 0.2, "warn_threshold": 0.1, "direction": "lower"}
2130            },
2131            "deltas": {
2132                "wall_ms": {
2133                    "baseline": 100.0,
2134                    "current": 105.0,
2135                    "ratio": 1.05,
2136                    "pct": 0.05,
2137                    "regression": 0.05,
2138                    "status": "pass"
2139                }
2140            },
2141            "verdict": {
2142                "status": "pass",
2143                "counts": {"pass": 1, "warn": 0, "fail": 0, "skip": 0},
2144                "reasons": []
2145            }
2146        }"#;
2147
2148        let receipt: CompareReceipt =
2149            serde_json::from_str(json).expect("compare without significance");
2150        assert_eq!(receipt.deltas.len(), 1);
2151        let delta = receipt.deltas.get(&Metric::WallMs).unwrap();
2152        assert!(delta.significance.is_none());
2153        assert_eq!(delta.statistic, MetricStatistic::Median); // default
2154        assert_eq!(delta.status, MetricStatus::Pass);
2155    }
2156
2157    #[test]
2158    fn backward_compat_unknown_fields_are_ignored() {
2159        let json = r#"{
2160            "schema": "perfgate.run.v1",
2161            "tool": {"name": "perfgate", "version": "0.1.0"},
2162            "run": {
2163                "id": "test",
2164                "started_at": "2024-01-01T00:00:00Z",
2165                "ended_at": "2024-01-01T00:01:00Z",
2166                "host": {"os": "linux", "arch": "x86_64", "future_field": "ignored"}
2167            },
2168            "bench": {
2169                "name": "test",
2170                "command": ["echo"],
2171                "repeat": 1,
2172                "warmup": 0,
2173                "some_new_option": true
2174            },
2175            "samples": [{"wall_ms": 10, "exit_code": 0, "extra_metric": 42}],
2176            "stats": {
2177                "wall_ms": {"median": 10, "min": 10, "max": 10},
2178                "new_metric": {"median": 1, "min": 1, "max": 1}
2179            },
2180            "new_top_level_field": "should be ignored"
2181        }"#;
2182
2183        let receipt: RunReceipt =
2184            serde_json::from_str(json).expect("unknown fields should be ignored");
2185        assert_eq!(receipt.bench.name, "test");
2186        assert_eq!(receipt.samples.len(), 1);
2187    }
2188
2189    #[test]
2190    fn roundtrip_run_receipt_all_optionals_none() {
2191        let receipt = RunReceipt {
2192            schema: RUN_SCHEMA_V1.to_string(),
2193            tool: ToolInfo {
2194                name: "perfgate".into(),
2195                version: "0.1.0".into(),
2196            },
2197            run: RunMeta {
2198                id: "rt".into(),
2199                started_at: "2024-01-01T00:00:00Z".into(),
2200                ended_at: "2024-01-01T00:01:00Z".into(),
2201                host: HostInfo {
2202                    os: "linux".into(),
2203                    arch: "x86_64".into(),
2204                    cpu_count: None,
2205                    memory_bytes: None,
2206                    hostname_hash: None,
2207                },
2208            },
2209            bench: BenchMeta {
2210                name: "minimal".into(),
2211                cwd: None,
2212                command: vec!["true".into()],
2213                repeat: 1,
2214                warmup: 0,
2215                work_units: None,
2216                timeout_ms: None,
2217            },
2218            samples: vec![Sample {
2219                wall_ms: 1,
2220                exit_code: 0,
2221                warmup: false,
2222                timed_out: false,
2223                cpu_ms: None,
2224                page_faults: None,
2225                ctx_switches: None,
2226                max_rss_kb: None,
2227                io_read_bytes: None,
2228                io_write_bytes: None,
2229                network_packets: None,
2230                energy_uj: None,
2231                binary_bytes: None,
2232                stdout: None,
2233                stderr: None,
2234            }],
2235            stats: Stats {
2236                wall_ms: U64Summary::new(1, 1, 1),
2237                cpu_ms: None,
2238                page_faults: None,
2239                ctx_switches: None,
2240                max_rss_kb: None,
2241                io_read_bytes: None,
2242                io_write_bytes: None,
2243                network_packets: None,
2244                energy_uj: None,
2245                binary_bytes: None,
2246                throughput_per_s: None,
2247            },
2248        };
2249
2250        let json = serde_json::to_string(&receipt).unwrap();
2251        let back: RunReceipt = serde_json::from_str(&json).unwrap();
2252        assert_eq!(receipt, back);
2253
2254        // Verify optional fields are omitted from JSON
2255        let value: serde_json::Value = serde_json::from_str(&json).unwrap();
2256        let host = &value["run"]["host"];
2257        assert!(host.get("cpu_count").is_none());
2258        assert!(host.get("memory_bytes").is_none());
2259        assert!(host.get("hostname_hash").is_none());
2260    }
2261
2262    #[test]
2263    fn roundtrip_compare_receipt_all_optionals_none() {
2264        let receipt = CompareReceipt {
2265            schema: COMPARE_SCHEMA_V1.to_string(),
2266            tool: ToolInfo {
2267                name: "perfgate".into(),
2268                version: "0.1.0".into(),
2269            },
2270            bench: BenchMeta {
2271                name: "minimal".into(),
2272                cwd: None,
2273                command: vec!["true".into()],
2274                repeat: 1,
2275                warmup: 0,
2276                work_units: None,
2277                timeout_ms: None,
2278            },
2279            baseline_ref: CompareRef {
2280                path: None,
2281                run_id: None,
2282            },
2283            current_ref: CompareRef {
2284                path: None,
2285                run_id: None,
2286            },
2287            budgets: BTreeMap::new(),
2288            deltas: BTreeMap::new(),
2289            verdict: Verdict {
2290                status: VerdictStatus::Pass,
2291                counts: VerdictCounts {
2292                    pass: 0,
2293                    warn: 0,
2294                    fail: 0,
2295                    skip: 0,
2296                },
2297                reasons: vec![],
2298            },
2299        };
2300
2301        let json = serde_json::to_string(&receipt).unwrap();
2302        let back: CompareReceipt = serde_json::from_str(&json).unwrap();
2303        assert_eq!(receipt, back);
2304    }
2305
2306    /// Test backward compatibility: full RunReceipt without new host fields parses
2307    #[test]
2308    fn backward_compat_run_receipt_old_format() {
2309        let json = r#"{
2310            "schema": "perfgate.run.v1",
2311            "tool": {"name": "perfgate", "version": "0.1.0"},
2312            "run": {
2313                "id": "test-id",
2314                "started_at": "2024-01-01T00:00:00Z",
2315                "ended_at": "2024-01-01T00:01:00Z",
2316                "host": {"os": "linux", "arch": "x86_64"}
2317            },
2318            "bench": {
2319                "name": "test",
2320                "command": ["echo", "hello"],
2321                "repeat": 5,
2322                "warmup": 0
2323            },
2324            "samples": [{"wall_ms": 100, "exit_code": 0}],
2325            "stats": {
2326                "wall_ms": {"median": 100, "min": 90, "max": 110}
2327            }
2328        }"#;
2329
2330        let receipt: RunReceipt = serde_json::from_str(json).expect("should parse old format");
2331        assert_eq!(receipt.run.host.os, "linux");
2332        assert_eq!(receipt.run.host.arch, "x86_64");
2333        assert!(receipt.run.host.cpu_count.is_none());
2334        assert!(receipt.run.host.memory_bytes.is_none());
2335        assert!(receipt.run.host.hostname_hash.is_none());
2336    }
2337}
2338
2339#[cfg(test)]
2340mod property_tests {
2341    use super::*;
2342    use proptest::prelude::*;
2343
2344    // Strategy for generating valid non-empty strings (for names, IDs, etc.)
2345    fn non_empty_string() -> impl Strategy<Value = String> {
2346        "[a-zA-Z0-9_-]{1,20}".prop_map(|s| s)
2347    }
2348
2349    // Strategy for generating valid RFC3339 timestamps
2350    fn rfc3339_timestamp() -> impl Strategy<Value = String> {
2351        (
2352            2020u32..2030,
2353            1u32..13,
2354            1u32..29,
2355            0u32..24,
2356            0u32..60,
2357            0u32..60,
2358        )
2359            .prop_map(|(year, month, day, hour, min, sec)| {
2360                format!(
2361                    "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
2362                    year, month, day, hour, min, sec
2363                )
2364            })
2365    }
2366
2367    // Strategy for ToolInfo
2368    fn tool_info_strategy() -> impl Strategy<Value = ToolInfo> {
2369        (non_empty_string(), non_empty_string())
2370            .prop_map(|(name, version)| ToolInfo { name, version })
2371    }
2372
2373    // Strategy for HostInfo
2374    fn host_info_strategy() -> impl Strategy<Value = HostInfo> {
2375        (
2376            non_empty_string(),
2377            non_empty_string(),
2378            proptest::option::of(1u32..256),
2379            proptest::option::of(1u64..68719476736), // Up to 64GB
2380            proptest::option::of("[a-f0-9]{64}"),    // SHA-256 hex hash
2381        )
2382            .prop_map(
2383                |(os, arch, cpu_count, memory_bytes, hostname_hash)| HostInfo {
2384                    os,
2385                    arch,
2386                    cpu_count,
2387                    memory_bytes,
2388                    hostname_hash,
2389                },
2390            )
2391    }
2392
2393    // Strategy for RunMeta
2394    fn run_meta_strategy() -> impl Strategy<Value = RunMeta> {
2395        (
2396            non_empty_string(),
2397            rfc3339_timestamp(),
2398            rfc3339_timestamp(),
2399            host_info_strategy(),
2400        )
2401            .prop_map(|(id, started_at, ended_at, host)| RunMeta {
2402                id,
2403                started_at,
2404                ended_at,
2405                host,
2406            })
2407    }
2408
2409    // Strategy for BenchMeta
2410    fn bench_meta_strategy() -> impl Strategy<Value = BenchMeta> {
2411        (
2412            non_empty_string(),
2413            proptest::option::of(non_empty_string()),
2414            proptest::collection::vec(non_empty_string(), 1..5),
2415            1u32..100,
2416            0u32..10,
2417            proptest::option::of(1u64..10000),
2418            proptest::option::of(100u64..60000),
2419        )
2420            .prop_map(
2421                |(name, cwd, command, repeat, warmup, work_units, timeout_ms)| BenchMeta {
2422                    name,
2423                    cwd,
2424                    command,
2425                    repeat,
2426                    warmup,
2427                    work_units,
2428                    timeout_ms,
2429                },
2430            )
2431    }
2432
2433    // Strategy for Sample
2434    fn sample_strategy() -> impl Strategy<Value = Sample> {
2435        (
2436            0u64..100000,
2437            -128i32..128,
2438            any::<bool>(),
2439            any::<bool>(),
2440            proptest::option::of(0u64..1000000),   // cpu_ms
2441            proptest::option::of(0u64..1000000),   // page_faults
2442            proptest::option::of(0u64..1000000),   // ctx_switches
2443            proptest::option::of(0u64..1000000),   // max_rss_kb
2444            proptest::option::of(0u64..1000000),   // energy_uj
2445            proptest::option::of(0u64..100000000), // binary_bytes
2446            proptest::option::of("[a-zA-Z0-9 ]{0,50}"),
2447            proptest::option::of("[a-zA-Z0-9 ]{0,50}"),
2448        )
2449            .prop_map(
2450                |(
2451                    wall_ms,
2452                    exit_code,
2453                    warmup,
2454                    timed_out,
2455                    cpu_ms,
2456                    page_faults,
2457                    ctx_switches,
2458                    max_rss_kb,
2459                    energy_uj,
2460                    binary_bytes,
2461                    stdout,
2462                    stderr,
2463                )| Sample {
2464                    wall_ms,
2465                    exit_code,
2466                    warmup,
2467                    timed_out,
2468                    cpu_ms,
2469                    page_faults,
2470                    ctx_switches,
2471                    max_rss_kb,
2472                    io_read_bytes: None,
2473                    io_write_bytes: None,
2474                    network_packets: None,
2475                    energy_uj,
2476                    binary_bytes,
2477                    stdout,
2478                    stderr,
2479                },
2480            )
2481    }
2482
2483    // Strategy for U64Summary
2484    fn u64_summary_strategy() -> impl Strategy<Value = U64Summary> {
2485        (0u64..1000000, 0u64..1000000, 0u64..1000000).prop_map(|(a, b, c)| {
2486            let mut vals = [a, b, c];
2487            vals.sort();
2488            U64Summary::new(vals[1], vals[0], vals[2])
2489        })
2490    }
2491
2492    // Strategy for F64Summary - using finite positive floats
2493    fn f64_summary_strategy() -> impl Strategy<Value = F64Summary> {
2494        (0.0f64..1000000.0, 0.0f64..1000000.0, 0.0f64..1000000.0).prop_map(|(a, b, c)| {
2495            let mut vals = [a, b, c];
2496            vals.sort_by(|x, y| x.partial_cmp(y).unwrap());
2497            F64Summary::new(vals[1], vals[0], vals[2])
2498        })
2499    }
2500
2501    // Strategy for Stats
2502    fn stats_strategy() -> impl Strategy<Value = Stats> {
2503        (
2504            u64_summary_strategy(),
2505            proptest::option::of(u64_summary_strategy()), // cpu_ms
2506            proptest::option::of(u64_summary_strategy()), // page_faults
2507            proptest::option::of(u64_summary_strategy()), // ctx_switches
2508            proptest::option::of(u64_summary_strategy()), // max_rss_kb
2509            proptest::option::of(u64_summary_strategy()), // io_read_bytes
2510            proptest::option::of(u64_summary_strategy()), // io_write_bytes
2511            proptest::option::of(u64_summary_strategy()), // network_packets
2512            proptest::option::of(u64_summary_strategy()), // energy_uj
2513            proptest::option::of(u64_summary_strategy()), // binary_bytes
2514            proptest::option::of(f64_summary_strategy()),
2515        )
2516            .prop_map(
2517                |(
2518                    wall_ms,
2519                    cpu_ms,
2520                    page_faults,
2521                    ctx_switches,
2522                    max_rss_kb,
2523                    io_read_bytes,
2524                    io_write_bytes,
2525                    network_packets,
2526                    energy_uj,
2527                    binary_bytes,
2528                    throughput_per_s,
2529                )| Stats {
2530                    wall_ms,
2531                    cpu_ms,
2532                    page_faults,
2533                    ctx_switches,
2534                    max_rss_kb,
2535                    io_read_bytes,
2536                    io_write_bytes,
2537                    network_packets,
2538                    energy_uj,
2539                    binary_bytes,
2540                    throughput_per_s,
2541                },
2542            )
2543    }
2544
2545    // Strategy for RunReceipt
2546    fn run_receipt_strategy() -> impl Strategy<Value = RunReceipt> {
2547        (
2548            tool_info_strategy(),
2549            run_meta_strategy(),
2550            bench_meta_strategy(),
2551            proptest::collection::vec(sample_strategy(), 1..10),
2552            stats_strategy(),
2553        )
2554            .prop_map(|(tool, run, bench, samples, stats)| RunReceipt {
2555                schema: RUN_SCHEMA_V1.to_string(),
2556                tool,
2557                run,
2558                bench,
2559                samples,
2560                stats,
2561            })
2562    }
2563
2564    // **Property 8: Serialization Round-Trip (RunReceipt)**
2565    //
2566    // For any valid RunReceipt, serializing to JSON then deserializing
2567    // SHALL produce an equivalent value.
2568    //
2569    // **Validates: Requirements 10.1**
2570    proptest! {
2571        #![proptest_config(ProptestConfig::with_cases(100))]
2572
2573        #[test]
2574        fn run_receipt_serialization_round_trip(receipt in run_receipt_strategy()) {
2575            // Serialize to JSON
2576            let json = serde_json::to_string(&receipt)
2577                .expect("RunReceipt should serialize to JSON");
2578
2579            // Deserialize back
2580            let deserialized: RunReceipt = serde_json::from_str(&json)
2581                .expect("JSON should deserialize back to RunReceipt");
2582
2583            // Compare - for f64 fields we need to handle floating point comparison
2584            prop_assert_eq!(&receipt.schema, &deserialized.schema);
2585            prop_assert_eq!(&receipt.tool, &deserialized.tool);
2586            prop_assert_eq!(&receipt.run, &deserialized.run);
2587            prop_assert_eq!(&receipt.bench, &deserialized.bench);
2588            prop_assert_eq!(receipt.samples.len(), deserialized.samples.len());
2589
2590            // Compare samples
2591            for (orig, deser) in receipt.samples.iter().zip(deserialized.samples.iter()) {
2592                prop_assert_eq!(orig.wall_ms, deser.wall_ms);
2593                prop_assert_eq!(orig.exit_code, deser.exit_code);
2594                prop_assert_eq!(orig.warmup, deser.warmup);
2595                prop_assert_eq!(orig.timed_out, deser.timed_out);
2596                prop_assert_eq!(orig.cpu_ms, deser.cpu_ms);
2597                prop_assert_eq!(orig.page_faults, deser.page_faults);
2598                prop_assert_eq!(orig.ctx_switches, deser.ctx_switches);
2599                prop_assert_eq!(orig.max_rss_kb, deser.max_rss_kb);
2600                prop_assert_eq!(orig.binary_bytes, deser.binary_bytes);
2601                prop_assert_eq!(&orig.stdout, &deser.stdout);
2602                prop_assert_eq!(&orig.stderr, &deser.stderr);
2603            }
2604
2605            // Compare stats
2606            prop_assert_eq!(&receipt.stats.wall_ms, &deserialized.stats.wall_ms);
2607            prop_assert_eq!(&receipt.stats.cpu_ms, &deserialized.stats.cpu_ms);
2608            prop_assert_eq!(&receipt.stats.page_faults, &deserialized.stats.page_faults);
2609            prop_assert_eq!(&receipt.stats.ctx_switches, &deserialized.stats.ctx_switches);
2610            prop_assert_eq!(&receipt.stats.max_rss_kb, &deserialized.stats.max_rss_kb);
2611            prop_assert_eq!(&receipt.stats.binary_bytes, &deserialized.stats.binary_bytes);
2612
2613            // For f64 throughput, compare with tolerance for floating point
2614            // JSON serialization may lose some precision for large floats
2615            match (&receipt.stats.throughput_per_s, &deserialized.stats.throughput_per_s) {
2616                (Some(orig), Some(deser)) => {
2617                    // Use relative tolerance for floating point comparison
2618                    let rel_tol = |a: f64, b: f64| {
2619                        if a == 0.0 && b == 0.0 {
2620                            true
2621                        } else {
2622                            let max_val = a.abs().max(b.abs());
2623                            (a - b).abs() / max_val < 1e-10
2624                        }
2625                    };
2626                    prop_assert!(rel_tol(orig.min, deser.min), "min mismatch: {} vs {}", orig.min, deser.min);
2627                    prop_assert!(rel_tol(orig.median, deser.median), "median mismatch: {} vs {}", orig.median, deser.median);
2628                    prop_assert!(rel_tol(orig.max, deser.max), "max mismatch: {} vs {}", orig.max, deser.max);
2629                }
2630                (None, None) => {}
2631                _ => prop_assert!(false, "throughput_per_s presence mismatch"),
2632            }
2633        }
2634    }
2635
2636    // --- Strategies for CompareReceipt ---
2637
2638    // Strategy for CompareRef
2639    fn compare_ref_strategy() -> impl Strategy<Value = CompareRef> {
2640        (
2641            proptest::option::of(non_empty_string()),
2642            proptest::option::of(non_empty_string()),
2643        )
2644            .prop_map(|(path, run_id)| CompareRef { path, run_id })
2645    }
2646
2647    // Strategy for Direction
2648    fn direction_strategy() -> impl Strategy<Value = Direction> {
2649        prop_oneof![Just(Direction::Lower), Just(Direction::Higher),]
2650    }
2651
2652    // Strategy for Budget - using finite positive floats for thresholds
2653    fn budget_strategy() -> impl Strategy<Value = Budget> {
2654        (0.01f64..1.0, 0.01f64..1.0, direction_strategy()).prop_map(
2655            |(threshold, warn_factor, direction)| {
2656                // warn_threshold should be <= threshold
2657                let warn_threshold = threshold * warn_factor;
2658                Budget {
2659                    noise_threshold: None,
2660                    noise_policy: NoisePolicy::Ignore,
2661                    threshold,
2662                    warn_threshold,
2663                    direction,
2664                }
2665            },
2666        )
2667    }
2668
2669    // Strategy for MetricStatus
2670    fn metric_status_strategy() -> impl Strategy<Value = MetricStatus> {
2671        prop_oneof![
2672            Just(MetricStatus::Pass),
2673            Just(MetricStatus::Warn),
2674            Just(MetricStatus::Fail),
2675        ]
2676    }
2677
2678    // Strategy for Delta - using finite positive floats
2679    fn delta_strategy() -> impl Strategy<Value = Delta> {
2680        (
2681            0.1f64..10000.0, // baseline (positive, non-zero)
2682            0.1f64..10000.0, // current (positive, non-zero)
2683            metric_status_strategy(),
2684        )
2685            .prop_map(|(baseline, current, status)| {
2686                let ratio = current / baseline;
2687                let pct = (current - baseline) / baseline;
2688                let regression = if pct > 0.0 { pct } else { 0.0 };
2689                Delta {
2690                    baseline,
2691                    current,
2692                    ratio,
2693                    pct,
2694                    regression,
2695                    cv: None,
2696                    noise_threshold: None,
2697                    statistic: MetricStatistic::Median,
2698                    significance: None,
2699                    status,
2700                }
2701            })
2702    }
2703
2704    // Strategy for VerdictStatus
2705    fn verdict_status_strategy() -> impl Strategy<Value = VerdictStatus> {
2706        prop_oneof![
2707            Just(VerdictStatus::Pass),
2708            Just(VerdictStatus::Warn),
2709            Just(VerdictStatus::Fail),
2710        ]
2711    }
2712
2713    // Strategy for VerdictCounts
2714    fn verdict_counts_strategy() -> impl Strategy<Value = VerdictCounts> {
2715        (0u32..10, 0u32..10, 0u32..10, 0u32..10).prop_map(|(pass, warn, fail, skip)| {
2716            VerdictCounts {
2717                pass,
2718                warn,
2719                fail,
2720                skip,
2721            }
2722        })
2723    }
2724
2725    // Strategy for Verdict
2726    fn verdict_strategy() -> impl Strategy<Value = Verdict> {
2727        (
2728            verdict_status_strategy(),
2729            verdict_counts_strategy(),
2730            proptest::collection::vec("[a-zA-Z0-9 ]{1,50}", 0..5),
2731        )
2732            .prop_map(|(status, counts, reasons)| Verdict {
2733                status,
2734                counts,
2735                reasons,
2736            })
2737    }
2738
2739    // Strategy for Metric
2740    fn metric_strategy() -> impl Strategy<Value = Metric> {
2741        prop_oneof![
2742            Just(Metric::BinaryBytes),
2743            Just(Metric::CpuMs),
2744            Just(Metric::CtxSwitches),
2745            Just(Metric::MaxRssKb),
2746            Just(Metric::PageFaults),
2747            Just(Metric::ThroughputPerS),
2748            Just(Metric::WallMs),
2749        ]
2750    }
2751
2752    // Strategy for BTreeMap<Metric, Budget>
2753    fn budgets_map_strategy() -> impl Strategy<Value = BTreeMap<Metric, Budget>> {
2754        proptest::collection::btree_map(metric_strategy(), budget_strategy(), 0..8)
2755    }
2756
2757    // Strategy for BTreeMap<Metric, Delta>
2758    fn deltas_map_strategy() -> impl Strategy<Value = BTreeMap<Metric, Delta>> {
2759        proptest::collection::btree_map(metric_strategy(), delta_strategy(), 0..8)
2760    }
2761
2762    // Strategy for CompareReceipt
2763    fn compare_receipt_strategy() -> impl Strategy<Value = CompareReceipt> {
2764        (
2765            tool_info_strategy(),
2766            bench_meta_strategy(),
2767            compare_ref_strategy(),
2768            compare_ref_strategy(),
2769            budgets_map_strategy(),
2770            deltas_map_strategy(),
2771            verdict_strategy(),
2772        )
2773            .prop_map(
2774                |(tool, bench, baseline_ref, current_ref, budgets, deltas, verdict)| {
2775                    CompareReceipt {
2776                        schema: COMPARE_SCHEMA_V1.to_string(),
2777                        tool,
2778                        bench,
2779                        baseline_ref,
2780                        current_ref,
2781                        budgets,
2782                        deltas,
2783                        verdict,
2784                    }
2785                },
2786            )
2787    }
2788
2789    // Helper function for comparing f64 values with tolerance
2790    fn f64_approx_eq(a: f64, b: f64) -> bool {
2791        if a == 0.0 && b == 0.0 {
2792            true
2793        } else {
2794            let max_val = a.abs().max(b.abs());
2795            if max_val == 0.0 {
2796                true
2797            } else {
2798                (a - b).abs() / max_val < 1e-10
2799            }
2800        }
2801    }
2802
2803    // **Property 8: Serialization Round-Trip (CompareReceipt)**
2804    //
2805    // For any valid CompareReceipt, serializing to JSON then deserializing
2806    // SHALL produce an equivalent value.
2807    //
2808    // **Validates: Requirements 10.2**
2809    proptest! {
2810        #![proptest_config(ProptestConfig::with_cases(100))]
2811
2812        #[test]
2813        fn compare_receipt_serialization_round_trip(receipt in compare_receipt_strategy()) {
2814            // Serialize to JSON
2815            let json = serde_json::to_string(&receipt)
2816                .expect("CompareReceipt should serialize to JSON");
2817
2818            // Deserialize back
2819            let deserialized: CompareReceipt = serde_json::from_str(&json)
2820                .expect("JSON should deserialize back to CompareReceipt");
2821
2822            // Compare non-f64 fields directly
2823            prop_assert_eq!(&receipt.schema, &deserialized.schema);
2824            prop_assert_eq!(&receipt.tool, &deserialized.tool);
2825            prop_assert_eq!(&receipt.bench, &deserialized.bench);
2826            prop_assert_eq!(&receipt.baseline_ref, &deserialized.baseline_ref);
2827            prop_assert_eq!(&receipt.current_ref, &deserialized.current_ref);
2828            prop_assert_eq!(&receipt.verdict, &deserialized.verdict);
2829
2830            // Compare budgets map - contains f64 fields
2831            prop_assert_eq!(receipt.budgets.len(), deserialized.budgets.len());
2832            for (metric, orig_budget) in &receipt.budgets {
2833                let deser_budget = deserialized.budgets.get(metric)
2834                    .expect("Budget metric should exist in deserialized");
2835                prop_assert!(
2836                    f64_approx_eq(orig_budget.threshold, deser_budget.threshold),
2837                    "Budget threshold mismatch for {:?}: {} vs {}",
2838                    metric, orig_budget.threshold, deser_budget.threshold
2839                );
2840                prop_assert!(
2841                    f64_approx_eq(orig_budget.warn_threshold, deser_budget.warn_threshold),
2842                    "Budget warn_threshold mismatch for {:?}: {} vs {}",
2843                    metric, orig_budget.warn_threshold, deser_budget.warn_threshold
2844                );
2845                prop_assert_eq!(orig_budget.direction, deser_budget.direction);
2846            }
2847
2848            // Compare deltas map - contains f64 fields
2849            prop_assert_eq!(receipt.deltas.len(), deserialized.deltas.len());
2850            for (metric, orig_delta) in &receipt.deltas {
2851                let deser_delta = deserialized.deltas.get(metric)
2852                    .expect("Delta metric should exist in deserialized");
2853                prop_assert!(
2854                    f64_approx_eq(orig_delta.baseline, deser_delta.baseline),
2855                    "Delta baseline mismatch for {:?}: {} vs {}",
2856                    metric, orig_delta.baseline, deser_delta.baseline
2857                );
2858                prop_assert!(
2859                    f64_approx_eq(orig_delta.current, deser_delta.current),
2860                    "Delta current mismatch for {:?}: {} vs {}",
2861                    metric, orig_delta.current, deser_delta.current
2862                );
2863                prop_assert!(
2864                    f64_approx_eq(orig_delta.ratio, deser_delta.ratio),
2865                    "Delta ratio mismatch for {:?}: {} vs {}",
2866                    metric, orig_delta.ratio, deser_delta.ratio
2867                );
2868                prop_assert!(
2869                    f64_approx_eq(orig_delta.pct, deser_delta.pct),
2870                    "Delta pct mismatch for {:?}: {} vs {}",
2871                    metric, orig_delta.pct, deser_delta.pct
2872                );
2873                prop_assert!(
2874                    f64_approx_eq(orig_delta.regression, deser_delta.regression),
2875                    "Delta regression mismatch for {:?}: {} vs {}",
2876                    metric, orig_delta.regression, deser_delta.regression
2877                );
2878                prop_assert_eq!(orig_delta.status, deser_delta.status);
2879            }
2880        }
2881    }
2882
2883    // --- Strategies for ConfigFile ---
2884
2885    // Strategy for BudgetOverride
2886    fn budget_override_strategy() -> impl Strategy<Value = BudgetOverride> {
2887        (
2888            proptest::option::of(0.01f64..1.0),
2889            proptest::option::of(direction_strategy()),
2890            proptest::option::of(0.5f64..1.0),
2891        )
2892            .prop_map(|(threshold, direction, warn_factor)| BudgetOverride {
2893                noise_threshold: None,
2894                noise_policy: None,
2895                threshold,
2896                direction,
2897                warn_factor,
2898                statistic: None,
2899            })
2900    }
2901
2902    // Strategy for BTreeMap<Metric, BudgetOverride>
2903    fn budget_overrides_map_strategy() -> impl Strategy<Value = BTreeMap<Metric, BudgetOverride>> {
2904        proptest::collection::btree_map(metric_strategy(), budget_override_strategy(), 0..4)
2905    }
2906
2907    // Strategy for BenchConfigFile
2908    fn bench_config_file_strategy() -> impl Strategy<Value = BenchConfigFile> {
2909        (
2910            non_empty_string(),
2911            proptest::option::of(non_empty_string()),
2912            proptest::option::of(1u64..10000),
2913            proptest::option::of("[0-9]+[smh]"), // humantime-like duration strings
2914            proptest::collection::vec(non_empty_string(), 1..5),
2915            proptest::option::of(1u32..100),
2916            proptest::option::of(0u32..10),
2917            proptest::option::of(proptest::collection::vec(metric_strategy(), 1..4)),
2918            proptest::option::of(budget_overrides_map_strategy()),
2919        )
2920            .prop_map(
2921                |(name, cwd, work, timeout, command, repeat, warmup, metrics, budgets)| {
2922                    BenchConfigFile {
2923                        name,
2924                        cwd,
2925                        work,
2926                        timeout,
2927                        command,
2928                        repeat,
2929                        warmup,
2930                        metrics,
2931                        budgets,
2932                    }
2933                },
2934            )
2935    }
2936
2937    // Strategy for DefaultsConfig
2938    fn defaults_config_strategy() -> impl Strategy<Value = DefaultsConfig> {
2939        (
2940            proptest::option::of(1u32..100),
2941            proptest::option::of(0u32..10),
2942            proptest::option::of(0.01f64..1.0),
2943            proptest::option::of(0.5f64..1.0),
2944            proptest::option::of(non_empty_string()),
2945            proptest::option::of(non_empty_string()),
2946            proptest::option::of(non_empty_string()),
2947            proptest::option::of(non_empty_string()),
2948        )
2949            .prop_map(
2950                |(
2951                    repeat,
2952                    warmup,
2953                    threshold,
2954                    warn_factor,
2955                    out_dir,
2956                    baseline_dir,
2957                    baseline_pattern,
2958                    markdown_template,
2959                )| DefaultsConfig {
2960                    noise_threshold: None,
2961                    noise_policy: None,
2962                    repeat,
2963                    warmup,
2964                    threshold,
2965                    warn_factor,
2966                    out_dir,
2967                    baseline_dir,
2968                    baseline_pattern,
2969                    markdown_template,
2970                },
2971            )
2972    }
2973
2974    // Strategy for BaselineServerConfig
2975    fn baseline_server_config_strategy() -> impl Strategy<Value = BaselineServerConfig> {
2976        (
2977            proptest::option::of(non_empty_string()),
2978            proptest::option::of(non_empty_string()),
2979            proptest::option::of(non_empty_string()),
2980            proptest::bool::ANY,
2981        )
2982            .prop_map(
2983                |(url, api_key, project, fallback_to_local)| BaselineServerConfig {
2984                    url,
2985                    api_key,
2986                    project,
2987                    fallback_to_local,
2988                },
2989            )
2990    }
2991
2992    // Strategy for ConfigFile
2993    fn config_file_strategy() -> impl Strategy<Value = ConfigFile> {
2994        (
2995            defaults_config_strategy(),
2996            baseline_server_config_strategy(),
2997            proptest::collection::vec(bench_config_file_strategy(), 0..5),
2998        )
2999            .prop_map(|(defaults, baseline_server, benches)| ConfigFile {
3000                defaults,
3001                baseline_server,
3002                benches,
3003            })
3004    }
3005
3006    // **Feature: comprehensive-test-coverage, Property 1: JSON Serialization Round-Trip**
3007    //
3008    // For any valid ConfigFile, serializing to JSON then deserializing
3009    // SHALL produce an equivalent value.
3010    //
3011    // **Validates: Requirements 4.2, 4.5**
3012    proptest! {
3013        #![proptest_config(ProptestConfig::with_cases(100))]
3014
3015        #[test]
3016        fn config_file_json_serialization_round_trip(config in config_file_strategy()) {
3017            // Serialize to JSON
3018            let json = serde_json::to_string(&config)
3019                .expect("ConfigFile should serialize to JSON");
3020
3021            // Deserialize back
3022            let deserialized: ConfigFile = serde_json::from_str(&json)
3023                .expect("JSON should deserialize back to ConfigFile");
3024
3025            // Compare defaults
3026            prop_assert_eq!(config.defaults.repeat, deserialized.defaults.repeat);
3027            prop_assert_eq!(config.defaults.warmup, deserialized.defaults.warmup);
3028            prop_assert_eq!(&config.defaults.out_dir, &deserialized.defaults.out_dir);
3029            prop_assert_eq!(&config.defaults.baseline_dir, &deserialized.defaults.baseline_dir);
3030            prop_assert_eq!(
3031                &config.defaults.baseline_pattern,
3032                &deserialized.defaults.baseline_pattern
3033            );
3034            prop_assert_eq!(
3035                &config.defaults.markdown_template,
3036                &deserialized.defaults.markdown_template
3037            );
3038
3039            // Compare f64 fields in defaults with tolerance
3040            match (config.defaults.threshold, deserialized.defaults.threshold) {
3041                (Some(orig), Some(deser)) => {
3042                    prop_assert!(
3043                        f64_approx_eq(orig, deser),
3044                        "defaults.threshold mismatch: {} vs {}",
3045                        orig, deser
3046                    );
3047                }
3048                (None, None) => {}
3049                _ => prop_assert!(false, "defaults.threshold presence mismatch"),
3050            }
3051
3052            match (config.defaults.warn_factor, deserialized.defaults.warn_factor) {
3053                (Some(orig), Some(deser)) => {
3054                    prop_assert!(
3055                        f64_approx_eq(orig, deser),
3056                        "defaults.warn_factor mismatch: {} vs {}",
3057                        orig, deser
3058                    );
3059                }
3060                (None, None) => {}
3061                _ => prop_assert!(false, "defaults.warn_factor presence mismatch"),
3062            }
3063
3064            // Compare benches
3065            prop_assert_eq!(config.benches.len(), deserialized.benches.len());
3066            for (orig_bench, deser_bench) in config.benches.iter().zip(deserialized.benches.iter()) {
3067                prop_assert_eq!(&orig_bench.name, &deser_bench.name);
3068                prop_assert_eq!(&orig_bench.cwd, &deser_bench.cwd);
3069                prop_assert_eq!(orig_bench.work, deser_bench.work);
3070                prop_assert_eq!(&orig_bench.timeout, &deser_bench.timeout);
3071                prop_assert_eq!(&orig_bench.command, &deser_bench.command);
3072                prop_assert_eq!(&orig_bench.metrics, &deser_bench.metrics);
3073
3074                // Compare budgets map with f64 tolerance
3075                match (&orig_bench.budgets, &deser_bench.budgets) {
3076                    (Some(orig_budgets), Some(deser_budgets)) => {
3077                        prop_assert_eq!(orig_budgets.len(), deser_budgets.len());
3078                        for (metric, orig_override) in orig_budgets {
3079                            let deser_override = deser_budgets.get(metric)
3080                                .expect("BudgetOverride metric should exist in deserialized");
3081
3082                            // Compare threshold with tolerance
3083                            match (orig_override.threshold, deser_override.threshold) {
3084                                (Some(orig), Some(deser)) => {
3085                                    prop_assert!(
3086                                        f64_approx_eq(orig, deser),
3087                                        "BudgetOverride threshold mismatch for {:?}: {} vs {}",
3088                                        metric, orig, deser
3089                                    );
3090                                }
3091                                (None, None) => {}
3092                                _ => prop_assert!(false, "BudgetOverride threshold presence mismatch for {:?}", metric),
3093                            }
3094
3095                            prop_assert_eq!(orig_override.direction, deser_override.direction);
3096
3097                            // Compare warn_factor with tolerance
3098                            match (orig_override.warn_factor, deser_override.warn_factor) {
3099                                (Some(orig), Some(deser)) => {
3100                                    prop_assert!(
3101                                        f64_approx_eq(orig, deser),
3102                                        "BudgetOverride warn_factor mismatch for {:?}: {} vs {}",
3103                                        metric, orig, deser
3104                                    );
3105                                }
3106                                (None, None) => {}
3107                                _ => prop_assert!(false, "BudgetOverride warn_factor presence mismatch for {:?}", metric),
3108                            }
3109                        }
3110                    }
3111                    (None, None) => {}
3112                    _ => prop_assert!(false, "bench.budgets presence mismatch"),
3113                }
3114            }
3115        }
3116    }
3117
3118    // **Feature: comprehensive-test-coverage, Property 1: JSON Serialization Round-Trip (TOML variant)**
3119    //
3120    // For any valid ConfigFile, serializing to TOML then deserializing
3121    // SHALL produce an equivalent value.
3122    //
3123    // **Validates: Requirements 4.2, 4.5**
3124    proptest! {
3125        #![proptest_config(ProptestConfig::with_cases(100))]
3126
3127        #[test]
3128        fn config_file_toml_serialization_round_trip(config in config_file_strategy()) {
3129            // Serialize to TOML
3130            let toml_str = toml::to_string(&config)
3131                .expect("ConfigFile should serialize to TOML");
3132
3133            // Deserialize back
3134            let deserialized: ConfigFile = toml::from_str(&toml_str)
3135                .expect("TOML should deserialize back to ConfigFile");
3136
3137            // Compare defaults
3138            prop_assert_eq!(config.defaults.repeat, deserialized.defaults.repeat);
3139            prop_assert_eq!(config.defaults.warmup, deserialized.defaults.warmup);
3140            prop_assert_eq!(&config.defaults.out_dir, &deserialized.defaults.out_dir);
3141            prop_assert_eq!(&config.defaults.baseline_dir, &deserialized.defaults.baseline_dir);
3142            prop_assert_eq!(
3143                &config.defaults.baseline_pattern,
3144                &deserialized.defaults.baseline_pattern
3145            );
3146            prop_assert_eq!(
3147                &config.defaults.markdown_template,
3148                &deserialized.defaults.markdown_template
3149            );
3150
3151            // Compare f64 fields in defaults with tolerance
3152            match (config.defaults.threshold, deserialized.defaults.threshold) {
3153                (Some(orig), Some(deser)) => {
3154                    prop_assert!(
3155                        f64_approx_eq(orig, deser),
3156                        "defaults.threshold mismatch: {} vs {}",
3157                        orig, deser
3158                    );
3159                }
3160                (None, None) => {}
3161                _ => prop_assert!(false, "defaults.threshold presence mismatch"),
3162            }
3163
3164            match (config.defaults.warn_factor, deserialized.defaults.warn_factor) {
3165                (Some(orig), Some(deser)) => {
3166                    prop_assert!(
3167                        f64_approx_eq(orig, deser),
3168                        "defaults.warn_factor mismatch: {} vs {}",
3169                        orig, deser
3170                    );
3171                }
3172                (None, None) => {}
3173                _ => prop_assert!(false, "defaults.warn_factor presence mismatch"),
3174            }
3175
3176            // Compare benches
3177            prop_assert_eq!(config.benches.len(), deserialized.benches.len());
3178            for (orig_bench, deser_bench) in config.benches.iter().zip(deserialized.benches.iter()) {
3179                prop_assert_eq!(&orig_bench.name, &deser_bench.name);
3180                prop_assert_eq!(&orig_bench.cwd, &deser_bench.cwd);
3181                prop_assert_eq!(orig_bench.work, deser_bench.work);
3182                prop_assert_eq!(&orig_bench.timeout, &deser_bench.timeout);
3183                prop_assert_eq!(&orig_bench.command, &deser_bench.command);
3184                prop_assert_eq!(&orig_bench.metrics, &deser_bench.metrics);
3185
3186                // Compare budgets map with f64 tolerance
3187                match (&orig_bench.budgets, &deser_bench.budgets) {
3188                    (Some(orig_budgets), Some(deser_budgets)) => {
3189                        prop_assert_eq!(orig_budgets.len(), deser_budgets.len());
3190                        for (metric, orig_override) in orig_budgets {
3191                            let deser_override = deser_budgets.get(metric)
3192                                .expect("BudgetOverride metric should exist in deserialized");
3193
3194                            // Compare threshold with tolerance
3195                            match (orig_override.threshold, deser_override.threshold) {
3196                                (Some(orig), Some(deser)) => {
3197                                    prop_assert!(
3198                                        f64_approx_eq(orig, deser),
3199                                        "BudgetOverride threshold mismatch for {:?}: {} vs {}",
3200                                        metric, orig, deser
3201                                    );
3202                                }
3203                                (None, None) => {}
3204                                _ => prop_assert!(false, "BudgetOverride threshold presence mismatch for {:?}", metric),
3205                            }
3206
3207                            prop_assert_eq!(orig_override.direction, deser_override.direction);
3208
3209                            // Compare warn_factor with tolerance
3210                            match (orig_override.warn_factor, deser_override.warn_factor) {
3211                                (Some(orig), Some(deser)) => {
3212                                    prop_assert!(
3213                                        f64_approx_eq(orig, deser),
3214                                        "BudgetOverride warn_factor mismatch for {:?}: {} vs {}",
3215                                        metric, orig, deser
3216                                    );
3217                                }
3218                                (None, None) => {}
3219                                _ => prop_assert!(false, "BudgetOverride warn_factor presence mismatch for {:?}", metric),
3220                            }
3221                        }
3222                    }
3223                    (None, None) => {}
3224                    _ => prop_assert!(false, "bench.budgets presence mismatch"),
3225                }
3226            }
3227        }
3228    }
3229
3230    // **Feature: comprehensive-test-coverage, Property 1: JSON Serialization Round-Trip**
3231    //
3232    // For any valid BenchConfigFile, serializing to JSON then deserializing
3233    // SHALL produce an equivalent value. This tests the BenchConfigFile type
3234    // in isolation with all optional fields.
3235    //
3236    // **Validates: Requirements 4.2, 4.5**
3237    proptest! {
3238        #![proptest_config(ProptestConfig::with_cases(100))]
3239
3240        #[test]
3241        fn bench_config_file_json_serialization_round_trip(bench_config in bench_config_file_strategy()) {
3242            // Serialize to JSON
3243            let json = serde_json::to_string(&bench_config)
3244                .expect("BenchConfigFile should serialize to JSON");
3245
3246            // Deserialize back
3247            let deserialized: BenchConfigFile = serde_json::from_str(&json)
3248                .expect("JSON should deserialize back to BenchConfigFile");
3249
3250            // Compare required fields
3251            prop_assert_eq!(&bench_config.name, &deserialized.name);
3252            prop_assert_eq!(&bench_config.command, &deserialized.command);
3253
3254            // Compare optional fields
3255            prop_assert_eq!(&bench_config.cwd, &deserialized.cwd);
3256            prop_assert_eq!(bench_config.work, deserialized.work);
3257            prop_assert_eq!(&bench_config.timeout, &deserialized.timeout);
3258            prop_assert_eq!(&bench_config.metrics, &deserialized.metrics);
3259
3260            // Compare budgets map with f64 tolerance
3261            match (&bench_config.budgets, &deserialized.budgets) {
3262                (Some(orig_budgets), Some(deser_budgets)) => {
3263                    prop_assert_eq!(orig_budgets.len(), deser_budgets.len());
3264                    for (metric, orig_override) in orig_budgets {
3265                        let deser_override = deser_budgets.get(metric)
3266                            .expect("BudgetOverride metric should exist in deserialized");
3267
3268                        // Compare threshold with tolerance
3269                        match (orig_override.threshold, deser_override.threshold) {
3270                            (Some(orig), Some(deser)) => {
3271                                prop_assert!(
3272                                    f64_approx_eq(orig, deser),
3273                                    "BudgetOverride threshold mismatch for {:?}: {} vs {}",
3274                                    metric, orig, deser
3275                                );
3276                            }
3277                            (None, None) => {}
3278                            _ => prop_assert!(false, "BudgetOverride threshold presence mismatch for {:?}", metric),
3279                        }
3280
3281                        prop_assert_eq!(orig_override.direction, deser_override.direction);
3282
3283                        // Compare warn_factor with tolerance
3284                        match (orig_override.warn_factor, deser_override.warn_factor) {
3285                            (Some(orig), Some(deser)) => {
3286                                prop_assert!(
3287                                    f64_approx_eq(orig, deser),
3288                                    "BudgetOverride warn_factor mismatch for {:?}: {} vs {}",
3289                                    metric, orig, deser
3290                                );
3291                            }
3292                            (None, None) => {}
3293                            _ => prop_assert!(false, "BudgetOverride warn_factor presence mismatch for {:?}", metric),
3294                        }
3295                    }
3296                }
3297                (None, None) => {}
3298                _ => prop_assert!(false, "budgets presence mismatch"),
3299            }
3300        }
3301    }
3302
3303    // **Feature: comprehensive-test-coverage, Property 1: JSON Serialization Round-Trip (TOML variant)**
3304    //
3305    // For any valid BenchConfigFile, serializing to TOML then deserializing
3306    // SHALL produce an equivalent value. This tests the BenchConfigFile type
3307    // in isolation with all optional fields.
3308    //
3309    // **Validates: Requirements 4.2, 4.5**
3310    proptest! {
3311        #![proptest_config(ProptestConfig::with_cases(100))]
3312
3313        #[test]
3314        fn bench_config_file_toml_serialization_round_trip(bench_config in bench_config_file_strategy()) {
3315            // Serialize to TOML
3316            let toml_str = toml::to_string(&bench_config)
3317                .expect("BenchConfigFile should serialize to TOML");
3318
3319            // Deserialize back
3320            let deserialized: BenchConfigFile = toml::from_str(&toml_str)
3321                .expect("TOML should deserialize back to BenchConfigFile");
3322
3323            // Compare required fields
3324            prop_assert_eq!(&bench_config.name, &deserialized.name);
3325            prop_assert_eq!(&bench_config.command, &deserialized.command);
3326
3327            // Compare optional fields
3328            prop_assert_eq!(&bench_config.cwd, &deserialized.cwd);
3329            prop_assert_eq!(bench_config.work, deserialized.work);
3330            prop_assert_eq!(&bench_config.timeout, &deserialized.timeout);
3331            prop_assert_eq!(&bench_config.metrics, &deserialized.metrics);
3332
3333            // Compare budgets map with f64 tolerance
3334            match (&bench_config.budgets, &deserialized.budgets) {
3335                (Some(orig_budgets), Some(deser_budgets)) => {
3336                    prop_assert_eq!(orig_budgets.len(), deser_budgets.len());
3337                    for (metric, orig_override) in orig_budgets {
3338                        let deser_override = deser_budgets.get(metric)
3339                            .expect("BudgetOverride metric should exist in deserialized");
3340
3341                        // Compare threshold with tolerance
3342                        match (orig_override.threshold, deser_override.threshold) {
3343                            (Some(orig), Some(deser)) => {
3344                                prop_assert!(
3345                                    f64_approx_eq(orig, deser),
3346                                    "BudgetOverride threshold mismatch for {:?}: {} vs {}",
3347                                    metric, orig, deser
3348                                );
3349                            }
3350                            (None, None) => {}
3351                            _ => prop_assert!(false, "BudgetOverride threshold presence mismatch for {:?}", metric),
3352                        }
3353
3354                        prop_assert_eq!(orig_override.direction, deser_override.direction);
3355
3356                        // Compare warn_factor with tolerance
3357                        match (orig_override.warn_factor, deser_override.warn_factor) {
3358                            (Some(orig), Some(deser)) => {
3359                                prop_assert!(
3360                                    f64_approx_eq(orig, deser),
3361                                    "BudgetOverride warn_factor mismatch for {:?}: {} vs {}",
3362                                    metric, orig, deser
3363                                );
3364                            }
3365                            (None, None) => {}
3366                            _ => prop_assert!(false, "BudgetOverride warn_factor presence mismatch for {:?}", metric),
3367                        }
3368                    }
3369                }
3370                (None, None) => {}
3371                _ => prop_assert!(false, "budgets presence mismatch"),
3372            }
3373        }
3374    }
3375
3376    // **Feature: comprehensive-test-coverage, Property 1: JSON Serialization Round-Trip**
3377    //
3378    // For any valid Budget, serializing to JSON then deserializing
3379    // SHALL produce an equivalent value.
3380    //
3381    // **Validates: Requirements 4.2, 4.5**
3382    proptest! {
3383        #![proptest_config(ProptestConfig::with_cases(100))]
3384
3385        #[test]
3386        fn budget_json_serialization_round_trip(budget in budget_strategy()) {
3387            // Serialize to JSON
3388            let json = serde_json::to_string(&budget)
3389                .expect("Budget should serialize to JSON");
3390
3391            // Deserialize back
3392            let deserialized: Budget = serde_json::from_str(&json)
3393                .expect("JSON should deserialize back to Budget");
3394
3395            // Compare f64 fields with tolerance
3396            prop_assert!(
3397                f64_approx_eq(budget.threshold, deserialized.threshold),
3398                "Budget threshold mismatch: {} vs {}",
3399                budget.threshold, deserialized.threshold
3400            );
3401            prop_assert!(
3402                f64_approx_eq(budget.warn_threshold, deserialized.warn_threshold),
3403                "Budget warn_threshold mismatch: {} vs {}",
3404                budget.warn_threshold, deserialized.warn_threshold
3405            );
3406            prop_assert_eq!(budget.direction, deserialized.direction);
3407        }
3408    }
3409
3410    // **Feature: comprehensive-test-coverage, Property 1: JSON Serialization Round-Trip**
3411    //
3412    // For any valid BudgetOverride, serializing to JSON then deserializing
3413    // SHALL produce an equivalent value.
3414    //
3415    // **Validates: Requirements 4.2, 4.5**
3416    proptest! {
3417        #![proptest_config(ProptestConfig::with_cases(100))]
3418
3419        #[test]
3420        fn budget_override_json_serialization_round_trip(budget_override in budget_override_strategy()) {
3421            // Serialize to JSON
3422            let json = serde_json::to_string(&budget_override)
3423                .expect("BudgetOverride should serialize to JSON");
3424
3425            // Deserialize back
3426            let deserialized: BudgetOverride = serde_json::from_str(&json)
3427                .expect("JSON should deserialize back to BudgetOverride");
3428
3429            // Compare threshold with tolerance
3430            match (budget_override.threshold, deserialized.threshold) {
3431                (Some(orig), Some(deser)) => {
3432                    prop_assert!(
3433                        f64_approx_eq(orig, deser),
3434                        "BudgetOverride threshold mismatch: {} vs {}",
3435                        orig, deser
3436                    );
3437                }
3438                (None, None) => {}
3439                _ => prop_assert!(false, "BudgetOverride threshold presence mismatch"),
3440            }
3441
3442            // Compare direction
3443            prop_assert_eq!(budget_override.direction, deserialized.direction);
3444
3445            // Compare warn_factor with tolerance
3446            match (budget_override.warn_factor, deserialized.warn_factor) {
3447                (Some(orig), Some(deser)) => {
3448                    prop_assert!(
3449                        f64_approx_eq(orig, deser),
3450                        "BudgetOverride warn_factor mismatch: {} vs {}",
3451                        orig, deser
3452                    );
3453                }
3454                (None, None) => {}
3455                _ => prop_assert!(false, "BudgetOverride warn_factor presence mismatch"),
3456            }
3457        }
3458    }
3459
3460    // **Feature: comprehensive-test-coverage, Property 1: JSON Serialization Round-Trip**
3461    //
3462    // For any valid Budget, the threshold relationship SHALL be preserved:
3463    // warn_threshold <= threshold. This property verifies that the Budget
3464    // strategy generates valid budgets and that serialization preserves
3465    // this invariant.
3466    //
3467    // **Validates: Requirements 4.2, 4.5**
3468    proptest! {
3469        #![proptest_config(ProptestConfig::with_cases(100))]
3470
3471        #[test]
3472        fn budget_threshold_relationship_preserved(budget in budget_strategy()) {
3473            // Verify the invariant holds for the generated budget
3474            prop_assert!(
3475                budget.warn_threshold <= budget.threshold,
3476                "Budget invariant violated: warn_threshold ({}) should be <= threshold ({})",
3477                budget.warn_threshold, budget.threshold
3478            );
3479
3480            // Serialize to JSON and back
3481            let json = serde_json::to_string(&budget)
3482                .expect("Budget should serialize to JSON");
3483            let deserialized: Budget = serde_json::from_str(&json)
3484                .expect("JSON should deserialize back to Budget");
3485
3486            // Verify the invariant is preserved after round-trip
3487            prop_assert!(
3488                deserialized.warn_threshold <= deserialized.threshold,
3489                "Budget invariant violated after round-trip: warn_threshold ({}) should be <= threshold ({})",
3490                deserialized.warn_threshold, deserialized.threshold
3491            );
3492        }
3493    }
3494
3495    // --- Leaf-type round-trip tests ---
3496
3497    proptest! {
3498        #![proptest_config(ProptestConfig::with_cases(100))]
3499
3500        #[test]
3501        fn host_info_serialization_round_trip(info in host_info_strategy()) {
3502            let json = serde_json::to_string(&info).expect("HostInfo should serialize");
3503            let back: HostInfo = serde_json::from_str(&json).expect("should deserialize");
3504            prop_assert_eq!(info, back);
3505        }
3506
3507        #[test]
3508        fn sample_serialization_round_trip(sample in sample_strategy()) {
3509            let json = serde_json::to_string(&sample).expect("Sample should serialize");
3510            let back: Sample = serde_json::from_str(&json).expect("should deserialize");
3511            prop_assert_eq!(sample, back);
3512        }
3513
3514        #[test]
3515        fn u64_summary_serialization_round_trip(summary in u64_summary_strategy()) {
3516            let json = serde_json::to_string(&summary).expect("U64Summary should serialize");
3517            let back: U64Summary = serde_json::from_str(&json).expect("should deserialize");
3518            prop_assert_eq!(summary, back);
3519        }
3520
3521        #[test]
3522        fn f64_summary_serialization_round_trip(summary in f64_summary_strategy()) {
3523            let json = serde_json::to_string(&summary).expect("F64Summary should serialize");
3524            let back: F64Summary = serde_json::from_str(&json).expect("should deserialize");
3525            prop_assert!(f64_approx_eq(summary.min, back.min));
3526            prop_assert!(f64_approx_eq(summary.median, back.median));
3527            prop_assert!(f64_approx_eq(summary.max, back.max));
3528        }
3529
3530        #[test]
3531        fn stats_serialization_round_trip(stats in stats_strategy()) {
3532            let json = serde_json::to_string(&stats).expect("Stats should serialize");
3533            let back: Stats = serde_json::from_str(&json).expect("should deserialize");
3534            prop_assert_eq!(&stats.wall_ms, &back.wall_ms);
3535            prop_assert_eq!(&stats.cpu_ms, &back.cpu_ms);
3536            prop_assert_eq!(&stats.page_faults, &back.page_faults);
3537            prop_assert_eq!(&stats.ctx_switches, &back.ctx_switches);
3538            prop_assert_eq!(&stats.max_rss_kb, &back.max_rss_kb);
3539            prop_assert_eq!(&stats.binary_bytes, &back.binary_bytes);
3540            match (&stats.throughput_per_s, &back.throughput_per_s) {
3541                (Some(orig), Some(deser)) => {
3542                    prop_assert!(f64_approx_eq(orig.min, deser.min));
3543                    prop_assert!(f64_approx_eq(orig.median, deser.median));
3544                    prop_assert!(f64_approx_eq(orig.max, deser.max));
3545                }
3546                (None, None) => {}
3547                _ => prop_assert!(false, "throughput_per_s presence mismatch"),
3548            }
3549        }
3550
3551        #[test]
3552        fn delta_serialization_round_trip(delta in delta_strategy()) {
3553            let json = serde_json::to_string(&delta).expect("Delta should serialize");
3554            let back: Delta = serde_json::from_str(&json).expect("should deserialize");
3555            prop_assert!(f64_approx_eq(delta.baseline, back.baseline));
3556            prop_assert!(f64_approx_eq(delta.current, back.current));
3557            prop_assert!(f64_approx_eq(delta.ratio, back.ratio));
3558            prop_assert!(f64_approx_eq(delta.pct, back.pct));
3559            prop_assert!(f64_approx_eq(delta.regression, back.regression));
3560            prop_assert_eq!(delta.statistic, back.statistic);
3561            prop_assert_eq!(delta.significance, back.significance);
3562            prop_assert_eq!(delta.status, back.status);
3563        }
3564
3565        #[test]
3566        fn verdict_serialization_round_trip(verdict in verdict_strategy()) {
3567            let json = serde_json::to_string(&verdict).expect("Verdict should serialize");
3568            let back: Verdict = serde_json::from_str(&json).expect("should deserialize");
3569            prop_assert_eq!(verdict, back);
3570        }
3571    }
3572
3573    // --- PerfgateReport round-trip ---
3574
3575    fn severity_strategy() -> impl Strategy<Value = Severity> {
3576        prop_oneof![Just(Severity::Warn), Just(Severity::Fail),]
3577    }
3578
3579    fn finding_data_strategy() -> impl Strategy<Value = FindingData> {
3580        (
3581            non_empty_string(),
3582            0.1f64..10000.0,
3583            0.1f64..10000.0,
3584            0.0f64..100.0,
3585            0.01f64..1.0,
3586            direction_strategy(),
3587        )
3588            .prop_map(
3589                |(metric_name, baseline, current, regression_pct, threshold, direction)| {
3590                    FindingData {
3591                        metric_name,
3592                        baseline,
3593                        current,
3594                        regression_pct,
3595                        threshold,
3596                        direction,
3597                    }
3598                },
3599            )
3600    }
3601
3602    fn report_finding_strategy() -> impl Strategy<Value = ReportFinding> {
3603        (
3604            non_empty_string(),
3605            non_empty_string(),
3606            severity_strategy(),
3607            non_empty_string(),
3608            proptest::option::of(finding_data_strategy()),
3609        )
3610            .prop_map(|(check_id, code, severity, message, data)| ReportFinding {
3611                check_id,
3612                code,
3613                severity,
3614                message,
3615                data,
3616            })
3617    }
3618
3619    fn report_summary_strategy() -> impl Strategy<Value = ReportSummary> {
3620        (0u32..100, 0u32..100, 0u32..100, 0u32..100).prop_map(
3621            |(pass_count, warn_count, fail_count, skip_count)| ReportSummary {
3622                pass_count,
3623                warn_count,
3624                fail_count,
3625                skip_count,
3626                total_count: pass_count + warn_count + fail_count + skip_count,
3627            },
3628        )
3629    }
3630
3631    fn perfgate_report_strategy() -> impl Strategy<Value = PerfgateReport> {
3632        (
3633            verdict_strategy(),
3634            proptest::option::of(compare_receipt_strategy()),
3635            proptest::collection::vec(report_finding_strategy(), 0..5),
3636            report_summary_strategy(),
3637        )
3638            .prop_map(|(verdict, compare, findings, summary)| PerfgateReport {
3639                report_type: REPORT_SCHEMA_V1.to_string(),
3640                verdict,
3641                compare,
3642                findings,
3643                summary,
3644            })
3645    }
3646
3647    proptest! {
3648        #![proptest_config(ProptestConfig::with_cases(50))]
3649
3650        #[test]
3651        fn perfgate_report_serialization_round_trip(report in perfgate_report_strategy()) {
3652            let json = serde_json::to_string(&report)
3653                .expect("PerfgateReport should serialize to JSON");
3654            let back: PerfgateReport = serde_json::from_str(&json)
3655                .expect("JSON should deserialize back to PerfgateReport");
3656
3657            prop_assert_eq!(&report.report_type, &back.report_type);
3658            prop_assert_eq!(&report.verdict, &back.verdict);
3659            prop_assert_eq!(&report.summary, &back.summary);
3660            prop_assert_eq!(report.findings.len(), back.findings.len());
3661            for (orig, deser) in report.findings.iter().zip(back.findings.iter()) {
3662                prop_assert_eq!(&orig.check_id, &deser.check_id);
3663                prop_assert_eq!(&orig.code, &deser.code);
3664                prop_assert_eq!(orig.severity, deser.severity);
3665                prop_assert_eq!(&orig.message, &deser.message);
3666                match (&orig.data, &deser.data) {
3667                    (Some(o), Some(d)) => {
3668                        prop_assert_eq!(&o.metric_name, &d.metric_name);
3669                        prop_assert!(f64_approx_eq(o.baseline, d.baseline));
3670                        prop_assert!(f64_approx_eq(o.current, d.current));
3671                        prop_assert!(f64_approx_eq(o.regression_pct, d.regression_pct));
3672                        prop_assert!(f64_approx_eq(o.threshold, d.threshold));
3673                        prop_assert_eq!(o.direction, d.direction);
3674                    }
3675                    (None, None) => {}
3676                    _ => prop_assert!(false, "finding data presence mismatch"),
3677                }
3678            }
3679            // CompareReceipt equality is covered by compare_receipt round-trip;
3680            // just verify presence matches.
3681            prop_assert_eq!(report.compare.is_some(), back.compare.is_some());
3682        }
3683    }
3684}
3685
3686// --- Golden fixture tests for sensor.report.v1 ---
3687
3688#[cfg(test)]
3689mod golden_tests {
3690    use super::*;
3691
3692    const FIXTURE_PASS: &str = include_str!("../../../contracts/fixtures/sensor_report_pass.json");
3693    const FIXTURE_FAIL: &str = include_str!("../../../contracts/fixtures/sensor_report_fail.json");
3694    const FIXTURE_WARN: &str = include_str!("../../../contracts/fixtures/sensor_report_warn.json");
3695    const FIXTURE_NO_BASELINE: &str =
3696        include_str!("../../../contracts/fixtures/sensor_report_no_baseline.json");
3697    const FIXTURE_ERROR: &str =
3698        include_str!("../../../contracts/fixtures/sensor_report_error.json");
3699    const FIXTURE_MULTI_BENCH: &str =
3700        include_str!("../../../contracts/fixtures/sensor_report_multi_bench.json");
3701
3702    #[test]
3703    fn golden_sensor_report_pass() {
3704        let report: SensorReport =
3705            serde_json::from_str(FIXTURE_PASS).expect("fixture should parse");
3706        assert_eq!(report.schema, SENSOR_REPORT_SCHEMA_V1);
3707        assert_eq!(report.tool.name, "perfgate");
3708        assert_eq!(report.verdict.status, SensorVerdictStatus::Pass);
3709        assert_eq!(report.verdict.counts.warn, 0);
3710        assert_eq!(report.verdict.counts.error, 0);
3711        assert!(report.findings.is_empty());
3712        assert_eq!(report.artifacts.len(), 4);
3713
3714        // Round-trip the parsed fixture
3715        let json = serde_json::to_string(&report).unwrap();
3716        let back: SensorReport = serde_json::from_str(&json).unwrap();
3717        assert_eq!(report, back);
3718    }
3719
3720    #[test]
3721    fn golden_sensor_report_fail() {
3722        let report: SensorReport =
3723            serde_json::from_str(FIXTURE_FAIL).expect("fixture should parse");
3724        assert_eq!(report.schema, SENSOR_REPORT_SCHEMA_V1);
3725        assert_eq!(report.verdict.status, SensorVerdictStatus::Fail);
3726        assert_eq!(report.verdict.counts.error, 1);
3727        assert_eq!(report.verdict.reasons, vec!["wall_ms_fail"]);
3728        assert_eq!(report.findings.len(), 1);
3729        assert_eq!(report.findings[0].check_id, CHECK_ID_BUDGET);
3730        assert_eq!(report.findings[0].code, FINDING_CODE_METRIC_FAIL);
3731        assert_eq!(report.findings[0].severity, SensorSeverity::Error);
3732
3733        let json = serde_json::to_string(&report).unwrap();
3734        let back: SensorReport = serde_json::from_str(&json).unwrap();
3735        assert_eq!(report, back);
3736    }
3737
3738    #[test]
3739    fn golden_sensor_report_warn() {
3740        let report: SensorReport =
3741            serde_json::from_str(FIXTURE_WARN).expect("fixture should parse");
3742        assert_eq!(report.schema, SENSOR_REPORT_SCHEMA_V1);
3743        assert_eq!(report.verdict.status, SensorVerdictStatus::Warn);
3744        assert_eq!(report.verdict.counts.warn, 1);
3745        assert_eq!(report.verdict.reasons, vec!["wall_ms_warn"]);
3746        assert_eq!(report.findings.len(), 1);
3747        assert_eq!(report.findings[0].severity, SensorSeverity::Warn);
3748
3749        let json = serde_json::to_string(&report).unwrap();
3750        let back: SensorReport = serde_json::from_str(&json).unwrap();
3751        assert_eq!(report, back);
3752    }
3753
3754    #[test]
3755    fn golden_sensor_report_no_baseline() {
3756        let report: SensorReport =
3757            serde_json::from_str(FIXTURE_NO_BASELINE).expect("fixture should parse");
3758        assert_eq!(report.schema, SENSOR_REPORT_SCHEMA_V1);
3759        assert_eq!(report.verdict.status, SensorVerdictStatus::Warn);
3760        assert_eq!(report.verdict.reasons, vec!["no_baseline"]);
3761        assert_eq!(
3762            report.run.capabilities.baseline.status,
3763            CapabilityStatus::Unavailable
3764        );
3765        assert_eq!(
3766            report.run.capabilities.baseline.reason.as_deref(),
3767            Some("no_baseline")
3768        );
3769        assert_eq!(report.findings.len(), 1);
3770        assert_eq!(report.findings[0].code, FINDING_CODE_BASELINE_MISSING);
3771
3772        let json = serde_json::to_string(&report).unwrap();
3773        let back: SensorReport = serde_json::from_str(&json).unwrap();
3774        assert_eq!(report, back);
3775    }
3776
3777    #[test]
3778    fn golden_sensor_report_error() {
3779        let report: SensorReport =
3780            serde_json::from_str(FIXTURE_ERROR).expect("fixture should parse");
3781        assert_eq!(report.schema, SENSOR_REPORT_SCHEMA_V1);
3782        assert_eq!(report.verdict.status, SensorVerdictStatus::Fail);
3783        assert_eq!(report.verdict.reasons, vec!["tool_error"]);
3784        assert_eq!(report.findings.len(), 1);
3785        assert_eq!(report.findings[0].check_id, CHECK_ID_TOOL_RUNTIME);
3786        assert_eq!(report.findings[0].code, FINDING_CODE_RUNTIME_ERROR);
3787        assert!(report.artifacts.is_empty());
3788
3789        let json = serde_json::to_string(&report).unwrap();
3790        let back: SensorReport = serde_json::from_str(&json).unwrap();
3791        assert_eq!(report, back);
3792    }
3793
3794    #[test]
3795    fn golden_sensor_report_multi_bench() {
3796        let report: SensorReport =
3797            serde_json::from_str(FIXTURE_MULTI_BENCH).expect("fixture should parse");
3798        assert_eq!(report.schema, SENSOR_REPORT_SCHEMA_V1);
3799        assert_eq!(report.verdict.status, SensorVerdictStatus::Warn);
3800        assert_eq!(report.verdict.counts.warn, 2);
3801        assert_eq!(report.findings.len(), 2);
3802        // Both findings are baseline-missing for different benches
3803        for finding in &report.findings {
3804            assert_eq!(finding.code, FINDING_CODE_BASELINE_MISSING);
3805        }
3806        assert_eq!(report.artifacts.len(), 5);
3807
3808        let json = serde_json::to_string(&report).unwrap();
3809        let back: SensorReport = serde_json::from_str(&json).unwrap();
3810        assert_eq!(report, back);
3811    }
3812}