Skip to main content

rsigma_eval/rule_draft/
correlation.rs

1//! Draft temporal correlations from grouped, timed exemplar events.
2
3use std::collections::{BTreeMap, BTreeSet};
4
5use chrono::DateTime;
6use rsigma_parser::Timespan;
7use serde::Serialize;
8use serde_json::Value;
9
10use crate::correlation_engine::{CorrelationConfig, CorrelationEngine};
11use crate::event::{Event, JsonEvent};
12use crate::key_shape::cluster_by_key_shape;
13
14use super::draft_core::{ValueForm, yaml_str, yaml_title_str};
15use super::{DraftCandidate, DraftConfig, DraftError};
16
17/// Source context attached by a grouped-input reader.
18#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
19pub struct SourceLocation {
20    /// Input file, when the event came from a file.
21    pub file: Option<String>,
22    /// One-based source line, when available.
23    pub line: Option<usize>,
24}
25
26impl SourceLocation {
27    fn label(&self) -> String {
28        match (&self.file, self.line) {
29            (Some(file), Some(line)) => format!("{file}:{line}"),
30            (Some(file), None) => file.clone(),
31            (None, Some(line)) => format!("line {line}"),
32            (None, None) => "unknown source".to_string(),
33        }
34    }
35}
36
37/// One event carrying exactly one absolute timestamp or relative offset.
38#[derive(Debug, Clone, PartialEq, Serialize)]
39pub struct TimedEvent {
40    /// RFC3339 timestamp. Mutually exclusive with [`Self::offset`].
41    pub timestamp: Option<String>,
42    /// Sigma timespan offset. Mutually exclusive with [`Self::timestamp`].
43    pub offset: Option<String>,
44    /// Event body.
45    pub event: Value,
46    /// Optional file/line context for validation errors.
47    pub source: SourceLocation,
48}
49
50/// One observed instance of the multi-event behavior.
51#[derive(Debug, Clone, PartialEq, Serialize)]
52pub struct GroupedExemplar {
53    /// Stable group identifier.
54    pub id: String,
55    /// Timed events in arbitrary input order.
56    pub events: Vec<TimedEvent>,
57}
58
59/// Requested correlation ordering.
60#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
61#[serde(rename_all = "snake_case")]
62pub enum CorrelationDraftType {
63    /// Infer ordered only when every positive group agrees.
64    #[default]
65    Auto,
66    /// Emit an unordered temporal correlation.
67    Temporal,
68    /// Require and emit identical ordering across groups.
69    TemporalOrdered,
70}
71
72impl CorrelationDraftType {
73    fn emitted_name(self, ordered: bool) -> &'static str {
74        match self {
75            Self::Temporal => "temporal",
76            Self::Auto if !ordered => "temporal",
77            Self::Auto | Self::TemporalOrdered => "temporal_ordered",
78        }
79    }
80}
81
82/// Tunables and caller-supplied metadata for correlation drafting.
83#[derive(Debug, Clone)]
84pub struct CorrelationDraftConfig {
85    /// Minimum number of positive groups.
86    pub min_groups: usize,
87    /// Minimum number of recurring slots.
88    pub min_slots: usize,
89    /// Key-shape Jaccard threshold.
90    pub similarity: f64,
91    /// Multiplier applied to the maximum observed positive span.
92    pub window_margin: f64,
93    /// Ordering mode.
94    pub correlation_type: CorrelationDraftType,
95    /// Explicit grouping fields. Empty means infer one field.
96    pub group_by: Vec<String>,
97    /// Correlation title override.
98    pub title: Option<String>,
99    /// Caller-supplied correlation id.
100    pub correlation_id: Option<String>,
101    /// Caller-supplied slot ids, in inferred slot order.
102    pub slot_ids: Vec<String>,
103    /// Shared per-slot detection drafting configuration.
104    pub detection: DraftConfig,
105}
106
107impl Default for CorrelationDraftConfig {
108    fn default() -> Self {
109        Self {
110            min_groups: 3,
111            min_slots: 2,
112            similarity: 0.6,
113            window_margin: 1.5,
114            correlation_type: CorrelationDraftType::Auto,
115            group_by: Vec::new(),
116            title: None,
117            correlation_id: None,
118            slot_ids: Vec::new(),
119            detection: DraftConfig::default(),
120        }
121    }
122}
123
124/// One drafted slot in the report.
125#[derive(Debug, Clone, Serialize)]
126pub struct CorrelationSlotReport {
127    /// Correlation rule reference.
128    pub name: String,
129    /// Detection rule id.
130    pub id: Option<String>,
131    /// Positive event count assigned to the slot.
132    pub support: usize,
133    /// Number of positive groups represented.
134    pub group_support: usize,
135    /// Selected detection field descriptions.
136    pub selected_fields: Vec<String>,
137}
138
139/// Isolated replay result for one positive or negative group.
140#[derive(Debug, Clone, Serialize)]
141pub struct CorrelationVerification {
142    /// Group identifier.
143    pub group: String,
144    /// Whether this was a negative group.
145    pub negative: bool,
146    /// Whether the target correlation fired.
147    pub fired: bool,
148}
149
150/// A verified drafted correlation and its evidence.
151#[derive(Debug, Clone, Serialize)]
152pub struct CorrelationDraftReport {
153    /// Paste-ready multi-document Sigma YAML.
154    pub rule_yaml: String,
155    /// `temporal` or `temporal_ordered`.
156    pub correlation_type: String,
157    /// Explicit or inferred grouping fields.
158    pub group_by: Vec<String>,
159    /// Chosen window.
160    pub timespan: String,
161    /// Raw positive first-to-last spans in seconds.
162    pub span_seconds: Vec<u64>,
163    /// Consecutive retained-slot gaps in seconds.
164    pub gap_seconds: Vec<u64>,
165    /// Per-slot drafting evidence.
166    pub slots: Vec<CorrelationSlotReport>,
167    /// Isolated positive and negative verification rows.
168    pub verification: Vec<CorrelationVerification>,
169    /// Advisory inference and lint notes.
170    pub warnings: Vec<String>,
171}
172
173/// Why a grouped correlation draft could not be produced.
174#[derive(Debug, thiserror::Error)]
175pub enum CorrelationDraftError {
176    /// Too few positive groups were provided.
177    #[error("at least {minimum} positive groups are required, got {actual}")]
178    TooFewGroups { minimum: usize, actual: usize },
179    /// A group has fewer than two events.
180    #[error("group '{group}' needs at least two events, got {actual}")]
181    TooFewEvents { group: String, actual: usize },
182    /// An event carries neither or both supported time keys.
183    #[error(
184        "group '{group}' event {event} at {location} must contain exactly one of timestamp or offset"
185    )]
186    InvalidTimeKeys {
187        group: String,
188        event: usize,
189        location: String,
190    },
191    /// A group mixes absolute and relative time.
192    #[error("group '{group}' mixes timestamp and offset time modes")]
193    MixedTimeMode { group: String },
194    /// A timestamp or offset is invalid.
195    #[error("group '{group}' event {event} at {location} has invalid {kind} value '{value}'")]
196    InvalidTime {
197        group: String,
198        event: usize,
199        location: String,
200        kind: &'static str,
201        value: String,
202    },
203    /// Two events in a group have the same parsed time.
204    #[error(
205        "group '{group}' events {first_event} and {second_event} have duplicate parsed time {time}"
206    )]
207    DuplicateTime {
208        group: String,
209        first_event: usize,
210        second_event: usize,
211        time: i64,
212    },
213    /// Too few recurring event slots survived.
214    #[error("at least {minimum} recurring slots are required, got {actual}")]
215    TooFewSlots { minimum: usize, actual: usize },
216    /// A recurring slot appears more than once in one group.
217    #[error("group '{group}' repeats slot {slot} at input events {events:?}")]
218    DuplicateSlot {
219        group: String,
220        slot: usize,
221        events: Vec<usize>,
222    },
223    /// Forced ordering disagrees with observed groups.
224    #[error("temporal_ordered was requested but groups disagree on slot order: {groups:?}")]
225    OrderInversion { groups: Vec<String> },
226    /// Window margin is invalid.
227    #[error("window margin must be finite and at least 1.0, got {0}")]
228    InvalidWindowMargin(f64),
229    /// The inferred window overflowed.
230    #[error("inferred correlation window overflowed")]
231    WindowOverflow,
232    /// Grouping inference found zero or multiple valid fields.
233    #[error("correlation entity is ambiguous; candidates: {candidates:?}")]
234    AmbiguousEntity { candidates: Vec<String> },
235    /// An explicit grouping field is absent or unstable.
236    #[error(
237        "group-by field '{field}' is absent or unstable in group '{group}' at input event {event}"
238    )]
239    InvalidEntity {
240        field: String,
241        group: String,
242        event: usize,
243    },
244    /// Fewer slot ids than inferred slots and no base rule id to derive from.
245    #[error(
246        "every drafted slot rule needs an id: {slots} slots were inferred but only {provided} slot ids were supplied"
247    )]
248    MissingSlotIds { slots: usize, provided: usize },
249    /// A per-slot detection could not be drafted.
250    #[error("failed to draft slot {slot}: {source}")]
251    SlotDraft {
252        slot: usize,
253        #[source]
254        source: DraftError,
255    },
256    /// One event matches the wrong slot rules.
257    #[error(
258        "group '{group}' event {event} assigned to '{assigned}' collides with {colliding:?}; selected forms: {forms:?}. Use cleaner exemplars, a representative baseline, or manual rule editing"
259    )]
260    CrossSlotMatch {
261        group: String,
262        event: usize,
263        assigned: String,
264        colliding: Vec<String>,
265        forms: Vec<String>,
266    },
267    /// A slot cannot match its assigned positives at the field floor.
268    #[error("slot '{slot}' cannot match assigned positives at the {floor}-field floor")]
269    SlotFloor { slot: String, floor: usize },
270    /// Emitted YAML did not parse or compile.
271    #[error("internal correlation draft error during {stage}: {message}")]
272    Internal {
273        stage: &'static str,
274        message: String,
275    },
276    /// An isolated positive group did not fire correctly.
277    #[error("positive group '{group}' {reason}")]
278    PositiveVerification { group: String, reason: String },
279    /// One or more negative groups fired the target correlation.
280    #[error("drafted correlation matched negative groups: {groups:?}")]
281    NegativeGroupMatched { groups: Vec<String> },
282}
283
284#[derive(Debug, Clone, Copy, PartialEq, Eq)]
285enum TimeMode {
286    Timestamp,
287    Offset,
288}
289
290#[derive(Debug, Clone)]
291struct NormalizedEvent {
292    input_index: usize,
293    time: i64,
294    event: Value,
295    keys: Vec<String>,
296    cluster: usize,
297}
298
299#[derive(Debug, Clone)]
300struct NormalizedGroup {
301    id: String,
302    events: Vec<NormalizedEvent>,
303}
304
305#[derive(Debug)]
306struct ShapeCluster {
307    seed_keys: Vec<String>,
308    members: Vec<(usize, usize)>,
309}
310
311#[derive(Debug)]
312struct ShapeItem {
313    keys: Vec<String>,
314    member: (usize, usize),
315}
316
317#[derive(Debug)]
318struct Slot {
319    cluster: usize,
320    name: String,
321    id: Option<String>,
322    members: Vec<(usize, usize)>,
323    candidate: DraftCandidate,
324    config: DraftConfig,
325}
326
327/// Draft, emit, and verify a temporal correlation from positive groups.
328///
329/// Every positive and negative group is replayed through a fresh correlation
330/// engine. `baseline` is a flat corpus used only for per-slot contrast.
331pub fn draft_correlation(
332    groups: &[GroupedExemplar],
333    negative_groups: &[GroupedExemplar],
334    baseline: &[Value],
335    config: &CorrelationDraftConfig,
336) -> Result<CorrelationDraftReport, CorrelationDraftError> {
337    let mut groups = normalize_groups(groups, config.min_groups)?;
338    let negatives = normalize_groups(negative_groups, 0)?;
339    let positive_count = groups.len();
340    groups.extend(negatives);
341    assign_clusters(&mut groups, config.similarity);
342    let negatives = groups.split_off(positive_count);
343
344    let (retained, mut warnings) = retained_clusters(&groups, config.min_slots)?;
345    validate_slot_counts(&groups, &retained)?;
346    let group_by = resolve_group_by(&groups, &retained, &config.group_by)?;
347    let (ordered, order, disagreeing) = infer_order(&groups, &retained);
348    if config.correlation_type == CorrelationDraftType::TemporalOrdered && !disagreeing.is_empty() {
349        return Err(CorrelationDraftError::OrderInversion {
350            groups: disagreeing,
351        });
352    }
353    if config.correlation_type == CorrelationDraftType::Auto && !disagreeing.is_empty() {
354        warnings.push(format!(
355            "slot order differs in groups {}; emitted temporal instead of temporal_ordered",
356            disagreeing.join(", ")
357        ));
358    }
359    let correlation_type = config.correlation_type.emitted_name(ordered);
360    let (timespan, spans, gaps) = infer_window(&groups, &retained, config.window_margin)?;
361    let correlation_id = config
362        .correlation_id
363        .clone()
364        .unwrap_or_else(|| "draft-correlation".to_string());
365
366    let mut slots = build_slots(
367        &groups,
368        baseline,
369        &group_by,
370        &order,
371        &config.slot_ids,
372        &config.detection,
373    )?;
374
375    let (rule_yaml, verification) = loop {
376        let yaml = emit_collection(
377            &groups,
378            &retained,
379            &slots,
380            correlation_type,
381            &group_by,
382            &timespan,
383            &correlation_id,
384            config,
385        );
386        let collection = rsigma_parser::parse_sigma_yaml(&yaml).map_err(|error| {
387            CorrelationDraftError::Internal {
388                stage: "parse",
389                message: error.to_string(),
390            }
391        })?;
392
393        if let Some((slot_index, local_event)) =
394            verify_identity(&collection, &groups, &retained, &slots)?
395        {
396            let slot = &mut slots[slot_index];
397            let floor = slot
398                .config
399                .min_fields
400                .min(slot.candidate.selected.len())
401                .max(1);
402            if slot.candidate.selected.len() <= floor
403                || slot
404                    .candidate
405                    .drop_lowest_eligible(Some(&[local_event]))
406                    .is_none()
407            {
408                return Err(CorrelationDraftError::SlotFloor {
409                    slot: slot.name.clone(),
410                    floor,
411                });
412            }
413            warnings.push(format!(
414                "relaxed slot '{}' after an assigned positive failed verification",
415                slot.name
416            ));
417            continue;
418        }
419
420        let mut verification =
421            verify_groups(&collection, &groups, &retained, &correlation_id, false)?;
422        let mut negative_rows =
423            verify_groups(&collection, &negatives, &retained, &correlation_id, true)?;
424        verification.append(&mut negative_rows);
425        break (yaml, verification);
426    };
427
428    for finding in rsigma_parser::lint_yaml_str(&rule_yaml) {
429        warnings.push(format!("lint {}: {}", finding.rule, finding.message));
430    }
431
432    let slot_reports = slots
433        .iter()
434        .map(|slot| CorrelationSlotReport {
435            name: slot.name.clone(),
436            id: slot.id.clone(),
437            support: slot.members.len(),
438            group_support: slot
439                .members
440                .iter()
441                .map(|(group, _)| group)
442                .collect::<BTreeSet<_>>()
443                .len(),
444            selected_fields: selected_forms(slot),
445        })
446        .collect();
447
448    Ok(CorrelationDraftReport {
449        rule_yaml,
450        correlation_type: correlation_type.to_string(),
451        group_by,
452        timespan: timespan.original,
453        span_seconds: spans,
454        gap_seconds: gaps,
455        slots: slot_reports,
456        verification,
457        warnings,
458    })
459}
460
461fn normalize_groups(
462    groups: &[GroupedExemplar],
463    minimum: usize,
464) -> Result<Vec<NormalizedGroup>, CorrelationDraftError> {
465    if groups.len() < minimum {
466        return Err(CorrelationDraftError::TooFewGroups {
467            minimum,
468            actual: groups.len(),
469        });
470    }
471    let mut normalized = Vec::with_capacity(groups.len());
472    for group in groups {
473        if group.events.len() < 2 {
474            return Err(CorrelationDraftError::TooFewEvents {
475                group: group.id.clone(),
476                actual: group.events.len(),
477            });
478        }
479        let mut mode = None;
480        let mut events = Vec::with_capacity(group.events.len());
481        for (index, timed) in group.events.iter().enumerate() {
482            let current = match (&timed.timestamp, &timed.offset) {
483                (Some(_), None) => TimeMode::Timestamp,
484                (None, Some(_)) => TimeMode::Offset,
485                _ => {
486                    return Err(CorrelationDraftError::InvalidTimeKeys {
487                        group: group.id.clone(),
488                        event: index,
489                        location: timed.source.label(),
490                    });
491                }
492            };
493            if mode.is_some_and(|expected| expected != current) {
494                return Err(CorrelationDraftError::MixedTimeMode {
495                    group: group.id.clone(),
496                });
497            }
498            mode = Some(current);
499            let time = match current {
500                TimeMode::Timestamp => {
501                    let raw = timed.timestamp.as_deref().unwrap();
502                    let parsed = DateTime::parse_from_rfc3339(raw).map_err(|_| {
503                        CorrelationDraftError::InvalidTime {
504                            group: group.id.clone(),
505                            event: index,
506                            location: timed.source.label(),
507                            kind: "timestamp",
508                            value: raw.to_string(),
509                        }
510                    })?;
511                    parsed.timestamp()
512                }
513                TimeMode::Offset => {
514                    let raw = timed.offset.as_deref().unwrap();
515                    let parsed =
516                        Timespan::parse(raw).map_err(|_| CorrelationDraftError::InvalidTime {
517                            group: group.id.clone(),
518                            event: index,
519                            location: timed.source.label(),
520                            kind: "offset",
521                            value: raw.to_string(),
522                        })?;
523                    i64::try_from(parsed.seconds).map_err(|_| {
524                        CorrelationDraftError::InvalidTime {
525                            group: group.id.clone(),
526                            event: index,
527                            location: timed.source.label(),
528                            kind: "offset",
529                            value: raw.to_string(),
530                        }
531                    })?
532                }
533            };
534            let json = JsonEvent::borrow(&timed.event);
535            let mut keys: Vec<String> = json
536                .field_keys()
537                .into_iter()
538                .map(|key| key.into_owned())
539                .collect();
540            keys.sort();
541            keys.dedup();
542            events.push(NormalizedEvent {
543                input_index: index,
544                time,
545                event: timed.event.clone(),
546                keys,
547                cluster: usize::MAX,
548            });
549        }
550        events.sort_by_key(|event| (event.time, event.input_index));
551        for pair in events.windows(2) {
552            if pair[0].time == pair[1].time {
553                return Err(CorrelationDraftError::DuplicateTime {
554                    group: group.id.clone(),
555                    first_event: pair[0].input_index,
556                    second_event: pair[1].input_index,
557                    time: pair[0].time,
558                });
559            }
560        }
561        normalized.push(NormalizedGroup {
562            id: group.id.clone(),
563            events,
564        });
565    }
566    normalized.sort_by(|a, b| a.id.cmp(&b.id));
567    Ok(normalized)
568}
569
570fn assign_clusters(groups: &mut [NormalizedGroup], similarity: f64) {
571    let items: Vec<ShapeItem> = groups
572        .iter()
573        .enumerate()
574        .flat_map(|(group, value)| {
575            value
576                .events
577                .iter()
578                .enumerate()
579                .map(move |(event, value)| ShapeItem {
580                    keys: value.keys.clone(),
581                    member: (group, event),
582                })
583        })
584        .collect();
585    let clusters = cluster_by_key_shape(
586        &items,
587        similarity,
588        |item| &item.keys,
589        |cluster: &ShapeCluster| &cluster.seed_keys,
590        |item| ShapeCluster {
591            seed_keys: item.keys.clone(),
592            members: vec![item.member],
593        },
594        |_, _| true,
595        |cluster, item| cluster.members.push(item.member),
596    );
597    for (cluster_index, cluster) in clusters.iter().enumerate() {
598        for &(group, event) in &cluster.members {
599            groups[group].events[event].cluster = cluster_index;
600        }
601    }
602}
603
604fn retained_clusters(
605    groups: &[NormalizedGroup],
606    minimum: usize,
607) -> Result<(BTreeSet<usize>, Vec<String>), CorrelationDraftError> {
608    let all: BTreeSet<usize> = groups
609        .iter()
610        .flat_map(|group| group.events.iter().map(|event| event.cluster))
611        .collect();
612    let retained: BTreeSet<usize> = all
613        .iter()
614        .copied()
615        .filter(|cluster| {
616            groups
617                .iter()
618                .all(|group| group.events.iter().any(|event| event.cluster == *cluster))
619        })
620        .collect();
621    if retained.len() < minimum {
622        return Err(CorrelationDraftError::TooFewSlots {
623            minimum,
624            actual: retained.len(),
625        });
626    }
627    let dropped: Vec<String> = all
628        .difference(&retained)
629        .map(|cluster| cluster.to_string())
630        .collect();
631    let warnings = if dropped.is_empty() {
632        Vec::new()
633    } else {
634        vec![format!(
635            "dropped incidental key-shape clusters absent from one or more positive groups: {}",
636            dropped.join(", ")
637        )]
638    };
639    Ok((retained, warnings))
640}
641
642fn validate_slot_counts(
643    groups: &[NormalizedGroup],
644    retained: &BTreeSet<usize>,
645) -> Result<(), CorrelationDraftError> {
646    for group in groups {
647        for &slot in retained {
648            let mut events: Vec<usize> = group
649                .events
650                .iter()
651                .filter(|event| event.cluster == slot)
652                .map(|event| event.input_index)
653                .collect();
654            events.sort_unstable();
655            if events.len() > 1 {
656                return Err(CorrelationDraftError::DuplicateSlot {
657                    group: group.id.clone(),
658                    slot,
659                    events,
660                });
661            }
662        }
663    }
664    Ok(())
665}
666
667/// Infer the expected slot order from the majority of groups so that the
668/// disagreeing list names the actual outliers, not everyone who differs from
669/// whichever group happens to sort first. Ties break to the smallest order
670/// for determinism.
671fn infer_order(
672    groups: &[NormalizedGroup],
673    retained: &BTreeSet<usize>,
674) -> (bool, Vec<usize>, Vec<String>) {
675    let orders: Vec<Vec<usize>> = groups
676        .iter()
677        .map(|group| {
678            group
679                .events
680                .iter()
681                .filter(|event| retained.contains(&event.cluster))
682                .map(|event| event.cluster)
683                .collect()
684        })
685        .collect();
686    let mut counts: BTreeMap<&Vec<usize>, usize> = BTreeMap::new();
687    for order in &orders {
688        *counts.entry(order).or_default() += 1;
689    }
690    let expected = counts
691        .iter()
692        .max_by(|a, b| a.1.cmp(b.1).then_with(|| b.0.cmp(a.0)))
693        .map(|(order, _)| (*order).clone())
694        .unwrap_or_default();
695    let disagreeing: Vec<String> = groups
696        .iter()
697        .zip(&orders)
698        .filter(|(_, order)| **order != expected)
699        .map(|(group, _)| group.id.clone())
700        .collect();
701    (disagreeing.is_empty(), expected, disagreeing)
702}
703
704fn infer_window(
705    groups: &[NormalizedGroup],
706    retained: &BTreeSet<usize>,
707    margin: f64,
708) -> Result<(Timespan, Vec<u64>, Vec<u64>), CorrelationDraftError> {
709    if !margin.is_finite() || margin < 1.0 {
710        return Err(CorrelationDraftError::InvalidWindowMargin(margin));
711    }
712    let mut spans = Vec::with_capacity(groups.len());
713    let mut gaps = Vec::new();
714    for group in groups {
715        let times: Vec<i64> = group
716            .events
717            .iter()
718            .filter(|event| retained.contains(&event.cluster))
719            .map(|event| event.time)
720            .collect();
721        let first = *times.first().expect("retained slots exist");
722        let last = *times.last().expect("retained slots exist");
723        spans.push(
724            u64::try_from(
725                last.checked_sub(first)
726                    .ok_or(CorrelationDraftError::WindowOverflow)?,
727            )
728            .map_err(|_| CorrelationDraftError::WindowOverflow)?,
729        );
730        for pair in times.windows(2) {
731            gaps.push(
732                u64::try_from(
733                    pair[1]
734                        .checked_sub(pair[0])
735                        .ok_or(CorrelationDraftError::WindowOverflow)?,
736                )
737                .map_err(|_| CorrelationDraftError::WindowOverflow)?,
738            );
739        }
740    }
741    let maximum = spans.iter().copied().max().unwrap_or(0).max(1);
742    let scaled = (maximum as f64 * margin).ceil();
743    if !scaled.is_finite() || scaled > u64::MAX as f64 {
744        return Err(CorrelationDraftError::WindowOverflow);
745    }
746    let rounded = round_window(scaled as u64)?;
747    let timespan = Timespan::parse(&rounded).map_err(|error| CorrelationDraftError::Internal {
748        stage: "window construction",
749        message: error.to_string(),
750    })?;
751    Ok((timespan, spans, gaps))
752}
753
754fn round_window(seconds: u64) -> Result<String, CorrelationDraftError> {
755    const STEPS: &[(u64, &str)] = &[
756        (30, "30s"),
757        (60, "1m"),
758        (300, "5m"),
759        (900, "15m"),
760        (3_600, "1h"),
761        (21_600, "6h"),
762        (86_400, "24h"),
763    ];
764    if let Some((_, label)) = STEPS.iter().find(|(step, _)| seconds <= *step) {
765        return Ok((*label).to_string());
766    }
767    let days = seconds
768        .checked_add(86_399)
769        .ok_or(CorrelationDraftError::WindowOverflow)?
770        / 86_400;
771    Ok(format!("{days}d"))
772}
773
774fn resolve_group_by(
775    groups: &[NormalizedGroup],
776    retained: &BTreeSet<usize>,
777    explicit: &[String],
778) -> Result<Vec<String>, CorrelationDraftError> {
779    if !explicit.is_empty() {
780        for field in explicit {
781            validate_entity_field(groups, retained, field)?;
782        }
783        return Ok(explicit.to_vec());
784    }
785    let mut common: Option<BTreeSet<String>> = None;
786    for group in groups {
787        for event in group
788            .events
789            .iter()
790            .filter(|event| retained.contains(&event.cluster))
791        {
792            let keys: BTreeSet<String> = event.keys.iter().cloned().collect();
793            common = Some(match common {
794                Some(existing) => existing.intersection(&keys).cloned().collect(),
795                None => keys,
796            });
797        }
798    }
799    let candidates: Vec<String> = common
800        .unwrap_or_default()
801        .into_iter()
802        .filter(|field| validate_entity_field(groups, retained, field).is_ok())
803        .filter(|field| {
804            let values: BTreeSet<String> = groups
805                .iter()
806                .filter_map(|group| stable_group_value(group, retained, field))
807                .collect();
808            values.len() == groups.len()
809        })
810        .collect();
811    if candidates.len() != 1 {
812        return Err(CorrelationDraftError::AmbiguousEntity { candidates });
813    }
814    Ok(candidates)
815}
816
817fn validate_entity_field(
818    groups: &[NormalizedGroup],
819    retained: &BTreeSet<usize>,
820    field: &str,
821) -> Result<(), CorrelationDraftError> {
822    for group in groups {
823        let mut expected = None;
824        for event in group
825            .events
826            .iter()
827            .filter(|event| retained.contains(&event.cluster))
828        {
829            let value = JsonEvent::borrow(&event.event)
830                .get_field(field)
831                .map(|value| format!("{value:?}"));
832            if value.is_none()
833                || expected
834                    .as_ref()
835                    .is_some_and(|known| Some(known) != value.as_ref())
836            {
837                return Err(CorrelationDraftError::InvalidEntity {
838                    field: field.to_string(),
839                    group: group.id.clone(),
840                    event: event.input_index,
841                });
842            }
843            expected = value;
844        }
845    }
846    Ok(())
847}
848
849fn stable_group_value(
850    group: &NormalizedGroup,
851    retained: &BTreeSet<usize>,
852    field: &str,
853) -> Option<String> {
854    let mut values = group
855        .events
856        .iter()
857        .filter(|event| retained.contains(&event.cluster))
858        .map(|event| {
859            JsonEvent::borrow(&event.event)
860                .get_field(field)
861                .map(|value| format!("{value:?}"))
862        });
863    let first = values.next()??;
864    values
865        .all(|value| value.as_deref() == Some(first.as_str()))
866        .then_some(first)
867}
868
869fn build_slots(
870    groups: &[NormalizedGroup],
871    baseline: &[Value],
872    group_by: &[String],
873    order: &[usize],
874    slot_ids: &[String],
875    detection_config: &DraftConfig,
876) -> Result<Vec<Slot>, CorrelationDraftError> {
877    // Identity verification keys on slot rule ids, so a slot without one can
878    // never pass and would grind through relaxation into a misleading
879    // floor error. Refuse up front instead.
880    if slot_ids.len() < order.len() && detection_config.rule_id.is_none() {
881        return Err(CorrelationDraftError::MissingSlotIds {
882            slots: order.len(),
883            provided: slot_ids.len(),
884        });
885    }
886    let mut slots = Vec::with_capacity(order.len());
887    let mut used_names = BTreeMap::new();
888    for (position, &cluster) in order.iter().enumerate() {
889        let members: Vec<(usize, usize)> = groups
890            .iter()
891            .enumerate()
892            .flat_map(|(group_index, group)| {
893                group
894                    .events
895                    .iter()
896                    .enumerate()
897                    .filter(move |(_, event)| event.cluster == cluster)
898                    .map(move |(event_index, _)| (group_index, event_index))
899            })
900            .collect();
901        let positives: Vec<Value> = members
902            .iter()
903            .map(|&(group, event)| groups[group].events[event].event.clone())
904            .collect();
905        let mut contrast = baseline.to_vec();
906        for group in groups {
907            contrast.extend(
908                group
909                    .events
910                    .iter()
911                    .filter(|event| event.cluster != cluster)
912                    .map(|event| event.event.clone()),
913            );
914        }
915        let mut slot_config = detection_config.clone();
916        for field in group_by {
917            if !slot_config
918                .exclude_fields
919                .iter()
920                .any(|excluded| excluded.eq_ignore_ascii_case(field))
921            {
922                slot_config.exclude_fields.push(field.clone());
923            }
924        }
925        slot_config.rule_id = slot_ids.get(position).cloned().or_else(|| {
926            detection_config
927                .rule_id
928                .as_ref()
929                .map(|base| format!("{base}-{}", position + 1))
930        });
931        let positive_events: Vec<JsonEvent<'_>> = positives.iter().map(JsonEvent::borrow).collect();
932        let contrast_events: Vec<JsonEvent<'_>> = contrast.iter().map(JsonEvent::borrow).collect();
933        let candidate = DraftCandidate::build(&positive_events, &contrast_events, &slot_config)
934            .map_err(|source| CorrelationDraftError::SlotDraft {
935                slot: position,
936                source,
937            })?;
938        let base = slot_name(&candidate, position);
939        let count = used_names.entry(base.clone()).or_insert(0usize);
940        *count += 1;
941        let name = if *count == 1 {
942            base
943        } else {
944            format!("{base}_{count}")
945        };
946        slots.push(Slot {
947            cluster,
948            name,
949            id: slot_config.rule_id.clone(),
950            members,
951            candidate,
952            config: slot_config,
953        });
954    }
955    Ok(slots)
956}
957
958fn slot_name(candidate: &DraftCandidate, position: usize) -> String {
959    let marker = candidate.selected.first().and_then(|&index| {
960        candidate.profiles[index]
961            .form
962            .as_ref()
963            .and_then(form_marker)
964    });
965    let slug = marker
966        .as_deref()
967        .map(slugify)
968        .filter(|slug| !slug.is_empty())
969        .unwrap_or_else(|| format!("{}", position + 1));
970    format!("slot_{slug}")
971}
972
973fn form_marker(form: &ValueForm) -> Option<String> {
974    match form {
975        ValueForm::Exact(value) => Some(value.as_display()),
976        ValueForm::OneOf(values) => values.first().map(|value| value.as_display()),
977        ValueForm::EndsWith(value) | ValueForm::StartsWith(value) | ValueForm::Contains(value) => {
978            Some(value.clone())
979        }
980        ValueForm::ContainsAll(values) => values.first().cloned(),
981    }
982}
983
984fn slugify(value: &str) -> String {
985    let mut slug = String::new();
986    let mut separator = false;
987    for character in value.chars() {
988        if character.is_ascii_alphanumeric() {
989            slug.push(character.to_ascii_lowercase());
990            separator = false;
991        } else if !slug.is_empty() && !separator {
992            slug.push('_');
993            separator = true;
994        }
995    }
996    while slug.ends_with('_') {
997        slug.pop();
998    }
999    slug
1000}
1001
1002#[allow(clippy::too_many_arguments)]
1003fn emit_collection(
1004    groups: &[NormalizedGroup],
1005    retained: &BTreeSet<usize>,
1006    slots: &[Slot],
1007    correlation_type: &str,
1008    group_by: &[String],
1009    timespan: &Timespan,
1010    correlation_id: &str,
1011    config: &CorrelationDraftConfig,
1012) -> String {
1013    let mut output = String::new();
1014    for (index, slot) in slots.iter().enumerate() {
1015        if index > 0 {
1016            output.push_str("---\n");
1017        }
1018        let positives: Vec<Value> = slot
1019            .members
1020            .iter()
1021            .map(|&(group, event)| groups[group].events[event].event.clone())
1022            .collect();
1023        let events: Vec<JsonEvent<'_>> = positives.iter().map(JsonEvent::borrow).collect();
1024        output.push_str(
1025            &slot
1026                .candidate
1027                .emit_named(&events, &slot.config, Some(&slot.name)),
1028        );
1029    }
1030    output.push_str("---\n");
1031    let title = config
1032        .title
1033        .clone()
1034        .unwrap_or_else(|| "Draft temporal correlation".to_string());
1035    output.push_str(&format!("title: {}\n", yaml_title_str(&title)));
1036    output.push_str(&format!("id: {}\n", yaml_str(correlation_id)));
1037    output.push_str("status: experimental\n");
1038    output.push_str("correlation:\n");
1039    output.push_str(&format!("    type: {correlation_type}\n"));
1040    output.push_str("    rules:\n");
1041    for slot in slots {
1042        output.push_str(&format!("        - {}\n", yaml_str(&slot.name)));
1043    }
1044    output.push_str("    group-by:\n");
1045    for field in group_by {
1046        output.push_str(&format!("        - {}\n", yaml_str(field)));
1047    }
1048    output.push_str(&format!("    timespan: {}\n", timespan.original));
1049    output.push_str("    condition:\n");
1050    output.push_str(&format!("        gte: {}\n", slots.len()));
1051    output.push_str("custom_attributes:\n");
1052    output.push_str("    rsigma.exemplars:\n");
1053    let representative = representative_group(groups, retained);
1054    output.push_str(&format!(
1055        "        - name: {}\n",
1056        yaml_str(&format!("observed group {}", representative.id))
1057    ));
1058    output.push_str("          expect: match\n");
1059    output.push_str("          events:\n");
1060    let first = representative
1061        .events
1062        .iter()
1063        .find(|event| retained.contains(&event.cluster))
1064        .map(|event| event.time)
1065        .unwrap_or(0);
1066    for event in representative
1067        .events
1068        .iter()
1069        .filter(|event| retained.contains(&event.cluster))
1070    {
1071        output.push_str(&format!(
1072            "              - offset: {}s\n",
1073            event.time - first
1074        ));
1075        output.push_str("                event:\n");
1076        emit_json_mapping(&mut output, &event.event, "                    ");
1077    }
1078    output.push_str("level: medium\n");
1079    output
1080}
1081
1082fn representative_group<'a>(
1083    groups: &'a [NormalizedGroup],
1084    retained: &BTreeSet<usize>,
1085) -> &'a NormalizedGroup {
1086    let mut ranked: Vec<(u64, &str, &NormalizedGroup)> = groups
1087        .iter()
1088        .map(|group| {
1089            let times: Vec<i64> = group
1090                .events
1091                .iter()
1092                .filter(|event| retained.contains(&event.cluster))
1093                .map(|event| event.time)
1094                .collect();
1095            let span = times
1096                .last()
1097                .zip(times.first())
1098                .and_then(|(last, first)| last.checked_sub(*first))
1099                .and_then(|value| u64::try_from(value).ok())
1100                .unwrap_or(0);
1101            (span, group.id.as_str(), group)
1102        })
1103        .collect();
1104    ranked.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(b.1)));
1105    ranked[ranked.len() / 2].2
1106}
1107
1108fn emit_json_mapping(output: &mut String, value: &Value, indent: &str) {
1109    let Some(mapping) = value.as_object() else {
1110        output.push_str(&format!(
1111            "{indent}value: {}\n",
1112            yaml_str(&value.to_string())
1113        ));
1114        return;
1115    };
1116    let mut keys: Vec<&String> = mapping.keys().collect();
1117    keys.sort();
1118    for key in keys {
1119        emit_json_value(output, key, &mapping[key], indent);
1120    }
1121}
1122
1123fn emit_json_value(output: &mut String, key: &str, value: &Value, indent: &str) {
1124    let key = yaml_str(key);
1125    match value {
1126        Value::Null => output.push_str(&format!("{indent}{key}: null\n")),
1127        Value::Bool(value) => output.push_str(&format!("{indent}{key}: {value}\n")),
1128        Value::Number(value) => output.push_str(&format!("{indent}{key}: {value}\n")),
1129        Value::String(value) => {
1130            output.push_str(&format!("{indent}{key}: {}\n", yaml_str(value)));
1131        }
1132        Value::Object(_) => {
1133            output.push_str(&format!("{indent}{key}:\n"));
1134            emit_json_mapping(output, value, &format!("{indent}    "));
1135        }
1136        Value::Array(values) => {
1137            output.push_str(&format!("{indent}{key}:\n"));
1138            for value in values {
1139                match value {
1140                    Value::Null => output.push_str(&format!("{indent}    - null\n")),
1141                    Value::Bool(value) => {
1142                        output.push_str(&format!("{indent}    - {value}\n"));
1143                    }
1144                    Value::Number(value) => {
1145                        output.push_str(&format!("{indent}    - {value}\n"));
1146                    }
1147                    Value::String(value) => {
1148                        output.push_str(&format!("{indent}    - {}\n", yaml_str(value)));
1149                    }
1150                    _ => output
1151                        .push_str(&format!("{indent}    - {}\n", yaml_str(&value.to_string()))),
1152                }
1153            }
1154        }
1155    }
1156}
1157
1158fn verify_identity(
1159    collection: &rsigma_parser::SigmaCollection,
1160    groups: &[NormalizedGroup],
1161    retained: &BTreeSet<usize>,
1162    slots: &[Slot],
1163) -> Result<Option<(usize, usize)>, CorrelationDraftError> {
1164    for (group_index, group) in groups.iter().enumerate() {
1165        for (event_index, event) in group
1166            .events
1167            .iter()
1168            .enumerate()
1169            .filter(|(_, event)| retained.contains(&event.cluster))
1170        {
1171            let assigned = slots
1172                .iter()
1173                .position(|slot| slot.cluster == event.cluster)
1174                .expect("retained cluster has a slot");
1175            let local_event = slots[assigned]
1176                .members
1177                .iter()
1178                .position(|member| *member == (group_index, event_index))
1179                .expect("slot member exists");
1180            let mut engine = CorrelationEngine::new(CorrelationConfig::default());
1181            engine
1182                .add_collection(collection)
1183                .map_err(|error| CorrelationDraftError::Internal {
1184                    stage: "compile",
1185                    message: error.to_string(),
1186                })?;
1187            let json = JsonEvent::borrow(&event.event);
1188            let results = engine.process_event_at(&json, 1_700_000_000);
1189            let matched: BTreeSet<&str> = results
1190                .iter()
1191                .filter(|result| result.is_detection())
1192                .filter_map(|result| result.header.rule_id.as_deref())
1193                .collect();
1194            let assigned_id = slots[assigned].id.as_deref();
1195            if assigned_id.is_none_or(|id| !matched.contains(id)) {
1196                return Ok(Some((assigned, local_event)));
1197            }
1198            let colliding: Vec<String> = slots
1199                .iter()
1200                .enumerate()
1201                .filter(|(index, slot)| {
1202                    *index != assigned && slot.id.as_deref().is_some_and(|id| matched.contains(id))
1203                })
1204                .map(|(_, slot)| slot.name.clone())
1205                .collect();
1206            if !colliding.is_empty() {
1207                let mut forms = selected_forms(&slots[assigned]);
1208                for slot in slots.iter().filter(|slot| colliding.contains(&slot.name)) {
1209                    forms.extend(selected_forms(slot));
1210                }
1211                return Err(CorrelationDraftError::CrossSlotMatch {
1212                    group: group.id.clone(),
1213                    event: event.input_index,
1214                    assigned: slots[assigned].name.clone(),
1215                    colliding,
1216                    forms,
1217                });
1218            }
1219        }
1220    }
1221    Ok(None)
1222}
1223
1224fn selected_forms(slot: &Slot) -> Vec<String> {
1225    slot.candidate
1226        .selected
1227        .iter()
1228        .map(|&index| {
1229            let profile = &slot.candidate.profiles[index];
1230            format!(
1231                "{}={:?}",
1232                profile.field(),
1233                profile.form.as_ref().expect("selected form")
1234            )
1235        })
1236        .collect()
1237}
1238
1239fn verify_groups(
1240    collection: &rsigma_parser::SigmaCollection,
1241    groups: &[NormalizedGroup],
1242    retained: &BTreeSet<usize>,
1243    correlation_id: &str,
1244    negative: bool,
1245) -> Result<Vec<CorrelationVerification>, CorrelationDraftError> {
1246    let mut rows = Vec::with_capacity(groups.len());
1247    let mut failed_negatives = Vec::new();
1248    for group in groups {
1249        let mut engine = CorrelationEngine::new(CorrelationConfig::default());
1250        engine
1251            .add_collection(collection)
1252            .map_err(|error| CorrelationDraftError::Internal {
1253                stage: "compile",
1254                message: error.to_string(),
1255            })?;
1256        // Positives replay only retained-slot events: incidental clusters are
1257        // dropped noise by design. Negatives replay every event, because slot
1258        // rules match on field predicates, not key shapes; an event that
1259        // clusters away from every retained slot can still satisfy a slot
1260        // selection and must not be skipped.
1261        let replay_events: Vec<&NormalizedEvent> = group
1262            .events
1263            .iter()
1264            .filter(|event| negative || retained.contains(&event.cluster))
1265            .collect();
1266        let first = replay_events.first().map(|event| event.time).unwrap_or(0);
1267        let mut fired = false;
1268        for (index, event) in replay_events.iter().enumerate() {
1269            let json = JsonEvent::borrow(&event.event);
1270            let timestamp = 1_700_000_000i64
1271                .checked_add(event.time - first)
1272                .ok_or(CorrelationDraftError::WindowOverflow)?;
1273            let target = engine
1274                .process_event_at(&json, timestamp)
1275                .iter()
1276                .any(|result| {
1277                    result.is_correlation()
1278                        && result.header.rule_id.as_deref() == Some(correlation_id)
1279                });
1280            if target {
1281                fired = true;
1282                if !negative && index + 1 < replay_events.len() {
1283                    return Err(CorrelationDraftError::PositiveVerification {
1284                        group: group.id.clone(),
1285                        reason: format!("fired prematurely at retained event {index}"),
1286                    });
1287                }
1288            }
1289        }
1290        if negative && fired {
1291            failed_negatives.push(group.id.clone());
1292        } else if !negative && !fired {
1293            return Err(CorrelationDraftError::PositiveVerification {
1294                group: group.id.clone(),
1295                reason: "did not fire by the end of the group".to_string(),
1296            });
1297        }
1298        rows.push(CorrelationVerification {
1299            group: group.id.clone(),
1300            negative,
1301            fired,
1302        });
1303    }
1304    if !failed_negatives.is_empty() {
1305        return Err(CorrelationDraftError::NegativeGroupMatched {
1306            groups: failed_negatives,
1307        });
1308    }
1309    Ok(rows)
1310}
1311
1312#[cfg(test)]
1313mod tests {
1314    use serde_json::json;
1315
1316    use super::*;
1317
1318    fn timed(offset: Option<&str>, timestamp: Option<&str>, event: Value) -> TimedEvent {
1319        TimedEvent {
1320            timestamp: timestamp.map(str::to_string),
1321            offset: offset.map(str::to_string),
1322            event,
1323            source: SourceLocation::default(),
1324        }
1325    }
1326
1327    fn group(id: &str, user: &str, reversed: bool) -> GroupedExemplar {
1328        let first = timed(
1329            Some(if reversed { "20s" } else { "0s" }),
1330            None,
1331            json!({"kind": "reset", "user": user, "factor": "totp", "reset_reason": "recovery"}),
1332        );
1333        let second = timed(
1334            Some(if reversed { "0s" } else { "20s" }),
1335            None,
1336            json!({"kind": "session", "user": user, "new_asn": true, "asn": 64512, "session_type": "web"}),
1337        );
1338        GroupedExemplar {
1339            id: id.to_string(),
1340            events: vec![first, second],
1341        }
1342    }
1343
1344    fn groups() -> Vec<GroupedExemplar> {
1345        vec![
1346            group("g1", "alice", false),
1347            group("g2", "bob", false),
1348            group("g3", "carol", false),
1349        ]
1350    }
1351
1352    fn config() -> CorrelationDraftConfig {
1353        CorrelationDraftConfig {
1354            correlation_id: Some("00000000-0000-4000-8000-000000000003".to_string()),
1355            slot_ids: vec![
1356                "00000000-0000-4000-8000-000000000001".to_string(),
1357                "00000000-0000-4000-8000-000000000002".to_string(),
1358            ],
1359            detection: DraftConfig {
1360                date: Some("2026-09-04".to_string()),
1361                min_fields: 1,
1362                ..DraftConfig::default()
1363            },
1364            ..CorrelationDraftConfig::default()
1365        }
1366    }
1367
1368    #[test]
1369    fn validates_and_sorts_timestamp_groups() {
1370        let mut values = groups();
1371        for (index, group) in values.iter_mut().enumerate() {
1372            group.events[0].offset = None;
1373            group.events[0].timestamp = Some(format!("2026-01-01T00:00:{:02}Z", 20 + index));
1374            group.events[1].offset = None;
1375            group.events[1].timestamp = Some(format!("2026-01-01T00:00:{index:02}Z"));
1376        }
1377        let normalized = normalize_groups(&values, 3).unwrap();
1378        assert!(
1379            normalized
1380                .iter()
1381                .all(|group| group.events[0].time < group.events[1].time)
1382        );
1383    }
1384
1385    #[test]
1386    fn validates_offset_groups() {
1387        let normalized = normalize_groups(&groups(), 3).unwrap();
1388        assert_eq!(normalized[0].events[0].time, 0);
1389        assert_eq!(normalized[0].events[1].time, 20);
1390    }
1391
1392    #[test]
1393    fn rejects_mixed_neither_both_invalid_and_duplicate_times() {
1394        let mut mixed = groups();
1395        mixed[0].events[1].offset = None;
1396        mixed[0].events[1].timestamp = Some("2026-01-01T00:00:00Z".to_string());
1397        assert!(matches!(
1398            normalize_groups(&mixed, 3),
1399            Err(CorrelationDraftError::MixedTimeMode { .. })
1400        ));
1401
1402        let mut neither = groups();
1403        neither[0].events[0].offset = None;
1404        assert!(matches!(
1405            normalize_groups(&neither, 3),
1406            Err(CorrelationDraftError::InvalidTimeKeys { .. })
1407        ));
1408
1409        let mut both = groups();
1410        both[0].events[0].timestamp = Some("2026-01-01T00:00:00Z".to_string());
1411        assert!(matches!(
1412            normalize_groups(&both, 3),
1413            Err(CorrelationDraftError::InvalidTimeKeys { .. })
1414        ));
1415
1416        let mut invalid = groups();
1417        invalid[0].events[0].offset = Some("bad".to_string());
1418        assert!(matches!(
1419            normalize_groups(&invalid, 3),
1420            Err(CorrelationDraftError::InvalidTime { .. })
1421        ));
1422
1423        let mut duplicate = groups();
1424        duplicate[0].events[1].offset = Some("0s".to_string());
1425        assert!(matches!(
1426            normalize_groups(&duplicate, 3),
1427            Err(CorrelationDraftError::DuplicateTime { .. })
1428        ));
1429    }
1430
1431    #[test]
1432    fn rejects_too_few_groups_and_events() {
1433        assert!(matches!(
1434            normalize_groups(&groups()[..2], 3),
1435            Err(CorrelationDraftError::TooFewGroups { .. })
1436        ));
1437        let mut values = groups();
1438        values[0].events.pop();
1439        assert!(matches!(
1440            normalize_groups(&values, 3),
1441            Err(CorrelationDraftError::TooFewEvents { .. })
1442        ));
1443    }
1444
1445    #[test]
1446    fn drafts_ordered_collection_deterministically() {
1447        let first = draft_correlation(&groups(), &[], &[], &config()).unwrap();
1448        let second = draft_correlation(&groups(), &[], &[], &config()).unwrap();
1449        assert_eq!(first.rule_yaml, second.rule_yaml);
1450        assert_eq!(
1451            first.rule_yaml,
1452            include_str!("golden/correlation_ordered.yaml")
1453        );
1454        assert_eq!(first.correlation_type, "temporal_ordered");
1455        assert_eq!(first.group_by, vec!["user"]);
1456        assert!(first.rule_yaml.contains("rsigma.exemplars:"));
1457        assert!(rsigma_parser::parse_sigma_yaml(&first.rule_yaml).is_ok());
1458        assert!(first.verification.iter().all(|row| row.fired));
1459    }
1460
1461    #[test]
1462    fn unordered_collection_matches_golden() {
1463        let mut cfg = config();
1464        cfg.correlation_type = CorrelationDraftType::Temporal;
1465        let report = draft_correlation(&groups(), &[], &[], &cfg).unwrap();
1466        assert_eq!(
1467            report.rule_yaml,
1468            include_str!("golden/correlation_unordered.yaml")
1469        );
1470    }
1471
1472    #[test]
1473    fn auto_downgrades_order_inversions_and_forced_order_errors() {
1474        let mut values = groups();
1475        values[2] = group("g3", "carol", true);
1476        let report = draft_correlation(&values, &[], &[], &config()).unwrap();
1477        assert_eq!(report.correlation_type, "temporal");
1478        assert!(report.warnings.iter().any(|warning| warning.contains("g3")));
1479
1480        let mut forced = config();
1481        forced.correlation_type = CorrelationDraftType::TemporalOrdered;
1482        assert!(matches!(
1483            draft_correlation(&values, &[], &[], &forced),
1484            Err(CorrelationDraftError::OrderInversion { .. })
1485        ));
1486    }
1487
1488    #[test]
1489    fn majority_order_blames_the_actual_outlier() {
1490        // g1 is the inverted group; the warning must name g1 alone, not the
1491        // majority that happens to differ from the first-sorted group.
1492        let mut values = groups();
1493        values[0] = group("g1", "alice", true);
1494        let report = draft_correlation(&values, &[], &[], &config()).unwrap();
1495        assert_eq!(report.correlation_type, "temporal");
1496        assert!(report.warnings.iter().any(|warning| {
1497            warning.contains("g1") && !warning.contains("g2") && !warning.contains("g3")
1498        }));
1499    }
1500
1501    #[test]
1502    fn window_uses_maximum_span_and_never_rounds_down() {
1503        let mut values = groups();
1504        values[1].events[1].offset = Some("61s".to_string());
1505        let report = draft_correlation(&values, &[], &[], &config()).unwrap();
1506        assert_eq!(report.span_seconds, vec![20, 61, 20]);
1507        assert_eq!(report.timespan, "5m");
1508        assert!(matches!(
1509            infer_window(
1510                &normalize_groups(&groups(), 3).unwrap(),
1511                &BTreeSet::new(),
1512                0.9
1513            ),
1514            Err(CorrelationDraftError::InvalidWindowMargin(_))
1515        ));
1516        assert_eq!(round_window(86_401).unwrap(), "2d");
1517    }
1518
1519    #[test]
1520    fn incidental_clusters_are_dropped_with_a_warning() {
1521        let mut values = groups();
1522        values[0].events.push(timed(
1523            Some("10s"),
1524            None,
1525            json!({"audit_only": true, "trace_marker": "one"}),
1526        ));
1527        let report = draft_correlation(&values, &[], &[], &config()).unwrap();
1528        assert!(
1529            report
1530                .warnings
1531                .iter()
1532                .any(|warning| warning.contains("incidental key-shape"))
1533        );
1534        assert_eq!(report.slots.len(), 2);
1535    }
1536
1537    #[test]
1538    fn repeated_retained_slot_is_a_pointed_error() {
1539        let mut values = groups();
1540        values[0].events.push(timed(
1541            Some("10s"),
1542            None,
1543            json!({"kind": "reset", "user": "alice", "factor": "totp", "reset_reason": "recovery"}),
1544        ));
1545        // Input positions, not time-sorted positions: the duplicate reset was
1546        // appended as input event 2 even though it sorts between the others.
1547        assert!(matches!(
1548            draft_correlation(&values, &[], &[], &config()),
1549            Err(CorrelationDraftError::DuplicateSlot {
1550                group,
1551                slot: _,
1552                events
1553            }) if group == "g1" && events == vec![0, 2]
1554        ));
1555    }
1556
1557    #[test]
1558    fn missing_slot_ids_error_before_verification() {
1559        let mut cfg = config();
1560        cfg.slot_ids.truncate(1);
1561        cfg.detection.rule_id = None;
1562        assert!(matches!(
1563            draft_correlation(&groups(), &[], &[], &cfg),
1564            Err(CorrelationDraftError::MissingSlotIds {
1565                slots: 2,
1566                provided: 1
1567            })
1568        ));
1569    }
1570
1571    #[test]
1572    fn too_few_recurring_slots_is_an_error() {
1573        let values: Vec<GroupedExemplar> = [("g1", "alice"), ("g2", "bob"), ("g3", "carol")]
1574            .into_iter()
1575            .map(|(id, user)| GroupedExemplar {
1576                id: id.to_string(),
1577                events: vec![
1578                    timed(Some("0s"), None, json!({"kind": "same", "user": user})),
1579                    timed(Some("10s"), None, json!({"kind": "other", "user": user})),
1580                ],
1581            })
1582            .collect();
1583        assert!(matches!(
1584            draft_correlation(&values, &[], &[], &config()),
1585            Err(CorrelationDraftError::TooFewSlots {
1586                minimum: 2,
1587                actual: 1
1588            })
1589        ));
1590    }
1591
1592    #[test]
1593    fn explicit_composite_entity_is_excluded_from_slots() {
1594        let mut values = groups();
1595        for group in &mut values {
1596            let tenant = format!("tenant-{}", group.id);
1597            for event in &mut group.events {
1598                event.event["tenant"] = json!(tenant);
1599            }
1600        }
1601        let mut cfg = config();
1602        cfg.group_by = vec!["user".to_string(), "tenant".to_string()];
1603        let report = draft_correlation(&values, &[], &[], &cfg).unwrap();
1604        assert_eq!(report.group_by, cfg.group_by);
1605        assert!(
1606            report
1607                .slots
1608                .iter()
1609                .flat_map(|slot| &slot.selected_fields)
1610                .all(|field| !field.starts_with("user=") && !field.starts_with("tenant="))
1611        );
1612    }
1613
1614    #[test]
1615    fn rejects_ambiguous_entity_and_negative_matches() {
1616        let mut ambiguous = groups();
1617        for group in &mut ambiguous {
1618            let tenant = format!("tenant-{}", group.id);
1619            for event in &mut group.events {
1620                event.event["tenant"] = json!(tenant);
1621            }
1622        }
1623        match draft_correlation(&ambiguous, &[], &[], &config()) {
1624            Err(CorrelationDraftError::AmbiguousEntity { candidates }) => {
1625                assert_eq!(candidates, vec!["tenant", "user"]);
1626            }
1627            other => panic!("expected deterministic entity ambiguity, got {other:?}"),
1628        }
1629        assert!(matches!(
1630            draft_correlation(&groups(), &[group("bad", "mallory", false)], &[], &config()),
1631            Err(CorrelationDraftError::NegativeGroupMatched { .. })
1632        ));
1633    }
1634
1635    #[test]
1636    fn invalid_entity_reports_input_position() {
1637        // g1 arrives in reverse input order, so input event 1 sorts first.
1638        // Dropping the group-by field there must blame input position 1.
1639        let mut values = groups();
1640        values[0] = group("g1", "alice", true);
1641        values[0].events[1]
1642            .event
1643            .as_object_mut()
1644            .unwrap()
1645            .remove("user");
1646        let mut cfg = config();
1647        cfg.group_by = vec!["user".to_string()];
1648        assert!(matches!(
1649            draft_correlation(&values, &[], &[], &cfg),
1650            Err(CorrelationDraftError::InvalidEntity { field, group, event })
1651                if field == "user" && group == "g1" && event == 1
1652        ));
1653    }
1654
1655    #[test]
1656    fn entity_inference_refuses_to_guess_when_no_field_is_group_stable() {
1657        let mut values = groups();
1658        for group in &mut values {
1659            group.events[1].event["user"] = json!(format!("{}-other", group.id));
1660        }
1661        assert!(matches!(
1662            draft_correlation(&values, &[], &[], &config()),
1663            Err(CorrelationDraftError::AmbiguousEntity { candidates }) if candidates.is_empty()
1664        ));
1665    }
1666
1667    #[test]
1668    fn clean_negative_group_isolated_replay_stays_clear() {
1669        let mut negative = group("benign", "mallory", false);
1670        negative.events[0].event["factor"] = json!("webauthn");
1671        negative.events[0].event["reset_reason"] = json!("admin");
1672        negative.events[1].event["asn"] = json!(64496);
1673        negative.events[1].event["new_asn"] = json!(false);
1674        negative.events[1].event["session_type"] = json!("mobile");
1675        let report = draft_correlation(&groups(), &[negative], &[], &config()).unwrap();
1676        assert!(
1677            report
1678                .verification
1679                .iter()
1680                .any(|row| { row.group == "benign" && row.negative && !row.fired })
1681        );
1682    }
1683
1684    #[test]
1685    fn negative_events_outside_retained_clusters_still_replay() {
1686        // Slot rules match on field predicates, not key shapes. These negative
1687        // events carry enough extra keys to fall below the Jaccard threshold
1688        // against every retained slot, yet still satisfy the slot selections,
1689        // so skipping them would falsely pass the negative gate.
1690        let mut negative = group("shifted", "mallory", false);
1691        for (event, extras) in negative.events.iter_mut().zip([3usize, 4]) {
1692            for extra in 0..extras {
1693                event.event[format!("extra_{extra}")] = json!("noise");
1694            }
1695        }
1696        assert!(matches!(
1697            draft_correlation(&groups(), &[negative], &[], &config()),
1698            Err(CorrelationDraftError::NegativeGroupMatched { groups }) if groups == vec!["shifted"]
1699        ));
1700    }
1701
1702    #[test]
1703    fn verification_ignores_unrelated_correlation_results() {
1704        let report = draft_correlation(&groups(), &[], &[], &config()).unwrap();
1705        let collection = rsigma_parser::parse_sigma_yaml(&report.rule_yaml).unwrap();
1706        let mut normalized = normalize_groups(&groups(), 3).unwrap();
1707        assign_clusters(&mut normalized, 0.6);
1708        let (retained, _) = retained_clusters(&normalized, 2).unwrap();
1709        assert!(matches!(
1710            verify_groups(
1711                &collection,
1712                &normalized,
1713                &retained,
1714                "unrelated-correlation",
1715                false
1716            ),
1717            Err(CorrelationDraftError::PositiveVerification { .. })
1718        ));
1719    }
1720
1721    #[test]
1722    fn verification_rejects_premature_target_firing() {
1723        let report = draft_correlation(&groups(), &[], &[], &config()).unwrap();
1724        let yaml = report
1725            .rule_yaml
1726            .replace(
1727                "        - slot_totp\n        - slot_64512\n",
1728                "        - slot_totp\n",
1729            )
1730            .replace("        gte: 2\n", "        gte: 1\n");
1731        let collection = rsigma_parser::parse_sigma_yaml(&yaml).unwrap();
1732        let mut normalized = normalize_groups(&groups(), 3).unwrap();
1733        assign_clusters(&mut normalized, 0.6);
1734        let (retained, _) = retained_clusters(&normalized, 2).unwrap();
1735        assert!(matches!(
1736            verify_groups(
1737                &collection,
1738                &normalized,
1739                &retained,
1740                "00000000-0000-4000-8000-000000000003",
1741                false
1742            ),
1743            Err(CorrelationDraftError::PositiveVerification { reason, .. })
1744                if reason.contains("prematurely")
1745        ));
1746    }
1747}