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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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}