Skip to main content

zenkey_fleet/model/
infer.rs

1//! Registry inference (#225, RFC 08 §6.1 v1.34): a draft `registry/` from
2//! the wire, marked as a draft.
3//!
4//! The registry is the adoption cliff — a fleet without one gets nothing
5//! from this suite's best half, and hand-writing two hundred entries is why
6//! it never gets one. Yet every input a `[[subject]]` wants is already in
7//! this crate's hands: the grammar gives producer, class and subject; the
8//! wire gives the encoding and the QoS that actually rode; a count over a
9//! window gives a rate class and a refresh interval; the key population
10//! gives observed expansions; the sample documents give a JSON Schema; RFC
11//! 08 §4's suffix rule turns `_ms`/`_bytes`/`_percent` leaves into units.
12//! This module joins them into an [`InferReport`] that
13//! [`to_draft_toml`] writes out, one file per producer.
14//!
15//! Pure, the house pattern of [`crate::judge::field`]: nothing here takes a
16//! session, so a `.zrec` replays through the same inference as live
17//! traffic, and the tests feed synthetic rows.
18//!
19//! # What a draft is
20//!
21//! The inverse of the lie RFC 08 §6.1 forbids: it describes surfaces that
22//! *were* served, by an author who cannot vouch that they *will* be. So the
23//! file carries `draft = true` (a marker `zenkey-build` **refuses**, not a
24//! comment a human is trusted to notice), `compat = "none"`, no `since`,
25//! and a header saying every field is a guess. Everything the observation
26//! could not establish is **absent**, never defaulted — the one exception
27//! the emitter makes is stated on the entry as a comment (`rate = "rare"`
28//! for an events family with at most one sample, which is the tightest
29//! class consistent with the evidence, not a default). Observed QoS is a
30//! **comment**, never a field: writing it as `qos = …` would launder a
31//! current publisher bug into a contract. The one `qos` a draft writes is
32//! the alert family's — `state` under a leading `alert` chunk — because RFC
33//! 08 §5 (v1.23) *requires* `qos = "alert"` there: that is the family's
34//! normative profile, not a reading of the wire, and the entry says so.
35//!
36//! # The literal-vs-`{var}` heuristic
37//!
38//! The registry convention's rule is semantic — *a leaf naming a distinct
39//! measurement is a literal; a leaf that is a value of a dimension is a
40//! `{var}`* — and observation cannot read meaning. What it can read is
41//! structure and population, so the heuristic uses three kinds of evidence,
42//! evaluated bottom-up on one chunk trie per (producer, class) that spans
43//! every origin:
44//!
45//! * **E1 — interior siblings with one sub-structure.** Under one prefix,
46//!   sibling chunks that each carry a subtree are one `{var}` position when
47//!   their subtrees are shape-identical *after* recursive inference of the
48//!   merged subtree, they share at least half their concrete tails (so
49//!   `sda`/`sdb` merge and `system`/`tcp` do not), and every inferred tail
50//!   is served by every member. `cpu0/usage`, `cpu1/usage` → `{v}/usage`.
51//!   Siblings are only tried against each other when their tails reach the
52//!   same depths, which is what bounds the pairwise work.
53//! * **E2 — leaf siblings whose membership varies by origin.** Leaf chunks
54//!   under one prefix are one `{var}` when some of them are published by
55//!   only a subset of the origins that publish under the prefix: a
56//!   population that differs from host to host is data, not vocabulary.
57//!   Otherwise every leaf is a literal measurement.
58//! * **E3 — write-once leaves under `events`.** RFC 04 §1.3 makes an events
59//!   key write-once, so ≥ 2 leaf siblings each seen exactly once are one
60//!   `{var}` even from a single origin.
61//! * **Rest.** A prefix whose children are host-conditional at two depths
62//!   — at least one leaf and at least two interior chunks, with unlike
63//!   subtrees, each missing from some origin — with at least
64//!   [`REST_MIN_PATHS`] distinct tails is a device-defined tree and becomes
65//!   one trailing `{rest...}`.
66//!
67//! **The failure modes, named** (each is also a comment on the entry it
68//! produces, so the reviewer sees it where it matters):
69//!
70//! 1. *One expansion cannot prove a dimension.* A `{var}` observed with one
71//!    value, or a leaf dimension observed from one origin, is emitted as a
72//!    literal. A single-origin window therefore drafts every leaf literal,
73//!    and the run's caveat says so.
74//! 2. *A literal pair with one shape looks like a dimension.* `cpu/usage`
75//!    and `mem/usage` are indistinguishable from `{v}/usage`; E1 merges them
76//!    and the entry lists the siblings it merged.
77//! 3. *A closed vocabulary looks like measurements.* `tcp/{state}` whose
78//!    eleven states every host publishes has no E2 signal and comes out as
79//!    eleven literals. The convention chose `{var}` there by meaning, which
80//!    is exactly what a review supplies.
81//! 4. *A dimension whose values also carry value-specific subjects splits.*
82//!    `cgroup/{resource}/pressure/{stat}` beside `cgroup/cpu/nr_throttled`
83//!    and `cgroup/memory/current`: the members' subtrees differ, so E1
84//!    keeps them literal and the shared `pressure/{stat}` family appears
85//!    once per value.
86//! 5. *A host-conditional measurement reads as a dimension.* A host with no
87//!    swap never publishes `memory/swap_total`; E2 then reads the whole
88//!    `memory/*` leaf set as `memory/{v}`. The entry lists the siblings.
89//! 6. *A device-defined tree with too few host-varying paths stays
90//!    literal*, and a producer with host-conditional subjects at two depths
91//!    can become `{rest...}` — the rest rule's two sides.
92//!
93//! Var names come from a hint slice's pattern when one binds with the same
94//! var positions, else `{v<depth>}`. Cardinality is the largest distinct
95//! member count any one origin published at the position, rounded **up**
96//! to a power of ten. `rate` (events only) is the busiest origin's count
97//! over the span, classed `rare` ≤ 1/h, `low` ≤ 60/h, else `burst(n/h)`
98//! with `n` rounded up to a power of ten. `ttl_s` (state) is twice the
99//! longest median inter-arrival among the expansions, rounded up — a
100//! *field* because the lint requires one, with a comment saying it is a
101//! hint; absent, with the lint left to name it, when no expansion refreshed
102//! inside the window. `unit` follows RFC 08 §4's suffix rule and nothing
103//! else; `_total` becomes a `kind` guess instead. `encoding` is a field
104//! only when every sample agreed.
105//!
106//! The schema inference is deliberately minimal — kinds per dotted path
107//! from [`PathStats`], objects with `properties` and a `required` of what
108//! every document carried, arrays without `items`, `integer` when every
109//! number was integral — and neither this nor [`crate::model::jsonschema`]
110//! should become a JSON Schema implementation.
111
112use 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
123/// How many distinct concrete tails a prefix needs before its
124/// host-conditional children are read as a device-defined `{rest...}` tree.
125pub const REST_MIN_PATHS: usize = 3;
126
127/// The fraction of concrete tails two interior siblings must share before
128/// E1 merges them — `sda`/`sdb` share everything, `system`/`tcp` nothing,
129/// and the threshold is what keeps one shared leaf name from merging two
130/// unrelated measurement groups.
131const ANCHOR_MIN: f64 = 0.5;
132
133/// How many sibling values an entry's `{var}` comment names.
134const SIBLING_EXAMPLES: usize = 5;
135
136/// Intervals kept per key for the median inter-arrival (bounded, O6).
137const INTERVAL_CAP: usize = 64;
138
139/// The default key bound — one trie node per distinct wire key.
140pub const DEFAULT_MAX_KEYS: usize = 20_000;
141/// The default path bound across every key's documents.
142pub const DEFAULT_MAX_PATHS: usize = 8_192;
143
144// ─── the observation ────────────────────────────────────────────────────────
145
146/// Who a key's `[[subject]]` belongs to: a host producer, or a service
147/// origin (whose registry file is a `[service]` file, RFC 03 §1.5).
148#[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/// What one wire key did over the window.
164#[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    /// Inter-arrival seconds, bounded — the refresh evidence.
173    intervals: Vec<f64>,
174    encodings: BTreeMap<String, u64>,
175    qos: BTreeMap<String, u64>,
176    /// The leaf value's kind evidence: numeric samples, whether any
177    /// decreased, whether any was negative; text / bool counts.
178    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
230/// The leaf value a `kind` guess is about: the document itself when it is
231/// a scalar, else its `value` field (the RFC 08 §2 self-describing value
232/// object and the reference `TelemetryPoint` both spell it so).
233fn 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/// Bounded per-key observations over one window, fed one sample at a time.
242///
243/// Keys are projected through [`describe_key`] under `base`; a key that
244/// does not parse, or sits on a verbatim plane, is counted and skipped —
245/// there is no `[[subject]]` to draft for it.
246#[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    /// Feed one sample. `qos` is the label the caller resolved for the
277    /// wire's axes (a profile name when one matches, else the axes spelled
278    /// out); `doc` the structural document when the payload carried one
279    /// ([`crate::model::decode::structural_value`]) — `None` counts it as
280    /// undocumented rather than pretending its fields were absent.
281    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    /// A sample whose payload was too large to read (O6): counted, never
345    /// read as an absent document.
346    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    /// First to last sample, seconds (0 for fewer than two samples).
372    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// ─── the trie ───────────────────────────────────────────────────────────────
385
386#[derive(Debug, Default)]
387struct Node {
388    children: BTreeMap<String, Node>,
389    /// Keys whose tail ends here.
390    terminal: Vec<usize>,
391    /// Origins publishing anything at or under this node.
392    origins: BTreeSet<String>,
393    /// Every concrete tail below this node, `""` for "ends here".
394    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    /// The depths of this node's tails — the coarse shape two siblings must
416    /// share before E1 even tries to merge them. Names are deliberately
417    /// not part of it: a nested dimension's values differ by name at every
418    /// depth, and the anchor test is what reads the names.
419    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/// A `{var}` position's population: per origin, the member chunks.
434#[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/// One inferred family: the pattern, the keys it covers, the reasons.
467#[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
492/// A merged view: the concrete nodes standing at one inferred position.
493struct 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
537/// The inference over one view, bottom-up. Returns the families found at
538/// or below it, each with `prefix` prepended.
539fn 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    // ── Rest: a device-defined tree ───────────────────────────────────────
557    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    // ── Leaves: E2 / E3 ───────────────────────────────────────────────────
607    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    // ── Interiors: E1 ─────────────────────────────────────────────────────
670    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    // Greedy clustering: a sibling joins the first cluster it merges with
676    // consistently; a merge is tried only between siblings of one coarse
677    // shape, and accepted by the E1 test in `merge_accepted`.
678    // One cluster: the depth profile its members share, and the members.
679    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            // Failure mode 2 is the uniform case: every origin published
723            // the same members. A population that varies by origin is the
724            // dimension evidence E2 reads, whatever the depth.
725            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
766/// The E1 test: the merged subtree infers to patterns every member serves
767/// in full, and the members share at least [`ANCHOR_MIN`] of their tails.
768fn 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    // Relative patterns below the var position.
802    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
855// ─── the inference ──────────────────────────────────────────────────────────
856
857/// Draft the registry from the observation. `hints` is a loaded slice set
858/// when one was available: it names `{var}`s where a declared pattern binds
859/// with the same positions, and resolves a service origin's name; the draft
860/// comes out without one, and says so.
861pub 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    // One trie per (owner, class).
874    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    // Per owner, each subject's inferred schema, in subject order; named
884    // and deduplicated by `assign_types` once the producer is whole.
885    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
960/// The generated-variant names `zenkey-build` would derive — the literal
961/// chunks CamelCased, or every chunk when there is no literal — and an
962/// override where two paths collide, so the draft lints as written.
963fn 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        // The literal-only path keeps the default; each var-bearing path is
1000        // named with its vars, then its class if that still collides.
1001        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
1018/// Name the types and bind the subjects to them, deduplicated per producer
1019/// by schema identity. A shape one subject carries is named after that
1020/// subject's literal chunks (`DemoCpuUsagePercent`); a shape several share
1021/// is named after their common leading literals (`DemoMemory`), or
1022/// `<Producer>Shape<n>` when they share none — never after whichever
1023/// member happened to come first. A subject with no structural sample
1024/// binds an `…Opaque` type with no schema.
1025fn 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    // Deterministic order: by first member, which follows subject order.
1043    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    // ── the path ──────────────────────────────────────────────────────────
1115    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    // ── cardinality ───────────────────────────────────────────────────────
1137    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    // ── counts ────────────────────────────────────────────────────────────
1159    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    // ── rate (events) ─────────────────────────────────────────────────────
1163    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    // ── ttl_s (state) ─────────────────────────────────────────────────────
1192    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    // ── unit / kind (RFC 08 §4) ───────────────────────────────────────────
1217    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    // ── encoding ──────────────────────────────────────────────────────────
1228    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    // ── qos: observed is a comment, never a field; the alert family's
1251    // normative profile is a field, because RFC 08 §5 (v1.23) requires the
1252    // declaration and the draft would not lint without it ────────────────
1253    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    // ── schema / type ─────────────────────────────────────────────────────
1288    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        // Named once the producer is whole (`assign_types`).
1303        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
1319/// A hint slice's var names, when one of its declared patterns of this
1320/// class binds one member tail with the same var positions.
1321fn 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
1363/// The `kind` guess (RFC 08 §2, v1.32) — a comment, never a field. The
1364/// `_total` suffix (RFC 08 §4) says counter; the samples say whether the
1365/// series ever decreased; the two fold into one line.
1366fn 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
1421/// RFC 08 §4's suffix rule — normative for the suffix, so these are the
1422/// only spellings; anything else stays absent.
1423fn 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// ─── schema inference ───────────────────────────────────────────────────────
1475
1476#[derive(Debug, Clone, Default)]
1477struct PooledPath {
1478    seen: u64,
1479    kinds: BTreeMap<&'static str, u64>,
1480    all_integral: bool,
1481}
1482
1483/// Pool the members' path statistics and build a schema. Returns the
1484/// schema (`None` when no member carried a document), the undocumented
1485/// sample count and the document count.
1486fn 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            // Present in every document iff some descendant leaf was.
1565            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                // Seen both as a scalar and as an object: say both.
1576                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// ─── the draft emitter ──────────────────────────────────────────────────────
1654
1655/// Where a draft came from — the header every file states.
1656#[derive(Debug, Clone)]
1657pub struct Provenance {
1658    /// `--app`, or `"unknown"`.
1659    pub app: String,
1660    /// The selector or capture.
1661    pub source: String,
1662    /// The span the observation covered, seconds.
1663    pub span_s: f64,
1664    /// RFC 3339, when the draft was written.
1665    pub at: String,
1666    pub keys: usize,
1667    pub samples: u64,
1668    pub dropped: u64,
1669}
1670
1671/// One producer's draft file (RFC 08 §6.1). Its own emitter, not
1672/// [`zenkey::slice_to_toml`]: a slice cannot carry `draft = true` or the
1673/// per-entry comments, and those are the point.
1674pub 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
1737/// The `types.toml` for a draft directory: one row per inferred type,
1738/// `kind = "json-schema"` with the sidecar's path when a schema was
1739/// inferred, `kind = "unknown"` when no sample was structural. The
1740/// `schema` key is informational — the lint reads `kind` alone.
1741pub 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
1772/// The sidecar schema documents: `(relative path, contents)` per inferred
1773/// type that has one.
1774pub 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
1804/// Every relative path a draft run would write — checked before anything
1805/// is written, so an occupied output directory refuses whole.
1806pub 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    /// E1: interior siblings with one shape merge; E2: leaf siblings whose
1854    /// membership varies merge; leaf siblings every origin publishes stay
1855    /// literal.
1856    #[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        // `cpu/usage` and `cpu/{v1}/usage` would collide on `CpuUsage`; the
1911        // var-bearing one is overridden so the draft lints.
1912        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    /// Failure mode 1, as a fact: one origin, one value — a literal.
1938    #[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    /// Failure mode 2, as a fact: two literal measurements with one shape
1962    /// come out as a `{var}`, and the entry says which siblings it merged.
1963    #[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    /// E3 and the rate class: write-once events keys from one origin are
1984    /// one `{var}`, and the class follows the count over the span.
1985    #[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        // 5 in 40 s = 450/h → burst(1000/h).
2003        assert_eq!(s[0].rate.as_deref(), Some("burst(1000/h)"));
2004        assert_eq!(s[0].cardinality, Some(10));
2005    }
2006
2007    /// State: ttl_s is twice the median inter-arrival, or absent with the
2008    /// reason when nothing refreshed.
2009    #[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        // The schema: an object with both fields required, one boolean, one
2037        // number. `health` and `sensor` share the shape and no literal, so
2038        // the type is named for neither.
2039        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    /// Integral numbers become `integer`; a field absent from some document
2056    /// is not required; kinds that vary list every type.
2057    #[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    /// The draft file: the marker, no `since`, every unestablished field
2093    /// absent, comments above the entry — and it parses as TOML.
2094    #[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        // The alert family is the one exception, by RFC 08 §5's rule.
2130        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    /// A hint slice names the vars where its pattern binds with the same
2160    /// positions, and nowhere else.
2161    #[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}