1use 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#[derive(Debug, Clone)]
54pub struct DiscoveryConfig {
55 pub min_support: u64,
58 pub similarity: f64,
61 pub max_candidates: usize,
63 pub max_predicates: usize,
66 pub value_markers: bool,
69 pub max_value_cardinality: usize,
73 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
98#[serde(rename_all = "kebab-case")]
99pub enum CandidateSource {
100 Corpus,
102 KeysOnly,
105}
106
107#[derive(Debug, Clone, PartialEq, Eq)]
111pub struct FieldProfile {
112 pub field: String,
114 pub present: u64,
116 pub total: u64,
118 pub distinct_values: Vec<String>,
122 pub value_overflow: bool,
126}
127
128impl FieldProfile {
129 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 pub fn cardinality(&self) -> usize {
140 self.distinct_values.len()
141 }
142}
143
144#[derive(Debug, Clone)]
146pub struct DiscoveryCandidate {
147 pub name: String,
149 pub specificity: u32,
152 pub predicates: Vec<SchemaPredicate>,
154 pub support: u64,
156 pub coverage_of_unknown: f64,
158 pub sample_field_sets: Vec<Vec<String>>,
160 pub overlap_warnings: Vec<String>,
162 pub source: CandidateSource,
164}
165
166impl DiscoveryCandidate {
167 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 pub fn predicate_descriptions(&self) -> Vec<String> {
179 self.predicates.iter().map(describe_predicate).collect()
180 }
181}
182
183#[derive(Debug, Clone, Default, PartialEq, Eq)]
185pub struct DiscoveryStats {
186 pub events_mined: u64,
189 pub shapes: usize,
191 pub clusters: usize,
193 pub candidates: usize,
195}
196
197#[derive(Debug, Clone)]
199pub struct DiscoveryReport {
200 pub candidates: Vec<DiscoveryCandidate>,
202 pub stats: DiscoveryStats,
204}
205
206impl DiscoveryReport {
207 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
227pub 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 let mut shapes: HashMap<Vec<String>, ShapeStat> = HashMap::new();
248 let mut events_mined: u64 = 0;
249
250 for event in events {
251 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
297pub 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
304pub 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
314fn 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
336struct ShapeStat {
343 keys: Vec<String>,
344 count: u64,
345 values: HashMap<String, ValueAcc>,
346}
347
348#[derive(Default, Clone)]
351struct ValueAcc {
352 values: BTreeSet<String>,
353 count: u64,
355 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 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
387struct Cluster {
389 seed_keys: Vec<String>,
391 total: u64,
392 key_counts: HashMap<String, u64>,
394 values: HashMap<String, ValueAcc>,
396 sample_keys: Vec<Vec<String>>,
398}
399
400const MAX_SAMPLE_KEYSETS: usize = 3;
401const MAX_SAMPLE_KEYS_PER_SET: usize = 24;
402const 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 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
460fn 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 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 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 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 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
529fn 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
544fn 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 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 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 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 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
686fn 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
709fn 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
724fn 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 _ => 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
753fn 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
784fn truncate_keys(keys: &[String]) -> Vec<String> {
789 keys.iter().take(MAX_SAMPLE_KEYS_PER_SET).cloned().collect()
790}
791
792fn 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 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
841fn 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 other => format!(" # unsupported predicate omitted: {other:?}\n"),
866 }
867}
868
869fn 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 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 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 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 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 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 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 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}