Skip to main content

rsigma_eval/
schema_discovery.rs

1//! Schema signature discovery: mine unrecognized events into candidate
2//! declarative [`SchemaSignature`]s a human reviews and commits.
3//!
4//! The shipped schema work recognizes events against declarative signatures
5//! ([`crate::schema`]) and surfaces whatever matches none: the classifier
6//! reports `unknown`, and the [`SchemaObserver`](crate::SchemaObserver) samples
7//! bounded, redacted field-key shapes of those unknowns. This module turns that
8//! signal into ranked candidate signatures, so an operator stops hand-authoring
9//! every signature from scratch.
10//!
11//! The "learning" here is glass-box unsupervised mining, not a black-box model:
12//! cluster the unrecognized events by structural fingerprint, pick the fields
13//! (and low-cardinality values) that discriminate each cluster, and emit the
14//! same `schemas:` YAML the classifier already consumes via
15//! [`parse_schema_signatures`](crate::parse_schema_signatures). Every proposed
16//! predicate is human-readable and explainable from the reported stats.
17//!
18//! # Two inputs, one core
19//!
20//! - **Offline** ([`mine_events`]): a raw event corpus. Events already
21//!   recognized by a built-in or user signature are excluded; only `unknown`
22//!   and `generic_json` events are mined. Low-cardinality, non-sensitive field
23//!   values are retained in-process so candidates can carry `equals`/`in` value
24//!   predicates.
25//! - **Online** ([`mine_shapes`]): the daemon's already-captured
26//!   [`UnknownShapeEntry`] sample. That sample is
27//!   keys-only by construction (values are never retained), so online proposals
28//!   use presence predicates only and are tagged [`CandidateSource::KeysOnly`].
29//!
30//! Both feed the same cluster/select/rank stages, so the two surfaces cannot
31//! drift on what a "good" signature looks like.
32//!
33//! Detection-side only: discovery reads events or a redacted sample and
34//! proposes config. It does not collect, transport, or normalize events, and it
35//! never applies a discovered signature on its own.
36
37use std::collections::{BTreeMap, BTreeSet, HashMap};
38
39use serde::Serialize;
40
41use crate::event::Event;
42use crate::schema::{
43    SchemaClassifier, SchemaPredicate, SchemaSignature, UnknownShapeEntry, validate_schema_config,
44};
45
46// =============================================================================
47// Configuration
48// =============================================================================
49
50/// Tunables for a discovery run. [`Default`] is a sensible starting point;
51/// the CLI exposes each as a flag.
52#[derive(Debug, Clone)]
53pub struct DiscoveryConfig {
54    /// Minimum number of events a cluster must contain to yield a candidate.
55    /// Filters out one-off shapes that are not worth a signature.
56    pub min_support: u64,
57    /// Jaccard similarity (0.0-1.0) at or above which a shape merges into an
58    /// existing cluster. Higher means stricter (more, tighter clusters).
59    pub similarity: f64,
60    /// Maximum number of candidates emitted, highest support first.
61    pub max_candidates: usize,
62    /// Maximum predicates in a single candidate signature. Kept small so
63    /// proposals stay readable and reviewable.
64    pub max_predicates: usize,
65    /// Whether to propose `equals`/`in` value predicates (offline only; the
66    /// online path never has values regardless of this flag).
67    pub value_markers: bool,
68    /// A field is only a value-marker candidate when its distinct string values
69    /// within a cluster do not exceed this cap (a low-cardinality constant like
70    /// `vendor` or `Channel`, not a free-form field).
71    pub max_value_cardinality: usize,
72    /// Fraction (0.0-1.0) of a cluster's events a field must appear in to be a
73    /// "core" field eligible as a predicate.
74    pub core_presence: f64,
75}
76
77impl Default for DiscoveryConfig {
78    fn default() -> Self {
79        Self {
80            min_support: 3,
81            similarity: 0.6,
82            max_candidates: 20,
83            max_predicates: 3,
84            value_markers: true,
85            max_value_cardinality: 8,
86            core_presence: 0.9,
87        }
88    }
89}
90
91// =============================================================================
92// Public report types
93// =============================================================================
94
95/// Where a candidate's evidence came from, and therefore how strong it is.
96#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
97#[serde(rename_all = "kebab-case")]
98pub enum CandidateSource {
99    /// Mined from a raw corpus; may carry value predicates.
100    Corpus,
101    /// Mined from the daemon's keys-only unknown-shape sample; presence
102    /// predicates only.
103    KeysOnly,
104}
105
106/// Per-field statistics gathered over a set of events in scope (a cluster or a
107/// whole corpus). A small standalone type so other corpus-analysis features can
108/// build on the same profile rather than duplicating the aggregation.
109#[derive(Debug, Clone, PartialEq, Eq)]
110pub struct FieldProfile {
111    /// Dot-joined field path.
112    pub field: String,
113    /// Events in scope that contained the field.
114    pub present: u64,
115    /// Total events in scope.
116    pub total: u64,
117    /// Distinct string values seen, sorted, capped for memory. Empty when
118    /// values were not retained (the online path) or the field is not
119    /// string-valued.
120    pub distinct_values: Vec<String>,
121    /// True when more distinct values were seen than retained, or a value was
122    /// dropped as too long/sensitive to retain, so `distinct_values` is not the
123    /// full set and the field is not a safe value marker.
124    pub value_overflow: bool,
125}
126
127impl FieldProfile {
128    /// Fraction of in-scope events that contained the field (0.0-1.0).
129    pub fn prevalence(&self) -> f64 {
130        if self.total == 0 {
131            0.0
132        } else {
133            self.present as f64 / self.total as f64
134        }
135    }
136
137    /// Number of distinct retained string values.
138    pub fn cardinality(&self) -> usize {
139        self.distinct_values.len()
140    }
141}
142
143/// One proposed signature plus the evidence behind it.
144#[derive(Debug, Clone)]
145pub struct DiscoveryCandidate {
146    /// Placeholder schema name (a human should rename it).
147    pub name: String,
148    /// Suggested tie-break specificity, above `generic_json` and below the
149    /// strong built-ins.
150    pub specificity: u32,
151    /// The conjunction of predicates that recognizes the cluster.
152    pub predicates: Vec<SchemaPredicate>,
153    /// Events in the cluster this candidate was mined from.
154    pub support: u64,
155    /// `support` as a fraction of all mined events (0.0-1.0).
156    pub coverage_of_unknown: f64,
157    /// A few representative (redacted) field-key sets from the cluster, capped.
158    pub sample_field_sets: Vec<Vec<String>>,
159    /// Advisory notes: shadowing against built-ins, incomplete separation, etc.
160    pub overlap_warnings: Vec<String>,
161    /// Corpus (value-capable) or keys-only (presence-only).
162    pub source: CandidateSource,
163}
164
165impl DiscoveryCandidate {
166    /// The candidate as a [`SchemaSignature`] (for validation, dry-run
167    /// reclassification, or loading into a classifier).
168    pub fn signature(&self) -> SchemaSignature {
169        SchemaSignature {
170            name: self.name.clone(),
171            predicates: self.predicates.clone(),
172            specificity: self.specificity,
173        }
174    }
175
176    /// Human-readable one-line descriptions of the predicates, in order.
177    pub fn predicate_descriptions(&self) -> Vec<String> {
178        self.predicates.iter().map(describe_predicate).collect()
179    }
180}
181
182/// Summary counters for a discovery run.
183#[derive(Debug, Clone, Default, PartialEq, Eq)]
184pub struct DiscoveryStats {
185    /// Events fed into mining (offline: unknown/generic_json only; online: the
186    /// sum of sampled shape counts).
187    pub events_mined: u64,
188    /// Distinct field-key shapes seen.
189    pub shapes: usize,
190    /// Clusters formed.
191    pub clusters: usize,
192    /// Candidates emitted (after `min_support` and `max_candidates`).
193    pub candidates: usize,
194}
195
196/// The result of a discovery run: ranked candidates plus run stats.
197#[derive(Debug, Clone)]
198pub struct DiscoveryReport {
199    /// Candidates, highest support first.
200    pub candidates: Vec<DiscoveryCandidate>,
201    /// Run stats.
202    pub stats: DiscoveryStats,
203}
204
205impl DiscoveryReport {
206    /// Render the candidates as a `schemas:` YAML block ready to paste into a
207    /// `--schema-config` file. Guaranteed to round-trip through
208    /// [`parse_schema_signatures`](crate::parse_schema_signatures).
209    pub fn to_signatures_yaml(&self) -> String {
210        if self.candidates.is_empty() {
211            return "schemas: []\n".to_string();
212        }
213        let mut out = String::from("schemas:\n");
214        for c in &self.candidates {
215            out.push_str(&format!("  - name: {}\n", yaml_scalar(&c.name)));
216            out.push_str(&format!("    specificity: {}\n", c.specificity));
217            out.push_str("    match:\n");
218            for p in &c.predicates {
219                out.push_str(&predicate_to_yaml(p));
220            }
221        }
222        out
223    }
224}
225
226// =============================================================================
227// Entry points
228// =============================================================================
229
230/// Mine a raw event corpus (offline). Events recognized by `classifier` as any
231/// specific schema are excluded; only `unknown` and `generic_json` events are
232/// mined. Pass a classifier built from the built-ins plus any user signatures
233/// (via [`SchemaClassifier::with_user_signatures`]) so already-defined schemas
234/// are never re-proposed.
235pub fn mine_events<E, I>(
236    events: I,
237    classifier: &SchemaClassifier,
238    config: &DiscoveryConfig,
239) -> DiscoveryReport
240where
241    E: Event,
242    I: IntoIterator<Item = E>,
243{
244    // Aggregate mined events into distinct key-set shapes, tracking
245    // low-cardinality string values per field for value-marker proposals.
246    let mut shapes: HashMap<Vec<String>, ShapeStat> = HashMap::new();
247    let mut events_mined: u64 = 0;
248
249    for event in events {
250        // Prefilter: skip anything a specific schema already recognizes.
251        // generic_json is the low-specificity catch-all, so it counts as
252        // "unrecognized" and is mineable.
253        match classifier.classify(&event) {
254            Some(m) if m.name != "generic_json" => continue,
255            _ => {}
256        }
257
258        let mut keys: Vec<String> = event
259            .field_keys()
260            .into_iter()
261            .map(|k| k.into_owned())
262            .collect();
263        keys.sort();
264        keys.dedup();
265        if keys.is_empty() {
266            continue;
267        }
268        events_mined += 1;
269
270        let entry = shapes.entry(keys.clone()).or_insert_with(|| ShapeStat {
271            keys,
272            count: 0,
273            values: HashMap::new(),
274        });
275        entry.count += 1;
276        if config.value_markers {
277            for field in &entry.keys.clone() {
278                if let Some(val) = event
279                    .get_field(field)
280                    .and_then(|v| v.as_str().map(|s| s.into_owned()))
281                {
282                    entry
283                        .values
284                        .entry(field.clone())
285                        .or_default()
286                        .record(&val, config.max_value_cardinality);
287                }
288            }
289        }
290    }
291
292    let shape_vec: Vec<ShapeStat> = shapes.into_values().collect();
293    build_report(shape_vec, events_mined, config, CandidateSource::Corpus)
294}
295
296/// Mine the daemon's keys-only unknown-shape sample (online). Proposals use
297/// presence predicates only (values are never retained in the sample).
298pub fn mine_shapes(shapes: &[UnknownShapeEntry], config: &DiscoveryConfig) -> DiscoveryReport {
299    let (shape_vec, events_mined) = shape_stats_from_entries(shapes);
300    build_report(shape_vec, events_mined, config, CandidateSource::KeysOnly)
301}
302
303/// Count how many distinct schema clusters the keys-only sample forms, without
304/// the cost of selecting, validating, and ranking candidates. Cheap enough to
305/// refresh a gauge on every metrics scrape; equal to
306/// [`mine_shapes`]`(shapes, config).stats.clusters`.
307pub fn cluster_count(shapes: &[UnknownShapeEntry], config: &DiscoveryConfig) -> usize {
308    let (mut shape_vec, _) = shape_stats_from_entries(shapes);
309    shape_vec.sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.keys.cmp(&b.keys)));
310    cluster_shapes(&shape_vec, config).len()
311}
312
313/// Convert redacted keys-only shape entries into internal `ShapeStat`s (no
314/// values), returning the total event count. Skips empty-key shapes.
315fn shape_stats_from_entries(shapes: &[UnknownShapeEntry]) -> (Vec<ShapeStat>, u64) {
316    let mut events_mined: u64 = 0;
317    let shape_vec: Vec<ShapeStat> = shapes
318        .iter()
319        .filter(|s| !s.keys.is_empty())
320        .map(|s| {
321            events_mined += s.count;
322            let mut keys = s.keys.clone();
323            keys.sort();
324            keys.dedup();
325            ShapeStat {
326                keys,
327                count: s.count,
328                values: HashMap::new(),
329            }
330        })
331        .collect();
332    (shape_vec, events_mined)
333}
334
335// =============================================================================
336// Internal aggregation types
337// =============================================================================
338
339/// A distinct field-key shape with its event count and (offline) per-field
340/// value accumulators.
341struct ShapeStat {
342    keys: Vec<String>,
343    count: u64,
344    values: HashMap<String, ValueAcc>,
345}
346
347/// Accumulates the distinct string values of one field, capped, dropping values
348/// that are too long or structured to be safe, stable markers.
349#[derive(Default, Clone)]
350struct ValueAcc {
351    values: BTreeSet<String>,
352    /// Events (with this shape) where the field held a usable string value.
353    count: u64,
354    /// True once the distinct set exceeded the cap or an unusable value was
355    /// seen, disqualifying the field as a value marker.
356    overflow: bool,
357}
358
359impl ValueAcc {
360    fn record(&mut self, value: &str, cap: usize) {
361        if looks_sensitive(value) {
362            self.overflow = true;
363            return;
364        }
365        self.count += 1;
366        if self.values.contains(value) {
367            return;
368        }
369        if self.values.len() >= cap {
370            self.overflow = true;
371            return;
372        }
373        self.values.insert(value.to_string());
374    }
375
376    /// The field is a usable value marker: values retained, none dropped, and
377    /// present with a string value in nearly every event.
378    fn usable(&self, cluster_total: u64, core_presence: f64) -> bool {
379        !self.overflow
380            && !self.values.is_empty()
381            && cluster_total > 0
382            && (self.count as f64 / cluster_total as f64) >= core_presence
383    }
384}
385
386/// A merged cluster of similar shapes.
387struct Cluster {
388    /// Key set of the first shape that formed the cluster (the merge anchor).
389    seed_keys: Vec<String>,
390    total: u64,
391    /// Per-key event count within the cluster.
392    key_counts: HashMap<String, u64>,
393    /// Per-field merged value accumulators (empty on the online path).
394    values: HashMap<String, ValueAcc>,
395    /// A few representative key sets, capped, for the report.
396    sample_keys: Vec<Vec<String>>,
397}
398
399const MAX_SAMPLE_KEYSETS: usize = 3;
400const MAX_SAMPLE_KEYS_PER_SET: usize = 24;
401/// Ceiling on distinct values retained per field while merging shapes into a
402/// cluster; past this the field is flagged overflow and disqualified as a value
403/// marker.
404const VALUE_MERGE_CAP: usize = 64;
405
406impl Cluster {
407    fn from_shape(shape: &ShapeStat) -> Self {
408        let mut key_counts = HashMap::new();
409        for k in &shape.keys {
410            key_counts.insert(k.clone(), shape.count);
411        }
412        Cluster {
413            seed_keys: shape.keys.clone(),
414            total: shape.count,
415            key_counts,
416            values: shape.values.clone(),
417            sample_keys: vec![truncate_keys(&shape.keys)],
418        }
419    }
420
421    fn merge(&mut self, shape: &ShapeStat) {
422        self.total += shape.count;
423        for k in &shape.keys {
424            *self.key_counts.entry(k.clone()).or_insert(0) += shape.count;
425        }
426        for (field, acc) in &shape.values {
427            let dst = self.values.entry(field.clone()).or_default();
428            dst.count += acc.count;
429            dst.overflow |= acc.overflow;
430            for v in &acc.values {
431                if dst.values.len() >= VALUE_MERGE_CAP {
432                    dst.overflow = true;
433                    break;
434                }
435                dst.values.insert(v.clone());
436            }
437        }
438        if self.sample_keys.len() < MAX_SAMPLE_KEYSETS {
439            let t = truncate_keys(&shape.keys);
440            if !self.sample_keys.contains(&t) {
441                self.sample_keys.push(t);
442            }
443        }
444    }
445
446    /// Fields present in nearly every cluster event, sorted by name.
447    fn core_fields(&self, core_presence: f64) -> Vec<String> {
448        let mut fields: Vec<String> = self
449            .key_counts
450            .iter()
451            .filter(|&(_, &c)| self.total > 0 && (c as f64 / self.total as f64) >= core_presence)
452            .map(|(k, _)| k.clone())
453            .collect();
454        fields.sort();
455        fields
456    }
457}
458
459// =============================================================================
460// Mining pipeline
461// =============================================================================
462
463fn build_report(
464    mut shapes: Vec<ShapeStat>,
465    events_mined: u64,
466    config: &DiscoveryConfig,
467    source: CandidateSource,
468) -> DiscoveryReport {
469    let shape_count = shapes.len();
470
471    // Deterministic order: most frequent first, then lexicographic by keys.
472    shapes.sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.keys.cmp(&b.keys)));
473
474    let clusters = cluster_shapes(&shapes, config);
475
476    // Global per-key counts for cross-cluster discriminativeness.
477    let mut global_key_counts: HashMap<String, u64> = HashMap::new();
478    for cluster in &clusters {
479        for (k, c) in &cluster.key_counts {
480            *global_key_counts.entry(k.clone()).or_insert(0) += *c;
481        }
482    }
483    let total_events: u64 = clusters.iter().map(|c| c.total).sum();
484
485    // Build one candidate per qualifying cluster.
486    let mut candidates: Vec<DiscoveryCandidate> = Vec::new();
487    let mut used_names: BTreeMap<String, u32> = BTreeMap::new();
488    for (idx, cluster) in clusters.iter().enumerate() {
489        if cluster.total < config.min_support {
490            continue;
491        }
492        if let Some(mut candidate) = select_candidate(
493            cluster,
494            idx,
495            &clusters,
496            &global_key_counts,
497            total_events,
498            config,
499            source,
500        ) {
501            candidate.name = unique_name(candidate.name, &mut used_names);
502            candidates.push(candidate);
503        }
504    }
505
506    // Rank: support desc, coverage desc, name asc. Then cap.
507    candidates.sort_by(|a, b| {
508        b.support
509            .cmp(&a.support)
510            .then_with(|| {
511                b.coverage_of_unknown
512                    .partial_cmp(&a.coverage_of_unknown)
513                    .unwrap_or(std::cmp::Ordering::Equal)
514            })
515            .then_with(|| a.name.cmp(&b.name))
516    });
517    candidates.truncate(config.max_candidates);
518
519    let stats = DiscoveryStats {
520        events_mined,
521        shapes: shape_count,
522        clusters: clusters.len(),
523        candidates: candidates.len(),
524    };
525    DiscoveryReport { candidates, stats }
526}
527
528/// Greedy Jaccard clustering with a value-based diversity guard. Expects
529/// `shapes` already ordered most-frequent-first (the merge anchor is the first
530/// shape of each cluster), so the result is deterministic.
531fn cluster_shapes(shapes: &[ShapeStat], config: &DiscoveryConfig) -> Vec<Cluster> {
532    let mut clusters: Vec<Cluster> = Vec::new();
533    for shape in shapes {
534        let mut placed = false;
535        for cluster in &mut clusters {
536            if jaccard(&shape.keys, &cluster.seed_keys) >= config.similarity
537                && diversity_ok(cluster, shape)
538            {
539                cluster.merge(shape);
540                placed = true;
541                break;
542            }
543        }
544        if !placed {
545            clusters.push(Cluster::from_shape(shape));
546        }
547    }
548    clusters
549}
550
551/// Refuse to merge a shape into a cluster when they disagree on a single
552/// otherwise-constant marker field (for example `vendor: foo` vs `vendor: bar`),
553/// which would fuse two genuinely different schemas. Value-based, so it is a
554/// no-op on the keys-only online path.
555fn diversity_ok(cluster: &Cluster, shape: &ShapeStat) -> bool {
556    for (field, shape_acc) in &shape.values {
557        if shape_acc.overflow || shape_acc.values.is_empty() {
558            continue;
559        }
560        let Some(cluster_acc) = cluster.values.get(field) else {
561            continue;
562        };
563        if cluster_acc.overflow || cluster_acc.values.is_empty() {
564            continue;
565        }
566        // Only guard on fields that look like a constant marker on both sides
567        // (low cardinality) and that are core to the cluster.
568        let core = cluster
569            .key_counts
570            .get(field)
571            .is_some_and(|&c| cluster.total > 0 && (c as f64 / cluster.total as f64) >= 0.9);
572        let low_card = cluster_acc.values.len() <= 4 && shape_acc.values.len() <= 4;
573        if core && low_card && cluster_acc.values.is_disjoint(&shape_acc.values) {
574            return false;
575        }
576    }
577    true
578}
579
580#[allow(clippy::too_many_arguments)]
581fn select_candidate(
582    cluster: &Cluster,
583    cluster_idx: usize,
584    all: &[Cluster],
585    global_key_counts: &HashMap<String, u64>,
586    total_events: u64,
587    config: &DiscoveryConfig,
588    source: CandidateSource,
589) -> Option<DiscoveryCandidate> {
590    let core = cluster.core_fields(config.core_presence);
591    if core.is_empty() {
592        return None;
593    }
594
595    // Score each core field by discriminativeness minus a value-cardinality
596    // penalty, so a rarer / lower-cardinality marker ranks above a near-ubiquitous
597    // field. Deterministic tie-break by field name.
598    let out_total = total_events.saturating_sub(cluster.total);
599    let mut scored: Vec<(String, f64)> = core
600        .iter()
601        .map(|field| {
602            let in_count = cluster.key_counts.get(field).copied().unwrap_or(0);
603            let in_frac = in_count as f64 / cluster.total.max(1) as f64;
604            let out_count = global_key_counts
605                .get(field)
606                .copied()
607                .unwrap_or(0)
608                .saturating_sub(in_count);
609            let out_frac = if out_total == 0 {
610                0.0
611            } else {
612                out_count as f64 / out_total as f64
613            };
614            let value_card = cluster
615                .values
616                .get(field)
617                .map(|a| a.values.len())
618                .unwrap_or(0);
619            let card_penalty = 0.15 * ((1 + value_card) as f64).ln();
620            (field.clone(), in_frac - out_frac - card_penalty)
621        })
622        .collect();
623    scored.sort_by(|a, b| {
624        b.1.partial_cmp(&a.1)
625            .unwrap_or(std::cmp::Ordering::Equal)
626            .then_with(|| a.0.cmp(&b.0))
627    });
628
629    // Greedily add predicates until the conjunction separates this cluster from
630    // the others, or the predicate budget is spent.
631    let mut predicates: Vec<SchemaPredicate> = Vec::new();
632    let mut has_value_pred = false;
633    for (field, _) in &scored {
634        if predicates.len() >= config.max_predicates {
635            break;
636        }
637        let pred = field_predicate(cluster, field, config);
638        if matches!(
639            pred,
640            SchemaPredicate::Equals { .. } | SchemaPredicate::In { .. }
641        ) {
642            has_value_pred = true;
643        }
644        predicates.push(pred);
645        if separates(&predicates, cluster_idx, all) {
646            break;
647        }
648    }
649    if predicates.is_empty() {
650        return None;
651    }
652
653    let separated = separates(&predicates, cluster_idx, all);
654    let mut overlap_warnings = Vec::new();
655    if !separated {
656        overlap_warnings.push(
657            "predicates do not fully separate this cluster from other unrecognized shapes; \
658             add a distinguishing field before committing"
659                .to_string(),
660        );
661    }
662
663    let specificity = suggest_specificity(predicates.len(), has_value_pred);
664    let name = suggest_name(&predicates);
665
666    let mut candidate = DiscoveryCandidate {
667        name,
668        specificity,
669        predicates,
670        support: cluster.total,
671        coverage_of_unknown: if total_events == 0 {
672            0.0
673        } else {
674            cluster.total as f64 / total_events as f64
675        },
676        sample_field_sets: cluster.sample_keys.clone(),
677        overlap_warnings,
678        source,
679    };
680
681    // Reject / annotate proposals shadowed by a built-in signature.
682    let findings = validate_schema_config(&[candidate.signature()], None);
683    for f in findings {
684        if f.contains("unreachable") {
685            return None;
686        }
687        candidate.overlap_warnings.push(f);
688    }
689
690    Some(candidate)
691}
692
693/// Choose the predicate for one field: a value predicate when the field is a
694/// safe low-cardinality marker (offline only), otherwise field-presence.
695fn field_predicate(cluster: &Cluster, field: &str, config: &DiscoveryConfig) -> SchemaPredicate {
696    if config.value_markers
697        && let Some(acc) = cluster.values.get(field)
698        && acc.usable(cluster.total, config.core_presence)
699        && acc.values.len() <= config.max_value_cardinality
700    {
701        let values: Vec<String> = acc.values.iter().cloned().collect();
702        if values.len() == 1 {
703            return SchemaPredicate::Equals {
704                field: field.to_string(),
705                value: values.into_iter().next().unwrap(),
706            };
707        }
708        return SchemaPredicate::In {
709            field: field.to_string(),
710            values,
711        };
712    }
713    SchemaPredicate::FieldPresent(field.to_string())
714}
715
716/// Does the predicate conjunction match only this cluster, and no other? Uses
717/// cluster aggregates (a conservative check on core presence and retained
718/// values), not raw events.
719fn separates(predicates: &[SchemaPredicate], cluster_idx: usize, all: &[Cluster]) -> bool {
720    for (idx, other) in all.iter().enumerate() {
721        if idx == cluster_idx {
722            continue;
723        }
724        if predicates.iter().all(|p| cluster_may_match(other, p)) {
725            return false;
726        }
727    }
728    true
729}
730
731/// Conservative: could this cluster plausibly satisfy the predicate, judged
732/// from its aggregates? Errs toward "yes" so separation is not overclaimed.
733fn cluster_may_match(cluster: &Cluster, pred: &SchemaPredicate) -> bool {
734    match pred {
735        SchemaPredicate::FieldPresent(f) => cluster.key_counts.contains_key(f),
736        SchemaPredicate::AnyOf(fs) => fs.iter().any(|f| cluster.key_counts.contains_key(f)),
737        SchemaPredicate::Equals { field, value } => match cluster.values.get(field) {
738            Some(acc) => acc.overflow || acc.values.contains(value),
739            None => cluster.key_counts.contains_key(field),
740        },
741        SchemaPredicate::In { field, values } => match cluster.values.get(field) {
742            Some(acc) => acc.overflow || values.iter().any(|v| acc.values.contains(v)),
743            None => cluster.key_counts.contains_key(field),
744        },
745        // Discovery only emits the forms above; anything else is treated as
746        // possibly matching so separation stays conservative.
747        _ => true,
748    }
749}
750
751fn suggest_specificity(predicate_count: usize, has_value_pred: bool) -> u32 {
752    let mut spec = 60u32;
753    if has_value_pred {
754        spec += 10;
755    }
756    spec += (predicate_count.saturating_sub(1) as u32) * 3;
757    spec.clamp(55, 104)
758}
759
760/// A placeholder name derived from the strongest marker, prefixed `discovered_`
761/// so it reads as a suggestion a human should rename.
762fn suggest_name(predicates: &[SchemaPredicate]) -> String {
763    let marker = predicates.iter().find_map(|p| match p {
764        SchemaPredicate::Equals { value, .. } => Some(value.clone()),
765        SchemaPredicate::In { field, .. } => Some(field.clone()),
766        _ => None,
767    });
768    let base = marker
769        .or_else(|| {
770            predicates.iter().find_map(|p| match p {
771                SchemaPredicate::FieldPresent(f) => Some(f.clone()),
772                SchemaPredicate::AnyOf(fs) => fs.first().cloned(),
773                _ => None,
774            })
775        })
776        .unwrap_or_default();
777    let slug = slugify(&base);
778    if slug.is_empty() {
779        "discovered".to_string()
780    } else {
781        format!("discovered_{slug}")
782    }
783}
784
785fn unique_name(name: String, used: &mut BTreeMap<String, u32>) -> String {
786    let n = used.entry(name.clone()).or_insert(0);
787    *n += 1;
788    if *n == 1 { name } else { format!("{name}_{n}") }
789}
790
791// =============================================================================
792// Small helpers
793// =============================================================================
794
795fn jaccard(a: &[String], b: &[String]) -> f64 {
796    if a.is_empty() && b.is_empty() {
797        return 1.0;
798    }
799    let sa: BTreeSet<&String> = a.iter().collect();
800    let sb: BTreeSet<&String> = b.iter().collect();
801    let inter = sa.intersection(&sb).count();
802    let union = sa.union(&sb).count();
803    if union == 0 {
804        0.0
805    } else {
806        inter as f64 / union as f64
807    }
808}
809
810fn truncate_keys(keys: &[String]) -> Vec<String> {
811    keys.iter().take(MAX_SAMPLE_KEYS_PER_SET).cloned().collect()
812}
813
814/// A value we should not retain or turn into a predicate: too long, or a
815/// free-form / structured value (command line, path, IP, URL) rather than a
816/// stable low-cardinality marker.
817fn looks_sensitive(value: &str) -> bool {
818    if value.len() > 64 || value.is_empty() {
819        return true;
820    }
821    if value
822        .chars()
823        .any(|c| c.is_whitespace() || matches!(c, '/' | '\\'))
824    {
825        return true;
826    }
827    // IPv4-ish: four or more dot-separated numeric segments.
828    let segments: Vec<&str> = value.split('.').collect();
829    segments.len() >= 4
830        && segments
831            .iter()
832            .all(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit()))
833}
834
835fn slugify(s: &str) -> String {
836    let mut out = String::new();
837    let mut prev_us = false;
838    for c in s.chars() {
839        if c.is_ascii_alphanumeric() {
840            out.push(c.to_ascii_lowercase());
841            prev_us = false;
842        } else if !prev_us && !out.is_empty() {
843            out.push('_');
844            prev_us = true;
845        }
846    }
847    while out.ends_with('_') {
848        out.pop();
849    }
850    out
851}
852
853fn describe_predicate(p: &SchemaPredicate) -> String {
854    match p {
855        SchemaPredicate::FieldPresent(f) => format!("field_present: {f}"),
856        SchemaPredicate::AnyOf(fs) => format!("any_of: [{}]", fs.join(", ")),
857        SchemaPredicate::Equals { field, value } => format!("{field} == \"{value}\""),
858        SchemaPredicate::In { field, values } => format!("{field} in [{}]", values.join(", ")),
859        other => format!("{other:?}"),
860    }
861}
862
863/// Render one predicate as YAML lines under a `match:` list. Only the forms
864/// discovery emits are handled explicitly.
865fn predicate_to_yaml(p: &SchemaPredicate) -> String {
866    match p {
867        SchemaPredicate::FieldPresent(f) => format!("      - field_present: {}\n", yaml_scalar(f)),
868        SchemaPredicate::AnyOf(fs) => {
869            let items: Vec<String> = fs.iter().map(|f| yaml_scalar(f)).collect();
870            format!("      - any_of: [{}]\n", items.join(", "))
871        }
872        SchemaPredicate::Equals { field, value } => format!(
873            "      - equals:\n          field: {}\n          value: {}\n",
874            yaml_scalar(field),
875            yaml_scalar(value)
876        ),
877        SchemaPredicate::In { field, values } => {
878            let items: Vec<String> = values.iter().map(|v| yaml_scalar(v)).collect();
879            format!(
880                "      - in:\n          field: {}\n          values: [{}]\n",
881                yaml_scalar(field),
882                items.join(", ")
883            )
884        }
885        // Not emitted by discovery; fall back to a presence predicate on a
886        // best-effort field so the YAML stays parseable rather than panicking.
887        other => format!("      # unsupported predicate omitted: {other:?}\n"),
888    }
889}
890
891/// Quote a scalar when needed so the emitted YAML always parses.
892fn yaml_scalar(s: &str) -> String {
893    let needs_quote = s.is_empty()
894        || s.chars().next().is_some_and(|c| {
895            matches!(
896                c,
897                '!' | '&'
898                    | '*'
899                    | '-'
900                    | '?'
901                    | '{'
902                    | '}'
903                    | '['
904                    | ']'
905                    | ','
906                    | '#'
907                    | '|'
908                    | '>'
909                    | '@'
910                    | '`'
911                    | '"'
912                    | '\''
913                    | '%'
914                    | ':'
915                    | ' '
916            )
917        })
918        || s.contains(": ")
919        || s.contains(" #")
920        || s.contains(['"', '\'', '\n', '\t'])
921        || s.ends_with(':')
922        || s.ends_with(' ');
923    if needs_quote {
924        format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
925    } else {
926        s.to_string()
927    }
928}
929
930#[cfg(test)]
931mod tests {
932    use super::*;
933    use crate::event::JsonEvent;
934    use serde_json::{Value, json};
935
936    fn events(values: &[Value]) -> Vec<JsonEvent<'_>> {
937        values.iter().map(JsonEvent::borrow).collect()
938    }
939
940    fn mine(values: &[Value], config: &DiscoveryConfig) -> DiscoveryReport {
941        let classifier = SchemaClassifier::builtin();
942        mine_events(events(values), &classifier, config)
943    }
944
945    fn vendor_corpus(n: usize, vendor: &str) -> Vec<Value> {
946        (0..n)
947            .map(|i| json!({"vendor": vendor, "event_type": "alert", "seq": i}))
948            .collect()
949    }
950
951    #[test]
952    fn mines_a_candidate_from_repeated_vendor_events() {
953        let corpus = vendor_corpus(10, "acme");
954        let report = mine(&corpus, &DiscoveryConfig::default());
955        assert_eq!(report.stats.events_mined, 10);
956        assert!(!report.candidates.is_empty());
957        let c = &report.candidates[0];
958        assert_eq!(c.support, 10);
959        assert_eq!(c.source, CandidateSource::Corpus);
960        // A constant low-cardinality field (vendor or event_type) becomes an
961        // equals value marker rather than a bare presence predicate.
962        assert!(
963            c.predicates
964                .iter()
965                .any(|p| matches!(p, SchemaPredicate::Equals { .. })),
966            "expected a value (equals) marker, got {:?}",
967            c.predicate_descriptions()
968        );
969    }
970
971    #[test]
972    fn excludes_events_recognized_by_builtins() {
973        let mut corpus = vendor_corpus(5, "acme");
974        // ECS events must never be mined or re-proposed.
975        for _ in 0..5 {
976            corpus.push(json!({"ecs.version": "8.11.0", "process.command_line": "whoami"}));
977        }
978        let report = mine(&corpus, &DiscoveryConfig::default());
979        assert_eq!(report.stats.events_mined, 5, "only the 5 vendor events");
980        assert!(report.candidates.iter().all(|c| c.name != "ecs"));
981    }
982
983    #[test]
984    fn generic_json_is_mineable_offline() {
985        // A single-field event classifies as generic_json (specificity 0), which
986        // counts as unrecognized and is mined.
987        let corpus: Vec<Value> = (0..4).map(|_| json!({"foo": "bar"})).collect();
988        let report = mine(&corpus, &DiscoveryConfig::default());
989        assert_eq!(report.stats.events_mined, 4);
990        assert!(!report.candidates.is_empty());
991    }
992
993    #[test]
994    fn diversity_guard_keeps_distinct_vendors_separate() {
995        // Two shapes with high key overlap (Jaccard 4/6 > the 0.6 default) that
996        // disagree on a constant marker field. Without the guard they would
997        // merge into one cluster; with it they stay separate.
998        let mut corpus: Vec<Value> = (0..6)
999            .map(|_| json!({"vendor": "foo", "a": 1, "b": 1, "c": 1, "d": 1}))
1000            .collect();
1001        corpus.extend((0..6).map(|_| json!({"vendor": "bar", "a": 1, "b": 1, "c": 1, "e": 1})));
1002        let report = mine(&corpus, &DiscoveryConfig::default());
1003        assert_eq!(
1004            report.candidates.len(),
1005            2,
1006            "diversity guard should keep the two shapes separate, got {}",
1007            report.candidates.len()
1008        );
1009        assert!(report.candidates.iter().all(|c| c.support == 6));
1010    }
1011
1012    #[test]
1013    fn min_support_filters_one_off_shapes() {
1014        let mut corpus = vendor_corpus(10, "acme");
1015        corpus.push(json!({"totally": "unique", "one": "off"}));
1016        let cfg = DiscoveryConfig {
1017            min_support: 3,
1018            ..DiscoveryConfig::default()
1019        };
1020        let report = mine(&corpus, &cfg);
1021        assert!(
1022            report.candidates.iter().all(|c| c.support >= 3),
1023            "no candidate below min_support"
1024        );
1025    }
1026
1027    #[test]
1028    fn keys_only_path_uses_presence_predicates() {
1029        let shapes = vec![
1030            UnknownShapeEntry {
1031                keys: vec!["a".into(), "b".into(), "vendor".into()],
1032                count: 8,
1033            },
1034            UnknownShapeEntry {
1035                keys: vec!["x".into(), "y".into(), "z".into()],
1036                count: 5,
1037            },
1038        ];
1039        let report = mine_shapes(&shapes, &DiscoveryConfig::default());
1040        assert_eq!(report.stats.events_mined, 13);
1041        assert!(!report.candidates.is_empty());
1042        for c in &report.candidates {
1043            assert_eq!(c.source, CandidateSource::KeysOnly);
1044            assert!(
1045                c.predicates.iter().all(|p| matches!(
1046                    p,
1047                    SchemaPredicate::FieldPresent(_) | SchemaPredicate::AnyOf(_)
1048                )),
1049                "keys-only proposals must be presence-only"
1050            );
1051        }
1052    }
1053
1054    #[test]
1055    fn cluster_count_matches_full_mine() {
1056        let shapes = vec![
1057            UnknownShapeEntry {
1058                keys: vec!["a".into(), "b".into(), "vendor".into()],
1059                count: 8,
1060            },
1061            UnknownShapeEntry {
1062                keys: vec!["x".into(), "y".into(), "z".into()],
1063                count: 5,
1064            },
1065            // An empty-key shape is skipped by both paths.
1066            UnknownShapeEntry {
1067                keys: vec![],
1068                count: 3,
1069            },
1070        ];
1071        let cfg = DiscoveryConfig::default();
1072        assert_eq!(
1073            cluster_count(&shapes, &cfg),
1074            mine_shapes(&shapes, &cfg).stats.clusters,
1075            "the cheap cluster count must equal the full pipeline's cluster count"
1076        );
1077    }
1078
1079    #[test]
1080    fn yaml_round_trips_through_parser() {
1081        let mut corpus = vendor_corpus(8, "acme");
1082        corpus.extend((0..6).map(|i| json!({"deviceName": "fw", "srcip": format!("h{i}")})));
1083        let report = mine(&corpus, &DiscoveryConfig::default());
1084        assert!(!report.candidates.is_empty());
1085        let yaml = report.to_signatures_yaml();
1086        let parsed = crate::schema::parse_schema_signatures(&yaml)
1087            .expect("emitted YAML must parse via parse_schema_signatures");
1088        assert_eq!(parsed.len(), report.candidates.len());
1089        // Loading the proposals into a classifier reclassifies the mined events.
1090        let classifier = SchemaClassifier::with_user_signatures(parsed);
1091        let hits = corpus
1092            .iter()
1093            .filter(|v| {
1094                classifier
1095                    .classify(&JsonEvent::borrow(v))
1096                    .is_some_and(|m| m.name != "generic_json")
1097            })
1098            .count();
1099        assert!(hits >= 8, "proposals should recognize the mined events");
1100    }
1101
1102    #[test]
1103    fn deterministic_across_runs() {
1104        let mut corpus = vendor_corpus(7, "acme");
1105        corpus.extend(vendor_corpus(4, "beta"));
1106        let a = mine(&corpus, &DiscoveryConfig::default()).to_signatures_yaml();
1107        let b = mine(&corpus, &DiscoveryConfig::default()).to_signatures_yaml();
1108        assert_eq!(a, b, "discovery output must be byte-identical across runs");
1109    }
1110
1111    #[test]
1112    fn high_cardinality_values_do_not_become_markers() {
1113        // command_line-like free-form values must never be emitted as markers.
1114        let corpus: Vec<Value> = (0..10)
1115            .map(|i| json!({"tool": "runner", "command_line": format!("run --job {i} /tmp/x")}))
1116            .collect();
1117        let report = mine(&corpus, &DiscoveryConfig::default());
1118        assert!(!report.candidates.is_empty());
1119        for c in &report.candidates {
1120            assert!(
1121                !c.predicates.iter().any(|p| matches!(
1122                    p,
1123                    SchemaPredicate::Equals { field, .. } if field == "command_line"
1124                )),
1125                "sensitive/free-form values must not become equals markers"
1126            );
1127        }
1128    }
1129
1130    #[test]
1131    fn empty_corpus_yields_no_candidates() {
1132        let report = mine(&[], &DiscoveryConfig::default());
1133        assert_eq!(report.stats.events_mined, 0);
1134        assert!(report.candidates.is_empty());
1135        assert_eq!(report.to_signatures_yaml(), "schemas: []\n");
1136    }
1137
1138    #[test]
1139    fn specificity_stays_below_strong_builtins() {
1140        let corpus = vendor_corpus(10, "acme");
1141        let report = mine(&corpus, &DiscoveryConfig::default());
1142        for c in &report.candidates {
1143            assert!(c.specificity >= 55 && c.specificity <= 104);
1144        }
1145    }
1146}