1use serde::{Deserialize, Serialize};
8
9use crate::quality::QualityReport;
10
11#[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#[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#[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#[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
55const DEFAULT_MIN_SAMPLES: u64 = 10;
60
61pub 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
101fn 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 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
129fn 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
139fn 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#[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 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 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 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}