1use crate::{F64Summary, RunMeta, Significance, ToolInfo, U64Summary};
4use schemars::JsonSchema;
5use serde::{Deserialize, Serialize};
6
7pub const PAIRED_SCHEMA_V1: &str = "perfgate.paired.v1";
8
9#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
10#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
11pub struct PairedBenchMeta {
12 pub name: String,
13 #[serde(skip_serializing_if = "Option::is_none")]
14 pub cwd: Option<String>,
15 pub baseline_command: Vec<String>,
16 pub current_command: Vec<String>,
17 pub repeat: u32,
18 pub warmup: u32,
19 #[serde(skip_serializing_if = "Option::is_none")]
20 pub work_units: Option<u64>,
21 #[serde(skip_serializing_if = "Option::is_none")]
22 pub timeout_ms: Option<u64>,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
26#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
27pub struct PairedSampleHalf {
28 pub wall_ms: u64,
29 pub exit_code: i32,
30 pub timed_out: bool,
31 #[serde(skip_serializing_if = "Option::is_none")]
32 pub max_rss_kb: Option<u64>,
33 #[serde(skip_serializing_if = "Option::is_none")]
34 pub stdout: Option<String>,
35 #[serde(skip_serializing_if = "Option::is_none")]
36 pub stderr: Option<String>,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
40#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
41pub struct PairedSample {
42 pub pair_index: u32,
43 #[serde(default)]
44 pub warmup: bool,
45 pub baseline: PairedSampleHalf,
46 pub current: PairedSampleHalf,
47 pub wall_diff_ms: i64,
48 #[serde(skip_serializing_if = "Option::is_none")]
49 pub rss_diff_kb: Option<i64>,
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
53#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
54pub struct PairedDiffSummary {
55 pub mean: f64,
56 pub median: f64,
57 pub std_dev: f64,
58 pub min: f64,
59 pub max: f64,
60 pub count: u32,
61 #[serde(skip_serializing_if = "Option::is_none")]
62 pub significance: Option<Significance>,
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
66#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
67pub struct PairedStats {
68 pub baseline_wall_ms: U64Summary,
69 pub current_wall_ms: U64Summary,
70 pub wall_diff_ms: PairedDiffSummary,
71 #[serde(skip_serializing_if = "Option::is_none")]
72 pub baseline_max_rss_kb: Option<U64Summary>,
73 #[serde(skip_serializing_if = "Option::is_none")]
74 pub current_max_rss_kb: Option<U64Summary>,
75 #[serde(skip_serializing_if = "Option::is_none")]
76 pub rss_diff_kb: Option<PairedDiffSummary>,
77 #[serde(skip_serializing_if = "Option::is_none")]
78 pub baseline_throughput_per_s: Option<F64Summary>,
79 #[serde(skip_serializing_if = "Option::is_none")]
80 pub current_throughput_per_s: Option<F64Summary>,
81 #[serde(skip_serializing_if = "Option::is_none")]
82 pub throughput_diff_per_s: Option<PairedDiffSummary>,
83}
84
85#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
86#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
87pub struct PairedRunReceipt {
88 pub schema: String,
89 pub tool: ToolInfo,
90 pub run: RunMeta,
91 pub bench: PairedBenchMeta,
92 pub samples: Vec<PairedSample>,
93 pub stats: PairedStats,
94}
95
96#[cfg(test)]
97mod tests {
98 use super::*;
99 use crate::{HostInfo, RunMeta, ToolInfo, U64Summary};
100
101 fn make_receipt() -> PairedRunReceipt {
102 PairedRunReceipt {
103 schema: PAIRED_SCHEMA_V1.to_string(),
104 tool: ToolInfo {
105 name: "perfgate".to_string(),
106 version: "0.1.0".to_string(),
107 },
108 run: RunMeta {
109 id: "run-id".to_string(),
110 started_at: "2024-01-01T00:00:00Z".to_string(),
111 ended_at: "2024-01-01T00:00:01Z".to_string(),
112 host: HostInfo {
113 os: "linux".to_string(),
114 arch: "x86_64".to_string(),
115 cpu_count: None,
116 memory_bytes: None,
117 hostname_hash: None,
118 },
119 },
120 bench: PairedBenchMeta {
121 name: "bench".to_string(),
122 cwd: None,
123 baseline_command: vec!["echo".to_string(), "baseline".to_string()],
124 current_command: vec!["echo".to_string(), "current".to_string()],
125 repeat: 2,
126 warmup: 0,
127 work_units: None,
128 timeout_ms: None,
129 },
130 samples: vec![PairedSample {
131 pair_index: 0,
132 warmup: false,
133 baseline: PairedSampleHalf {
134 wall_ms: 100,
135 exit_code: 0,
136 timed_out: false,
137 max_rss_kb: None,
138 stdout: None,
139 stderr: None,
140 },
141 current: PairedSampleHalf {
142 wall_ms: 110,
143 exit_code: 0,
144 timed_out: false,
145 max_rss_kb: None,
146 stdout: None,
147 stderr: None,
148 },
149 wall_diff_ms: 10,
150 rss_diff_kb: None,
151 }],
152 stats: PairedStats {
153 baseline_wall_ms: U64Summary::new(100, 100, 100),
154 current_wall_ms: U64Summary::new(110, 110, 110),
155 wall_diff_ms: PairedDiffSummary {
156 mean: 10.0,
157 median: 10.0,
158 std_dev: 0.0,
159 min: 10.0,
160 max: 10.0,
161 count: 1,
162 significance: None,
163 },
164 baseline_max_rss_kb: None,
165 current_max_rss_kb: None,
166 rss_diff_kb: None,
167 baseline_throughput_per_s: None,
168 current_throughput_per_s: None,
169 throughput_diff_per_s: None,
170 },
171 }
172 }
173
174 #[test]
175 fn paired_receipt_json_round_trip() {
176 let receipt = make_receipt();
177 let json = serde_json::to_string(&receipt).expect("serialize paired receipt");
178 let decoded: PairedRunReceipt =
179 serde_json::from_str(&json).expect("deserialize paired receipt");
180 assert_eq!(decoded.schema, PAIRED_SCHEMA_V1);
181 assert_eq!(decoded.bench.name, "bench");
182 assert_eq!(decoded.samples.len(), 1);
183 assert_eq!(decoded.stats.wall_diff_ms.count, 1);
184 }
185
186 #[test]
187 fn paired_receipt_omits_optional_fields_when_none() {
188 let receipt = make_receipt();
189 let json: serde_json::Value =
190 serde_json::from_str(&serde_json::to_string(&receipt).unwrap()).unwrap();
191
192 let bench = &json["bench"];
193 assert!(bench.get("cwd").is_none());
194 assert!(bench.get("work_units").is_none());
195 assert!(bench.get("timeout_ms").is_none());
196
197 let sample = &json["samples"][0];
198 assert!(sample["baseline"].get("max_rss_kb").is_none());
199 assert!(sample["baseline"].get("stdout").is_none());
200 assert!(sample["baseline"].get("stderr").is_none());
201 }
202}