1use std::collections::{BTreeMap, BTreeSet};
113
114use serde_json::Value;
115use zenkey::pattern::{PatternChunk, SubjectPattern};
116use zenkey::toml_quote;
117
118use crate::judge::field::{FieldObservation, PathStats, ROOT_PATH};
119use crate::model::facts::{KeyShape, OriginKind, describe_key};
120use crate::model::registry::SliceSet;
121use crate::report::{InferReport, InferredProducer, InferredSubject, InferredType};
122
123pub const REST_MIN_PATHS: usize = 3;
126
127const ANCHOR_MIN: f64 = 0.5;
132
133const SIBLING_EXAMPLES: usize = 5;
135
136const INTERVAL_CAP: usize = 64;
138
139pub const DEFAULT_MAX_KEYS: usize = 20_000;
141pub const DEFAULT_MAX_PATHS: usize = 8_192;
143
144#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
149enum Owner {
150 Producer(String),
151 Service(String),
152}
153
154impl Owner {
155 fn file_name(&self) -> String {
156 match self {
157 Owner::Producer(p) => p.clone(),
158 Owner::Service(o) => o.trim_start_matches('@').to_string(),
159 }
160 }
161}
162
163#[derive(Debug, Clone)]
165struct KeyObs {
166 owner: Owner,
167 class: String,
168 origin: String,
169 tail: Vec<String>,
170 samples: u64,
171 last_at_s: Option<f64>,
172 intervals: Vec<f64>,
174 encodings: BTreeMap<String, u64>,
175 qos: BTreeMap<String, u64>,
176 numeric: u64,
179 decreased: bool,
180 negative: bool,
181 last_num: Option<f64>,
182 text: u64,
183 boolean: u64,
184}
185
186impl KeyObs {
187 fn observe(
188 &mut self,
189 at_s: f64,
190 encoding: Option<&str>,
191 qos: Option<&str>,
192 doc: Option<&Value>,
193 ) {
194 self.samples += 1;
195 if let Some(last) = self.last_at_s
196 && at_s >= last
197 && self.intervals.len() < INTERVAL_CAP
198 {
199 self.intervals.push(at_s - last);
200 }
201 self.last_at_s = Some(at_s);
202 if let Some(e) = encoding {
203 *self.encodings.entry(e.to_string()).or_default() += 1;
204 }
205 if let Some(q) = qos {
206 *self.qos.entry(q.to_string()).or_default() += 1;
207 }
208 if let Some(v) = doc.and_then(leaf_value) {
209 match v {
210 Value::Number(n) => {
211 if let Some(n) = n.as_f64() {
212 self.numeric += 1;
213 if n < 0.0 {
214 self.negative = true;
215 }
216 if self.last_num.is_some_and(|p| n < p) {
217 self.decreased = true;
218 }
219 self.last_num = Some(n);
220 }
221 }
222 Value::String(_) => self.text += 1,
223 Value::Bool(_) => self.boolean += 1,
224 _ => {}
225 }
226 }
227 }
228}
229
230fn leaf_value(doc: &Value) -> Option<&Value> {
234 match doc {
235 Value::Object(m) => m.get("value"),
236 Value::Array(_) => None,
237 scalar => Some(scalar),
238 }
239}
240
241#[derive(Debug, Clone)]
247pub struct InferObservation {
248 base: String,
249 max_keys: usize,
250 keys: BTreeMap<String, KeyObs>,
251 keys_refused: u64,
252 fields: FieldObservation,
253 samples: u64,
254 unparsed: u64,
255 off_plane: u64,
256 first_at_s: Option<f64>,
257 last_at_s: Option<f64>,
258}
259
260impl InferObservation {
261 pub fn new(base: &str, max_keys: usize, max_paths: usize) -> InferObservation {
262 InferObservation {
263 base: base.to_string(),
264 max_keys: max_keys.max(1),
265 keys: BTreeMap::new(),
266 keys_refused: 0,
267 fields: FieldObservation::new(max_paths),
268 samples: 0,
269 unparsed: 0,
270 off_plane: 0,
271 first_at_s: None,
272 last_at_s: None,
273 }
274 }
275
276 pub fn observe(
282 &mut self,
283 key: &str,
284 at_s: f64,
285 encoding: Option<&str>,
286 qos: Option<&str>,
287 doc: Option<&Value>,
288 ) {
289 self.samples += 1;
290 self.first_at_s = Some(self.first_at_s.map_or(at_s, |f| f.min(at_s)));
291 self.last_at_s = Some(self.last_at_s.map_or(at_s, |l| l.max(at_s)));
292 if let Some(obs) = self.keys.get_mut(key) {
293 obs.observe(at_s, encoding, qos, doc);
294 self.fields.observe(key, at_s, doc);
295 return;
296 }
297 let d = describe_key(&self.base, key, None);
298 let KeyShape::V1(facts) = &d.facts.shape else {
299 self.unparsed += 1;
300 return;
301 };
302 if !facts.class_kind.is_data_class() {
303 self.off_plane += 1;
304 return;
305 }
306 let owner = match (facts.origin_kind, &facts.producer) {
307 (OriginKind::Host, Some(p)) => Owner::Producer(p.clone()),
308 (OriginKind::Service, _) => Owner::Service(facts.origin.clone()),
309 (OriginKind::Host, None) => {
310 self.unparsed += 1;
311 return;
312 }
313 };
314 if facts.subject.is_empty() {
315 self.unparsed += 1;
316 return;
317 }
318 if self.keys.len() >= self.max_keys {
319 self.keys_refused += 1;
320 return;
321 }
322 let mut obs = KeyObs {
323 owner,
324 class: facts.class.clone(),
325 origin: facts.origin.clone(),
326 tail: facts.subject.clone(),
327 samples: 0,
328 last_at_s: None,
329 intervals: Vec::new(),
330 encodings: BTreeMap::new(),
331 qos: BTreeMap::new(),
332 numeric: 0,
333 decreased: false,
334 negative: false,
335 last_num: None,
336 text: 0,
337 boolean: 0,
338 };
339 obs.observe(at_s, encoding, qos, doc);
340 self.keys.insert(key.to_string(), obs);
341 self.fields.observe(key, at_s, doc);
342 }
343
344 pub fn observe_unread(
347 &mut self,
348 key: &str,
349 at_s: f64,
350 encoding: Option<&str>,
351 qos: Option<&str>,
352 ) {
353 self.observe(key, at_s, encoding, qos, None);
354 if self.keys.contains_key(key) {
355 self.fields.observe_unread(key);
356 }
357 }
358
359 pub fn samples(&self) -> u64 {
360 self.samples
361 }
362
363 pub fn keys_seen(&self) -> usize {
364 self.keys.len()
365 }
366
367 pub fn keys_refused(&self) -> u64 {
368 self.keys_refused
369 }
370
371 pub fn span_s(&self) -> f64 {
373 match (self.first_at_s, self.last_at_s) {
374 (Some(f), Some(l)) => (l - f).max(0.0),
375 _ => 0.0,
376 }
377 }
378
379 fn origins(&self) -> BTreeSet<&str> {
380 self.keys.values().map(|k| k.origin.as_str()).collect()
381 }
382}
383
384#[derive(Debug, Default)]
387struct Node {
388 children: BTreeMap<String, Node>,
389 terminal: Vec<usize>,
391 origins: BTreeSet<String>,
393 tails: BTreeSet<String>,
395}
396
397impl Node {
398 fn is_leaf(&self) -> bool {
399 self.children.is_empty()
400 }
401
402 fn insert(&mut self, tail: &[String], origin: &str, key: usize) {
403 self.origins.insert(origin.to_string());
404 self.tails.insert(tail.join("/"));
405 match tail.split_first() {
406 None => self.terminal.push(key),
407 Some((head, rest)) => self
408 .children
409 .entry(head.clone())
410 .or_default()
411 .insert(rest, origin, key),
412 }
413 }
414
415 fn depth_profile(&self) -> BTreeSet<usize> {
420 self.tails
421 .iter()
422 .map(|t| {
423 if t.is_empty() {
424 0
425 } else {
426 t.matches('/').count() + 1
427 }
428 })
429 .collect()
430 }
431}
432
433#[derive(Debug, Clone, Default)]
435struct VarPop {
436 members: BTreeMap<String, BTreeSet<String>>,
437}
438
439impl VarPop {
440 fn add(&mut self, origin: &str, chunk: &str) {
441 self.members
442 .entry(origin.to_string())
443 .or_default()
444 .insert(chunk.to_string());
445 }
446
447 fn all(&self) -> BTreeSet<&str> {
448 self.members
449 .values()
450 .flat_map(|s| s.iter().map(String::as_str))
451 .collect()
452 }
453
454 fn max_per_origin(&self) -> usize {
455 self.members.values().map(BTreeSet::len).max().unwrap_or(0)
456 }
457}
458
459#[derive(Debug, Clone)]
460enum Chunk {
461 Literal(String),
462 Var(VarPop),
463 Rest(VarPop),
464}
465
466#[derive(Debug, Clone)]
468struct Family {
469 chunks: Vec<Chunk>,
470 members: Vec<usize>,
471 comments: Vec<String>,
472}
473
474impl Family {
475 fn matches(&self, tail: &[&str]) -> bool {
476 let has_rest = matches!(self.chunks.last(), Some(Chunk::Rest(_)));
477 let fixed = self.chunks.len() - usize::from(has_rest);
478 if has_rest {
479 if tail.len() <= fixed {
480 return false;
481 }
482 } else if tail.len() != fixed {
483 return false;
484 }
485 self.chunks.iter().zip(tail).all(|(c, t)| match c {
486 Chunk::Literal(l) => l == t,
487 Chunk::Var(_) | Chunk::Rest(_) => true,
488 })
489 }
490}
491
492struct View<'a> {
494 nodes: Vec<&'a Node>,
495}
496
497impl<'a> View<'a> {
498 fn origins(&self) -> BTreeSet<&'a str> {
499 self.nodes
500 .iter()
501 .flat_map(|n| n.origins.iter().map(String::as_str))
502 .collect()
503 }
504
505 fn children(&self) -> BTreeMap<&'a str, Vec<&'a Node>> {
506 let mut out: BTreeMap<&str, Vec<&Node>> = BTreeMap::new();
507 for n in &self.nodes {
508 for (name, child) in &n.children {
509 out.entry(name.as_str()).or_default().push(child);
510 }
511 }
512 out
513 }
514
515 fn terminal(&self) -> Vec<usize> {
516 self.nodes
517 .iter()
518 .flat_map(|n| n.terminal.iter().copied())
519 .collect()
520 }
521
522 fn tails(&self) -> BTreeSet<&'a str> {
523 self.nodes
524 .iter()
525 .flat_map(|n| n.tails.iter().map(String::as_str))
526 .collect()
527 }
528}
529
530fn origins_of(nodes: &[&Node]) -> BTreeSet<String> {
531 nodes
532 .iter()
533 .flat_map(|n| n.origins.iter().cloned())
534 .collect()
535}
536
537fn infer_view(view: &View<'_>, prefix: &[Chunk], keys: &[KeyObs]) -> Vec<Family> {
540 let mut out = Vec::new();
541 let terminal = view.terminal();
542 if !terminal.is_empty() {
543 out.push(Family {
544 chunks: prefix.to_vec(),
545 members: terminal,
546 comments: Vec::new(),
547 });
548 }
549 let children = view.children();
550 if children.is_empty() {
551 return out;
552 }
553 let view_origins: BTreeSet<String> = view.origins().into_iter().map(str::to_string).collect();
554 let partial = |nodes: &[&Node]| origins_of(nodes) != view_origins;
555
556 let mut partial_leaves = 0usize;
558 let mut partial_interiors: Vec<BTreeSet<&str>> = Vec::new();
559 for nodes in children.values() {
560 let leaf = nodes.iter().all(|n| n.is_leaf());
561 if !partial(nodes) {
562 continue;
563 }
564 if leaf {
565 partial_leaves += 1;
566 } else {
567 partial_interiors.push(
568 View {
569 nodes: nodes.clone(),
570 }
571 .tails(),
572 );
573 }
574 }
575 let unlike = partial_interiors.len() >= 2 && {
576 let mut distinct: Vec<&BTreeSet<&str>> = Vec::new();
577 for s in &partial_interiors {
578 if !distinct.contains(&s) {
579 distinct.push(s);
580 }
581 }
582 distinct.len() >= 2
583 };
584 let tails = view.tails();
585 if partial_leaves >= 1 && unlike && tails.len() >= REST_MIN_PATHS {
586 let mut pop = VarPop::default();
587 let mut members = Vec::new();
588 for n in &view.nodes {
589 collect_rest(n, "", &mut pop, &mut members);
590 }
591 let mut chunks = prefix.to_vec();
592 chunks.push(Chunk::Rest(pop));
593 out.push(Family {
594 chunks,
595 members,
596 comments: vec![format!(
597 "inferred {{rest...}}: {} distinct tails below this prefix, host-conditional at two \
598 depths — a device-defined tree (RFC 08 §2); if these are fixed subjects, \
599 register them one by one",
600 tails.len()
601 )],
602 });
603 return out;
604 }
605
606 let leaves: Vec<(&str, &Vec<&Node>)> = children
608 .iter()
609 .filter(|(_, nodes)| nodes.iter().all(|n| n.is_leaf()))
610 .map(|(name, nodes)| (*name, nodes))
611 .collect();
612 let leaf_class = leaves
613 .first()
614 .and_then(|(_, nodes)| nodes.first())
615 .and_then(|n| n.terminal.first())
616 .map(|&k| keys[k].class.as_str());
617 let any_partial_leaf = leaves.iter().any(|(_, nodes)| partial(nodes));
618 let write_once = leaf_class == Some("events")
619 && leaves.len() >= 2
620 && leaves.iter().all(|(_, nodes)| {
621 nodes
622 .iter()
623 .flat_map(|n| n.terminal.iter())
624 .all(|&k| keys[k].samples == 1)
625 });
626 if !leaves.is_empty() && (any_partial_leaf || write_once) {
627 let mut pop = VarPop::default();
628 let mut members = Vec::new();
629 for (name, nodes) in &leaves {
630 for n in nodes.iter() {
631 for o in &n.origins {
632 pop.add(o, name);
633 }
634 members.extend(n.terminal.iter().copied());
635 }
636 }
637 let names: Vec<&str> = leaves.iter().map(|(n, _)| *n).collect();
638 let reason = if write_once {
639 "each seen once under events — write-once keys (RFC 04 §1.3)"
640 } else {
641 "their membership varies by origin; a host-conditional measurement would look the same"
642 };
643 let mut chunks = prefix.to_vec();
644 chunks.push(Chunk::Var(pop));
645 out.push(Family {
646 chunks,
647 members,
648 comments: vec![format!(
649 "inferred {{var}} from {} leaf sibling(s): {} — {reason}; review",
650 names.len(),
651 examples(&names)
652 )],
653 });
654 } else {
655 for (name, nodes) in &leaves {
656 let mut chunks = prefix.to_vec();
657 chunks.push(Chunk::Literal((*name).to_string()));
658 out.push(Family {
659 chunks,
660 members: nodes
661 .iter()
662 .flat_map(|n| n.terminal.iter().copied())
663 .collect(),
664 comments: Vec::new(),
665 });
666 }
667 }
668
669 let interiors: Vec<(&str, &Vec<&Node>)> = children
671 .iter()
672 .filter(|(_, nodes)| !nodes.iter().all(|n| n.is_leaf()))
673 .map(|(name, nodes)| (*name, nodes))
674 .collect();
675 type Cluster<'n> = (BTreeSet<usize>, Vec<(&'n str, &'n Vec<&'n Node>)>);
680 let mut clusters: Vec<Cluster<'_>> = Vec::new();
681 for (name, nodes) in interiors {
682 let shape: BTreeSet<usize> = nodes.iter().flat_map(|n| n.depth_profile()).collect();
683 let mut placed = false;
684 for (cluster_shape, members) in clusters.iter_mut() {
685 if *cluster_shape != shape {
686 continue;
687 }
688 let mut trial: Vec<&Node> = members
689 .iter()
690 .flat_map(|(_, n)| n.iter().copied())
691 .collect();
692 trial.extend(nodes.iter().copied());
693 let per_member: Vec<Vec<&Node>> = members
694 .iter()
695 .map(|(_, n)| (*n).clone())
696 .chain(std::iter::once(nodes.clone()))
697 .collect();
698 if merge_accepted(&trial, &per_member, prefix, keys) {
699 members.push((name, nodes));
700 placed = true;
701 break;
702 }
703 }
704 if !placed {
705 clusters.push((shape, vec![(name, nodes)]));
706 }
707 }
708 for (_, members) in clusters {
709 if members.len() >= 2 {
710 let mut pop = VarPop::default();
711 for (name, nodes) in &members {
712 for n in nodes.iter() {
713 for o in &n.origins {
714 pop.add(o, name);
715 }
716 }
717 }
718 let names: Vec<&str> = members.iter().map(|(n, _)| *n).collect();
719 let leaf_only = members
720 .iter()
721 .all(|(_, nodes)| nodes.iter().all(|n| n.children.values().all(Node::is_leaf)));
722 let uniform = pop.members.values().collect::<BTreeSet<_>>().len() <= 1;
726 let comment = if leaf_only && uniform {
727 format!(
728 "inferred {{var}} from siblings: {} — a literal pair with one shape looks \
729 the same; review",
730 examples(&names)
731 )
732 } else {
733 format!(
734 "inferred {{var}} from siblings: {} ({} values on {} origin(s))",
735 examples(&names),
736 pop.all().len(),
737 pop.members.len()
738 )
739 };
740 let mut chunks = prefix.to_vec();
741 chunks.push(Chunk::Var(pop));
742 let merged: Vec<&Node> = members
743 .iter()
744 .flat_map(|(_, n)| n.iter().copied())
745 .collect();
746 for mut f in infer_view(&View { nodes: merged }, &chunks, keys) {
747 f.comments.insert(0, comment.clone());
748 out.push(f);
749 }
750 } else {
751 let (name, nodes) = &members[0];
752 let mut chunks = prefix.to_vec();
753 chunks.push(Chunk::Literal((*name).to_string()));
754 out.extend(infer_view(
755 &View {
756 nodes: (*nodes).clone(),
757 },
758 &chunks,
759 keys,
760 ));
761 }
762 }
763 out
764}
765
766fn merge_accepted(
769 trial: &[&Node],
770 per_member: &[Vec<&Node>],
771 prefix: &[Chunk],
772 keys: &[KeyObs],
773) -> bool {
774 let tail_sets: Vec<BTreeSet<&str>> = per_member
775 .iter()
776 .map(|nodes| {
777 View {
778 nodes: nodes.clone(),
779 }
780 .tails()
781 })
782 .collect();
783 let mut anchor = tail_sets[0].clone();
784 for s in &tail_sets[1..] {
785 anchor = anchor.intersection(s).copied().collect();
786 }
787 let smallest = tail_sets.iter().map(BTreeSet::len).min().unwrap_or(0);
788 if smallest == 0 || (anchor.len() as f64) < ANCHOR_MIN * smallest as f64 {
789 return false;
790 }
791 let mut var_prefix = prefix.to_vec();
792 var_prefix.push(Chunk::Var(VarPop::default()));
793 let families = infer_view(
794 &View {
795 nodes: trial.to_vec(),
796 },
797 &var_prefix,
798 keys,
799 );
800 let depth = var_prefix.len();
801 let relative: Vec<Family> = families
803 .iter()
804 .map(|f| Family {
805 chunks: f.chunks[depth..].to_vec(),
806 members: Vec::new(),
807 comments: Vec::new(),
808 })
809 .collect();
810 tail_sets.iter().all(|tails| {
811 let split: Vec<Vec<&str>> = tails.iter().map(|t| split_tail(t)).collect();
812 split
813 .iter()
814 .all(|tail| relative.iter().any(|f| f.matches(tail)))
815 && relative
816 .iter()
817 .all(|f| split.iter().any(|tail| f.matches(tail)))
818 })
819}
820
821fn split_tail(t: &str) -> Vec<&str> {
822 if t.is_empty() {
823 Vec::new()
824 } else {
825 t.split('/').collect()
826 }
827}
828
829fn collect_rest(node: &Node, path: &str, pop: &mut VarPop, members: &mut Vec<usize>) {
830 if !node.terminal.is_empty() && !path.is_empty() {
831 for o in &node.origins {
832 pop.add(o, path);
833 }
834 members.extend(node.terminal.iter().copied());
835 }
836 for (name, child) in &node.children {
837 let p = if path.is_empty() {
838 name.clone()
839 } else {
840 format!("{path}/{name}")
841 };
842 collect_rest(child, &p, pop, members);
843 }
844}
845
846fn examples(names: &[&str]) -> String {
847 let shown: Vec<&str> = names.iter().copied().take(SIBLING_EXAMPLES).collect();
848 if names.len() > SIBLING_EXAMPLES {
849 format!("{}, … ({} in all)", shown.join(", "), names.len())
850 } else {
851 shown.join(", ")
852 }
853}
854
855pub fn infer(
862 obs: &InferObservation,
863 source: &str,
864 window_s: Option<f64>,
865 hints: Option<&SliceSet>,
866) -> InferReport {
867 let keys: Vec<(&String, &KeyObs)> = obs.keys.iter().collect();
868 let key_obs: Vec<KeyObs> = keys.iter().map(|(_, k)| (*k).clone()).collect();
869 let key_names: Vec<&str> = keys.iter().map(|(k, _)| k.as_str()).collect();
870 let span_s = obs.span_s();
871 let origins = obs.origins().len();
872
873 let mut tries: BTreeMap<(Owner, String), Node> = BTreeMap::new();
875 for (i, k) in key_obs.iter().enumerate() {
876 tries
877 .entry((k.owner.clone(), k.class.clone()))
878 .or_default()
879 .insert(&k.tail, &k.origin, i);
880 }
881
882 let mut producers: BTreeMap<Owner, InferredProducer> = BTreeMap::new();
883 let mut type_shapes: BTreeMap<Owner, Vec<Option<Value>>> = BTreeMap::new();
886 let mut caveats = Vec::new();
887 if origins <= 1 {
888 caveats.push(
889 "1 origin observed: a leaf dimension cannot be told from a set of measurements from one \
890 host, so every leaf below is a literal (failure mode 1)"
891 .to_string(),
892 );
893 }
894 if hints.is_none() {
895 caveats.push("no registry hinted the inference: {var}s are named by position".to_string());
896 }
897
898 for ((owner, class), root) in &tries {
899 let families = infer_view(&View { nodes: vec![root] }, &[], &key_obs);
900 let hint_slice = hints.and_then(|h| match owner {
901 Owner::Producer(p) => h.get(p),
902 Owner::Service(o) => h.by_service_origin(o),
903 });
904 let name = match (owner, hint_slice) {
905 (Owner::Service(_), Some(s)) => s.name.clone(),
906 _ => owner.file_name(),
907 };
908 let producer = producers
909 .entry(owner.clone())
910 .or_insert_with(|| InferredProducer {
911 name: name.clone(),
912 service_origin: match owner {
913 Owner::Service(o) => Some(o.clone()),
914 Owner::Producer(_) => None,
915 },
916 subjects: Vec::new(),
917 types: Vec::new(),
918 });
919 let shapes = type_shapes.entry(owner.clone()).or_default();
920 for fam in families {
921 let (subject, schema) =
922 draft_subject(&fam, class, &key_obs, &key_names, obs, span_s, hint_slice);
923 shapes.push(schema);
924 producer.subjects.push(subject);
925 }
926 }
927
928 let mut out: Vec<InferredProducer> = Vec::new();
929 for (owner, mut producer) in producers {
930 let shapes = type_shapes.remove(&owner).unwrap_or_default();
931 producer.types = assign_types(&mut producer.subjects, shapes, &producer.name);
932 producer
933 .subjects
934 .sort_by(|a, b| (&a.class, &a.path).cmp(&(&b.class, &b.path)));
935 assign_variants(&mut producer.subjects);
936 out.push(producer);
937 }
938 out.sort_by(|a, b| a.name.cmp(&b.name));
939
940 InferReport {
941 source: source.to_string(),
942 window_s,
943 span_s,
944 samples: obs.samples,
945 dropped: 0,
946 keys_seen: obs.keys.len(),
947 keys_refused: obs.keys_refused,
948 origins,
949 unparsed_keys: obs.unparsed,
950 off_plane_keys: obs.off_plane,
951 undocumented: obs.fields.undocumented(),
952 unread: obs.fields.unread(),
953 paths_refused: obs.fields.dropped_paths(),
954 hinted_by_registry: hints.is_some(),
955 caveats,
956 producers: out,
957 }
958}
959
960fn assign_variants(subjects: &mut [InferredSubject]) {
964 fn chunks(path: &str) -> Vec<(bool, String)> {
965 path.split('/')
966 .map(
967 |c| match c.strip_prefix('{').and_then(|c| c.strip_suffix('}')) {
968 Some(v) => (true, v.trim_end_matches("...").to_string()),
969 None => (false, c.to_string()),
970 },
971 )
972 .collect()
973 }
974 fn default_variant(path: &str) -> String {
975 let chunks = chunks(path);
976 let literals: String = chunks
977 .iter()
978 .filter(|(var, _)| !var)
979 .map(|(_, c)| camel(c))
980 .collect();
981 if literals.is_empty() {
982 chunks.iter().map(|(_, c)| camel(c)).collect()
983 } else {
984 literals
985 }
986 }
987 let mut by_variant: BTreeMap<String, Vec<usize>> = BTreeMap::new();
988 for (i, s) in subjects.iter().enumerate() {
989 by_variant
990 .entry(default_variant(&s.path))
991 .or_default()
992 .push(i);
993 }
994 let mut taken: BTreeSet<String> = by_variant.keys().cloned().collect();
995 for (_, members) in by_variant {
996 if members.len() < 2 {
997 continue;
998 }
999 for &i in &members {
1002 let s = &subjects[i];
1003 if !s.path.contains('{') {
1004 continue;
1005 }
1006 let full: String = chunks(&s.path).iter().map(|(_, c)| camel(c)).collect();
1007 let name = if taken.contains(&full) {
1008 format!("{full}{}", camel(&s.class))
1009 } else {
1010 full
1011 };
1012 taken.insert(name.clone());
1013 subjects[i].variant = Some(name);
1014 }
1015 }
1016}
1017
1018fn assign_types(
1026 subjects: &mut [InferredSubject],
1027 schemas: Vec<Option<Value>>,
1028 producer: &str,
1029) -> Vec<InferredType> {
1030 let canonical: Vec<String> = schemas
1031 .iter()
1032 .map(|s| {
1033 s.as_ref()
1034 .map(|v| serde_json::to_string(v).unwrap_or_default())
1035 .unwrap_or_default()
1036 })
1037 .collect();
1038 let mut groups: BTreeMap<&str, Vec<usize>> = BTreeMap::new();
1039 for (i, c) in canonical.iter().enumerate() {
1040 groups.entry(c.as_str()).or_default().push(i);
1041 }
1042 let mut ordered: Vec<(&str, Vec<usize>)> = groups.into_iter().collect();
1044 ordered.sort_by_key(|(_, members)| members[0]);
1045 let literal_chunks = |path: &str| -> Vec<String> {
1046 path.split('/')
1047 .take_while(|c| !c.starts_with('{'))
1048 .map(str::to_string)
1049 .collect()
1050 };
1051 let mut taken: BTreeSet<String> = BTreeSet::new();
1052 let mut types = Vec::new();
1053 let mut shape_n = 0usize;
1054 for (_, members) in ordered {
1055 let schema = schemas[members[0]].clone();
1056 let mut stem = if members.len() == 1 {
1057 subjects[members[0]]
1058 .path
1059 .split('/')
1060 .filter(|c| !c.starts_with('{'))
1061 .map(camel)
1062 .collect::<String>()
1063 } else {
1064 let mut common = literal_chunks(&subjects[members[0]].path);
1065 for &i in &members[1..] {
1066 let other = literal_chunks(&subjects[i].path);
1067 let shared = common
1068 .iter()
1069 .zip(&other)
1070 .take_while(|(a, b)| a == b)
1071 .count();
1072 common.truncate(shared);
1073 }
1074 common.iter().map(|c| camel(c)).collect()
1075 };
1076 if stem.is_empty() {
1077 shape_n += 1;
1078 stem = format!("Shape{shape_n}");
1079 }
1080 let mut name = format!("{}{stem}", camel(producer));
1081 if schema.is_none() {
1082 name.push_str("Opaque");
1083 }
1084 let base = name.clone();
1085 let mut n = 2;
1086 while !taken.insert(name.clone()) {
1087 name = format!("{base}{n}");
1088 n += 1;
1089 }
1090 for &i in &members {
1091 subjects[i].type_name = name.clone();
1092 }
1093 types.push(InferredType {
1094 name,
1095 schema,
1096 subjects: members.len(),
1097 });
1098 }
1099 types
1100}
1101
1102fn draft_subject(
1103 fam: &Family,
1104 class: &str,
1105 keys: &[KeyObs],
1106 key_names: &[&str],
1107 obs: &InferObservation,
1108 span_s: f64,
1109 hint: Option<&zenkey::RegistrySlice>,
1110) -> (InferredSubject, Option<Value>) {
1111 let members: Vec<&KeyObs> = fam.members.iter().map(|&i| &keys[i]).collect();
1112 let mut comments = fam.comments.clone();
1113
1114 let var_names = hint_var_names(fam, class, hint, &members);
1116 let mut parts = Vec::new();
1117 let mut var_i = 0usize;
1118 for (depth, c) in fam.chunks.iter().enumerate() {
1119 match c {
1120 Chunk::Literal(l) => parts.push(l.clone()),
1121 Chunk::Var(_) | Chunk::Rest(_) => {
1122 let name = var_names
1123 .as_ref()
1124 .and_then(|v| v.get(var_i).cloned())
1125 .unwrap_or_else(|| format!("v{depth}"));
1126 var_i += 1;
1127 parts.push(match c {
1128 Chunk::Rest(_) => format!("{{{name}...}}"),
1129 _ => format!("{{{name}}}"),
1130 });
1131 }
1132 }
1133 }
1134 let path = parts.join("/");
1135
1136 let cardinality = fam
1138 .chunks
1139 .iter()
1140 .filter_map(|c| match c {
1141 Chunk::Var(p) | Chunk::Rest(p) => Some(p.max_per_origin()),
1142 Chunk::Literal(_) => None,
1143 })
1144 .max()
1145 .map(|n| round_up_pow10(n as u64) as i64);
1146 if fam
1147 .chunks
1148 .iter()
1149 .any(|c| matches!(c, Chunk::Var(_) | Chunk::Rest(_)))
1150 {
1151 comments.push(
1152 "cardinality: the largest distinct member count one origin published, rounded up to a \
1153 power of ten"
1154 .to_string(),
1155 );
1156 }
1157
1158 let samples: u64 = members.iter().map(|k| k.samples).sum();
1160 let origins: BTreeSet<&str> = members.iter().map(|k| k.origin.as_str()).collect();
1161
1162 let mut rate = None;
1164 if class == "events" {
1165 let mut per_origin: BTreeMap<&str, u64> = BTreeMap::new();
1166 for k in &members {
1167 *per_origin.entry(k.origin.as_str()).or_default() += k.samples;
1168 }
1169 let busiest = per_origin.values().copied().max().unwrap_or(0);
1170 if samples < 2 || span_s <= 0.0 {
1171 rate = Some("rare".to_string());
1172 comments.push(format!(
1173 "rate: 0–1 observed in {span_s:.1} s; rate class unestablished — `rare` is the \
1174 tightest class consistent with that, not a measurement"
1175 ));
1176 } else {
1177 let per_hour = busiest as f64 * 3600.0 / span_s;
1178 rate = Some(if per_hour <= 1.0 {
1179 "rare".to_string()
1180 } else if per_hour <= 60.0 {
1181 "low".to_string()
1182 } else {
1183 format!("burst({}/h)", round_up_pow10(per_hour.ceil() as u64))
1184 });
1185 comments.push(format!(
1186 "rate: {busiest} event(s) from the busiest origin over {span_s:.1} s"
1187 ));
1188 }
1189 }
1190
1191 let mut ttl_s = None;
1193 if class == "state" {
1194 let longest_median = members
1195 .iter()
1196 .filter_map(|k| median(&k.intervals))
1197 .fold(None, |acc: Option<f64>, m| {
1198 Some(acc.map_or(m, |a| a.max(m)))
1199 });
1200 match longest_median {
1201 Some(m) if m > 0.0 => {
1202 ttl_s = Some((2.0 * m).ceil() as i64);
1203 comments.push(format!(
1204 "ttl_s: a hint — twice the longest median inter-arrival ({m:.1} s) among {} \
1205 expansion(s)",
1206 members.len()
1207 ));
1208 }
1209 _ => comments.push(format!(
1210 "ttl_s not established: no refresh observed within {span_s:.1} s; the lint requires \
1211 one — set it from the producer's cadence"
1212 )),
1213 }
1214 }
1215
1216 let mut unit = None;
1218 let suffix_counter =
1219 matches!(fam.chunks.last(), Some(Chunk::Literal(l)) if l.ends_with("_total"));
1220 if let Some(Chunk::Literal(leaf)) = fam.chunks.last() {
1221 unit = unit_of(leaf).map(str::to_string);
1222 }
1223 if let Some(guess) = kind_guess(&members, suffix_counter) {
1224 comments.push(guess);
1225 }
1226
1227 let mut encodings: BTreeMap<&str, u64> = BTreeMap::new();
1229 for k in &members {
1230 for (e, n) in &k.encodings {
1231 *encodings.entry(e.as_str()).or_default() += n;
1232 }
1233 }
1234 let encoding = match encodings.len() {
1235 1 => encodings.keys().next().map(|e| e.to_string()),
1236 0 => None,
1237 _ => {
1238 comments.push(format!(
1239 "encodings observed: {} — no single encoding to declare",
1240 encodings
1241 .iter()
1242 .map(|(e, n)| format!("{e} ×{n}"))
1243 .collect::<Vec<_>>()
1244 .join(", ")
1245 ));
1246 None
1247 }
1248 };
1249
1250 let alert_family =
1254 class == "state" && matches!(fam.chunks.first(), Some(Chunk::Literal(l)) if l == "alert");
1255 let qos_field = alert_family.then(|| "alert".to_string());
1256 if alert_family {
1257 comments.push(
1258 "qos = \"alert\" is what RFC 08 §5 requires of the alert family (v1.23), not what was \
1259 observed — see the observed line below"
1260 .to_string(),
1261 );
1262 }
1263 let mut qos: BTreeMap<&str, u64> = BTreeMap::new();
1264 for k in &members {
1265 for (q, n) in &k.qos {
1266 *qos.entry(q.as_str()).or_default() += n;
1267 }
1268 }
1269 if !qos.is_empty() {
1270 let listed = qos
1271 .iter()
1272 .map(|(q, n)| {
1273 if qos.len() > 1 {
1274 format!("{q} ×{n}")
1275 } else {
1276 q.to_string()
1277 }
1278 })
1279 .collect::<Vec<_>>()
1280 .join(", ");
1281 comments.push(format!(
1282 "qos observed: {listed} — what rode, not a declaration; a wrong profile on the wire \
1283 would be laundered into a contract by writing it here"
1284 ));
1285 }
1286
1287 let (schema, undocumented, documents) = pooled_schema(fam, key_names, obs);
1289 if documents == 0 {
1290 comments.push(format!(
1291 "payload not structural in {undocumented} of {samples} samples; no schema inferred"
1292 ));
1293 } else if undocumented > 0 {
1294 comments.push(format!(
1295 "payload not structural in {undocumented} of {samples} samples; the schema covers the \
1296 {documents} that were"
1297 ));
1298 }
1299 let subject = InferredSubject {
1300 path,
1301 class: class.to_string(),
1302 type_name: String::new(),
1304 variant: None,
1305 unit,
1306 qos: qos_field,
1307 ttl_s,
1308 rate,
1309 cardinality,
1310 encoding,
1311 comments,
1312 origins: origins.len(),
1313 keys: members.len(),
1314 samples,
1315 };
1316 (subject, schema)
1317}
1318
1319fn hint_var_names(
1322 fam: &Family,
1323 class: &str,
1324 hint: Option<&zenkey::RegistrySlice>,
1325 members: &[&KeyObs],
1326) -> Option<Vec<String>> {
1327 let hint = hint?;
1328 let sample = members.first()?;
1329 let tail: Vec<&str> = sample.tail.iter().map(String::as_str).collect();
1330 for d in &hint.subjects {
1331 if d.class.token() != class {
1332 continue;
1333 }
1334 let Ok(p) = SubjectPattern::parse(&d.path) else {
1335 continue;
1336 };
1337 if p.matches(&tail).is_none() || p.chunks().len() != fam.chunks.len() {
1338 continue;
1339 }
1340 let same_positions = p.chunks().iter().zip(&fam.chunks).all(|(h, c)| {
1341 matches!(
1342 (h, c),
1343 (PatternChunk::Literal(_), Chunk::Literal(_))
1344 | (PatternChunk::Var(_), Chunk::Var(_))
1345 | (PatternChunk::Rest(_), Chunk::Rest(_))
1346 )
1347 });
1348 if same_positions {
1349 return Some(
1350 p.chunks()
1351 .iter()
1352 .filter_map(|c| match c {
1353 PatternChunk::Var(v) | PatternChunk::Rest(v) => Some(v.clone()),
1354 PatternChunk::Literal(_) => None,
1355 })
1356 .collect(),
1357 );
1358 }
1359 }
1360 None
1361}
1362
1363fn kind_guess(members: &[&KeyObs], suffix_counter: bool) -> Option<String> {
1367 let numeric: u64 = members.iter().map(|k| k.numeric).sum();
1368 let text: u64 = members.iter().map(|k| k.text).sum();
1369 let boolean: u64 = members.iter().map(|k| k.boolean).sum();
1370 let total = numeric + text + boolean;
1371 if total == 0 {
1372 return suffix_counter
1373 .then(|| "kind guess: counter (the `_total` suffix, RFC 08 §4)".to_string());
1374 }
1375 if numeric == total {
1376 let decreased = members.iter().any(|k| k.decreased);
1377 let negative = members.iter().any(|k| k.negative);
1378 let enough = members.iter().any(|k| k.numeric >= 2);
1379 if suffix_counter {
1380 return Some(if decreased || negative {
1381 format!(
1382 "kind: the `_total` suffix says counter (RFC 08 §4) but a decrease or a \
1383 negative was seen over {numeric} numeric sample(s) — review"
1384 )
1385 } else {
1386 format!(
1387 "kind guess: counter (the `_total` suffix, RFC 08 §4; never decreased over \
1388 {numeric} numeric sample(s))"
1389 )
1390 });
1391 }
1392 return Some(if decreased || negative {
1393 format!(
1394 "kind guess: gauge ({numeric} numeric sample(s); a decrease or a negative was seen)"
1395 )
1396 } else if enough {
1397 format!(
1398 "kind guess: counter or gauge — never decreased over {numeric} numeric sample(s) on {} \
1399 origin(s); a gauge that only rose looks the same",
1400 members
1401 .iter()
1402 .map(|k| k.origin.as_str())
1403 .collect::<BTreeSet<_>>()
1404 .len()
1405 )
1406 } else {
1407 format!("kind guess: numeric ({numeric} sample(s), too few to see a direction)")
1408 });
1409 }
1410 if text == total {
1411 return Some(format!("kind guess: text ({text} string sample(s))"));
1412 }
1413 if boolean == total {
1414 return Some(format!("kind guess: bool ({boolean} boolean sample(s))"));
1415 }
1416 Some(format!(
1417 "kind: mixed — {numeric} numeric, {text} text, {boolean} bool sample(s); no single kind"
1418 ))
1419}
1420
1421fn unit_of(leaf: &str) -> Option<&'static str> {
1424 const SUFFIXES: [(&str, &str); 6] = [
1425 ("_ms", "ms"),
1426 ("_us", "us"),
1427 ("_s", "s"),
1428 ("_bytes", "bytes"),
1429 ("_percent", "percent"),
1430 ("_ratio", "ratio"),
1431 ];
1432 SUFFIXES
1433 .iter()
1434 .find(|(suffix, _)| leaf.ends_with(suffix))
1435 .map(|(_, unit)| *unit)
1436}
1437
1438fn median(intervals: &[f64]) -> Option<f64> {
1439 if intervals.is_empty() {
1440 return None;
1441 }
1442 let mut v = intervals.to_vec();
1443 v.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
1444 let mid = v.len() / 2;
1445 Some(if v.len().is_multiple_of(2) {
1446 (v[mid - 1] + v[mid]) / 2.0
1447 } else {
1448 v[mid]
1449 })
1450}
1451
1452fn round_up_pow10(n: u64) -> u64 {
1453 let mut p = 1u64;
1454 while p < n {
1455 p = p.saturating_mul(10);
1456 }
1457 p
1458}
1459
1460fn camel(chunk: &str) -> String {
1461 chunk
1462 .split(|c: char| !c.is_ascii_alphanumeric())
1463 .filter(|s| !s.is_empty())
1464 .map(|s| {
1465 let mut cs = s.chars();
1466 match cs.next() {
1467 Some(f) => f.to_ascii_uppercase().to_string() + cs.as_str(),
1468 None => String::new(),
1469 }
1470 })
1471 .collect()
1472}
1473
1474#[derive(Debug, Clone, Default)]
1477struct PooledPath {
1478 seen: u64,
1479 kinds: BTreeMap<&'static str, u64>,
1480 all_integral: bool,
1481}
1482
1483fn pooled_schema(
1487 fam: &Family,
1488 key_names: &[&str],
1489 obs: &InferObservation,
1490) -> (Option<Value>, u64, u64) {
1491 let mut paths: BTreeMap<String, PooledPath> = BTreeMap::new();
1492 let mut documents = 0u64;
1493 let mut undocumented = 0u64;
1494 let by_key: BTreeMap<&str, &crate::judge::field::KeyFields> = obs.fields.iter().collect();
1495 for &i in &fam.members {
1496 let Some(fields) = by_key.get(key_names[i]) else {
1497 continue;
1498 };
1499 documents += fields.documents;
1500 undocumented += fields.undocumented + fields.unread;
1501 for (path, stats) in &fields.paths {
1502 let p = paths.entry(path.clone()).or_insert_with(|| PooledPath {
1503 all_integral: true,
1504 ..PooledPath::default()
1505 });
1506 pool(p, stats);
1507 }
1508 }
1509 if documents == 0 {
1510 return (None, undocumented, 0);
1511 }
1512 let truncated = obs.fields.dropped_paths() > 0;
1513 (
1514 Some(schema_of(&paths, documents, truncated)),
1515 undocumented,
1516 documents,
1517 )
1518}
1519
1520fn pool(p: &mut PooledPath, stats: &PathStats) {
1521 p.seen += stats.seen;
1522 for (k, n) in &stats.kinds {
1523 *p.kinds.entry(k).or_default() += n;
1524 }
1525 p.all_integral &= stats.all_integral;
1526}
1527
1528#[derive(Debug, Default)]
1529struct SchemaNode {
1530 children: BTreeMap<String, SchemaNode>,
1531 leaf: Option<PooledPath>,
1532}
1533
1534fn schema_of(paths: &BTreeMap<String, PooledPath>, documents: u64, truncated: bool) -> Value {
1535 if let Some(root) = paths.get(ROOT_PATH) {
1536 return scalar_schema(root);
1537 }
1538 let mut tree = SchemaNode::default();
1539 for (path, p) in paths {
1540 let mut node = &mut tree;
1541 for part in path.split('.') {
1542 node = node.children.entry(part.to_string()).or_default();
1543 }
1544 node.leaf = Some(p.clone());
1545 }
1546 let mut schema = object_schema(&tree, documents);
1547 if truncated && let Value::Object(m) = &mut schema {
1548 m.insert("additionalProperties".into(), Value::Bool(true));
1549 }
1550 schema
1551}
1552
1553fn object_schema(node: &SchemaNode, presence: u64) -> Value {
1554 let mut properties = serde_json::Map::new();
1555 let mut required = Vec::new();
1556 for (name, child) in &node.children {
1557 let schema = if child.children.is_empty() {
1558 child
1559 .leaf
1560 .as_ref()
1561 .map(scalar_schema)
1562 .unwrap_or(Value::Object(Default::default()))
1563 } else {
1564 let child_presence = child
1566 .leaf
1567 .as_ref()
1568 .map_or(0, |l| l.seen)
1569 .max(max_seen(child));
1570 let mut s = object_schema(child, child_presence);
1571 if let Some(leaf) = &child.leaf
1572 && let Value::Object(m) = &mut s
1573 && leaf.kinds.keys().any(|k| *k != "object")
1574 {
1575 let mut types: Vec<&str> = leaf.kinds.keys().map(|k| json_type(k, leaf)).collect();
1577 types.push("object");
1578 types.sort_unstable();
1579 types.dedup();
1580 m.insert("type".into(), types.into_iter().map(Value::from).collect());
1581 }
1582 s
1583 };
1584 let seen = child
1585 .leaf
1586 .as_ref()
1587 .map_or(0, |l| l.seen)
1588 .max(max_seen(child));
1589 if presence > 0 && seen >= presence {
1590 required.push(Value::from(name.as_str()));
1591 }
1592 properties.insert(name.clone(), schema);
1593 }
1594 let mut m = serde_json::Map::new();
1595 m.insert("type".into(), Value::from("object"));
1596 m.insert("properties".into(), Value::Object(properties));
1597 if !required.is_empty() {
1598 m.insert("required".into(), Value::Array(required));
1599 }
1600 Value::Object(m)
1601}
1602
1603fn max_seen(node: &SchemaNode) -> u64 {
1604 node.children
1605 .values()
1606 .map(|c| c.leaf.as_ref().map_or(0, |l| l.seen).max(max_seen(c)))
1607 .max()
1608 .unwrap_or(0)
1609}
1610
1611fn json_type(kind: &str, p: &PooledPath) -> &'static str {
1612 match kind {
1613 "number" => {
1614 if p.all_integral {
1615 "integer"
1616 } else {
1617 "number"
1618 }
1619 }
1620 "bool" => "boolean",
1621 "string" => "string",
1622 "null" => "null",
1623 "array" => "array",
1624 _ => "object",
1625 }
1626}
1627
1628fn scalar_schema(p: &PooledPath) -> Value {
1629 let mut types: Vec<&str> = p.kinds.keys().map(|k| json_type(k, p)).collect();
1630 types.sort_unstable();
1631 types.dedup();
1632 let mut m = serde_json::Map::new();
1633 match types.as_slice() {
1634 [one] => {
1635 m.insert("type".into(), Value::from(*one));
1636 }
1637 many => {
1638 m.insert(
1639 "type".into(),
1640 many.iter().map(|t| Value::from(*t)).collect(),
1641 );
1642 }
1643 }
1644 if types.contains(&"array") {
1645 m.insert(
1646 "$comment".into(),
1647 Value::from("items not inferred: arrays are leaves to the field walk"),
1648 );
1649 }
1650 Value::Object(m)
1651}
1652
1653#[derive(Debug, Clone)]
1657pub struct Provenance {
1658 pub app: String,
1660 pub source: String,
1662 pub span_s: f64,
1664 pub at: String,
1666 pub keys: usize,
1667 pub samples: u64,
1668 pub dropped: u64,
1669}
1670
1671pub fn to_draft_toml(producer: &InferredProducer, prov: &Provenance) -> String {
1675 let mut out = String::new();
1676 out.push_str(&format!(
1677 "# DRAFT — inferred from {} over {:.1} s on {}, {} keys, {} samples, dropped {};\n\
1678 # observation-derived and unreviewed (RFC 08 §6.1). Every field below is a guess a\n\
1679 # human has not confirmed. `draft = true` makes zenkey-build refuse this file until\n\
1680 # a review removes the marker and assigns `since`; `registry lint --allow-drafts`\n\
1681 # checks it meanwhile.\n\n",
1682 prov.source, prov.span_s, prov.at, prov.keys, prov.samples, prov.dropped
1683 ));
1684 out.push_str("[registry]\nversion = \"0.1\"\n");
1685 out.push_str(&format!("app = {}\n", toml_quote(&prov.app)));
1686 out.push_str("convention = 1\ncompat = \"none\"\ndraft = true\n");
1687 match &producer.service_origin {
1688 Some(origin) => out.push_str(&format!(
1689 "\n[service]\nname = {}\norigin = {}\n",
1690 toml_quote(&producer.name),
1691 toml_quote(origin)
1692 )),
1693 None => out.push_str(&format!(
1694 "\n[producer]\nname = {}\n",
1695 toml_quote(&producer.name)
1696 )),
1697 }
1698 for s in &producer.subjects {
1699 out.push('\n');
1700 for c in &s.comments {
1701 out.push_str(&format!("# {c}\n"));
1702 }
1703 out.push_str(&format!(
1704 "# observed: {} key(s) on {} origin(s), {} sample(s)\n",
1705 s.keys, s.origins, s.samples
1706 ));
1707 out.push_str("[[subject]]\n");
1708 out.push_str(&format!("path = {}\n", toml_quote(&s.path)));
1709 out.push_str(&format!("class = {}\n", toml_quote(&s.class)));
1710 out.push_str(&format!("type = {}\n", toml_quote(&s.type_name)));
1711 if let Some(v) = &s.variant {
1712 out.push_str(&format!("variant = {}\n", toml_quote(v)));
1713 }
1714 if let Some(u) = &s.unit {
1715 out.push_str(&format!("unit = {}\n", toml_quote(u)));
1716 }
1717 if let Some(q) = &s.qos {
1718 out.push_str(&format!("qos = {}\n", toml_quote(q)));
1719 }
1720 if let Some(t) = s.ttl_s {
1721 out.push_str(&format!("ttl_s = {t}\n"));
1722 }
1723 if let Some(r) = &s.rate {
1724 out.push_str(&format!("rate = {}\n", toml_quote(r)));
1725 }
1726 if let Some(c) = s.cardinality {
1727 out.push_str(&format!("cardinality = {c}\n"));
1728 }
1729 if let Some(e) = &s.encoding {
1730 out.push_str(&format!("encoding = {}\n", toml_quote(e)));
1731 }
1732 out.push_str("description = \"inferred; unreviewed\"\n");
1733 }
1734 out
1735}
1736
1737pub fn to_draft_types_toml(producers: &[InferredProducer], prov: &Provenance) -> String {
1742 let mut out = String::new();
1743 out.push_str(&format!(
1744 "# DRAFT type table — inferred from {} on {} (RFC 08 §5, §6.1); every schema here is\n\
1745 # the shape of what was observed, not a declaration. Review beside the producer files.\n",
1746 prov.source, prov.at
1747 ));
1748 for p in producers {
1749 for t in &p.types {
1750 out.push('\n');
1751 out.push_str(&format!(
1752 "# {}: bound by {} subject(s)\n",
1753 p.name, t.subjects
1754 ));
1755 out.push_str(&format!("[types.{}]\n", t.name));
1756 match &t.schema {
1757 Some(_) => {
1758 out.push_str("kind = \"json-schema\"\n");
1759 out.push_str(&format!(
1760 "schema = {}\n",
1761 toml_quote(&format!("schemas/{}.json", t.name))
1762 ));
1763 }
1764 None => out
1765 .push_str("kind = \"unknown\" # no structural sample; nothing to describe\n"),
1766 }
1767 }
1768 }
1769 out
1770}
1771
1772pub fn draft_schema_files(producers: &[InferredProducer]) -> Vec<(String, String)> {
1775 producers
1776 .iter()
1777 .flat_map(|p| p.types.iter())
1778 .filter_map(|t| {
1779 t.schema.as_ref().map(|s| {
1780 let mut doc = serde_json::Map::new();
1781 doc.insert(
1782 "$schema".into(),
1783 Value::from("https://json-schema.org/draft/2020-12/schema"),
1784 );
1785 doc.insert("title".into(), Value::from(t.name.as_str()));
1786 doc.insert(
1787 "$comment".into(),
1788 Value::from("DRAFT — inferred from observed samples (RFC 08 §6.1); unreviewed"),
1789 );
1790 if let Value::Object(m) = s {
1791 for (k, v) in m {
1792 doc.insert(k.clone(), v.clone());
1793 }
1794 }
1795 (
1796 format!("schemas/{}.json", t.name),
1797 serde_json::to_string_pretty(&Value::Object(doc)).unwrap_or_default() + "\n",
1798 )
1799 })
1800 })
1801 .collect()
1802}
1803
1804pub fn draft_file_names(report: &InferReport) -> Vec<String> {
1807 let mut names: Vec<String> = report
1808 .producers
1809 .iter()
1810 .map(|p| format!("{}.toml", p.name))
1811 .collect();
1812 names.push("types.toml".to_string());
1813 names.extend(
1814 draft_schema_files(&report.producers)
1815 .into_iter()
1816 .map(|(p, _)| p),
1817 );
1818 names
1819}
1820
1821#[cfg(test)]
1822mod tests {
1823 use super::*;
1824
1825 fn feed(
1826 obs: &mut InferObservation,
1827 origin: &str,
1828 class: &str,
1829 tail: &str,
1830 at: f64,
1831 doc: Option<&Value>,
1832 ) {
1833 obs.observe(
1834 &format!("v1/{origin}/{class}/demo/{tail}"),
1835 at,
1836 Some("application/json"),
1837 Some("sampled"),
1838 doc,
1839 );
1840 }
1841
1842 const A: &str = "h-aaaaaaaaaaaa";
1843 const B: &str = "h-bbbbbbbbbbbb";
1844
1845 fn paths(report: &InferReport) -> Vec<(String, String)> {
1846 report
1847 .producers
1848 .iter()
1849 .flat_map(|p| p.subjects.iter().map(|s| (s.class.clone(), s.path.clone())))
1850 .collect()
1851 }
1852
1853 #[test]
1857 fn interior_and_leaf_evidence_split_the_three_cases() {
1858 let mut obs = InferObservation::new("", 100, 100);
1859 let n = serde_json::json!({"value": 1});
1860 for (i, o) in [A, B].iter().enumerate() {
1861 let core = format!("core{i}");
1862 feed(
1863 &mut obs,
1864 o,
1865 "telemetry",
1866 &format!("cpu/{core}/usage"),
1867 0.0,
1868 Some(&n),
1869 );
1870 feed(
1871 &mut obs,
1872 o,
1873 "telemetry",
1874 &format!("cpu/{core}/usage"),
1875 1.0,
1876 Some(&n),
1877 );
1878 feed(&mut obs, o, "telemetry", "cpu/usage", 0.0, Some(&n));
1879 feed(&mut obs, o, "telemetry", "memory/total", 0.0, Some(&n));
1880 feed(&mut obs, o, "telemetry", "memory/used", 0.0, Some(&n));
1881 feed(
1882 &mut obs,
1883 o,
1884 "telemetry",
1885 &format!("disk/mnt{i}/used_bytes"),
1886 0.0,
1887 Some(&n),
1888 );
1889 }
1890 let r = infer(&obs, "v1/**", Some(10.0), None);
1891 let mut got = paths(&r);
1892 got.sort();
1893 assert_eq!(
1894 got,
1895 vec![
1896 ("telemetry".into(), "cpu/usage".into()),
1897 ("telemetry".into(), "cpu/{v1}/usage".into()),
1898 ("telemetry".into(), "disk/{v1}/used_bytes".into()),
1899 ("telemetry".into(), "memory/total".into()),
1900 ("telemetry".into(), "memory/used".into()),
1901 ],
1902 "{r:#?}"
1903 );
1904 let disk = r.producers[0]
1905 .subjects
1906 .iter()
1907 .find(|s| s.path == "disk/{v1}/used_bytes")
1908 .unwrap();
1909 assert_eq!(disk.unit.as_deref(), Some("bytes"));
1910 let core = r.producers[0]
1913 .subjects
1914 .iter()
1915 .find(|s| s.path == "cpu/{v1}/usage")
1916 .unwrap();
1917 assert_eq!(core.variant.as_deref(), Some("CpuV1Usage"));
1918 assert!(
1919 r.producers[0]
1920 .subjects
1921 .iter()
1922 .find(|s| s.path == "cpu/usage")
1923 .unwrap()
1924 .variant
1925 .is_none()
1926 );
1927 assert_eq!(disk.cardinality, Some(1));
1928 assert_eq!(disk.encoding.as_deref(), Some("application/json"));
1929 assert!(
1930 disk.comments
1931 .iter()
1932 .any(|c| c.starts_with("qos observed: sampled"))
1933 );
1934 assert!(disk.ttl_s.is_none() && disk.rate.is_none());
1935 }
1936
1937 #[test]
1939 fn one_expansion_cannot_prove_a_dimension() {
1940 let mut obs = InferObservation::new("", 100, 100);
1941 feed(&mut obs, A, "telemetry", "cpu/core0/usage", 0.0, None);
1942 feed(&mut obs, A, "telemetry", "tcp/established", 0.0, None);
1943 feed(&mut obs, A, "telemetry", "tcp/listen", 0.0, None);
1944 let r = infer(&obs, "v1/**", None, None);
1945 let got = paths(&r);
1946 assert!(
1947 got.contains(&("telemetry".into(), "cpu/core0/usage".into())),
1948 "{got:?}"
1949 );
1950 assert!(
1951 got.contains(&("telemetry".into(), "tcp/listen".into())),
1952 "{got:?}"
1953 );
1954 assert!(
1955 r.caveats.iter().any(|c| c.contains("1 origin")),
1956 "{:?}",
1957 r.caveats
1958 );
1959 }
1960
1961 #[test]
1964 fn a_literal_pair_with_one_shape_is_a_var_with_its_siblings_named() {
1965 let mut obs = InferObservation::new("", 100, 100);
1966 for o in [A, B] {
1967 feed(&mut obs, o, "telemetry", "cpu/usage", 0.0, None);
1968 feed(&mut obs, o, "telemetry", "mem/usage", 0.0, None);
1969 }
1970 let r = infer(&obs, "v1/**", None, None);
1971 let s = &r.producers[0].subjects;
1972 assert_eq!(s.len(), 1, "{s:#?}");
1973 assert_eq!(s[0].path, "{v0}/usage");
1974 assert!(
1975 s[0].comments
1976 .iter()
1977 .any(|c| c.contains("siblings: cpu, mem") && c.contains("literal pair")),
1978 "{:?}",
1979 s[0].comments
1980 );
1981 }
1982
1983 #[test]
1986 fn events_keys_are_write_once_and_rated() {
1987 let mut obs = InferObservation::new("", 100, 100);
1988 for i in 0..5 {
1989 feed(
1990 &mut obs,
1991 A,
1992 "events",
1993 &format!("boom/id{i}"),
1994 i as f64 * 10.0,
1995 None,
1996 );
1997 }
1998 let r = infer(&obs, "v1/**", Some(40.0), None);
1999 let s = &r.producers[0].subjects;
2000 assert_eq!(s.len(), 1, "{s:#?}");
2001 assert_eq!(s[0].path, "boom/{v1}");
2002 assert_eq!(s[0].rate.as_deref(), Some("burst(1000/h)"));
2004 assert_eq!(s[0].cardinality, Some(10));
2005 }
2006
2007 #[test]
2010 fn state_ttl_is_a_refresh_hint_or_absent() {
2011 let mut obs = InferObservation::new("", 100, 100);
2012 let ok = serde_json::json!({"ok": true, "load": 0.5});
2013 for t in [0.0, 5.0, 10.0, 15.0] {
2014 feed(&mut obs, A, "state", "health", t, Some(&ok));
2015 }
2016 feed(&mut obs, A, "state", "sensor", 0.0, Some(&ok));
2017 let r = infer(&obs, "v1/**", Some(20.0), None);
2018 let health = r.producers[0]
2019 .subjects
2020 .iter()
2021 .find(|s| s.path == "health")
2022 .unwrap();
2023 assert_eq!(health.ttl_s, Some(10));
2024 let sensor = r.producers[0]
2025 .subjects
2026 .iter()
2027 .find(|s| s.path == "sensor")
2028 .unwrap();
2029 assert_eq!(sensor.ttl_s, None);
2030 assert!(
2031 sensor
2032 .comments
2033 .iter()
2034 .any(|c| c.contains("ttl_s not established"))
2035 );
2036 let t = r.producers[0]
2040 .types
2041 .iter()
2042 .find(|t| t.name == "DemoShape1")
2043 .unwrap();
2044 assert_eq!(
2045 t.schema,
2046 Some(serde_json::json!({
2047 "type": "object",
2048 "properties": {"ok": {"type": "boolean"}, "load": {"type": "number"}},
2049 "required": ["load", "ok"],
2050 }))
2051 );
2052 assert_eq!(t.subjects, 2, "sensor shares the shape and the type");
2053 }
2054
2055 #[test]
2058 fn the_schema_reads_integrality_presence_and_mixed_kinds() {
2059 let mut obs = InferObservation::new("", 100, 100);
2060 feed(
2061 &mut obs,
2062 A,
2063 "telemetry",
2064 "x",
2065 0.0,
2066 Some(&serde_json::json!({"n": 1, "m": "a", "opt": [1]})),
2067 );
2068 feed(
2069 &mut obs,
2070 A,
2071 "telemetry",
2072 "x",
2073 1.0,
2074 Some(&serde_json::json!({"n": 2, "m": 3})),
2075 );
2076 let r = infer(&obs, "v1/**", None, None);
2077 let t = &r.producers[0].types[0];
2078 assert_eq!(
2079 t.schema,
2080 Some(serde_json::json!({
2081 "type": "object",
2082 "properties": {
2083 "n": {"type": "integer"},
2084 "m": {"type": ["integer", "string"]},
2085 "opt": {"type": "array", "$comment": "items not inferred: arrays are leaves to the field walk"},
2086 },
2087 "required": ["m", "n"],
2088 }))
2089 );
2090 }
2091
2092 #[test]
2095 fn the_draft_carries_the_marker_and_omits_what_it_could_not_establish() {
2096 let mut obs = InferObservation::new("", 100, 100);
2097 for o in [A, B] {
2098 feed(&mut obs, o, "state", "health", 0.0, None);
2099 }
2100 let r = infer(&obs, "v1/**", Some(1.0), None);
2101 let prov = Provenance {
2102 app: "unknown".into(),
2103 source: "v1/**".into(),
2104 span_s: r.span_s,
2105 at: "2026-09-06T00:00:00Z".into(),
2106 keys: r.keys_seen,
2107 samples: r.samples,
2108 dropped: 0,
2109 };
2110 let toml = to_draft_toml(&r.producers[0], &prov);
2111 assert!(
2112 toml.starts_with("# DRAFT — inferred from v1/** over"),
2113 "{toml}"
2114 );
2115 assert!(toml.contains("draft = true\n"));
2116 assert!(toml.contains("compat = \"none\"\n"));
2117 assert!(
2118 !toml.contains("since ="),
2119 "a draft has no version stream:\n{toml}"
2120 );
2121 assert!(
2122 !toml.contains("ttl_s ="),
2123 "unestablished ttl is absent:\n{toml}"
2124 );
2125 assert!(
2126 !toml.contains("\nqos ="),
2127 "observed qos is never a field:\n{toml}"
2128 );
2129 let mut alert = InferObservation::new("", 10, 10);
2131 for o in [A, B] {
2132 feed(&mut alert, o, "state", "alert/disk_full", 0.0, None);
2133 }
2134 let r2 = infer(&alert, "v1/**", None, None);
2135 let a = &r2.producers[0].subjects[0];
2136 assert_eq!(a.qos.as_deref(), Some("alert"));
2137 assert!(
2138 a.comments.iter().any(|c| c.contains("RFC 08 §5 requires")),
2139 "{:?}",
2140 a.comments
2141 );
2142 assert!(to_draft_toml(&r2.producers[0], &prov).contains("\nqos = \"alert\"\n"));
2143 assert!(toml.contains("# ttl_s not established"), "{toml}");
2144 assert!(toml.contains("description = \"inferred; unreviewed\""));
2145 let doc: toml::Value = toml::from_str(&toml).expect("the draft parses");
2146 assert_eq!(doc["registry"]["draft"], toml::Value::Boolean(true));
2147 let types = to_draft_types_toml(&r.producers, &prov);
2148 let doc: toml::Value = toml::from_str(&types).expect("the type table parses");
2149 assert_eq!(
2150 doc["types"]["DemoHealthOpaque"]["kind"].as_str(),
2151 Some("unknown")
2152 );
2153 assert_eq!(
2154 draft_file_names(&r),
2155 vec!["demo.toml".to_string(), "types.toml".to_string()]
2156 );
2157 }
2158
2159 #[test]
2162 fn a_hint_names_the_vars_it_can_bind() {
2163 let slice = zenkey::parse_slice(
2164 "[registry]\nversion = \"1.0\"\napp = \"t\"\nconvention = 1\n[producer]\nname = \"demo\"\n\
2165 [[subject]]\npath = \"cpu/{core}/usage\"\nclass = \"telemetry\"\ntype = \"P\"\n",
2166 )
2167 .unwrap();
2168 let hints = SliceSet::from_slices(vec![slice]);
2169 let mut obs = InferObservation::new("", 100, 100);
2170 for (i, o) in [A, B].iter().enumerate() {
2171 feed(
2172 &mut obs,
2173 o,
2174 "telemetry",
2175 &format!("cpu/core{i}/usage"),
2176 0.0,
2177 None,
2178 );
2179 feed(
2180 &mut obs,
2181 o,
2182 "telemetry",
2183 &format!("disk/mnt{i}/used"),
2184 0.0,
2185 None,
2186 );
2187 }
2188 let r = infer(&obs, "v1/**", None, Some(&hints));
2189 let got = paths(&r);
2190 assert!(
2191 got.contains(&("telemetry".into(), "cpu/{core}/usage".into())),
2192 "{got:?}"
2193 );
2194 assert!(
2195 got.contains(&("telemetry".into(), "disk/{v1}/used".into())),
2196 "{got:?}"
2197 );
2198 assert!(r.hinted_by_registry);
2199 }
2200
2201 #[test]
2202 fn helpers() {
2203 assert_eq!(round_up_pow10(0), 1);
2204 assert_eq!(round_up_pow10(1), 1);
2205 assert_eq!(round_up_pow10(2), 10);
2206 assert_eq!(round_up_pow10(10), 10);
2207 assert_eq!(round_up_pow10(11), 100);
2208 assert_eq!(camel("usage_percent"), "UsagePercent");
2209 assert_eq!(unit_of("rx_bytes"), Some("bytes"));
2210 assert_eq!(unit_of("p95_ms"), Some("ms"));
2211 assert_eq!(unit_of("usage"), None);
2212 assert_eq!(median(&[3.0, 1.0, 2.0]), Some(2.0));
2213 assert_eq!(median(&[1.0, 3.0]), Some(2.0));
2214 }
2215}