Skip to main content

perfgate_app/
trend.rs

1//! Trend analysis use case.
2//!
3//! Analyzes metric history from a sequence of run receipts to detect drift
4//! and predict budget threshold breaches.
5
6use perfgate_domain::{
7    DriftClass, TrendAnalysis, TrendConfig, analyze_trend, metric_value, spark_chart,
8};
9use perfgate_types::{Direction, Metric, RunReceipt};
10
11/// Request for local trend analysis from run receipt files.
12#[derive(Debug, Clone)]
13pub struct TrendRequest {
14    /// Run receipts in chronological order.
15    pub history: Vec<RunReceipt>,
16    /// Budget threshold as a fraction (e.g., 0.20 for 20%).
17    /// Applied relative to the first run's metric value.
18    pub threshold: f64,
19    /// Specific metric to analyze (if None, analyze all available metrics).
20    pub metric: Option<Metric>,
21    /// Trend classification config.
22    pub config: TrendConfig,
23}
24
25/// Result of trend analysis.
26#[derive(Debug, Clone)]
27pub struct TrendOutcome {
28    /// Per-metric trend analyses.
29    pub analyses: Vec<TrendAnalysis>,
30    /// Benchmark name (from first receipt).
31    pub bench_name: String,
32    /// Number of runs analyzed.
33    pub run_count: usize,
34}
35
36/// Use case for trend analysis.
37pub struct TrendUseCase;
38
39impl TrendUseCase {
40    /// Execute trend analysis on a series of run receipts.
41    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            // Compute absolute threshold from the first run's value and the relative threshold
69            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
98/// Determine which metrics have data across the run history.
99fn 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            // A metric is available if at least 2 runs have data for it
118            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
127/// Format trend analysis results for terminal display.
128pub 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
171/// Format a mini ASCII chart line for a metric's history.
172pub 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        // Single run => not enough data points, so no analyses
304        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}