converge_optimization/packs/anomaly_triage/
solver.rs1use super::types::*;
4use converge_pack::PackSolver;
5use converge_pack::gate::GateResult as Result;
6use converge_pack::gate::{ProblemSpec, ReplayEnvelope, SolverReport, StopReason};
7
8pub struct ThresholdSolver;
16
17impl ThresholdSolver {
18 pub fn solve_triage(
20 &self,
21 input: &AnomalyTriageInput,
22 spec: &ProblemSpec,
23 ) -> Result<(AnomalyTriageOutput, SolverReport)> {
24 let seed = spec.seed();
25
26 if input.anomalies.is_empty() {
27 let output = AnomalyTriageOutput::empty();
28 let replay = ReplayEnvelope::minimal(seed);
29 let report = SolverReport::feasible("threshold-v1", 0.0, StopReason::Feasible, replay);
30 return Ok((output, report));
31 }
32
33 let mut classified: Vec<_> = input
35 .anomalies
36 .iter()
37 .map(|a| {
38 let severity = a.classify_severity(&input.thresholds);
39 (a, severity, a.z_score.abs())
40 })
41 .collect();
42
43 classified.sort_by(|a, b| {
45 let severity_order = |s: &str| match s {
46 "critical" => 0,
47 "high" => 1,
48 "medium" => 2,
49 _ => 3,
50 };
51
52 let ord = severity_order(a.1).cmp(&severity_order(b.1));
53 if ord == std::cmp::Ordering::Equal {
54 b.2.partial_cmp(&a.2).unwrap_or(std::cmp::Ordering::Equal)
55 } else {
56 ord
57 }
58 });
59
60 let _tie_break = &spec.determinism.tie_break;
62
63 let mut final_order: Vec<(&Anomaly, &str, f64)> = Vec::new();
65 let mut current_key = (String::new(), f64::NEG_INFINITY);
66 let mut group: Vec<(&Anomaly, &str, f64)> = vec![];
67
68 for (anomaly, severity, z) in classified {
69 let key = (severity.to_string(), z);
70 if key.0 == current_key.0 && (key.1 - current_key.1).abs() < 0.01 {
71 group.push((anomaly, severity, z));
72 } else {
73 if !group.is_empty() {
74 group.sort_by(|a, b| a.0.id.cmp(&b.0.id));
75 final_order.extend(group.drain(..));
76 }
77 group = vec![(anomaly, severity, z)];
78 current_key = key;
79 }
80 }
81 if !group.is_empty() {
82 group.sort_by(|a, b| a.0.id.cmp(&b.0.id));
83 final_order.extend(group.drain(..));
84 }
85
86 let mut triaged = Vec::new();
88 let mut escalation_count = 0;
89 let mut summary = SeveritySummary::default();
90
91 for (priority, (anomaly, severity, z)) in final_order.into_iter().enumerate() {
92 let policy = input.get_policy(severity);
93 let escalate = policy
94 .map(|p| p.auto_escalate)
95 .unwrap_or(severity == "critical");
96
97 if escalate {
98 escalation_count += 1;
99 }
100
101 match severity {
103 "critical" => summary.critical += 1,
104 "high" => summary.high += 1,
105 "medium" => summary.medium += 1,
106 _ => summary.low += 1,
107 }
108
109 let recommended_actions = self.recommend_actions(severity, &policy);
110
111 triaged.push(TriagedAnomaly {
112 anomaly_id: anomaly.id.clone(),
113 severity: severity.to_string(),
114 priority: priority + 1,
115 escalate,
116 reason: format!("z-score {:.2} exceeds {} threshold", z, severity),
117 recommended_actions,
118 });
119 }
120
121 let output = AnomalyTriageOutput {
122 triaged,
123 escalation_count,
124 severity_summary: summary,
125 };
126
127 let replay = ReplayEnvelope::minimal(seed);
128 let report = SolverReport::optimal("threshold-v1", output.escalation_count as f64, replay);
129
130 Ok((output, report))
131 }
132
133 fn recommend_actions(&self, severity: &str, policy: &Option<&EscalationPolicy>) -> Vec<String> {
134 let mut actions = Vec::new();
135
136 match severity {
137 "critical" => {
138 actions.push("Immediate investigation required".to_string());
139 actions.push("Page on-call engineer".to_string());
140 }
141 "high" => {
142 actions.push("Investigate within 1 hour".to_string());
143 actions.push("Create incident ticket".to_string());
144 }
145 "medium" => {
146 actions.push("Review within 4 hours".to_string());
147 actions.push("Add to monitoring queue".to_string());
148 }
149 _ => {
150 actions.push("Log for trend analysis".to_string());
151 }
152 }
153
154 if let Some(p) = policy {
155 if !p.notify_channels.is_empty() {
156 actions.push(format!("Notify: {}", p.notify_channels.join(", ")));
157 }
158 }
159
160 actions
161 }
162}
163
164impl PackSolver for ThresholdSolver {
165 fn id(&self) -> &'static str {
166 "threshold-v1"
167 }
168
169 fn solve(&self, spec: &ProblemSpec) -> Result<(serde_json::Value, SolverReport)> {
170 let input: AnomalyTriageInput = spec.inputs_as()?;
171 let (output, report) = self.solve_triage(&input, spec)?;
172 let json = serde_json::to_value(&output)
173 .map_err(|e| converge_pack::GateError::invalid_input(e.to_string()))?;
174 Ok((json, report))
175 }
176
177 fn is_exact(&self) -> bool {
178 true }
180}
181
182#[cfg(test)]
183mod tests {
184 use super::*;
185 use converge_pack::gate::ObjectiveSpec;
186
187 fn create_test_input() -> AnomalyTriageInput {
188 AnomalyTriageInput {
189 anomalies: vec![
190 Anomaly {
191 id: "a1".to_string(),
192 timestamp: 1700000000,
193 source: "api-server".to_string(),
194 z_score: 5.2,
195 features: serde_json::json!({"metric": "latency"}),
196 },
197 Anomaly {
198 id: "a2".to_string(),
199 timestamp: 1700000010,
200 source: "database".to_string(),
201 z_score: 3.1,
202 features: serde_json::json!({"metric": "connections"}),
203 },
204 Anomaly {
205 id: "a3".to_string(),
206 timestamp: 1700000020,
207 source: "cache".to_string(),
208 z_score: 1.5,
209 features: serde_json::json!({"metric": "hit_rate"}),
210 },
211 ],
212 thresholds: SeverityThresholds::default(),
213 escalation_policies: vec![EscalationPolicy {
214 severity_level: "critical".to_string(),
215 auto_escalate: true,
216 notify_channels: vec!["pagerduty".to_string(), "slack-oncall".to_string()],
217 response_sla_minutes: 15,
218 }],
219 }
220 }
221
222 fn create_spec(input: &AnomalyTriageInput, seed: u64) -> ProblemSpec {
223 ProblemSpec::builder("test", "tenant")
224 .objective(ObjectiveSpec::minimize("risk"))
225 .inputs(input)
226 .unwrap()
227 .seed(seed)
228 .build()
229 .unwrap()
230 }
231
232 #[test]
233 fn test_severity_ordering() {
234 let solver = ThresholdSolver;
235 let input = create_test_input();
236 let spec = create_spec(&input, 42);
237
238 let (output, report) = solver.solve_triage(&input, &spec).unwrap();
239
240 assert!(report.feasible);
241 assert_eq!(output.triaged.len(), 3);
242
243 assert_eq!(output.triaged[0].anomaly_id, "a1");
245 assert_eq!(output.triaged[0].severity, "critical");
246 assert_eq!(output.triaged[0].priority, 1);
247
248 assert_eq!(output.triaged[1].anomaly_id, "a2");
250 assert_eq!(output.triaged[1].severity, "high");
251 }
252
253 #[test]
254 fn test_escalation() {
255 let solver = ThresholdSolver;
256 let input = create_test_input();
257 let spec = create_spec(&input, 42);
258
259 let (output, _) = solver.solve_triage(&input, &spec).unwrap();
260
261 assert_eq!(output.escalation_count, 1);
263 assert!(output.triaged[0].escalate);
264 }
265
266 #[test]
267 fn test_severity_summary() {
268 let solver = ThresholdSolver;
269 let input = create_test_input();
270 let spec = create_spec(&input, 42);
271
272 let (output, _) = solver.solve_triage(&input, &spec).unwrap();
273
274 assert_eq!(output.severity_summary.critical, 1);
275 assert_eq!(output.severity_summary.high, 1);
276 assert_eq!(output.severity_summary.low, 1);
277 }
278
279 #[test]
280 fn test_empty_anomalies() {
281 let solver = ThresholdSolver;
282 let input = AnomalyTriageInput {
283 anomalies: vec![],
284 thresholds: SeverityThresholds::default(),
285 escalation_policies: vec![],
286 };
287
288 let spec = create_spec(&input, 42);
289 let (output, report) = solver.solve_triage(&input, &spec).unwrap();
290
291 assert!(output.triaged.is_empty());
292 assert!(report.feasible);
293 }
294
295 #[test]
296 fn test_determinism() {
297 let solver = ThresholdSolver;
298 let input = create_test_input();
299
300 let spec1 = create_spec(&input, 12345);
301 let spec2 = create_spec(&input, 12345);
302
303 let (output1, _) = solver.solve_triage(&input, &spec1).unwrap();
304 let (output2, _) = solver.solve_triage(&input, &spec2).unwrap();
305
306 for (a, b) in output1.triaged.iter().zip(output2.triaged.iter()) {
307 assert_eq!(a.anomaly_id, b.anomaly_id);
308 assert_eq!(a.priority, b.priority);
309 }
310 }
311}