chronix_analytics/multivariate/
composite.rs1use std::collections::HashMap;
4use std::time::Instant;
5
6use crate::multivariate::context::MultiSeriesContext;
7
8use std::sync::mpsc;
11
12pub const DEFAULT_SIGNAL_CHANNEL_CAPACITY: usize = 1024;
14
15pub struct SignalSender {
21 tx: mpsc::SyncSender<CompositeSignal>,
22}
23
24pub struct SignalReceiver {
26 rx: mpsc::Receiver<CompositeSignal>,
27}
28
29pub fn signal_channel(capacity: usize) -> (SignalSender, SignalReceiver) {
35 let (tx, rx) = mpsc::sync_channel(capacity);
36 (SignalSender { tx }, SignalReceiver { rx })
37}
38
39impl SignalSender {
40 pub fn send(&self, signal: CompositeSignal) -> Result<(), mpsc::SendError<CompositeSignal>> {
44 self.tx.send(signal)
45 }
46
47 pub fn try_send(
51 &self,
52 signal: CompositeSignal,
53 ) -> Result<(), mpsc::TrySendError<CompositeSignal>> {
54 self.tx.try_send(signal)
55 }
56}
57
58impl SignalReceiver {
59 pub fn recv(&self) -> Result<CompositeSignal, mpsc::RecvError> {
61 self.rx.recv()
62 }
63
64 pub fn try_recv(&self) -> Result<CompositeSignal, mpsc::TryRecvError> {
66 self.rx.try_recv()
67 }
68
69 pub fn drain(&self) -> Vec<CompositeSignal> {
71 let mut signals = Vec::new();
72 while let Ok(sig) = self.rx.try_recv() {
73 signals.push(sig);
74 }
75 signals
76 }
77}
78
79#[derive(Debug, Default, Clone)]
81pub struct AnalyticsResults {
82 pub forecasts: std::collections::HashMap<String, Vec<f64>>,
84 pub anomaly_scores: std::collections::HashMap<String, Vec<f64>>,
86}
87
88#[derive(Debug, Clone)]
90pub struct CompositeSignal {
91 pub signal_type: String,
93 pub confidence: f64,
95 pub contributing_series: Vec<String>,
97 pub metadata: HashMap<String, String>,
99}
100
101pub struct CompositeSignalRule {
103 pub name: String,
105 pub signal_type: String,
107 pub contributing_series: Vec<String>,
109 pub cooldown_ns: i64,
111 #[allow(clippy::type_complexity)]
113 pub condition: Box<dyn Fn(&MultiSeriesContext, &AnalyticsResults) -> (bool, f64) + Send + Sync>,
114}
115
116pub struct CompositeSignalEngine {
118 last_fired: HashMap<String, Instant>,
120}
121
122impl CompositeSignalEngine {
123 pub fn new() -> Self {
125 Self {
126 last_fired: HashMap::new(),
127 }
128 }
129
130 pub fn evaluate(
132 &mut self,
133 rules: &[CompositeSignalRule],
134 ctx: &MultiSeriesContext,
135 analytics: &AnalyticsResults,
136 ) -> Vec<CompositeSignal> {
137 let now = Instant::now();
138 let mut signals = Vec::new();
139
140 for rule in rules {
141 if let Some(last) = self.last_fired.get(&rule.name) {
143 let elapsed_ns =
144 i64::try_from(now.duration_since(*last).as_nanos()).unwrap_or(i64::MAX);
145 if elapsed_ns < rule.cooldown_ns {
146 continue;
147 }
148 }
149
150 let (fire, confidence) = (rule.condition)(ctx, analytics);
151 if fire {
152 let mut metadata = HashMap::new();
154 metadata.insert("rule_name".to_string(), rule.name.clone());
155 metadata.insert("n_series".to_string(), ctx.matrix.n_series().to_string());
156 metadata.insert(
157 "n_timestamps".to_string(),
158 ctx.matrix.n_timestamps().to_string(),
159 );
160 metadata.insert("confidence".to_string(), format!("{confidence:.6}"));
161 if let Some(&first_ts) = ctx.matrix.timestamps.first() {
162 metadata.insert("window_start_ns".to_string(), first_ts.to_string());
163 }
164 if let Some(&last_ts) = ctx.matrix.timestamps.last() {
165 metadata.insert("window_end_ns".to_string(), last_ts.to_string());
166 }
167 for sid in &rule.contributing_series {
169 if let Some(idx) = ctx.matrix.series_ids.iter().position(|s| s == sid)
170 && let Some(&last_val) = ctx.matrix.data[idx].last()
171 {
172 metadata.insert(format!("latest_{sid}"), format!("{last_val:.6}"));
173 }
174 }
175
176 signals.push(CompositeSignal {
177 signal_type: rule.signal_type.clone(),
178 confidence,
179 contributing_series: rule.contributing_series.clone(),
180 metadata,
181 });
182 self.last_fired.insert(rule.name.clone(), now);
183 }
184 }
185 metrics::counter!("chronix_composite_signal_fired_total").increment(signals.len() as u64);
186 signals
187 }
188}
189
190impl Default for CompositeSignalEngine {
191 fn default() -> Self {
192 Self::new()
193 }
194}
195
196#[cfg(test)]
197mod tests {
198 use super::*;
199
200 fn test_ctx() -> MultiSeriesContext {
201 MultiSeriesContext::build(
202 vec![
203 (
204 "cpu".to_string(),
205 vec![0, 1_000_000_000, 2_000_000_000],
206 vec![90.0, 95.0, 98.0], ),
208 (
209 "memory".to_string(),
210 vec![0, 1_000_000_000, 2_000_000_000],
211 vec![85.0, 88.0, 92.0], ),
213 ],
214 None,
215 )
216 .unwrap()
217 }
218
219 #[test]
220 fn fires_when_conditions_met() {
221 let ctx = test_ctx();
222 let analytics = AnalyticsResults::default();
223 let rules = vec![CompositeSignalRule {
224 name: "high_load".to_string(),
225 signal_type: "critical".to_string(),
226 contributing_series: vec!["cpu".to_string(), "memory".to_string()],
227 cooldown_ns: 0,
228 condition: Box::new(|ctx, _analytics| {
229 let cpu = ctx.matrix.series_by_name("cpu").unwrap();
230 let mem = ctx.matrix.series_by_name("memory").unwrap();
231 let cpu_high = cpu.iter().any(|&v| v > 90.0);
232 let mem_high = mem.iter().any(|&v| v > 85.0);
233 (cpu_high && mem_high, 0.9)
234 }),
235 }];
236 let mut engine = CompositeSignalEngine::new();
237 let signals = engine.evaluate(&rules, &ctx, &analytics);
238 assert_eq!(signals.len(), 1);
239 assert_eq!(signals[0].signal_type, "critical");
240 assert!((signals[0].confidence - 0.9).abs() < 1e-6);
241 }
242
243 #[test]
244 fn does_not_fire_when_partial() {
245 let ctx = MultiSeriesContext::build(
246 vec![
247 (
248 "cpu".to_string(),
249 vec![0, 1_000_000_000],
250 vec![50.0, 55.0], ),
252 (
253 "memory".to_string(),
254 vec![0, 1_000_000_000],
255 vec![90.0, 95.0], ),
257 ],
258 None,
259 )
260 .unwrap();
261 let analytics = AnalyticsResults::default();
262 let rules = vec![CompositeSignalRule {
263 name: "high_load".to_string(),
264 signal_type: "critical".to_string(),
265 contributing_series: vec!["cpu".to_string(), "memory".to_string()],
266 cooldown_ns: 0,
267 condition: Box::new(|ctx, _analytics| {
268 let cpu = ctx.matrix.series_by_name("cpu").unwrap();
269 let mem = ctx.matrix.series_by_name("memory").unwrap();
270 let cpu_high = cpu.iter().any(|&v| v > 90.0);
271 let mem_high = mem.iter().any(|&v| v > 85.0);
272 (cpu_high && mem_high, 0.9)
273 }),
274 }];
275 let mut engine = CompositeSignalEngine::new();
276 let signals = engine.evaluate(&rules, &ctx, &analytics);
277 assert!(signals.is_empty());
278 }
279
280 #[test]
281 fn cooldown_suppresses_duplicate() {
282 let ctx = test_ctx();
283 let analytics = AnalyticsResults::default();
284 let rules = vec![CompositeSignalRule {
285 name: "always_fire".to_string(),
286 signal_type: "info".to_string(),
287 contributing_series: vec![],
288 cooldown_ns: 60_000_000_000, condition: Box::new(|_, _| (true, 1.0)),
290 }];
291 let mut engine = CompositeSignalEngine::new();
292 let first = engine.evaluate(&rules, &ctx, &analytics);
293 assert_eq!(first.len(), 1);
294 let second = engine.evaluate(&rules, &ctx, &analytics);
296 assert!(second.is_empty());
297 }
298
299 #[test]
300 fn analytics_results_condition() {
301 let ctx = test_ctx();
302 let mut analytics = AnalyticsResults::default();
303 analytics
304 .anomaly_scores
305 .insert("cpu".to_string(), vec![0.1, 0.2, 0.95]);
306 let rules = vec![CompositeSignalRule {
307 name: "anomaly_alert".to_string(),
308 signal_type: "anomaly".to_string(),
309 contributing_series: vec!["cpu".to_string()],
310 cooldown_ns: 0,
311 condition: Box::new(|_ctx, analytics| {
312 if let Some(scores) = analytics.anomaly_scores.get("cpu") {
313 let max_score = scores.iter().copied().fold(f64::NEG_INFINITY, f64::max);
314 (max_score > 0.9, max_score)
315 } else {
316 (false, 0.0)
317 }
318 }),
319 }];
320 let mut engine = CompositeSignalEngine::new();
321 let signals = engine.evaluate(&rules, &ctx, &analytics);
322 assert_eq!(signals.len(), 1);
323 assert_eq!(signals[0].signal_type, "anomaly");
324 assert!((signals[0].confidence - 0.95).abs() < 1e-6);
325 }
326
327 #[test]
328 fn signal_delivery_channel() {
329 let (sender, receiver) = signal_channel(DEFAULT_SIGNAL_CHANNEL_CAPACITY);
330
331 let signal = CompositeSignal {
332 signal_type: "test_rule".to_string(),
333 confidence: 0.95,
334 contributing_series: vec!["cpu".to_string()],
335 metadata: HashMap::new(),
336 };
337
338 sender.send(signal).unwrap();
339
340 let received = receiver.recv().unwrap();
341 assert_eq!(received.signal_type, "test_rule");
342 assert!((received.confidence - 0.95).abs() < 1e-10);
343 }
344
345 #[test]
346 fn bounded_channel_backpressure() {
347 let (sender, receiver) = signal_channel(2);
349
350 let make_signal = |i: usize| CompositeSignal {
351 signal_type: format!("sig_{i}"),
352 confidence: 0.5,
353 contributing_series: vec![],
354 metadata: HashMap::new(),
355 };
356
357 sender.send(make_signal(0)).unwrap();
359 sender.send(make_signal(1)).unwrap();
360
361 let result = sender.try_send(make_signal(2));
363 assert!(result.is_err(), "Channel should be full at capacity=2");
364
365 let _ = receiver.recv().unwrap();
367 sender.try_send(make_signal(2)).unwrap();
368
369 let drained = receiver.drain();
371 assert_eq!(drained.len(), 2);
372 assert_eq!(drained[0].signal_type, "sig_1");
373 assert_eq!(drained[1].signal_type, "sig_2");
374 }
375}