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