1use perfgate_domain::{
7 DriftClass, TrendAnalysis, TrendConfig, analyze_trend, metric_value, spark_chart,
8};
9use perfgate_types::{Direction, Metric, RunReceipt};
10
11#[derive(Debug, Clone)]
13pub struct TrendRequest {
14 pub history: Vec<RunReceipt>,
16 pub threshold: f64,
19 pub metric: Option<Metric>,
21 pub config: TrendConfig,
23}
24
25#[derive(Debug, Clone)]
27pub struct TrendOutcome {
28 pub analyses: Vec<TrendAnalysis>,
30 pub bench_name: String,
32 pub run_count: usize,
34}
35
36pub struct TrendUseCase;
38
39impl TrendUseCase {
40 pub fn execute(&self, request: TrendRequest) -> anyhow::Result<TrendOutcome> {
42 if request.history.is_empty() {
43 anyhow::bail!("no run receipts provided for trend analysis");
44 }
45
46 let bench_name = request.history[0].bench.name.clone();
47 let run_count = request.history.len();
48
49 let metrics_to_analyze = if let Some(m) = request.metric {
50 vec![m]
51 } else {
52 available_metrics(&request.history)
53 };
54
55 let mut analyses = Vec::new();
56
57 for metric in &metrics_to_analyze {
58 let values: Vec<f64> = request
59 .history
60 .iter()
61 .filter_map(|run| metric_value(&run.stats, *metric))
62 .collect();
63
64 if values.len() < 2 {
65 continue;
66 }
67
68 let baseline_value = values[0];
70 let direction = metric.default_direction();
71 let lower_is_better = direction == Direction::Lower;
72
73 let absolute_threshold = if lower_is_better {
74 baseline_value * (1.0 + request.threshold)
75 } else {
76 baseline_value * (1.0 - request.threshold)
77 };
78
79 if let Some(analysis) = analyze_trend(
80 &values,
81 metric.as_str(),
82 absolute_threshold,
83 lower_is_better,
84 &request.config,
85 ) {
86 analyses.push(analysis);
87 }
88 }
89
90 Ok(TrendOutcome {
91 analyses,
92 bench_name,
93 run_count,
94 })
95 }
96}
97
98fn available_metrics(runs: &[RunReceipt]) -> Vec<Metric> {
100 let all_metrics = [
101 Metric::WallMs,
102 Metric::CpuMs,
103 Metric::MaxRssKb,
104 Metric::PageFaults,
105 Metric::CtxSwitches,
106 Metric::IoReadBytes,
107 Metric::IoWriteBytes,
108 Metric::NetworkPackets,
109 Metric::EnergyUj,
110 Metric::BinaryBytes,
111 Metric::ThroughputPerS,
112 ];
113
114 all_metrics
115 .into_iter()
116 .filter(|m| {
117 let count = runs
119 .iter()
120 .filter(|r| metric_value(&r.stats, *m).is_some())
121 .count();
122 count >= 2
123 })
124 .collect()
125}
126
127pub fn format_trend_output(outcome: &TrendOutcome) -> String {
129 let mut out = String::new();
130
131 out.push_str(&format!(
132 "Trend Analysis: {} ({} runs)\n",
133 outcome.bench_name, outcome.run_count
134 ));
135 out.push_str(&"=".repeat(60));
136 out.push('\n');
137
138 if outcome.analyses.is_empty() {
139 out.push_str("No trend data available (need at least 2 data points per metric).\n");
140 return out;
141 }
142
143 for analysis in &outcome.analyses {
144 let icon = match analysis.drift {
145 DriftClass::Stable => "[OK]",
146 DriftClass::Improving => "[++]",
147 DriftClass::Degrading => "[!!]",
148 DriftClass::Critical => "[XX]",
149 };
150
151 out.push_str(&format!("\n{} {}\n", icon, analysis.metric));
152 out.push_str(&format!(" Drift: {}\n", analysis.drift));
153 out.push_str(&format!(
154 " Slope: {:+.4}/run\n",
155 analysis.slope_per_run
156 ));
157 out.push_str(&format!(" R-squared: {:.4}\n", analysis.r_squared));
158 out.push_str(&format!(
159 " Headroom: {:.1}%\n",
160 analysis.current_headroom_pct
161 ));
162
163 if let Some(runs) = analysis.runs_to_breach {
164 out.push_str(&format!(" Breach in: ~{} runs\n", runs));
165 }
166 }
167
168 out
169}
170
171pub fn format_trend_chart(values: &[f64], metric_name: &str) -> String {
173 let chart = spark_chart(values);
174 format!(" {} [{}]", metric_name, chart)
175}
176
177#[cfg(test)]
178mod tests {
179 use super::*;
180 use perfgate_types::{
181 BenchMeta, HostInfo, RunMeta, RunReceipt, Sample, Stats, ToolInfo, U64Summary,
182 };
183
184 fn make_run(name: &str, wall_median: u64) -> RunReceipt {
185 RunReceipt {
186 schema: "perfgate.run.v1".to_string(),
187 tool: ToolInfo {
188 name: "perfgate".to_string(),
189 version: "test".to_string(),
190 },
191 run: RunMeta {
192 id: format!("run-{}", wall_median),
193 started_at: "2024-01-01T00:00:00Z".to_string(),
194 ended_at: "2024-01-01T00:00:01Z".to_string(),
195 host: HostInfo {
196 os: "linux".to_string(),
197 arch: "x86_64".to_string(),
198 cpu_count: None,
199 memory_bytes: None,
200 hostname_hash: None,
201 },
202 },
203 bench: BenchMeta {
204 name: name.to_string(),
205 cwd: None,
206 command: vec!["echo".to_string()],
207 repeat: 5,
208 warmup: 0,
209 work_units: None,
210 timeout_ms: None,
211 },
212 samples: vec![Sample {
213 wall_ms: wall_median,
214 exit_code: 0,
215 warmup: false,
216 timed_out: false,
217 cpu_ms: None,
218 page_faults: None,
219 ctx_switches: None,
220 max_rss_kb: None,
221 io_read_bytes: None,
222 io_write_bytes: None,
223 network_packets: None,
224 energy_uj: None,
225 binary_bytes: None,
226 stdout: None,
227 stderr: None,
228 }],
229 stats: Stats {
230 wall_ms: U64Summary {
231 median: wall_median,
232 min: wall_median,
233 max: wall_median,
234 mean: Some(wall_median as f64),
235 stddev: Some(0.0),
236 },
237 cpu_ms: None,
238 page_faults: None,
239 ctx_switches: None,
240 max_rss_kb: None,
241 io_read_bytes: None,
242 io_write_bytes: None,
243 network_packets: None,
244 energy_uj: None,
245 binary_bytes: None,
246 throughput_per_s: None,
247 },
248 }
249 }
250
251 #[test]
252 fn trend_usecase_degrading() {
253 let history = vec![
254 make_run("bench-a", 100),
255 make_run("bench-a", 105),
256 make_run("bench-a", 110),
257 make_run("bench-a", 115),
258 make_run("bench-a", 120),
259 ];
260
261 let request = TrendRequest {
262 history,
263 threshold: 0.30,
264 metric: Some(Metric::WallMs),
265 config: TrendConfig::default(),
266 };
267
268 let outcome = TrendUseCase.execute(request).unwrap();
269 assert_eq!(outcome.bench_name, "bench-a");
270 assert_eq!(outcome.run_count, 5);
271 assert_eq!(outcome.analyses.len(), 1);
272
273 let a = &outcome.analyses[0];
274 assert_eq!(a.metric, "wall_ms");
275 assert!(matches!(
276 a.drift,
277 DriftClass::Degrading | DriftClass::Critical
278 ));
279 }
280
281 #[test]
282 fn trend_usecase_empty_history() {
283 let request = TrendRequest {
284 history: vec![],
285 threshold: 0.20,
286 metric: None,
287 config: TrendConfig::default(),
288 };
289
290 assert!(TrendUseCase.execute(request).is_err());
291 }
292
293 #[test]
294 fn trend_usecase_single_run() {
295 let request = TrendRequest {
296 history: vec![make_run("bench-a", 100)],
297 threshold: 0.20,
298 metric: Some(Metric::WallMs),
299 config: TrendConfig::default(),
300 };
301
302 let outcome = TrendUseCase.execute(request).unwrap();
303 assert!(outcome.analyses.is_empty());
305 }
306
307 #[test]
308 fn format_trend_output_basic() {
309 let outcome = TrendOutcome {
310 analyses: vec![TrendAnalysis {
311 metric: "wall_ms".to_string(),
312 slope_per_run: 2.5,
313 intercept: 100.0,
314 r_squared: 0.95,
315 drift: DriftClass::Degrading,
316 runs_to_breach: Some(8),
317 current_headroom_pct: 15.0,
318 sample_count: 5,
319 }],
320 bench_name: "my-bench".to_string(),
321 run_count: 5,
322 };
323
324 let text = format_trend_output(&outcome);
325 assert!(text.contains("my-bench"));
326 assert!(text.contains("5 runs"));
327 assert!(text.contains("wall_ms"));
328 assert!(text.contains("degrading"));
329 assert!(text.contains("~8 runs"));
330 }
331}