Skip to main content

perfgate_types/
structured_evidence.rs

1use crate::{
2    BenchMeta, CompareRef, Delta, MetricStatus, RunMeta, ToolInfo, TradeoffDowngrade, TradeoffRule,
3    Verdict,
4};
5use schemars::JsonSchema;
6use serde::{Deserialize, Serialize};
7use serde_json::Value;
8use std::collections::BTreeMap;
9
10/// Scope of a named performance probe inside a workload.
11#[derive(Debug, Copy, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
12#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
13#[serde(rename_all = "snake_case")]
14pub enum ProbeScope {
15    Local,
16    Enclosing,
17    Dominant,
18    Total,
19}
20
21/// A numeric metric observed for a named probe.
22#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
23#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
24pub struct ProbeMetricValue {
25    pub value: f64,
26
27    #[serde(skip_serializing_if = "Option::is_none", default)]
28    pub unit: Option<String>,
29
30    #[serde(skip_serializing_if = "Option::is_none", default)]
31    pub statistic: Option<String>,
32}
33
34/// One named probe observation from external instrumentation.
35#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
36#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
37pub struct ProbeObservation {
38    pub name: String,
39
40    #[serde(skip_serializing_if = "Option::is_none", default)]
41    pub parent: Option<String>,
42
43    #[serde(skip_serializing_if = "Option::is_none", default)]
44    pub scope: Option<ProbeScope>,
45
46    #[serde(skip_serializing_if = "Option::is_none", default)]
47    pub iteration: Option<u32>,
48
49    #[serde(skip_serializing_if = "Option::is_none", default)]
50    pub started_at: Option<String>,
51
52    #[serde(skip_serializing_if = "Option::is_none", default)]
53    pub ended_at: Option<String>,
54
55    #[serde(skip_serializing_if = "Option::is_none", default)]
56    pub items: Option<u64>,
57
58    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
59    pub metrics: BTreeMap<String, ProbeMetricValue>,
60
61    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
62    pub attributes: BTreeMap<String, String>,
63}
64
65/// A versioned receipt for named probe observations (`perfgate.probe.v1`).
66#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
67#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
68pub struct ProbeReceipt {
69    pub schema: String,
70    pub tool: ToolInfo,
71    pub run: RunMeta,
72
73    #[serde(skip_serializing_if = "Option::is_none", default)]
74    pub bench: Option<BenchMeta>,
75
76    #[serde(skip_serializing_if = "Option::is_none", default)]
77    pub scenario: Option<String>,
78
79    pub probes: Vec<ProbeObservation>,
80
81    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
82    pub metadata: BTreeMap<String, String>,
83}
84
85/// Comparison evidence for one named probe.
86#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
87#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
88pub struct ProbeCompareObservation {
89    pub name: String,
90
91    #[serde(skip_serializing_if = "Option::is_none", default)]
92    pub parent: Option<String>,
93
94    #[serde(skip_serializing_if = "Option::is_none", default)]
95    pub scope: Option<ProbeScope>,
96
97    pub baseline_count: u32,
98    pub current_count: u32,
99
100    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
101    pub deltas: BTreeMap<String, Delta>,
102
103    pub status: MetricStatus,
104
105    #[serde(default, skip_serializing_if = "Vec::is_empty")]
106    pub reasons: Vec<String>,
107}
108
109/// A versioned receipt for named probe deltas (`perfgate.probe_compare.v1`).
110#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
111#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
112pub struct ProbeCompareReceipt {
113    pub schema: String,
114    pub tool: ToolInfo,
115    pub run: RunMeta,
116
117    #[serde(skip_serializing_if = "Option::is_none", default)]
118    pub bench: Option<BenchMeta>,
119
120    #[serde(skip_serializing_if = "Option::is_none", default)]
121    pub scenario: Option<String>,
122
123    #[serde(skip_serializing_if = "Option::is_none", default)]
124    pub baseline_ref: Option<CompareRef>,
125
126    #[serde(skip_serializing_if = "Option::is_none", default)]
127    pub current_ref: Option<CompareRef>,
128
129    pub probes: Vec<ProbeCompareObservation>,
130    pub verdict: Verdict,
131
132    #[serde(default, skip_serializing_if = "Vec::is_empty")]
133    pub warnings: Vec<String>,
134}
135
136/// Scenario definition captured in a scenario evaluation receipt.
137#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
138#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
139pub struct ScenarioMeta {
140    pub name: String,
141    pub weight: f64,
142
143    #[serde(skip_serializing_if = "Option::is_none", default)]
144    pub description: Option<String>,
145
146    #[serde(skip_serializing_if = "Option::is_none", default)]
147    pub command: Option<Vec<String>>,
148}
149
150/// A scenario component such as one benchmark, phase, or probe group.
151#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
152#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
153pub struct ScenarioComponent {
154    pub name: String,
155    pub weight: f64,
156
157    #[serde(skip_serializing_if = "Option::is_none", default)]
158    pub benchmark: Option<String>,
159
160    #[serde(skip_serializing_if = "Option::is_none", default)]
161    pub compare_ref: Option<CompareRef>,
162
163    #[serde(skip_serializing_if = "Option::is_none", default)]
164    pub probe_compare_ref: Option<CompareRef>,
165
166    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
167    pub deltas: BTreeMap<String, Delta>,
168
169    #[serde(default, skip_serializing_if = "Vec::is_empty")]
170    pub probes: Vec<String>,
171
172    pub status: MetricStatus,
173
174    #[serde(default, skip_serializing_if = "Vec::is_empty")]
175    pub reasons: Vec<String>,
176}
177
178/// A versioned receipt for weighted workload scenarios (`perfgate.scenario.v1`).
179#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
180#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
181pub struct ScenarioReceipt {
182    pub schema: String,
183    pub tool: ToolInfo,
184    pub run: RunMeta,
185    pub scenario: ScenarioMeta,
186
187    #[serde(skip_serializing_if = "Option::is_none", default)]
188    pub baseline_ref: Option<CompareRef>,
189
190    #[serde(skip_serializing_if = "Option::is_none", default)]
191    pub current_ref: Option<CompareRef>,
192
193    pub components: Vec<ScenarioComponent>,
194
195    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
196    pub weighted_deltas: BTreeMap<String, Delta>,
197
198    pub verdict: Verdict,
199
200    #[serde(default, skip_serializing_if = "Vec::is_empty")]
201    pub warnings: Vec<String>,
202}
203
204/// Outcome of a tradeoff policy decision.
205#[derive(Debug, Copy, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
206#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
207#[serde(rename_all = "snake_case")]
208pub enum TradeoffDecisionStatus {
209    Accepted,
210    Rejected,
211    NeedsReview,
212    NotEvaluated,
213}
214
215/// Evaluation result for one tradeoff requirement.
216#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
217#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
218pub struct TradeoffRequirementOutcome {
219    pub metric: String,
220
221    #[serde(skip_serializing_if = "Option::is_none", default)]
222    pub probe: Option<String>,
223
224    pub required_change: f64,
225
226    #[serde(skip_serializing_if = "Option::is_none", default)]
227    pub observed_change: Option<f64>,
228
229    pub satisfied: bool,
230    pub status: MetricStatus,
231
232    #[serde(skip_serializing_if = "Option::is_none", default)]
233    pub reason: Option<String>,
234}
235
236/// Evaluation result for one local regression allowance.
237#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
238#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
239pub struct TradeoffAllowanceOutcome {
240    pub metric: String,
241    pub probe: String,
242    pub max_regression: f64,
243
244    #[serde(skip_serializing_if = "Option::is_none", default)]
245    pub observed_regression: Option<f64>,
246
247    pub satisfied: bool,
248    pub status: MetricStatus,
249
250    #[serde(skip_serializing_if = "Option::is_none", default)]
251    pub reason: Option<String>,
252}
253
254/// Evaluation result for one named tradeoff rule.
255#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
256#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
257pub struct TradeoffRuleOutcome {
258    pub name: String,
259    pub status: TradeoffDecisionStatus,
260    pub accepted: bool,
261
262    #[serde(skip_serializing_if = "Option::is_none", default)]
263    pub downgrade_to: Option<TradeoffDowngrade>,
264
265    #[serde(skip_serializing_if = "Option::is_none", default)]
266    pub reason: Option<String>,
267
268    #[serde(default, skip_serializing_if = "Vec::is_empty")]
269    pub requirements: Vec<TradeoffRequirementOutcome>,
270
271    #[serde(default, skip_serializing_if = "Vec::is_empty")]
272    pub allowances: Vec<TradeoffAllowanceOutcome>,
273}
274
275/// Probe-level tradeoff evidence.
276#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
277#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
278pub struct TradeoffProbeOutcome {
279    pub name: String,
280
281    #[serde(skip_serializing_if = "Option::is_none", default)]
282    pub scope: Option<ProbeScope>,
283
284    #[serde(skip_serializing_if = "Option::is_none", default)]
285    pub weight: Option<f64>,
286
287    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
288    pub deltas: BTreeMap<String, Delta>,
289
290    pub status: MetricStatus,
291
292    #[serde(skip_serializing_if = "Option::is_none", default)]
293    pub reason: Option<String>,
294}
295
296/// Final structured tradeoff decision.
297#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
298#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
299pub struct TradeoffDecision {
300    pub accepted_tradeoff: bool,
301    #[serde(default)]
302    pub review_required: bool,
303
304    #[serde(default, skip_serializing_if = "Vec::is_empty")]
305    pub review_reasons: Vec<String>,
306
307    pub status: MetricStatus,
308    pub reason: String,
309}
310
311/// A versioned receipt explaining accepted or rejected tradeoffs (`perfgate.tradeoff.v1`).
312#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
313#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
314pub struct TradeoffReceipt {
315    pub schema: String,
316    pub tool: ToolInfo,
317    pub run: RunMeta,
318
319    #[serde(skip_serializing_if = "Option::is_none", default)]
320    pub scenario: Option<String>,
321
322    #[serde(skip_serializing_if = "Option::is_none", default)]
323    pub baseline_ref: Option<CompareRef>,
324
325    #[serde(skip_serializing_if = "Option::is_none", default)]
326    pub current_ref: Option<CompareRef>,
327
328    #[serde(default, skip_serializing_if = "Vec::is_empty")]
329    pub configured_rules: Vec<TradeoffRule>,
330
331    pub rules: Vec<TradeoffRuleOutcome>,
332
333    #[serde(default, skip_serializing_if = "Vec::is_empty")]
334    pub probes: Vec<TradeoffProbeOutcome>,
335
336    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
337    pub weighted_deltas: BTreeMap<String, Delta>,
338
339    pub decision: TradeoffDecision,
340    pub verdict: Verdict,
341
342    #[serde(default, skip_serializing_if = "Vec::is_empty")]
343    pub warnings: Vec<String>,
344}
345
346/// A manifest for the artifacts produced by `perfgate decision evaluate`.
347#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
348#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
349pub struct DecisionArtifactIndex {
350    pub schema: String,
351    pub scenario: String,
352    pub tradeoff: String,
353    pub decision: String,
354    pub probe_compares: Vec<String>,
355    pub compare_receipts: Vec<String>,
356}
357
358/// Metadata captured when exporting a portable decision bundle.
359#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
360pub struct DecisionBundleMetadata {
361    pub index_path: String,
362
363    #[serde(skip_serializing_if = "Option::is_none", default)]
364    pub git_ref: Option<String>,
365
366    #[serde(skip_serializing_if = "Option::is_none", default)]
367    pub git_sha: Option<String>,
368}
369
370/// Kind of artifact embedded in a portable decision bundle.
371#[derive(Debug, Copy, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
372#[serde(rename_all = "snake_case")]
373pub enum DecisionBundleArtifactKind {
374    DecisionIndex,
375    Scenario,
376    Tradeoff,
377    DecisionMarkdown,
378    ProbeCompare,
379    CompareReceipt,
380}
381
382/// Embedded artifact content.
383#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
384#[serde(tag = "type", rename_all = "snake_case")]
385pub enum DecisionBundleArtifactContent {
386    Json { value: Value },
387    Text { value: String },
388}
389
390/// One artifact embedded in a portable decision bundle.
391#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
392pub struct DecisionBundleArtifact {
393    pub path: String,
394    pub kind: DecisionBundleArtifactKind,
395    pub media_type: String,
396    pub sha256: String,
397
398    #[serde(skip_serializing_if = "Option::is_none", default)]
399    pub schema: Option<String>,
400
401    pub content: DecisionBundleArtifactContent,
402}
403
404/// A portable receipt bundle for structured performance decisions.
405#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
406pub struct DecisionBundleReceipt {
407    pub schema: String,
408    pub tool: ToolInfo,
409    pub run: RunMeta,
410    pub metadata: DecisionBundleMetadata,
411    pub index: DecisionArtifactIndex,
412    pub artifacts: Vec<DecisionBundleArtifact>,
413}
414
415#[cfg(test)]
416mod tests {
417    use super::*;
418    use crate::{
419        DECISION_BUNDLE_SCHEMA_V1, DECISION_INDEX_SCHEMA_V1, PROBE_COMPARE_SCHEMA_V1,
420        PROBE_SCHEMA_V1, SCENARIO_SCHEMA_V1, TRADEOFF_SCHEMA_V1, U64Summary, VerdictCounts,
421        VerdictStatus,
422    };
423
424    fn tool() -> ToolInfo {
425        ToolInfo {
426            name: "perfgate".into(),
427            version: "0.16.0".into(),
428        }
429    }
430
431    fn run() -> RunMeta {
432        RunMeta {
433            id: "run-1".into(),
434            started_at: "2026-05-08T00:00:00Z".into(),
435            ended_at: "2026-05-08T00:00:01Z".into(),
436            host: crate::HostInfo {
437                os: "linux".into(),
438                arch: "x86_64".into(),
439                cpu_count: None,
440                memory_bytes: None,
441                hostname_hash: None,
442            },
443        }
444    }
445
446    fn verdict() -> Verdict {
447        Verdict {
448            status: VerdictStatus::Pass,
449            counts: VerdictCounts {
450                pass: 1,
451                warn: 0,
452                fail: 0,
453                skip: 0,
454            },
455            reasons: Vec::new(),
456        }
457    }
458
459    fn wall_delta() -> Delta {
460        Delta {
461            baseline: 100.0,
462            current: 92.0,
463            ratio: 0.92,
464            pct: -0.08,
465            regression: 0.0,
466            cv: None,
467            noise_threshold: None,
468            statistic: crate::MetricStatistic::Median,
469            significance: None,
470            status: MetricStatus::Pass,
471        }
472    }
473
474    #[test]
475    fn probe_receipt_round_trips() {
476        let mut metrics = BTreeMap::new();
477        metrics.insert(
478            "wall_ms".into(),
479            ProbeMetricValue {
480                value: 12.4,
481                unit: Some("ms".into()),
482                statistic: None,
483            },
484        );
485
486        let receipt = ProbeReceipt {
487            schema: PROBE_SCHEMA_V1.into(),
488            tool: tool(),
489            run: run(),
490            bench: None,
491            scenario: Some("large_file_parse".into()),
492            probes: vec![ProbeObservation {
493                name: "parser.tokenize".into(),
494                parent: Some("request.total".into()),
495                scope: Some(ProbeScope::Local),
496                iteration: Some(1),
497                started_at: None,
498                ended_at: None,
499                items: Some(10_000),
500                metrics,
501                attributes: BTreeMap::new(),
502            }],
503            metadata: BTreeMap::new(),
504        };
505
506        let json = serde_json::to_string(&receipt).expect("serialize probe receipt");
507        let parsed: ProbeReceipt = serde_json::from_str(&json).expect("parse probe receipt");
508        assert_eq!(parsed.schema, PROBE_SCHEMA_V1);
509        assert_eq!(parsed.probes[0].name, "parser.tokenize");
510    }
511
512    #[test]
513    fn scenario_receipt_round_trips() {
514        let mut weighted_deltas = BTreeMap::new();
515        weighted_deltas.insert("wall_ms".into(), wall_delta());
516
517        let receipt = ScenarioReceipt {
518            schema: SCENARIO_SCHEMA_V1.into(),
519            tool: tool(),
520            run: run(),
521            scenario: ScenarioMeta {
522                name: "large_file_parse".into(),
523                weight: 0.4,
524                description: None,
525                command: Some(vec!["cargo".into(), "bench".into()]),
526            },
527            baseline_ref: None,
528            current_ref: None,
529            components: vec![ScenarioComponent {
530                name: "parser.batch_loop".into(),
531                weight: 1.0,
532                benchmark: Some("large-file".into()),
533                compare_ref: None,
534                probe_compare_ref: Some(CompareRef {
535                    path: Some("artifacts/perfgate/large-file/probe-compare.json".into()),
536                    run_id: Some("probe-current".into()),
537                }),
538                deltas: weighted_deltas.clone(),
539                probes: vec!["parser.tokenize".into()],
540                status: MetricStatus::Pass,
541                reasons: Vec::new(),
542            }],
543            weighted_deltas,
544            verdict: verdict(),
545            warnings: Vec::new(),
546        };
547
548        let json = serde_json::to_string(&receipt).expect("serialize scenario receipt");
549        let parsed: ScenarioReceipt = serde_json::from_str(&json).expect("parse scenario receipt");
550        assert_eq!(parsed.schema, SCENARIO_SCHEMA_V1);
551        assert_eq!(parsed.scenario.name, "large_file_parse");
552    }
553
554    #[test]
555    fn probe_compare_receipt_round_trips() {
556        let receipt = ProbeCompareReceipt {
557            schema: PROBE_COMPARE_SCHEMA_V1.into(),
558            tool: tool(),
559            run: run(),
560            bench: Some(crate::BenchMeta {
561                name: "parser".into(),
562                cwd: None,
563                command: vec!["cargo".into(), "bench".into()],
564                repeat: 2,
565                warmup: 0,
566                work_units: None,
567                timeout_ms: None,
568            }),
569            scenario: Some("large_file_parse".into()),
570            baseline_ref: Some(CompareRef {
571                path: Some("baselines/probes.json".into()),
572                run_id: Some("baseline-run".into()),
573            }),
574            current_ref: Some(CompareRef {
575                path: Some("artifacts/perfgate/probes.json".into()),
576                run_id: Some("current-run".into()),
577            }),
578            probes: vec![ProbeCompareObservation {
579                name: "parser.tokenize".into(),
580                parent: Some("parser.total".into()),
581                scope: Some(ProbeScope::Local),
582                baseline_count: 1,
583                current_count: 1,
584                deltas: BTreeMap::from([("wall_ms".into(), wall_delta())]),
585                status: MetricStatus::Pass,
586                reasons: Vec::new(),
587            }],
588            verdict: verdict(),
589            warnings: Vec::new(),
590        };
591
592        let json = serde_json::to_string(&receipt).expect("serialize probe compare receipt");
593        let parsed: ProbeCompareReceipt =
594            serde_json::from_str(&json).expect("parse probe compare receipt");
595        assert_eq!(parsed.schema, PROBE_COMPARE_SCHEMA_V1);
596        assert_eq!(parsed.probes[0].name, "parser.tokenize");
597    }
598
599    #[test]
600    fn tradeoff_receipt_round_trips() {
601        let receipt = TradeoffReceipt {
602            schema: TRADEOFF_SCHEMA_V1.into(),
603            tool: tool(),
604            run: run(),
605            scenario: Some("large_file_parse".into()),
606            baseline_ref: None,
607            current_ref: None,
608            configured_rules: Vec::new(),
609            rules: vec![TradeoffRuleOutcome {
610                name: "tokenizer-slower-if-parser-faster".into(),
611                status: TradeoffDecisionStatus::Accepted,
612                accepted: true,
613                downgrade_to: Some(TradeoffDowngrade::Pass),
614                reason: Some("dominant parser loop improved".into()),
615                requirements: vec![TradeoffRequirementOutcome {
616                    metric: "wall_ms".into(),
617                    probe: Some("parser.batch_loop".into()),
618                    required_change: -0.08,
619                    observed_change: Some(-0.104),
620                    satisfied: true,
621                    status: MetricStatus::Pass,
622                    reason: None,
623                }],
624                allowances: vec![TradeoffAllowanceOutcome {
625                    metric: "wall_ms".into(),
626                    probe: "parser.tokenize".into(),
627                    max_regression: 0.03,
628                    observed_regression: Some(0.021),
629                    satisfied: true,
630                    status: MetricStatus::Pass,
631                    reason: None,
632                }],
633            }],
634            probes: vec![TradeoffProbeOutcome {
635                name: "parser.tokenize".into(),
636                scope: Some(ProbeScope::Local),
637                weight: Some(0.2),
638                deltas: BTreeMap::from([("wall_ms".into(), wall_delta())]),
639                status: MetricStatus::Warn,
640                reason: Some("local slowdown".into()),
641            }],
642            weighted_deltas: BTreeMap::from([("wall_ms".into(), wall_delta())]),
643            decision: TradeoffDecision {
644                accepted_tradeoff: true,
645                review_required: false,
646                review_reasons: Vec::new(),
647                status: MetricStatus::Pass,
648                reason: "local slowdown offset by dominant-loop improvement".into(),
649            },
650            verdict: verdict(),
651            warnings: Vec::new(),
652        };
653
654        let json = serde_json::to_string(&receipt).expect("serialize tradeoff receipt");
655        let parsed: TradeoffReceipt = serde_json::from_str(&json).expect("parse tradeoff receipt");
656        assert_eq!(parsed.schema, TRADEOFF_SCHEMA_V1);
657        assert!(parsed.decision.accepted_tradeoff);
658    }
659
660    #[test]
661    fn decision_artifact_index_round_trips() {
662        let receipt = DecisionArtifactIndex {
663            schema: DECISION_INDEX_SCHEMA_V1.into(),
664            scenario: "artifacts/perfgate/scenario.json".into(),
665            tradeoff: "artifacts/perfgate/tradeoff.json".into(),
666            decision: "artifacts/perfgate/decision.md".into(),
667            probe_compares: vec!["artifacts/perfgate/large-file/probe-compare.json".into()],
668            compare_receipts: vec!["artifacts/perfgate/large-file/compare.json".into()],
669        };
670
671        let json = serde_json::to_string(&receipt).expect("serialize decision index");
672        let parsed: DecisionArtifactIndex =
673            serde_json::from_str(&json).expect("parse decision index");
674        assert_eq!(parsed.schema, DECISION_INDEX_SCHEMA_V1);
675        assert_eq!(parsed.scenario, "artifacts/perfgate/scenario.json");
676        assert_eq!(
677            parsed.probe_compares,
678            vec!["artifacts/perfgate/large-file/probe-compare.json"]
679        );
680    }
681
682    #[test]
683    fn decision_bundle_receipt_round_trips() {
684        let index = DecisionArtifactIndex {
685            schema: DECISION_INDEX_SCHEMA_V1.into(),
686            scenario: "artifacts/perfgate/scenario.json".into(),
687            tradeoff: "artifacts/perfgate/tradeoff.json".into(),
688            decision: "artifacts/perfgate/decision.md".into(),
689            probe_compares: vec!["artifacts/perfgate/large-file/probe-compare.json".into()],
690            compare_receipts: vec!["artifacts/perfgate/large-file/compare.json".into()],
691        };
692        let receipt = DecisionBundleReceipt {
693            schema: DECISION_BUNDLE_SCHEMA_V1.into(),
694            tool: tool(),
695            run: run(),
696            metadata: DecisionBundleMetadata {
697                index_path: "artifacts/perfgate/decision.index.json".into(),
698                git_ref: Some("main".into()),
699                git_sha: Some("abc123".into()),
700            },
701            index,
702            artifacts: vec![
703                DecisionBundleArtifact {
704                    path: "artifacts/perfgate/decision.index.json".into(),
705                    kind: DecisionBundleArtifactKind::DecisionIndex,
706                    media_type: "application/json".into(),
707                    sha256: "00".repeat(32),
708                    schema: Some(DECISION_INDEX_SCHEMA_V1.into()),
709                    content: DecisionBundleArtifactContent::Json {
710                        value: serde_json::json!({
711                            "schema": DECISION_INDEX_SCHEMA_V1,
712                            "scenario": "artifacts/perfgate/scenario.json",
713                            "tradeoff": "artifacts/perfgate/tradeoff.json",
714                            "decision": "artifacts/perfgate/decision.md",
715                            "probe_compares": [],
716                            "compare_receipts": []
717                        }),
718                    },
719                },
720                DecisionBundleArtifact {
721                    path: "artifacts/perfgate/decision.md".into(),
722                    kind: DecisionBundleArtifactKind::DecisionMarkdown,
723                    media_type: "text/markdown; charset=utf-8".into(),
724                    sha256: "11".repeat(32),
725                    schema: None,
726                    content: DecisionBundleArtifactContent::Text {
727                        value: "# Decision".into(),
728                    },
729                },
730            ],
731        };
732
733        let json = serde_json::to_string(&receipt).expect("serialize decision bundle");
734        let parsed: DecisionBundleReceipt =
735            serde_json::from_str(&json).expect("parse decision bundle");
736        assert_eq!(parsed.schema, DECISION_BUNDLE_SCHEMA_V1);
737        assert_eq!(parsed.artifacts.len(), 2);
738        assert_eq!(
739            parsed.artifacts[0].kind,
740            DecisionBundleArtifactKind::DecisionIndex
741        );
742    }
743
744    #[test]
745    fn minimal_probe_metric_can_represent_existing_summaries() {
746        let wall = U64Summary::new(12, 10, 14);
747        let value = ProbeMetricValue {
748            value: wall.median as f64,
749            unit: Some("ms".into()),
750            statistic: Some("median".into()),
751        };
752        assert_eq!(value.value, 12.0);
753    }
754}