Skip to main content

chronix_analytics/multivariate/
composite.rs

1//! Composite signal engine for multi-condition alerting.
2
3use std::collections::HashMap;
4use std::time::Instant;
5
6use crate::multivariate::context::MultiSeriesContext;
7
8// ── Signal delivery channel ─────────────────────────────────────────────
9
10use std::sync::mpsc;
11
12/// Default channel capacity for signal delivery.
13pub const DEFAULT_SIGNAL_CHANNEL_CAPACITY: usize = 1024;
14
15/// A channel-based signal delivery layer.
16///
17/// Composite signals are sent through this bounded channel to downstream
18/// consumers (alerting, logging, webhook triggers, etc.). The channel
19/// applies back-pressure when full, preventing unbounded memory growth.
20pub struct SignalSender {
21    tx: mpsc::SyncSender<CompositeSignal>,
22}
23
24/// Receiver end of the signal delivery channel.
25pub struct SignalReceiver {
26    rx: mpsc::Receiver<CompositeSignal>,
27}
28
29/// Creates a new bounded signal delivery channel (sender + receiver).
30///
31/// The `capacity` parameter controls the maximum number of unprocessed
32/// signals that can be buffered. Use [`DEFAULT_SIGNAL_CHANNEL_CAPACITY`]
33/// for a reasonable default. Senders will block when the channel is full.
34pub 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    /// Sends a composite signal to the delivery layer.
41    ///
42    /// Blocks if the channel is full (back-pressure).
43    pub fn send(&self, signal: CompositeSignal) -> Result<(), mpsc::SendError<CompositeSignal>> {
44        self.tx.send(signal)
45    }
46
47    /// Tries to send a signal without blocking.
48    ///
49    /// Returns `TrySendError::Full` if the channel is at capacity.
50    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    /// Receives the next signal (blocking).
60    pub fn recv(&self) -> Result<CompositeSignal, mpsc::RecvError> {
61        self.rx.recv()
62    }
63
64    /// Tries to receive a signal without blocking.
65    pub fn try_recv(&self) -> Result<CompositeSignal, mpsc::TryRecvError> {
66        self.rx.try_recv()
67    }
68
69    /// Drains all pending signals into a Vec.
70    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/// Results from upstream analytics (forecasts, anomaly scores) passed to composite signal conditions.
80#[derive(Debug, Default, Clone)]
81pub struct AnalyticsResults {
82    /// Per-series forecast values (series_id → predicted values).
83    pub forecasts: std::collections::HashMap<String, Vec<f64>>,
84    /// Per-series anomaly scores (series_id → scores).
85    pub anomaly_scores: std::collections::HashMap<String, Vec<f64>>,
86}
87
88/// A composite signal emitted when conditions are met.
89#[derive(Debug, Clone)]
90pub struct CompositeSignal {
91    /// Signal type identifier.
92    pub signal_type: String,
93    /// Confidence 0.0 − 1.0.
94    pub confidence: f64,
95    /// Which series contributed to this signal.
96    pub contributing_series: Vec<String>,
97    /// Arbitrary metadata.
98    pub metadata: HashMap<String, String>,
99}
100
101/// A rule that evaluates against a multi-series context.
102pub struct CompositeSignalRule {
103    /// Unique name of this rule.
104    pub name: String,
105    /// Signal type emitted when the rule fires (e.g. "critical").
106    pub signal_type: String,
107    /// Series that contribute to this composite signal.
108    pub contributing_series: Vec<String>,
109    /// Cooldown between firings (nanoseconds).
110    pub cooldown_ns: i64,
111    /// Condition closure: receives context reference and analytics results, returns (should_fire, confidence).
112    #[allow(clippy::type_complexity)]
113    pub condition: Box<dyn Fn(&MultiSeriesContext, &AnalyticsResults) -> (bool, f64) + Send + Sync>,
114}
115
116/// Engine that evaluates composite signal rules.
117pub struct CompositeSignalEngine {
118    /// Tracks last fire time per rule name.
119    last_fired: HashMap<String, Instant>,
120}
121
122impl CompositeSignalEngine {
123    /// Creates a new engine with no cooldown state.
124    pub fn new() -> Self {
125        Self {
126            last_fired: HashMap::new(),
127        }
128    }
129
130    /// Evaluate all rules against the context.
131    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            // Check cooldown
142            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                // Populate metadata with contextual information
153                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                // Include per-contributing-series latest values
168                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], // high CPU
207                ),
208                (
209                    "memory".to_string(),
210                    vec![0, 1_000_000_000, 2_000_000_000],
211                    vec![85.0, 88.0, 92.0], // high memory
212                ),
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], // low CPU
251                ),
252                (
253                    "memory".to_string(),
254                    vec![0, 1_000_000_000],
255                    vec![90.0, 95.0], // high memory
256                ),
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, // 60s cooldown
289            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        // Immediately evaluate again — should be suppressed
295        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        // Create a channel with capacity=2
348        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        // Fill the channel
358        sender.send(make_signal(0)).unwrap();
359        sender.send(make_signal(1)).unwrap();
360
361        // Third send should fail with try_send (channel full)
362        let result = sender.try_send(make_signal(2));
363        assert!(result.is_err(), "Channel should be full at capacity=2");
364
365        // Drain one and try again
366        let _ = receiver.recv().unwrap();
367        sender.try_send(make_signal(2)).unwrap();
368
369        // Drain all
370        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}