Skip to main content

edda_aggregate/
controls.rs

1//! Generic threshold-based controls suggestion engine.
2//!
3//! Evaluates a set of [`ThresholdRule`]s against a [`QualityReport`] and
4//! produces [`ControlsSuggestion`]s when metrics breach their thresholds.
5//! This module is pure computation (no I/O, no Karvi dependency).
6
7use serde::{Deserialize, Serialize};
8
9use crate::quality::QualityReport;
10
11// ── Types ──
12
13/// Which metric a threshold rule evaluates.
14#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
15#[serde(rename_all = "snake_case")]
16pub enum MetricKind {
17    SuccessRate,
18    AvgCostUsd,
19    AvgLatencyMs,
20    TotalCostUsd,
21}
22
23/// Comparison operator for threshold evaluation.
24#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
25#[serde(rename_all = "snake_case")]
26pub enum ThresholdOp {
27    Lt,
28    Gt,
29    Lte,
30    Gte,
31}
32
33/// A threshold rule: when a metric breaches the threshold, suggest an action.
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct ThresholdRule {
36    pub name: String,
37    pub metric: MetricKind,
38    pub operator: ThresholdOp,
39    pub threshold: f64,
40    pub action: String,
41    pub reason_template: String,
42}
43
44/// A suggestion produced by evaluating a rule against a quality report.
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct ControlsSuggestion {
47    pub rule_name: String,
48    pub metric_name: String,
49    pub current_value: f64,
50    pub threshold: f64,
51    pub action: String,
52    pub reason: String,
53}
54
55// ── Evaluation ──
56
57/// Minimum number of total steps required before rules fire.
58/// Prevents knee-jerk reactions on insufficient data.
59const DEFAULT_MIN_SAMPLES: u64 = 10;
60
61/// Evaluate threshold rules against a quality report.
62///
63/// Returns suggestions for every rule whose metric breaches its threshold.
64/// If the report has fewer than `min_samples` total steps, returns empty.
65pub fn evaluate_controls_rules(
66    rules: &[ThresholdRule],
67    report: &QualityReport,
68    min_samples: Option<u64>,
69) -> Vec<ControlsSuggestion> {
70    let min = min_samples.unwrap_or(DEFAULT_MIN_SAMPLES);
71    if report.total_steps < min {
72        return Vec::new();
73    }
74
75    let mut suggestions = Vec::new();
76
77    for rule in rules {
78        let value = extract_metric(report, &rule.metric);
79        if breaches(value, &rule.operator, rule.threshold) {
80            let reason = rule
81                .reason_template
82                .replace("{value}", &format_metric_value(value, &rule.metric))
83                .replace(
84                    "{threshold}",
85                    &format_metric_value(rule.threshold, &rule.metric),
86                );
87            suggestions.push(ControlsSuggestion {
88                rule_name: rule.name.clone(),
89                metric_name: format!("{:?}", rule.metric),
90                current_value: value,
91                threshold: rule.threshold,
92                action: rule.action.clone(),
93                reason,
94            });
95        }
96    }
97
98    suggestions
99}
100
101/// Extract the overall metric value from a quality report.
102fn extract_metric(report: &QualityReport, kind: &MetricKind) -> f64 {
103    match kind {
104        MetricKind::SuccessRate => report.overall_success_rate,
105        MetricKind::TotalCostUsd => report.total_cost_usd,
106        MetricKind::AvgCostUsd => {
107            if report.total_steps > 0 {
108                report.total_cost_usd / report.total_steps as f64
109            } else {
110                0.0
111            }
112        }
113        MetricKind::AvgLatencyMs => {
114            // Average across all models, weighted by step count.
115            let total_latency: f64 = report
116                .models
117                .iter()
118                .map(|m| m.avg_latency_ms * m.total_steps as f64)
119                .sum();
120            if report.total_steps > 0 {
121                total_latency / report.total_steps as f64
122            } else {
123                0.0
124            }
125        }
126    }
127}
128
129/// Check whether `value` breaches the threshold according to `op`.
130fn breaches(value: f64, op: &ThresholdOp, threshold: f64) -> bool {
131    match op {
132        ThresholdOp::Lt => value < threshold,
133        ThresholdOp::Gt => value > threshold,
134        ThresholdOp::Lte => value <= threshold,
135        ThresholdOp::Gte => value >= threshold,
136    }
137}
138
139/// Format a metric value for display in reason strings.
140fn format_metric_value(value: f64, kind: &MetricKind) -> String {
141    match kind {
142        MetricKind::SuccessRate => format!("{:.0}%", value * 100.0),
143        MetricKind::AvgCostUsd | MetricKind::TotalCostUsd => format!("${:.2}", value),
144        MetricKind::AvgLatencyMs => format!("{:.0}ms", value),
145    }
146}
147
148// ── Tests ──
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153    use crate::quality::{ModelQuality, QualityReport};
154
155    fn make_report(success_rate: f64, total_steps: u64, cost: f64, latency: f64) -> QualityReport {
156        QualityReport {
157            models: vec![ModelQuality {
158                model: "test-model".to_string(),
159                runtime: "test-runtime".to_string(),
160                total_steps,
161                success_count: (total_steps as f64 * success_rate) as u64,
162                failed_count: total_steps - (total_steps as f64 * success_rate) as u64,
163                cancelled_count: 0,
164                success_rate,
165                avg_cost_usd: if total_steps > 0 {
166                    cost / total_steps as f64
167                } else {
168                    0.0
169                },
170                avg_latency_ms: latency,
171                total_cost_usd: cost,
172                total_tokens_in: 0,
173                total_tokens_out: 0,
174            }],
175            total_steps,
176            overall_success_rate: success_rate,
177            total_cost_usd: cost,
178        }
179    }
180
181    fn sample_rules() -> Vec<ThresholdRule> {
182        vec![
183            ThresholdRule {
184                name: "low-success".to_string(),
185                metric: MetricKind::SuccessRate,
186                operator: ThresholdOp::Lt,
187                threshold: 0.60,
188                action: "disable_auto_dispatch".to_string(),
189                reason_template: "Success rate {value} below {threshold} threshold".to_string(),
190            },
191            ThresholdRule {
192                name: "high-cost".to_string(),
193                metric: MetricKind::AvgCostUsd,
194                operator: ThresholdOp::Gt,
195                threshold: 0.50,
196                action: "reduce_concurrency".to_string(),
197                reason_template: "Average cost {value} exceeds {threshold}".to_string(),
198            },
199            ThresholdRule {
200                name: "high-latency".to_string(),
201                metric: MetricKind::AvgLatencyMs,
202                operator: ThresholdOp::Gt,
203                threshold: 30000.0,
204                action: "flag_slow_model".to_string(),
205                reason_template: "Average latency {value} exceeds {threshold}".to_string(),
206            },
207        ]
208    }
209
210    #[test]
211    fn empty_report_no_suggestions() {
212        let report = make_report(0.0, 0, 0.0, 0.0);
213        let suggestions = evaluate_controls_rules(&sample_rules(), &report, None);
214        assert!(suggestions.is_empty());
215    }
216
217    #[test]
218    fn min_samples_guard_skips_low_count() {
219        let report = make_report(0.30, 5, 1.0, 1000.0);
220        let suggestions = evaluate_controls_rules(&sample_rules(), &report, None);
221        assert!(suggestions.is_empty(), "Should skip with only 5 samples");
222    }
223
224    #[test]
225    fn min_samples_custom_threshold() {
226        let report = make_report(0.30, 5, 1.0, 1000.0);
227        let suggestions = evaluate_controls_rules(&sample_rules(), &report, Some(3));
228        assert!(
229            !suggestions.is_empty(),
230            "Should fire with custom min_samples=3"
231        );
232    }
233
234    #[test]
235    fn success_rate_below_threshold_triggers() {
236        let report = make_report(0.40, 20, 2.0, 1000.0);
237        let suggestions = evaluate_controls_rules(&sample_rules(), &report, None);
238        let names: Vec<&str> = suggestions.iter().map(|s| s.rule_name.as_str()).collect();
239        assert!(names.contains(&"low-success"));
240        let s = suggestions
241            .iter()
242            .find(|s| s.rule_name == "low-success")
243            .unwrap();
244        assert_eq!(s.action, "disable_auto_dispatch");
245        assert!((s.current_value - 0.40).abs() < 1e-9);
246    }
247
248    #[test]
249    fn success_rate_above_threshold_no_trigger() {
250        let report = make_report(0.80, 20, 2.0, 1000.0);
251        let suggestions = evaluate_controls_rules(&sample_rules(), &report, None);
252        let names: Vec<&str> = suggestions.iter().map(|s| s.rule_name.as_str()).collect();
253        assert!(!names.contains(&"low-success"));
254    }
255
256    #[test]
257    fn high_cost_triggers() {
258        // 20 steps, total cost $15 -> avg $0.75 > $0.50 threshold
259        let report = make_report(0.80, 20, 15.0, 1000.0);
260        let suggestions = evaluate_controls_rules(&sample_rules(), &report, None);
261        let names: Vec<&str> = suggestions.iter().map(|s| s.rule_name.as_str()).collect();
262        assert!(names.contains(&"high-cost"));
263    }
264
265    #[test]
266    fn high_latency_triggers() {
267        let report = make_report(0.80, 20, 2.0, 35000.0);
268        let suggestions = evaluate_controls_rules(&sample_rules(), &report, None);
269        let names: Vec<&str> = suggestions.iter().map(|s| s.rule_name.as_str()).collect();
270        assert!(names.contains(&"high-latency"));
271    }
272
273    #[test]
274    fn multiple_rules_can_trigger() {
275        // Low success + high cost + high latency
276        let report = make_report(0.30, 20, 15.0, 35000.0);
277        let suggestions = evaluate_controls_rules(&sample_rules(), &report, None);
278        assert_eq!(suggestions.len(), 3);
279    }
280
281    #[test]
282    fn no_rules_no_suggestions() {
283        let report = make_report(0.30, 20, 15.0, 35000.0);
284        let suggestions = evaluate_controls_rules(&[], &report, None);
285        assert!(suggestions.is_empty());
286    }
287
288    #[test]
289    fn reason_template_formatting() {
290        let report = make_report(0.40, 20, 2.0, 1000.0);
291        let suggestions = evaluate_controls_rules(&sample_rules(), &report, None);
292        let s = suggestions
293            .iter()
294            .find(|s| s.rule_name == "low-success")
295            .unwrap();
296        assert!(
297            s.reason.contains("40%"),
298            "Reason should contain formatted percentage: {}",
299            s.reason
300        );
301        assert!(
302            s.reason.contains("60%"),
303            "Reason should contain threshold: {}",
304            s.reason
305        );
306    }
307
308    #[test]
309    fn lte_and_gte_operators() {
310        let rules = vec![
311            ThresholdRule {
312                name: "exact-lte".to_string(),
313                metric: MetricKind::SuccessRate,
314                operator: ThresholdOp::Lte,
315                threshold: 0.50,
316                action: "test".to_string(),
317                reason_template: "test".to_string(),
318            },
319            ThresholdRule {
320                name: "exact-gte".to_string(),
321                metric: MetricKind::TotalCostUsd,
322                operator: ThresholdOp::Gte,
323                threshold: 5.0,
324                action: "test".to_string(),
325                reason_template: "test".to_string(),
326            },
327        ];
328
329        // success_rate exactly 0.50 should trigger Lte 0.50
330        // total_cost exactly 5.0 should trigger Gte 5.0
331        let report = make_report(0.50, 20, 5.0, 1000.0);
332        let suggestions = evaluate_controls_rules(&rules, &report, None);
333        assert_eq!(suggestions.len(), 2);
334    }
335}