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