Skip to main content

converge_optimization/packs/anomaly_triage/
solver.rs

1//! Solver for Anomaly Triage pack
2
3use super::types::*;
4use converge_pack::PackSolver;
5use converge_pack::gate::GateResult as Result;
6use converge_pack::gate::{ProblemSpec, ReplayEnvelope, SolverReport, StopReason};
7
8/// Threshold-based solver for anomaly triage
9///
10/// Algorithm:
11/// 1. Classify each anomaly by z-score threshold
12/// 2. Sort by severity then by z-score (most severe first)
13/// 3. Apply escalation policies
14/// 4. Assign priorities
15pub struct ThresholdSolver;
16
17impl ThresholdSolver {
18    /// Solve the anomaly triage problem
19    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        // Classify and sort anomalies
34        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        // Sort by severity (critical > high > medium > low) then by z-score descending
44        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        // Apply tie-breaking for equal severity and z-score
61        let _tie_break = &spec.determinism.tie_break;
62
63        // Group by (severity, z-score) for tie-breaking
64        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        // Build triaged output
87        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            // Update summary
102            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 // Deterministic threshold-based classification
179    }
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        // Critical should be first
244        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        // High should be second
249        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        // Only critical should be escalated (based on policy)
262        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}