Skip to main content

rsigma_eval/correlation/
window.rs

1use std::collections::{HashMap, HashSet, VecDeque};
2
3use rsigma_parser::{ConditionExpr, CorrelationType, WindowMode};
4use serde::Serialize;
5
6use super::CompiledCondition;
7
8// =============================================================================
9// Window State
10// =============================================================================
11
12/// Per-group mutable state within a time window.
13///
14/// Each variant matches the type of aggregation being performed.
15#[derive(Debug, Clone, Serialize, serde::Deserialize)]
16pub enum WindowState {
17    /// For `event_count`: timestamps of matching events.
18    EventCount { timestamps: VecDeque<i64> },
19    /// For `value_count`: (timestamp, field_value) pairs.
20    ValueCount { entries: VecDeque<(i64, String)> },
21    /// For `temporal` / `temporal_ordered`: rule_ref -> list of hit timestamps.
22    Temporal {
23        rule_hits: HashMap<String, VecDeque<i64>>,
24    },
25    /// For `value_sum`, `value_avg`, `value_percentile`, `value_median`:
26    /// (timestamp, numeric_value) pairs.
27    NumericAgg { entries: VecDeque<(i64, f64)> },
28}
29
30impl WindowState {
31    /// Create a new empty window state for the given correlation type.
32    pub fn new_for(corr_type: CorrelationType) -> Self {
33        match corr_type {
34            CorrelationType::EventCount => WindowState::EventCount {
35                timestamps: VecDeque::new(),
36            },
37            CorrelationType::ValueCount => WindowState::ValueCount {
38                entries: VecDeque::new(),
39            },
40            CorrelationType::Temporal | CorrelationType::TemporalOrdered => WindowState::Temporal {
41                rule_hits: HashMap::new(),
42            },
43            CorrelationType::ValueSum
44            | CorrelationType::ValueAvg
45            | CorrelationType::ValuePercentile
46            | CorrelationType::ValueMedian => WindowState::NumericAgg {
47                entries: VecDeque::new(),
48            },
49        }
50    }
51
52    /// Remove all entries older than the cutoff timestamp.
53    pub fn evict(&mut self, cutoff: i64) {
54        match self {
55            WindowState::EventCount { timestamps } => {
56                while timestamps.front().is_some_and(|&t| t < cutoff) {
57                    timestamps.pop_front();
58                }
59            }
60            WindowState::ValueCount { entries } => {
61                while entries.front().is_some_and(|(t, _)| *t < cutoff) {
62                    entries.pop_front();
63                }
64            }
65            WindowState::Temporal { rule_hits } => {
66                for timestamps in rule_hits.values_mut() {
67                    while timestamps.front().is_some_and(|&t| t < cutoff) {
68                        timestamps.pop_front();
69                    }
70                }
71                // Remove empty rule entries
72                rule_hits.retain(|_, ts| !ts.is_empty());
73            }
74            WindowState::NumericAgg { entries } => {
75                while entries.front().is_some_and(|(t, _)| *t < cutoff) {
76                    entries.pop_front();
77                }
78            }
79        }
80    }
81
82    /// Returns true if this state has no entries.
83    pub fn is_empty(&self) -> bool {
84        match self {
85            WindowState::EventCount { timestamps } => timestamps.is_empty(),
86            WindowState::ValueCount { entries } => entries.is_empty(),
87            WindowState::Temporal { rule_hits } => rule_hits.is_empty(),
88            WindowState::NumericAgg { entries } => entries.is_empty(),
89        }
90    }
91
92    /// Returns the most recent timestamp in this window, or `None` if empty.
93    pub fn latest_timestamp(&self) -> Option<i64> {
94        match self {
95            WindowState::EventCount { timestamps } => timestamps.back().copied(),
96            WindowState::ValueCount { entries } => entries.back().map(|(t, _)| *t),
97            WindowState::Temporal { rule_hits } => {
98                rule_hits.values().filter_map(|ts| ts.back().copied()).max()
99            }
100            WindowState::NumericAgg { entries } => entries.back().map(|(t, _)| *t),
101        }
102    }
103
104    /// Returns the oldest timestamp in this window, or `None` if empty.
105    ///
106    /// Entries are appended in arrival order, so the front of each deque holds
107    /// the earliest timestamp. For temporal state the minimum is taken across
108    /// all per-rule deques.
109    pub fn earliest_timestamp(&self) -> Option<i64> {
110        match self {
111            WindowState::EventCount { timestamps } => timestamps.front().copied(),
112            WindowState::ValueCount { entries } => entries.front().map(|(t, _)| *t),
113            WindowState::Temporal { rule_hits } => rule_hits
114                .values()
115                .filter_map(|ts| ts.front().copied())
116                .min(),
117            WindowState::NumericAgg { entries } => entries.front().map(|(t, _)| *t),
118        }
119    }
120
121    /// Enforce a per-group entry cap by dropping the oldest entries.
122    ///
123    /// `cap` is the maximum number of retained entries (clamped to at least
124    /// 1). For temporal state the cap applies to each referenced rule's hit
125    /// deque independently, since the deques are what grow with event rate.
126    ///
127    /// When `preserve_front` is set (session windows), the oldest entry is
128    /// kept and truncation happens behind it: the front entry anchors the
129    /// session's total-span cap (`timespan`), so dropping it would silently
130    /// let the session extend past the cap. Truncation can only under-count;
131    /// it never extends a window.
132    pub fn truncate_oldest(&mut self, cap: usize, preserve_front: bool) {
133        let cap = cap.max(1);
134
135        fn truncate_deque<T>(deque: &mut VecDeque<T>, cap: usize, preserve_front: bool) {
136            if deque.len() <= cap {
137                return;
138            }
139            if preserve_front {
140                let anchor = deque.pop_front().expect("len > cap >= 1");
141                // Keep the anchor plus the newest cap - 1 entries.
142                let excess = deque.len() - (cap - 1);
143                deque.drain(..excess);
144                deque.push_front(anchor);
145            } else {
146                let excess = deque.len() - cap;
147                deque.drain(..excess);
148            }
149        }
150
151        match self {
152            WindowState::EventCount { timestamps } => {
153                truncate_deque(timestamps, cap, preserve_front);
154            }
155            WindowState::ValueCount { entries } => {
156                truncate_deque(entries, cap, preserve_front);
157            }
158            WindowState::Temporal { rule_hits } => {
159                for timestamps in rule_hits.values_mut() {
160                    truncate_deque(timestamps, cap, preserve_front);
161                }
162            }
163            WindowState::NumericAgg { entries } => {
164                truncate_deque(entries, cap, preserve_front);
165            }
166        }
167    }
168
169    /// Clear all entries from the window state (used by `CorrelationAction::Reset`).
170    pub fn clear(&mut self) {
171        match self {
172            WindowState::EventCount { timestamps } => timestamps.clear(),
173            WindowState::ValueCount { entries } => entries.clear(),
174            WindowState::Temporal { rule_hits } => rule_hits.clear(),
175            WindowState::NumericAgg { entries } => entries.clear(),
176        }
177    }
178
179    /// Record an event_count hit.
180    pub fn push_event_count(&mut self, ts: i64) {
181        if let WindowState::EventCount { timestamps } = self {
182            timestamps.push_back(ts);
183        }
184    }
185
186    /// Record a value_count hit with the field value.
187    pub fn push_value_count(&mut self, ts: i64, value: String) {
188        if let WindowState::ValueCount { entries } = self {
189            entries.push_back((ts, value));
190        }
191    }
192
193    /// Record a temporal hit for a specific rule reference.
194    pub fn push_temporal(&mut self, ts: i64, rule_ref: &str) {
195        if let WindowState::Temporal { rule_hits } = self {
196            rule_hits
197                .entry(rule_ref.to_string())
198                .or_default()
199                .push_back(ts);
200        }
201    }
202
203    /// Record a numeric aggregation value.
204    pub fn push_numeric(&mut self, ts: i64, value: f64) {
205        if let WindowState::NumericAgg { entries } = self {
206            entries.push_back((ts, value));
207        }
208    }
209
210    /// Evaluate the window state against the correlation condition.
211    ///
212    /// Returns `Some(aggregated_value)` if the condition is satisfied,
213    /// `None` otherwise.
214    ///
215    /// For temporal correlations with an extended expression, the expression
216    /// is evaluated against the set of rules that have fired in the window.
217    pub fn check_condition(
218        &self,
219        condition: &CompiledCondition,
220        corr_type: CorrelationType,
221        rule_refs: &[String],
222        extended_expr: Option<&ConditionExpr>,
223    ) -> Option<f64> {
224        let value = match (self, corr_type) {
225            (WindowState::EventCount { timestamps }, CorrelationType::EventCount) => {
226                timestamps.len() as f64
227            }
228            (WindowState::ValueCount { entries }, CorrelationType::ValueCount) => {
229                // Count distinct values
230                let distinct: HashSet<&String> = entries.iter().map(|(_, v)| v).collect();
231                distinct.len() as f64
232            }
233            (WindowState::Temporal { rule_hits }, CorrelationType::Temporal) => {
234                // If an extended expression is provided, evaluate it
235                if let Some(expr) = extended_expr {
236                    if eval_temporal_expr(expr, rule_hits) {
237                        return Some(count_fired_rules(rule_refs, rule_hits) as f64);
238                    } else {
239                        return None;
240                    }
241                }
242                count_fired_rules(rule_refs, rule_hits) as f64
243            }
244            (WindowState::Temporal { rule_hits }, CorrelationType::TemporalOrdered) => {
245                // If an extended expression is provided, evaluate it first
246                if let Some(expr) = extended_expr
247                    && !eval_temporal_expr(expr, rule_hits)
248                {
249                    return None;
250                }
251                // Check if all referenced rules fired in order
252                if check_temporal_ordered(rule_refs, rule_hits) {
253                    count_distinct_rules(rule_refs) as f64
254                } else {
255                    0.0
256                }
257            }
258            (WindowState::NumericAgg { entries }, CorrelationType::ValueSum) => {
259                entries.iter().map(|(_, v)| v).sum()
260            }
261            (WindowState::NumericAgg { entries }, CorrelationType::ValueAvg) => {
262                if entries.is_empty() {
263                    0.0
264                } else {
265                    let sum: f64 = entries.iter().map(|(_, v)| v).sum();
266                    sum / entries.len() as f64
267                }
268            }
269            (WindowState::NumericAgg { entries }, CorrelationType::ValuePercentile) => {
270                // Proper percentile calculation using linear interpolation.
271                // The condition threshold represents a percentile rank (0-100).
272                // We compute the value at that percentile from the window data.
273                if entries.is_empty() {
274                    return None;
275                }
276                let mut values: Vec<f64> = entries
277                    .iter()
278                    .map(|(_, v)| *v)
279                    .filter(|v| v.is_finite())
280                    .collect();
281                if values.is_empty() {
282                    return None;
283                }
284                values.sort_by(|a, b| a.total_cmp(b));
285                let percentile_rank = condition.percentile.map(|p| p as f64).unwrap_or(50.0);
286                let pval = percentile_linear_interp(&values, percentile_rank);
287                return Some(pval);
288            }
289            (WindowState::NumericAgg { entries }, CorrelationType::ValueMedian) => {
290                // An empty window has no median. Returning `0.0` here would
291                // spuriously satisfy predicates like `lte: 0` or `eq: 0`, so
292                // match the percentile branch and skip evaluation.
293                if entries.is_empty() {
294                    return None;
295                }
296                let mut values: Vec<f64> = entries
297                    .iter()
298                    .map(|(_, v)| *v)
299                    .filter(|v| v.is_finite())
300                    .collect();
301                if values.is_empty() {
302                    return None;
303                }
304                values.sort_by(|a, b| a.total_cmp(b));
305                let mid = values.len() / 2;
306                if values.len().is_multiple_of(2) && values.len() >= 2 {
307                    (values[mid - 1] + values[mid]) / 2.0
308                } else {
309                    values[mid]
310                }
311            }
312            _ => return None, // mismatched state/type
313        };
314
315        if condition.check(value) {
316            Some(value)
317        } else {
318            None
319        }
320    }
321}
322
323impl WindowState {
324    /// Number of retained entries in this window. For temporal state this is
325    /// the total across all per-rule hit deques.
326    pub fn entry_count(&self) -> usize {
327        match self {
328            WindowState::EventCount { timestamps } => timestamps.len(),
329            WindowState::ValueCount { entries } => entries.len(),
330            WindowState::Temporal { rule_hits } => rule_hits.values().map(VecDeque::len).sum(),
331            WindowState::NumericAgg { entries } => entries.len(),
332        }
333    }
334
335    /// Compute the current aggregate value for this window *without* applying
336    /// the correlation's threshold condition.
337    ///
338    /// Unlike [`check_condition`](WindowState::check_condition), which returns
339    /// `None` when the threshold is not met, this returns the raw "got" value
340    /// the introspection surface reports so an operator can see the gap to the
341    /// threshold. Returns `None` only when no value is defined (an empty
342    /// percentile/median window, or a state/type mismatch).
343    pub fn current_value(
344        &self,
345        corr_type: CorrelationType,
346        rule_refs: &[String],
347        percentile: Option<u64>,
348    ) -> Option<f64> {
349        match (self, corr_type) {
350            (WindowState::EventCount { timestamps }, CorrelationType::EventCount) => {
351                Some(timestamps.len() as f64)
352            }
353            (WindowState::ValueCount { entries }, CorrelationType::ValueCount) => {
354                let distinct: HashSet<&String> = entries.iter().map(|(_, v)| v).collect();
355                Some(distinct.len() as f64)
356            }
357            (
358                WindowState::Temporal { rule_hits },
359                CorrelationType::Temporal | CorrelationType::TemporalOrdered,
360            ) => Some(count_fired_rules(rule_refs, rule_hits) as f64),
361            (WindowState::NumericAgg { entries }, CorrelationType::ValueSum) => {
362                Some(entries.iter().map(|(_, v)| v).sum())
363            }
364            (WindowState::NumericAgg { entries }, CorrelationType::ValueAvg) => {
365                if entries.is_empty() {
366                    Some(0.0)
367                } else {
368                    let sum: f64 = entries.iter().map(|(_, v)| v).sum();
369                    Some(sum / entries.len() as f64)
370                }
371            }
372            (WindowState::NumericAgg { entries }, CorrelationType::ValuePercentile) => {
373                let mut values: Vec<f64> = entries
374                    .iter()
375                    .map(|(_, v)| *v)
376                    .filter(|v| v.is_finite())
377                    .collect();
378                if values.is_empty() {
379                    return None;
380                }
381                values.sort_by(|a, b| a.total_cmp(b));
382                let rank = percentile.map(|p| p as f64).unwrap_or(50.0);
383                Some(percentile_linear_interp(&values, rank))
384            }
385            (WindowState::NumericAgg { entries }, CorrelationType::ValueMedian) => {
386                let mut values: Vec<f64> = entries
387                    .iter()
388                    .map(|(_, v)| *v)
389                    .filter(|v| v.is_finite())
390                    .collect();
391                if values.is_empty() {
392                    return None;
393                }
394                values.sort_by(|a, b| a.total_cmp(b));
395                let mid = values.len() / 2;
396                let v = if values.len().is_multiple_of(2) && values.len() >= 2 {
397                    (values[mid - 1] + values[mid]) / 2.0
398                } else {
399                    values[mid]
400                };
401                Some(v)
402            }
403            _ => None,
404        }
405    }
406}
407
408/// Start of the tumbling bucket that contains `ts`, aligned to epoch.
409///
410/// Uses `rem_euclid` so negative timestamps align to the bucket below them
411/// rather than toward zero.
412fn bucket_start(ts: i64, timespan: i64) -> i64 {
413    ts - ts.rem_euclid(timespan)
414}
415
416/// Outcome of a window's pre-insert maintenance for a new event.
417#[derive(Debug, Clone, Copy, PartialEq, Eq)]
418pub enum WindowDecision {
419    /// The current window continues; push the event into it.
420    Extend,
421    /// The window rolled over and the state was cleared; push the event into
422    /// the fresh window. Callers must also clear any associated event buffers
423    /// so they stay in sync with the state.
424    Reset,
425    /// The event predates the current window (a late arrival in an earlier
426    /// tumbling bucket); do not push it and leave the state untouched.
427    Discard,
428}
429
430/// Apply a correlation window's pre-insert maintenance for a new event at `ts`,
431/// before the event is pushed into `state`.
432///
433/// - `Sliding` evicts entries older than `ts - timespan` (the existing default)
434///   and always extends.
435/// - `Tumbling` resets the window when `ts` falls in a *later*
436///   boundary-aligned bucket than the latest retained entry, and discards
437///   events that fall in an *earlier* bucket (a late arrival must not wipe the
438///   active bucket's accumulation).
439/// - `Session` resets when `ts` is more than `gap` after the latest entry, or
440///   when extending would push the total span past `timespan` (the hard cap).
441///
442/// Out-of-order arrivals follow the engine's arrival-order contract: decisions
443/// are made relative to the entries currently retained, not a global watermark.
444pub fn apply_window_open(
445    state: &mut WindowState,
446    ts: i64,
447    timespan_secs: u64,
448    window: WindowMode,
449    gap_secs: Option<u64>,
450) -> WindowDecision {
451    let timespan = timespan_secs as i64;
452    match window {
453        WindowMode::Sliding => {
454            state.evict(ts - timespan);
455            WindowDecision::Extend
456        }
457        WindowMode::Tumbling => {
458            if timespan <= 0 {
459                return WindowDecision::Extend;
460            }
461            match state.latest_timestamp() {
462                Some(last) if bucket_start(ts, timespan) > bucket_start(last, timespan) => {
463                    state.clear();
464                    WindowDecision::Reset
465                }
466                Some(last) if bucket_start(ts, timespan) < bucket_start(last, timespan) => {
467                    WindowDecision::Discard
468                }
469                _ => WindowDecision::Extend,
470            }
471        }
472        WindowMode::Session => {
473            // `gap` is required for session windows; fall back to `timespan` if
474            // it is somehow absent so the window still has a bound.
475            let gap = gap_secs.map(|g| g as i64).unwrap_or(timespan);
476            let reset = match (state.earliest_timestamp(), state.latest_timestamp()) {
477                (Some(start), Some(last)) => {
478                    (ts - last) > gap || (timespan > 0 && (ts - start) > timespan)
479                }
480                _ => false,
481            };
482            if reset {
483                state.clear();
484                WindowDecision::Reset
485            } else {
486                WindowDecision::Extend
487            }
488        }
489    }
490}
491
492/// Number of distinct rule references, so a rule listed twice in `rules`
493/// counts once.
494fn count_distinct_rules(rule_refs: &[String]) -> usize {
495    rule_refs.iter().collect::<HashSet<_>>().len()
496}
497
498/// Number of distinct referenced rules with at least one hit in the window.
499fn count_fired_rules(rule_refs: &[String], rule_hits: &HashMap<String, VecDeque<i64>>) -> usize {
500    rule_refs
501        .iter()
502        .filter(|r| rule_hits.get(r.as_str()).is_some_and(|ts| !ts.is_empty()))
503        .collect::<HashSet<_>>()
504        .len()
505}
506
507/// Check if all referenced rules fired in the correct order within the window.
508///
509/// For `temporal_ordered`, each rule must have at least one hit, and there
510/// must exist a sequence of timestamps (one per rule) that is non-decreasing
511/// and follows the rule ordering.
512fn check_temporal_ordered(
513    rule_refs: &[String],
514    rule_hits: &HashMap<String, VecDeque<i64>>,
515) -> bool {
516    if rule_refs.is_empty() {
517        return true;
518    }
519
520    // All rules must have at least one hit
521    for r in rule_refs {
522        if rule_hits.get(r.as_str()).is_none_or(|ts| ts.is_empty()) {
523            return false;
524        }
525    }
526
527    // Check if there's a valid ordered sequence: for each rule in order,
528    // find a timestamp >= the previous rule's chosen timestamp.
529    fn find_ordered(
530        rule_refs: &[String],
531        rule_hits: &HashMap<String, VecDeque<i64>>,
532        idx: usize,
533        min_ts: i64,
534    ) -> bool {
535        if idx >= rule_refs.len() {
536            return true;
537        }
538        let Some(timestamps) = rule_hits.get(&rule_refs[idx]) else {
539            return false;
540        };
541        for &ts in timestamps {
542            if ts >= min_ts && find_ordered(rule_refs, rule_hits, idx + 1, ts) {
543                return true;
544            }
545        }
546        false
547    }
548
549    find_ordered(rule_refs, rule_hits, 0, i64::MIN)
550}
551
552/// Evaluate a boolean condition expression against the set of rules that have
553/// fired within the temporal window.
554///
555/// Each `Identifier` in the expression is treated as a rule reference — it's
556/// `true` if that rule has at least one hit in `rule_hits`.
557pub(super) fn eval_temporal_expr(
558    expr: &ConditionExpr,
559    rule_hits: &HashMap<String, VecDeque<i64>>,
560) -> bool {
561    match expr {
562        ConditionExpr::Identifier(name) => rule_hits
563            .get(name.as_str())
564            .is_some_and(|ts| !ts.is_empty()),
565        ConditionExpr::And(children) => children.iter().all(|c| eval_temporal_expr(c, rule_hits)),
566        ConditionExpr::Or(children) => children.iter().any(|c| eval_temporal_expr(c, rule_hits)),
567        ConditionExpr::Not(child) => !eval_temporal_expr(child, rule_hits),
568        ConditionExpr::Selector { .. } => {
569            // Selectors are not meaningful for temporal condition evaluation
570            false
571        }
572    }
573}
574
575/// Compute the value at a given percentile rank using linear interpolation.
576///
577/// Returns 0.0 if `values` is empty.
578/// `values` must be sorted in ascending order.
579/// `percentile` is from 0.0 to 100.0.
580pub(super) fn percentile_linear_interp(values: &[f64], percentile: f64) -> f64 {
581    if values.is_empty() {
582        return 0.0;
583    }
584    let n = values.len();
585    if n == 1 {
586        return values[0];
587    }
588
589    // Clamp percentile to [0, 100]
590    let p = percentile.clamp(0.0, 100.0) / 100.0;
591
592    // Use the "C = 1" interpolation method (most common in statistics)
593    // rank = p * (n - 1)
594    let rank = p * (n - 1) as f64;
595    let lower = rank.floor() as usize;
596    let upper = rank.ceil() as usize;
597    let fraction = rank - lower as f64;
598
599    if lower == upper || upper >= n {
600        values[lower.min(n - 1)]
601    } else {
602        values[lower] + fraction * (values[upper] - values[lower])
603    }
604}