Skip to main content

converge_optimization/packs/anomaly_triage/
mod.rs

1//! Anomaly Triage Pack
2//!
3//! JTBD: "Triages anomalies with stable thresholds and escalation rules."
4//!
5//! ## Problem
6//!
7//! Given:
8//! - Detected anomalies with features (z-scores)
9//! - Severity thresholds
10//! - Escalation policies
11//!
12//! Find:
13//! - Prioritized triage list with escalation recommendations
14//!
15//! ## Solver
16//!
17//! Uses threshold-based classification:
18//! 1. Classify each anomaly by z-score threshold
19//! 2. Sort by severity then by z-score
20//! 3. Apply escalation policies
21//! 4. Assign sequential priorities
22
23mod invariants;
24mod solver;
25mod types;
26
27pub use invariants::*;
28pub use solver::*;
29pub use types::*;
30
31use crate::packs::{InvariantDef, InvariantResult, Pack, PackSolveResult, default_gate_evaluation};
32use converge_pack::CONFIDENCE_STEP_MAJOR;
33use converge_pack::gate::GateResult as Result;
34use converge_pack::gate::{KernelTraceLink, ProblemSpec, PromotionGate, ProposedPlan};
35
36/// Anomaly Triage Pack
37pub struct AnomalyTriagePack;
38
39impl Pack for AnomalyTriagePack {
40    fn name(&self) -> &'static str {
41        "anomaly-triage"
42    }
43
44    fn version(&self) -> &'static str {
45        "1.0.0"
46    }
47
48    fn validate_inputs(&self, inputs: &serde_json::Value) -> Result<()> {
49        let input: AnomalyTriageInput = serde_json::from_value(inputs.clone()).map_err(|e| {
50            converge_pack::GateError::invalid_input(format!("Invalid input: {}", e))
51        })?;
52        input.validate()
53    }
54
55    fn invariants(&self) -> &[InvariantDef] {
56        INVARIANTS
57    }
58
59    fn solve(&self, spec: &ProblemSpec) -> Result<PackSolveResult> {
60        let input: AnomalyTriageInput = spec.inputs_as()?;
61        input.validate()?;
62
63        let solver = ThresholdSolver;
64        let (output, report) = solver.solve_triage(&input, spec)?;
65
66        let trace = KernelTraceLink::audit_only(format!("trace-{}", spec.problem_id));
67        let confidence = calculate_confidence(&output);
68
69        let plan = ProposedPlan::from_payload(
70            format!("plan-{}", spec.problem_id),
71            self.name(),
72            output.summary(),
73            &output,
74            confidence,
75            trace,
76        )?;
77
78        Ok(PackSolveResult::new(plan, report))
79    }
80
81    fn check_invariants(&self, plan: &ProposedPlan) -> Result<Vec<InvariantResult>> {
82        let output: AnomalyTriageOutput = plan.plan_as()?;
83        Ok(check_all_invariants(&output))
84    }
85
86    fn evaluate_gate(
87        &self,
88        _plan: &ProposedPlan,
89        invariant_results: &[InvariantResult],
90    ) -> PromotionGate {
91        default_gate_evaluation(invariant_results, self.invariants())
92    }
93}
94
95fn calculate_confidence(output: &AnomalyTriageOutput) -> f64 {
96    if output.triaged.is_empty() {
97        return 0.5; // Empty is valid but low confidence
98    }
99
100    let mut confidence: f64 = 0.6;
101
102    // Higher confidence if all anomalies have recommendations
103    let all_have_recommendations = output
104        .triaged
105        .iter()
106        .all(|t| !t.recommended_actions.is_empty());
107    if all_have_recommendations {
108        confidence += CONFIDENCE_STEP_MAJOR;
109    }
110
111    // Higher confidence if critical items are escalated
112    let critical_escalated = output
113        .triaged
114        .iter()
115        .filter(|t| t.severity == "critical")
116        .all(|t| t.escalate);
117    if critical_escalated {
118        confidence += CONFIDENCE_STEP_MAJOR;
119    }
120
121    confidence.min(1.0)
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127    use converge_pack::gate::ObjectiveSpec;
128
129    fn create_test_input() -> AnomalyTriageInput {
130        AnomalyTriageInput {
131            anomalies: vec![
132                Anomaly {
133                    id: "a1".to_string(),
134                    timestamp: 1700000000,
135                    source: "api-server".to_string(),
136                    z_score: 5.2,
137                    features: serde_json::json!({}),
138                },
139                Anomaly {
140                    id: "a2".to_string(),
141                    timestamp: 1700000010,
142                    source: "database".to_string(),
143                    z_score: 2.5,
144                    features: serde_json::json!({}),
145                },
146            ],
147            thresholds: SeverityThresholds::default(),
148            escalation_policies: vec![EscalationPolicy {
149                severity_level: "critical".to_string(),
150                auto_escalate: true,
151                notify_channels: vec!["pagerduty".to_string()],
152                response_sla_minutes: 15,
153            }],
154        }
155    }
156
157    #[test]
158    fn test_pack_name() {
159        let pack = AnomalyTriagePack;
160        assert_eq!(pack.name(), "anomaly-triage");
161        assert_eq!(pack.version(), "1.0.0");
162    }
163
164    #[test]
165    fn test_validate_inputs() {
166        let pack = AnomalyTriagePack;
167        let input = create_test_input();
168        let json = serde_json::to_value(&input).unwrap();
169        assert!(pack.validate_inputs(&json).is_ok());
170    }
171
172    #[test]
173    fn test_solve_basic() {
174        let pack = AnomalyTriagePack;
175        let input = create_test_input();
176
177        let spec = ProblemSpec::builder("test-001", "test-tenant")
178            .objective(ObjectiveSpec::minimize("risk"))
179            .inputs(&input)
180            .unwrap()
181            .seed(42)
182            .build()
183            .unwrap();
184
185        let result = pack.solve(&spec).unwrap();
186        assert!(result.is_feasible());
187
188        let output: AnomalyTriageOutput = result.plan.plan_as().unwrap();
189        assert_eq!(output.triaged.len(), 2);
190    }
191
192    #[test]
193    fn test_check_invariants() {
194        let pack = AnomalyTriagePack;
195        let input = create_test_input();
196
197        let spec = ProblemSpec::builder("test-002", "test-tenant")
198            .objective(ObjectiveSpec::minimize("risk"))
199            .inputs(&input)
200            .unwrap()
201            .seed(42)
202            .build()
203            .unwrap();
204
205        let result = pack.solve(&spec).unwrap();
206        let invariants = pack.check_invariants(&result.plan).unwrap();
207
208        let all_pass = invariants.iter().all(|r| r.passed);
209        assert!(all_pass);
210    }
211
212    #[test]
213    fn test_gate_promotes() {
214        let pack = AnomalyTriagePack;
215        let input = create_test_input();
216
217        let spec = ProblemSpec::builder("test-003", "test-tenant")
218            .objective(ObjectiveSpec::minimize("risk"))
219            .inputs(&input)
220            .unwrap()
221            .seed(42)
222            .build()
223            .unwrap();
224
225        let result = pack.solve(&spec).unwrap();
226        let invariants = pack.check_invariants(&result.plan).unwrap();
227        let gate = pack.evaluate_gate(&result.plan, &invariants);
228
229        assert!(gate.is_promoted());
230    }
231
232    #[test]
233    fn test_determinism() {
234        let pack = AnomalyTriagePack;
235        let input = create_test_input();
236
237        let spec1 = ProblemSpec::builder("test-a", "tenant")
238            .objective(ObjectiveSpec::minimize("risk"))
239            .inputs(&input)
240            .unwrap()
241            .seed(99999)
242            .build()
243            .unwrap();
244
245        let spec2 = ProblemSpec::builder("test-b", "tenant")
246            .objective(ObjectiveSpec::minimize("risk"))
247            .inputs(&input)
248            .unwrap()
249            .seed(99999)
250            .build()
251            .unwrap();
252
253        let result1 = pack.solve(&spec1).unwrap();
254        let result2 = pack.solve(&spec2).unwrap();
255
256        let output1: AnomalyTriageOutput = result1.plan.plan_as().unwrap();
257        let output2: AnomalyTriageOutput = result2.plan.plan_as().unwrap();
258
259        for (a, b) in output1.triaged.iter().zip(output2.triaged.iter()) {
260            assert_eq!(a.anomaly_id, b.anomaly_id);
261            assert_eq!(a.priority, b.priority);
262        }
263    }
264}