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