1use 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#[derive(Debug, Clone)]
53pub struct DiscoveryConfig {
54 pub min_support: u64,
57 pub similarity: f64,
60 pub max_candidates: usize,
62 pub max_predicates: usize,
65 pub value_markers: bool,
68 pub max_value_cardinality: usize,
72 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
97#[serde(rename_all = "kebab-case")]
98pub enum CandidateSource {
99 Corpus,
101 KeysOnly,
104}
105
106#[derive(Debug, Clone, PartialEq, Eq)]
110pub struct FieldProfile {
111 pub field: String,
113 pub present: u64,
115 pub total: u64,
117 pub distinct_values: Vec<String>,
121 pub value_overflow: bool,
125}
126
127impl FieldProfile {
128 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 pub fn cardinality(&self) -> usize {
139 self.distinct_values.len()
140 }
141}
142
143#[derive(Debug, Clone)]
145pub struct DiscoveryCandidate {
146 pub name: String,
148 pub specificity: u32,
151 pub predicates: Vec<SchemaPredicate>,
153 pub support: u64,
155 pub coverage_of_unknown: f64,
157 pub sample_field_sets: Vec<Vec<String>>,
159 pub overlap_warnings: Vec<String>,
161 pub source: CandidateSource,
163}
164
165impl DiscoveryCandidate {
166 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 pub fn predicate_descriptions(&self) -> Vec<String> {
178 self.predicates.iter().map(describe_predicate).collect()
179 }
180}
181
182#[derive(Debug, Clone, Default, PartialEq, Eq)]
184pub struct DiscoveryStats {
185 pub events_mined: u64,
188 pub shapes: usize,
190 pub clusters: usize,
192 pub candidates: usize,
194}
195
196#[derive(Debug, Clone)]
198pub struct DiscoveryReport {
199 pub candidates: Vec<DiscoveryCandidate>,
201 pub stats: DiscoveryStats,
203}
204
205impl DiscoveryReport {
206 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
226pub 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 let mut shapes: HashMap<Vec<String>, ShapeStat> = HashMap::new();
247 let mut events_mined: u64 = 0;
248
249 for event in events {
250 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
296pub 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
303pub 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
313fn 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
335struct ShapeStat {
342 keys: Vec<String>,
343 count: u64,
344 values: HashMap<String, ValueAcc>,
345}
346
347#[derive(Default, Clone)]
350struct ValueAcc {
351 values: BTreeSet<String>,
352 count: u64,
354 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 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
386struct Cluster {
388 seed_keys: Vec<String>,
390 total: u64,
391 key_counts: HashMap<String, u64>,
393 values: HashMap<String, ValueAcc>,
395 sample_keys: Vec<Vec<String>>,
397}
398
399const MAX_SAMPLE_KEYSETS: usize = 3;
400const MAX_SAMPLE_KEYS_PER_SET: usize = 24;
401const 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 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
459fn 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 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 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 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 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
528fn 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
551fn 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 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 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 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 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
693fn 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
716fn 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
731fn 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 _ => 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
760fn 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
791fn 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
814fn 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 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
863fn 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 other => format!(" # unsupported predicate omitted: {other:?}\n"),
888 }
889}
890
891fn 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 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 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 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 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 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 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 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}