Skip to main content

zenkey_fleet/judge/
field.rs

1//! Field intelligence (#223): the stuck sensor that passes every check.
2//!
3//! Validation is per-sample and binary; per-key stats are about *arrival*.
4//! Between them sits the failure mode neither can see: a key publishing at
5//! exactly its declared rate, payload validating perfectly, whose
6//! `temperature_c` has not moved in four hours because the sensor died. This
7//! module makes a *field* — a dotted path inside a decoded structural value
8//! ([`crate::model::decode::structural_value`]) — a first-class observed thing:
9//! bounded per-path statistics over a window (presence, type stability,
10//! last-change, change count, numeric min/max/last, small-domain distinct
11//! values) yielding three finding kinds:
12//!
13//! - **`field-vanished`** — a path present in earlier samples, absent since,
14//!   while later payloads still parse. If the schema declares it optional,
15//!   validation reads `Valid` without it *by construction*. Vanished requires
16//!   SEEN then absent: a path never observed is not a vanished path
17//!   (RFC 09 §5.1 O4 — "not asked" never renders as "no").
18//! - **`field-stuck`** — a numeric path unchanged across a span long relative
19//!   to the subject's declared `ttl_s`, while the key kept publishing. An
20//!   observation with a stated window, never a verdict: a constant-by-design
21//!   field always reads this way, and with no declared `ttl_s` there is
22//!   nothing to be long *relative to*, so nothing fires (O4).
23//! - **`field-new`** — a path the served schema never declared:
24//!   `schema-drift` at field granularity. Judged only where the served
25//!   schema actually enumerates properties; a free-form subtree is
26//!   unjudgeable, not new (O4).
27//!
28//! The path table is **bounded and reports what it dropped** (O6) — never a
29//! silent truncation. The judges are pure functions over the observation,
30//! the house pattern of [`crate::judge::condition`] (#227) and [`crate::judge::budget`]
31//! (#221): testable without a bus. Surfaces: `zenctl field <selector>
32//! [--for S]`, the doctor listen phase (#161) via the appended
33//! [`crate::report::CheckId`], and the Inspector's Fields section (#223,
34//! `zengui/src/view/fields.rs`) — the field table with per-field sparklines
35//! through `series.rs`/`spark.rs`, each stating that its window is the
36//! history ring's and not the observation's (#400).
37
38use std::collections::{BTreeMap, BTreeSet};
39use std::hash::{Hash, Hasher};
40use std::time::Duration;
41
42use crate::Result;
43use serde_json::Value;
44use zenoh::Session;
45
46use crate::judge::common::{FINDING_CAP, producer_of};
47use crate::model::decode::SchemaStore;
48use crate::model::examples::Examples;
49use crate::model::jsonschema::{COMBINATORS, resolve_ref};
50use crate::model::registry::SliceSet;
51use crate::report::{CheckId, DoctorFinding, DoctorSeverity, FieldReport, FieldRow};
52
53/// Default bound on the per-path table, across every key the window sees. A
54/// high-cardinality document can blow a path table the way a `{var}` family
55/// blows a key table (#221), so the cap and its cost are reported like every
56/// other bound (RFC 09 §5.1 O6).
57pub const DEFAULT_MAX_PATHS: usize = 512;
58
59/// How many distinct values a path may show and still count as small-domain.
60pub const DISTINCT_CAP: usize = 8;
61
62/// A value longer than this cannot be a small-domain member — tracking a set
63/// of megabyte blobs would move the memory bound into the values.
64const DISTINCT_VALUE_CAP: usize = 64;
65
66/// How long an unchanged span must be, relative to the declared `ttl_s`,
67/// before `field-stuck` fires. One ttl unchanged is a slow sensor; three is
68/// three consecutive refresh deadlines carrying the same number.
69pub const STUCK_TTL_FACTOR: f64 = 3.0;
70
71/// How many consecutive trailing document samples a seen path must be absent
72/// from before `field-vanished` fires — one missing sample is jitter.
73pub const VANISHED_MIN_ABSENT: u64 = 3;
74
75/// A stuck path must have been observed at least this often — "the key kept
76/// publishing" is part of the finding's meaning.
77const STUCK_MIN_SEEN: u64 = 3;
78
79/// Dropped-path examples carried by the bound report (O6 names, not just
80/// counts — enough to recognise the document that exploded).
81const DROPPED_EXAMPLE_CAP: usize = 5;
82
83/// The dotted path of a document root that is not an object (a bare scalar
84/// or array payload) — one field, named like `jq`'s root.
85pub const ROOT_PATH: &str = "$";
86
87/// How deep the declared-path walk may descend before it stops and calls the
88/// rest of that subtree open. `$defs` nest, and a recursive type would
89/// otherwise walk forever.
90const SCHEMA_DEPTH_CAP: usize = 32;
91
92/// How many subschema nodes one document's walk may visit. The depth cap
93/// alone does not bound the *work*: a combinator tree fans out
94/// multiplicatively, so N nested two-armed `oneOf`s are 2^N walks at a depth
95/// of N. This is the bound that actually holds, and a served schema is a
96/// stranger's document — the walk runs on whatever a producer replies with.
97///
98/// Deliberately not an O6-reported bound like the path table's: this one
99/// bounds a *fetched artifact* walked once per (producer, type) and cached,
100/// not a population of observations, and exhausting it degrades to
101/// "unjudgeable" — which the report already carries — rather than to a
102/// silently shorter answer.
103const SCHEMA_NODE_BUDGET: usize = 10_000;
104
105// ─── the observation ────────────────────────────────────────────────────────
106
107/// Bounded per-(key, dotted-path) statistics over one window.
108///
109/// Fed structural values as they ride; judged afterwards by the pure
110/// functions below. The bound is over the *total* path population across
111/// keys, and every path refused for the bound is counted and exemplified —
112/// a table that silently stops growing is indistinguishable from a document
113/// that stopped changing (O6).
114#[derive(Debug, Clone)]
115pub struct FieldObservation {
116    max_paths: usize,
117    keys: BTreeMap<String, KeyFields>,
118    paths: usize,
119    /// Refused path observations: the count *and* the names, in one
120    /// collector, so they cannot drift apart (O6).
121    dropped: Examples<String>,
122}
123
124/// One key's document samples and the paths inside them.
125#[derive(Debug, Clone, Default)]
126pub struct KeyFields {
127    /// Samples that carried a structural document (the population every
128    /// presence ratio is against).
129    pub documents: u64,
130    /// Samples that carried none (plain text, opaque bytes) — fields are
131    /// unobservable for them, which is stated, not folded into absence (O4).
132    pub undocumented: u64,
133    /// Samples whose payload was past [`crate::OBSERVE_LIMIT`] and therefore never
134    /// read. **Not** `undocumented`: "we did not look" is not "there was
135    /// nothing to see" (RFC 09 §5.1 O4).
136    pub unread: u64,
137    /// Per dotted path, the stats.
138    pub paths: BTreeMap<String, PathStats>,
139}
140
141/// What one dotted path did across one key's window.
142#[derive(Debug, Clone)]
143pub struct PathStats {
144    /// Document samples in which the path was present.
145    pub seen: u64,
146    /// Window-relative seconds of first / last presence.
147    pub first_at_s: f64,
148    pub last_at_s: f64,
149    /// The key's document-sample index at last presence — what "absent since"
150    /// is measured against.
151    pub last_seen_sample: u64,
152    /// JSON kind → occurrences (type stability: one entry is stable).
153    pub kinds: BTreeMap<&'static str, u64>,
154    /// Times the value differed from the previous observation of this path.
155    pub changes: u64,
156    /// Window-relative seconds of the last change; `None` = never changed.
157    pub last_change_at_s: Option<f64>,
158    /// Numeric min/max/last, when the path carried numbers.
159    pub num_min: Option<f64>,
160    pub num_max: Option<f64>,
161    pub num_last: Option<f64>,
162    /// Whether every number seen was integral — what lets a schema
163    /// inference say `integer` rather than `number` (#225). Vacuously true
164    /// until a fractional value arrives.
165    pub all_integral: bool,
166    /// Small-domain distinct values (canonical JSON), until the domain
167    /// overflows [`DISTINCT_CAP`].
168    pub distinct: BTreeSet<String>,
169    /// The domain outgrew the cap (or carried values too large to track) —
170    /// the set above is then cleared, not silently partial.
171    pub distinct_overflow: bool,
172    /// Fingerprint of the last observed value, for change detection.
173    last_fingerprint: Option<u64>,
174}
175
176impl PathStats {
177    fn new(at_s: f64, sample: u64) -> PathStats {
178        PathStats {
179            seen: 0,
180            first_at_s: at_s,
181            last_at_s: at_s,
182            last_seen_sample: sample,
183            kinds: BTreeMap::new(),
184            changes: 0,
185            last_change_at_s: None,
186            num_min: None,
187            num_max: None,
188            num_last: None,
189            all_integral: true,
190            distinct: BTreeSet::new(),
191            distinct_overflow: false,
192            last_fingerprint: None,
193        }
194    }
195
196    fn observe(&mut self, at_s: f64, sample: u64, value: &Value) {
197        self.seen += 1;
198        self.last_at_s = at_s;
199        self.last_seen_sample = sample;
200        *self.kinds.entry(kind_of(value)).or_default() += 1;
201        let canonical = serde_json::to_string(value).unwrap_or_default();
202        let fingerprint = {
203            let mut h = std::collections::hash_map::DefaultHasher::new();
204            canonical.hash(&mut h);
205            h.finish()
206        };
207        if let Some(prev) = self.last_fingerprint
208            && prev != fingerprint
209        {
210            self.changes += 1;
211            self.last_change_at_s = Some(at_s);
212        }
213        self.last_fingerprint = Some(fingerprint);
214        if let Some(n) = value.as_f64() {
215            self.num_min = Some(self.num_min.map_or(n, |m| m.min(n)));
216            self.num_max = Some(self.num_max.map_or(n, |m| m.max(n)));
217            self.num_last = Some(n);
218            if n.fract() != 0.0 {
219                self.all_integral = false;
220            }
221        }
222        if !self.distinct_overflow {
223            if canonical.len() > DISTINCT_VALUE_CAP {
224                self.distinct_overflow = true;
225                self.distinct.clear();
226            } else {
227                self.distinct.insert(canonical);
228                if self.distinct.len() > DISTINCT_CAP {
229                    self.distinct_overflow = true;
230                    self.distinct.clear();
231                }
232            }
233        }
234    }
235}
236
237impl FieldObservation {
238    pub fn new(max_paths: usize) -> FieldObservation {
239        FieldObservation {
240            max_paths: max_paths.max(1),
241            keys: BTreeMap::new(),
242            paths: 0,
243            dropped: Examples::new(DROPPED_EXAMPLE_CAP),
244        }
245    }
246
247    /// Feed one sample. `doc` is the structural value when the payload
248    /// carried one ([`crate::model::decode::structural_value`]); `None` counts the
249    /// sample as undocumented rather than pretending its fields were absent.
250    pub fn observe_unread(&mut self, key: &str) {
251        self.keys.entry(key.to_string()).or_default().unread += 1;
252    }
253
254    /// Samples skipped because their payload was too large to read.
255    pub fn unread(&self) -> u64 {
256        self.keys.values().map(|k| k.unread).sum()
257    }
258
259    pub fn observe(&mut self, key: &str, at_s: f64, doc: Option<&Value>) {
260        let entry = self.keys.entry(key.to_string()).or_default();
261        let Some(doc) = doc else {
262            entry.undocumented += 1;
263            return;
264        };
265        entry.documents += 1;
266        let sample = entry.documents;
267        let mut leaves = Vec::new();
268        flatten(doc, &mut leaves);
269        for (path, value) in leaves {
270            match entry.paths.get_mut(&path) {
271                Some(stats) => stats.observe(at_s, sample, value),
272                None if self.paths < self.max_paths => {
273                    let mut stats = PathStats::new(at_s, sample);
274                    stats.observe(at_s, sample, value);
275                    entry.paths.insert(path, stats);
276                    self.paths += 1;
277                }
278                // The bound: refused, counted, exemplified — never silent.
279                None => self.dropped.push_with(|| format!("{key} · {path}")),
280            }
281        }
282    }
283
284    /// Per-key observations, for the judges and the report rows.
285    pub fn iter(&self) -> impl Iterator<Item = (&str, &KeyFields)> {
286        self.keys.iter().map(|(k, v)| (k.as_str(), v))
287    }
288
289    pub fn keys_seen(&self) -> usize {
290        self.keys.len()
291    }
292
293    /// Distinct (key, path) pairs currently tracked.
294    pub fn paths(&self) -> usize {
295        self.paths
296    }
297
298    pub fn max_paths(&self) -> usize {
299        self.max_paths
300    }
301
302    /// Path observations refused to stay within the bound (RFC 09 §5.1 O6).
303    pub fn dropped_paths(&self) -> u64 {
304        self.dropped.total() as u64
305    }
306
307    /// Up to a handful of `key · path` names among the refused (the cap is
308    /// `DROPPED_EXAMPLE_CAP` — enough to recognise the document that
309    /// exploded, without pasting the population).
310    pub fn dropped_examples(&self) -> &[String] {
311        self.dropped.as_slice()
312    }
313
314    /// Samples that carried no structural document, across every key.
315    pub fn undocumented(&self) -> u64 {
316        self.keys.values().map(|k| k.undocumented).sum()
317    }
318}
319
320/// One observed value's JSON kind, for the type-stability count.
321fn kind_of(v: &Value) -> &'static str {
322    match v {
323        Value::Null => "null",
324        Value::Bool(_) => "bool",
325        Value::Number(_) => "number",
326        Value::String(_) => "string",
327        Value::Array(_) => "array",
328        Value::Object(_) => "object",
329    }
330}
331
332/// Flatten a structural document into dotted leaf paths. Objects recurse
333/// (`a.b.c`); arrays and scalars are leaves — indexing into arrays would
334/// mint a path per element and hand the cardinality problem a wildcard. A
335/// non-object root is the single leaf [`ROOT_PATH`]; an empty object is its
336/// own leaf (a present-but-empty subtree is presence, not absence).
337pub fn flatten<'v>(doc: &'v Value, out: &mut Vec<(String, &'v Value)>) {
338    fn walk<'v>(prefix: &str, v: &'v Value, out: &mut Vec<(String, &'v Value)>) {
339        match v {
340            Value::Object(map) if !map.is_empty() => {
341                for (name, child) in map {
342                    let path = if prefix.is_empty() {
343                        name.clone()
344                    } else {
345                        format!("{prefix}.{name}")
346                    };
347                    walk(&path, child, out);
348                }
349            }
350            leaf => out.push(if prefix.is_empty() {
351                (ROOT_PATH.to_string(), leaf)
352            } else {
353                (prefix.to_string(), leaf)
354            }),
355        }
356    }
357    walk("", doc, out);
358}
359
360// ─── the declared-path surface (field-new's other half) ─────────────────────
361
362/// The dotted paths a served JSON Schema declares, with the subtrees it
363/// leaves free-form. `field-new` is judgeable only against this: a schema
364/// kind this build cannot enumerate (protobuf, CDR), or a document that
365/// enumerates nothing, yields `None` and no finding — unjudgeable is not
366/// new (O4).
367#[derive(Debug, Clone, Default, PartialEq, Eq)]
368pub struct DeclaredPaths {
369    declared: BTreeSet<String>,
370    /// Prefixes whose subschema enumerates nothing — anything below is
371    /// unjudgeable rather than undeclared.
372    open: BTreeSet<String>,
373}
374
375impl DeclaredPaths {
376    /// Walk a JSON Schema document into the dotted paths it declares.
377    ///
378    /// Three shapes, because a `schemars`-derived document uses all three:
379    /// `properties` descends; `oneOf`/`anyOf`/`allOf` branches are **unioned**
380    /// — a path declared in *any* branch is declared — and local `$ref`
381    /// pointers resolve against the document root (`#/$defs/…`,
382    /// `#/definitions/…`).
383    ///
384    /// Both additions fix the same defect (#384). A tagged enum renders as a
385    /// `oneOf` whose every branch declares the tag and content fields, and
386    /// `schemars` hoists every nested named type into `$defs` and references
387    /// it — so a walker that descends `properties` alone sees neither, and
388    /// reports every field of every tagged enum and every nested struct as
389    /// `field-new` on every sample. The check's own purpose goes with it: a
390    /// warning that fires on conforming traffic cannot carry a real one.
391    ///
392    /// A `$ref` this walk cannot follow — external, missing, already on the
393    /// current chain (a recursive type), or past a bound below — leaves its
394    /// subtree **open**, never closed. "Could not follow" is unjudgeable, and
395    /// rendering unjudgeable as "undeclared" is the O4 violation this check
396    /// exists inside of.
397    ///
398    /// `None` when the document enumerates nothing, or when the root itself
399    /// is unjudgeable: there is then no declared surface and `field-new` is
400    /// unjudgeable for the whole type, which is not the same as clean (O4).
401    pub fn from_json_schema(doc: &Value) -> Option<DeclaredPaths> {
402        let mut out = DeclaredPaths::default();
403        let mut walk = Walk {
404            root: doc,
405            visiting: BTreeSet::new(),
406            budget: SCHEMA_NODE_BUDGET,
407            root_open: false,
408        };
409        walk.node(doc, "", 0, &mut out);
410        (!walk.root_open && !out.declared.is_empty()).then_some(out)
411    }
412
413    /// Whether the schema accounts for this path — declared, or under a
414    /// free-form subtree it deliberately left open.
415    pub fn accounts_for(&self, path: &str) -> bool {
416        if path == ROOT_PATH || self.declared.contains(path) {
417            return true;
418        }
419        // Any ancestor being open makes the path unjudgeable, not new.
420        let mut prefix = String::new();
421        for chunk in path.split('.') {
422            if !prefix.is_empty() {
423                prefix.push('.');
424            }
425            prefix.push_str(chunk);
426            if self.open.contains(&prefix) {
427                return true;
428            }
429        }
430        false
431    }
432}
433
434/// The state one document's walk carries: the root to resolve `$ref` against,
435/// the pointers on the current chain (which is what stops a recursive type
436/// from recursing forever), and the two bounds.
437struct Walk<'d> {
438    root: &'d Value,
439    visiting: BTreeSet<String>,
440    budget: usize,
441    /// The root subtree itself turned out unjudgeable — there is no path to
442    /// hang that on, so it collapses the whole document to `None`.
443    root_open: bool,
444}
445
446impl Walk<'_> {
447    /// Mark a subtree unjudgeable. At the root there is no path to mark, so
448    /// the whole document goes.
449    fn open(&mut self, prefix: &str, out: &mut DeclaredPaths) {
450        if prefix.is_empty() {
451            self.root_open = true;
452        } else {
453            out.open.insert(prefix.to_string());
454        }
455    }
456
457    /// One subschema node. `prefix` is the dotted path it describes — empty
458    /// at the root.
459    fn node(&mut self, node: &Value, prefix: &str, depth: usize, out: &mut DeclaredPaths) {
460        // Both bounds land on the same honest answer: stop, and say the rest
461        // is unknown rather than absent (O4).
462        if depth > SCHEMA_DEPTH_CAP || self.budget == 0 {
463            self.open(prefix, out);
464            return;
465        }
466        self.budget -= 1;
467
468        // A boolean schema (`true`/`false`) enumerates nothing, and neither
469        // does anything malformed. Not open: `false` accepts no instance and
470        // `true` is not an object surface — neither claims a subtree.
471        let Some(obj) = node.as_object() else {
472            return;
473        };
474
475        // Whether this node said anything about its own shape. A node that
476        // did not, and calls itself an object, is a free-form subtree.
477        let mut described = false;
478
479        if let Some(pointer) = obj.get("$ref").and_then(Value::as_str) {
480            match resolve_ref(self.root, pointer) {
481                Some(target) if !self.visiting.contains(pointer) => {
482                    self.visiting.insert(pointer.to_string());
483                    self.node(target, prefix, depth + 1, out);
484                    self.visiting.remove(pointer);
485                    described = true;
486                }
487                // Unresolvable, or already on this chain. Either way what is
488                // below cannot be enumerated from here.
489                _ => self.open(prefix, out),
490            }
491        }
492
493        // Union, not intersection: `oneOf` is how a sum type reaches the
494        // wire, and each branch declares the fields its own variant carries.
495        // A field present in one branch is declared by the schema, and the
496        // finer verdict — "declared only in some branches" — is a different
497        // check from "never declared".
498        for combinator in COMBINATORS {
499            if let Some(arms) = obj.get(combinator).and_then(Value::as_array) {
500                for arm in arms {
501                    self.node(arm, prefix, depth + 1, out);
502                }
503                described = true;
504            }
505        }
506
507        if let Some(props) = obj.get("properties").and_then(Value::as_object) {
508            for (name, child) in props {
509                let path = if prefix.is_empty() {
510                    name.clone()
511                } else {
512                    format!("{prefix}.{name}")
513                };
514                self.node(child, &path, depth + 1, out);
515                out.declared.insert(path);
516            }
517            described = true;
518        }
519
520        // An object subschema with no enumerated properties is a free-form
521        // subtree: everything under it is unjudgeable.
522        if !described && obj.get("type").and_then(Value::as_str) == Some("object") {
523            self.open(prefix, out);
524        }
525    }
526}
527
528// ─── the judges (pure — the #227/#221 house pattern) ────────────────────────
529
530/// What the judges may know about one key beyond its stats. Everything is
531/// optional, and every `None` suppresses the finding that needed it rather
532/// than guessing (O4).
533#[derive(Debug, Clone, Default)]
534pub struct KeyFieldContext {
535    /// The subject's declared `ttl_s`, when the key refined to a registered
536    /// subject — what `field-stuck` is long *relative to*.
537    pub ttl_s: Option<i64>,
538    /// The registered type name, for the `field-new` evidence line.
539    pub type_name: Option<String>,
540    /// The served schema's declared paths, when derivable.
541    pub declared: Option<DeclaredPaths>,
542}
543
544/// `field-vanished`: SEEN, then absent from at least [`VANISHED_MIN_ABSENT`]
545/// consecutive trailing document samples. A path never seen is not vanished
546/// — that would be rendering "not asked" as "no" (O4).
547pub fn judge_vanished(stats: &PathStats, key_documents: u64) -> bool {
548    stats.seen > 0 && key_documents.saturating_sub(stats.last_seen_sample) >= VANISHED_MIN_ABSENT
549}
550
551/// `field-stuck`: a purely numeric path, zero changes, observed at least
552/// `STUCK_MIN_SEEN` times across a span of at least [`STUCK_TTL_FACTOR`] ×
553/// the declared `ttl_s`. No declared ttl, no finding: there is nothing to be
554/// long relative to (O4) — and the numeric requirement is what keeps a
555/// constant-by-design hostname or enum out of the noise.
556pub fn judge_stuck(stats: &PathStats, ttl_s: Option<i64>) -> bool {
557    let Some(ttl) = ttl_s.filter(|t| *t > 0) else {
558        return false;
559    };
560    stats.changes == 0
561        && stats.seen >= STUCK_MIN_SEEN
562        && stats.kinds.len() == 1
563        && stats.kinds.contains_key("number")
564        && (stats.last_at_s - stats.first_at_s) >= STUCK_TTL_FACTOR * ttl as f64
565}
566
567/// `field-new`: the served schema enumerates its properties and this path is
568/// not among them (nor under a free-form subtree). With no declared surface
569/// there is no finding — unjudgeable is not new (O4).
570pub fn judge_new(path: &str, declared: Option<&DeclaredPaths>) -> bool {
571    declared.is_some_and(|d| !d.accounts_for(path))
572}
573
574/// Judge a whole observation into doctor findings, capped per check the way
575/// the doctor caps its listen findings. `ctx` supplies what is known per key;
576/// a key it does not name gets the empty context (everything unjudgeable).
577pub fn judge_fields(
578    obs: &FieldObservation,
579    window_s: f64,
580    ctx: &BTreeMap<String, KeyFieldContext>,
581) -> Vec<DoctorFinding> {
582    let empty = KeyFieldContext::default();
583
584    let mut vanished = Examples::new(FINDING_CAP);
585
586    let mut stuck = Examples::new(FINDING_CAP);
587
588    let mut new = Examples::new(FINDING_CAP);
589
590    for (key, fields) in obs.iter() {
591        let c = ctx.get(key).unwrap_or(&empty);
592        for (path, stats) in &fields.paths {
593            if judge_vanished(stats, fields.documents) {
594                vanished.push_with(|| DoctorFinding {
595                    severity: DoctorSeverity::Warning,
596                    check: CheckId::FieldVanished,
597                    subject: format!("{key} · {path}"),
598                    evidence: format!(
599                        "present in {} of {} document sample(s) in {window_s:.0}s, absent \
600                         from the last {} — seen, then gone; a schema that declares it \
601                         optional reads Valid without it by construction",
602                        stats.seen,
603                        fields.documents,
604                        fields.documents - stats.last_seen_sample
605                    ),
606                    citation: None,
607                });
608            }
609            if judge_stuck(stats, c.ttl_s) {
610                let ttl = c.ttl_s.unwrap_or(0);
611                stuck.push_with(|| DoctorFinding {
612                    severity: DoctorSeverity::Warning,
613                    check: CheckId::FieldStuck,
614                    subject: format!("{key} · {path}"),
615                    evidence: format!(
616                        "value {} unchanged across {} sample(s) spanning {:.1}s — at least \
617                         {STUCK_TTL_FACTOR:.0}× the declared ttl_s {ttl}s — while the key \
618                         kept publishing. An observation over this {window_s:.0}s window, \
619                         not a verdict: a constant-by-design field always reads this way",
620                        stats
621                            .num_last
622                            .map(|n| n.to_string())
623                            .unwrap_or_else(|| "?".into()),
624                        stats.seen,
625                        stats.last_at_s - stats.first_at_s,
626                    ),
627                    citation: Some("RFC 04 §1.2".into()),
628                });
629            }
630            if judge_new(path, c.declared.as_ref()) {
631                new.push_with(|| DoctorFinding {
632                    severity: DoctorSeverity::Warning,
633                    check: CheckId::FieldNew,
634                    subject: format!("{key} · {path}"),
635                    evidence: format!(
636                        "present in {} of {} document sample(s) but never declared by the \
637                         served schema{} — schema drift at field granularity",
638                        stats.seen,
639                        fields.documents,
640                        c.type_name
641                            .as_deref()
642                            .map(|t| format!(" for {t}"))
643                            .unwrap_or_default()
644                    ),
645                    citation: Some("RFC 08 §7".into()),
646                });
647            }
648        }
649    }
650    let mut findings = Vec::new();
651    for (check, hits) in [
652        (CheckId::FieldVanished, vanished),
653        (CheckId::FieldStuck, stuck),
654        (CheckId::FieldNew, new),
655    ] {
656        let more = hits.more("more path(s) with the same finding");
657        findings.extend(hits.into_vec());
658        if let Some(evidence) = more {
659            findings.push(DoctorFinding {
660                severity: DoctorSeverity::Info,
661                check,
662                subject: "fleet".into(),
663                evidence,
664                citation: None,
665            });
666        }
667    }
668    findings
669}
670
671// ─── the window runner (zenctl field) ───────────────────────────────────────
672
673/// What a `zenctl field` run watches, and its bounds.
674#[derive(Debug, Clone)]
675pub struct FieldSpec {
676    /// Full wire selector to watch (the session is un-namespaced, RFC 09 §5).
677    pub selector: String,
678    /// The observation window.
679    pub window: Duration,
680    /// The per-path table bound (RFC 09 §5.1 O6).
681    pub max_paths: usize,
682}
683
684/// Watch one selector for the window and report per-path statistics plus the
685/// three findings. The subscriber is declared **before** the window opens
686/// (O4). `slices: None` means no registry was loaded: declared `ttl_s` and
687/// type names are then unknown, `field-stuck` and `field-new` are
688/// unjudgeable, and the report says so rather than reading clean (O4; #246).
689pub async fn run_field(
690    fleet: &crate::Fleet<'_>,
691    slices: Option<&SliceSet>,
692    store: &SchemaStore,
693    spec: &FieldSpec,
694) -> Result<FieldReport> {
695    use crate::{FleetEvent, StreamItem};
696
697    let (session, base) = (fleet.session(), fleet.base());
698
699    let monitor = crate::Monitor::start(session, crate::MonitorSpec::default()).await?;
700
701    let mut events = monitor.events();
702
703    // Declared before the window opens: not-asked must never read as "no" —
704    // and a declaration that fails takes the monitor down with it (#336).
705    let monitor = monitor.watching([spec.selector.as_str()]).await?;
706    let opened = tokio::time::Instant::now();
707    let deadline = opened + spec.window;
708
709    let mut obs = FieldObservation::new(spec.max_paths);
710    let mut samples: u64 = 0;
711    let mut dropped: u64 = 0;
712    // Bounded (#107): one projection per distinct key, LRU past the bound,
713    // evictions counted into the report (O6).
714    let mut facts = crate::model::facts::FactsCache::default();
715
716    // One timer for the whole window, not one per iteration (#346).
717    // `sleep_until` builds a future and registers a timer each time it
718    // is evaluated, and a `select!` in a loop evaluates it on every
719    // pass — at 100k samples/s that is 100k registrations a second for
720    // a deadline that never moves.
721    let window_over = tokio::time::sleep_until(deadline);
722    tokio::pin!(window_over);
723    loop {
724        let item = tokio::select! {
725            item = events.recv() => item,
726            () = &mut window_over => break,
727        };
728        match item {
729            Some(StreamItem::Event(FleetEvent::Sample(s))) => {
730                samples += 1;
731                // Bounded, and the skip is counted rather than read as an
732                // absent document (#346).
733                let bytes = s.payload.to_bytes();
734                if bytes.len() > crate::model::decode::OBSERVE_LIMIT {
735                    obs.observe_unread(&s.key);
736                } else {
737                    let doc = crate::model::decode::structural_value(&bytes);
738                    obs.observe(&s.key, opened.elapsed().as_secs_f64(), doc.as_ref());
739                }
740                facts.ensure(base, &s.key, slices);
741            }
742            Some(StreamItem::Dropped(n)) => dropped += n,
743            Some(_) => continue,
744            None => break,
745        }
746    }
747    monitor.shutdown().await?;
748
749    let window_s = spec.window.as_secs_f64();
750    let ctx = field_context(session, store, slices, &facts).await;
751    let findings = judge_fields(&obs, window_s, &ctx);
752
753    let mut rows = Vec::new();
754    for (key, fields) in obs.iter() {
755        for (path, stats) in &fields.paths {
756            rows.push(FieldRow {
757                key: key.to_string(),
758                path: path.clone(),
759                seen: stats.seen,
760                documents: fields.documents,
761                kinds: stats.kinds.keys().map(|k| k.to_string()).collect(),
762                changes: stats.changes,
763                last_change_s: stats.last_change_at_s,
764                min: stats.num_min,
765                max: stats.num_max,
766                last: stats.num_last,
767                // `None` = the domain outgrew the cap; `Some` is total.
768                values: (!stats.distinct_overflow)
769                    .then(|| stats.distinct.iter().cloned().collect()),
770            });
771        }
772    }
773
774    Ok(FieldReport {
775        selector: spec.selector.clone(),
776        window_s,
777        samples,
778        keys_seen: obs.keys_seen(),
779        dropped,
780        undocumented: obs.undocumented(),
781        unread: obs.unread(),
782        registry_loaded: slices.is_some(),
783        paths: obs.paths(),
784        max_paths: obs.max_paths(),
785        paths_dropped: obs.dropped_paths(),
786        paths_dropped_examples: obs.dropped_examples().to_vec(),
787        facts_evicted: facts.evicted(),
788        rows,
789        findings,
790    })
791}
792
793/// Build the per-key judge context: declared `ttl_s`/type from the resolved
794/// facts, declared paths from each producer's served schema (fetched through
795/// the store's ordinary cache — one `describe` per producer, not per key).
796pub(crate) async fn field_context(
797    session: &Session,
798    store: &SchemaStore,
799    slices: Option<&SliceSet>,
800    facts: &crate::model::facts::FactsCache,
801) -> BTreeMap<String, KeyFieldContext> {
802    let mut declared_cache: BTreeMap<(String, String), Option<DeclaredPaths>> = BTreeMap::new();
803
804    let mut ctx = BTreeMap::new();
805
806    for (key, f) in facts.iter() {
807        let mut c = KeyFieldContext::default();
808        if let crate::model::facts::Registration::Registered(sf) = &f.registration {
809            c.ttl_s = sf.ttl_s;
810            c.type_name = Some(sf.type_name.clone());
811            if let Some(producer) = producer_of(f, slices)
812                && !sf.type_name.is_empty()
813            {
814                let cache_key = (producer.clone(), sf.type_name.clone());
815                if !declared_cache.contains_key(&cache_key) {
816                    let declared = store
817                        .schema_for(session, &producer, &sf.type_name)
818                        .await
819                        .and_then(|schema| {
820                            schema
821                                .json_document()
822                                .and_then(DeclaredPaths::from_json_schema)
823                        });
824                    declared_cache.insert(cache_key.clone(), declared);
825                }
826                c.declared = declared_cache.get(&cache_key).cloned().flatten();
827            }
828        }
829        ctx.insert(key.to_string(), c);
830    }
831    ctx
832}
833
834#[cfg(test)]
835mod tests {
836    use super::*;
837    use serde_json::json;
838
839    fn observe_docs(obs: &mut FieldObservation, key: &str, docs: &[(f64, Value)]) {
840        for (at, doc) in docs {
841            obs.observe(key, *at, Some(doc));
842        }
843    }
844
845    /// Objects flatten to dotted leaves; arrays and scalars are leaves; a
846    /// non-object root is the single `$` field.
847    #[test]
848    fn flattening_recurses_objects_and_stops_at_arrays() {
849        let doc = json!({"a": {"b": 1, "c": [1, 2]}, "d": "x", "e": {}});
850        let mut leaves = Vec::new();
851        flatten(&doc, &mut leaves);
852        let paths: Vec<&str> = leaves.iter().map(|(p, _)| p.as_str()).collect();
853        assert_eq!(paths, ["a.b", "a.c", "d", "e"]);
854
855        let scalar = json!(42.0);
856        let mut leaves = Vec::new();
857        flatten(&scalar, &mut leaves);
858        assert_eq!(leaves.len(), 1);
859        assert_eq!(leaves[0].0, ROOT_PATH);
860    }
861
862    /// The acceptance bound: the path table refuses past its cap, counts
863    /// every refusal, and names examples — never a silent truncation (O6).
864    #[test]
865    fn the_path_table_is_bounded_and_reports_what_it_dropped() {
866        let mut obs = FieldObservation::new(4);
867        let wide: serde_json::Map<String, Value> =
868            (0..20).map(|i| (format!("f{i:02}"), json!(i))).collect();
869        obs.observe("k", 0.0, Some(&Value::Object(wide)));
870        assert_eq!(obs.paths(), 4, "the bound holds");
871        assert_eq!(obs.dropped_paths(), 16, "every refusal is counted");
872        assert!(
873            obs.dropped_examples().iter().any(|e| e.contains("k · f04")),
874            "refused paths are named: {:?}",
875            obs.dropped_examples()
876        );
877        // A tracked path keeps updating even while the table is full.
878        obs.observe("k", 1.0, Some(&json!({"f00": 9})));
879        let (_, fields) = obs.iter().next().unwrap();
880        assert_eq!(fields.paths["f00"].seen, 2);
881    }
882
883    /// Vanished needs SEEN then absent: a path never observed is not a
884    /// vanished path — "not asked" never renders as "no" (O4).
885    #[test]
886    fn vanished_needs_seen_then_absent() {
887        let mut obs = FieldObservation::new(64);
888        let with = json!({"seq": 1, "opt": true});
889        let without = json!({"seq": 2});
890        observe_docs(
891            &mut obs,
892            "k",
893            &[
894                (0.0, with),
895                (1.0, without.clone()),
896                (2.0, without.clone()),
897                (3.0, without.clone()),
898                (4.0, without),
899            ],
900        );
901        let (_, fields) = obs.iter().next().unwrap();
902        assert!(judge_vanished(&fields.paths["opt"], fields.documents));
903        assert!(
904            !judge_vanished(&fields.paths["seq"], fields.documents),
905            "a path present in the last sample has not vanished"
906        );
907        let findings = judge_fields(&obs, 5.0, &BTreeMap::new());
908        let vanished: Vec<_> = findings
909            .iter()
910            .filter(|f| f.check == CheckId::FieldVanished)
911            .collect();
912        assert_eq!(vanished.len(), 1, "{findings:?}");
913        assert!(vanished[0].subject.ends_with("· opt"));
914        assert!(
915            vanished[0].evidence.contains("1 of 5"),
916            "presence is counted: {}",
917            vanished[0].evidence
918        );
919        // One missing sample is jitter, not a vanish.
920        let mut obs = FieldObservation::new(64);
921        observe_docs(
922            &mut obs,
923            "k",
924            &[
925                (0.0, json!({"opt": 1})),
926                (1.0, json!({})),
927                (2.0, json!({"opt": 1})),
928            ],
929        );
930        assert!(
931            judge_fields(&obs, 3.0, &BTreeMap::new())
932                .iter()
933                .all(|f| f.check != CheckId::FieldVanished)
934        );
935    }
936
937    /// Stuck is numeric, ttl-relative, and never a verdict without a declared
938    /// ttl: a frozen number across ≥3×ttl fires, a changing one does not, a
939    /// constant string (hostname) does not, and no ttl means nothing to be
940    /// long relative to (O4).
941    #[test]
942    fn stuck_is_numeric_ttl_relative_and_suppressed_without_a_ttl() {
943        let mut obs = FieldObservation::new(64);
944        let docs: Vec<(f64, Value)> = (0..8)
945            .map(|i| {
946                (
947                    i as f64,
948                    json!({"temperature_c": 21.5, "seq": i, "host": "web-1"}),
949                )
950            })
951            .collect();
952        observe_docs(&mut obs, "k", &docs);
953        let (_, fields) = obs.iter().next().unwrap();
954        assert!(judge_stuck(&fields.paths["temperature_c"], Some(1)));
955        assert!(
956            !judge_stuck(&fields.paths["seq"], Some(1)),
957            "a changing numeric is not stuck"
958        );
959        assert!(
960            !judge_stuck(&fields.paths["host"], Some(1)),
961            "a constant string is constant by design, not stuck"
962        );
963        assert!(
964            !judge_stuck(&fields.paths["temperature_c"], None),
965            "no declared ttl_s: nothing to be long relative to (O4)"
966        );
967        assert!(
968            !judge_stuck(&fields.paths["temperature_c"], Some(10)),
969            "a 7s span is not long relative to a 10s ttl"
970        );
971
972        let ctx: BTreeMap<String, KeyFieldContext> = [(
973            "k".to_string(),
974            KeyFieldContext {
975                ttl_s: Some(1),
976                ..KeyFieldContext::default()
977            },
978        )]
979        .into();
980        let findings = judge_fields(&obs, 8.0, &ctx);
981        let stuck: Vec<_> = findings
982            .iter()
983            .filter(|f| f.check == CheckId::FieldStuck)
984            .collect();
985        assert_eq!(stuck.len(), 1, "{findings:?}");
986        assert!(stuck[0].subject.ends_with("· temperature_c"));
987        assert!(stuck[0].evidence.contains("21.5"), "{}", stuck[0].evidence);
988        assert!(
989            stuck[0].evidence.contains("not a verdict"),
990            "stuck is an observation with a stated window: {}",
991            stuck[0].evidence
992        );
993        assert!(
994            stuck[0].evidence.contains("ttl_s 1s"),
995            "the ttl it is relative to is stated: {}",
996            stuck[0].evidence
997        );
998    }
999
1000    /// `field-new` is judged only against a schema that enumerates its
1001    /// properties: an undeclared path fires, a declared one does not, a
1002    /// free-form subtree is unjudgeable, and no schema means no finding (O4).
1003    #[test]
1004    fn new_is_judged_only_against_a_declaring_schema() {
1005        let declared = DeclaredPaths::from_json_schema(&json!({
1006            "type": "object",
1007            "properties": {
1008                "seq": {"type": "number"},
1009                "nested": {"type": "object", "properties": {"x": {"type": "number"}}},
1010                "freeform": {"type": "object"},
1011            },
1012        }))
1013        .expect("the schema enumerates properties");
1014        assert!(!judge_new("seq", Some(&declared)));
1015        assert!(!judge_new("nested.x", Some(&declared)));
1016        assert!(judge_new("extra", Some(&declared)));
1017        assert!(judge_new("nested.y", Some(&declared)));
1018        assert!(
1019            !judge_new("freeform.anything.at.all", Some(&declared)),
1020            "a free-form subtree is unjudgeable, not new"
1021        );
1022        assert!(!judge_new("extra", None), "no schema, no finding (O4)");
1023        assert_eq!(
1024            DeclaredPaths::from_json_schema(&json!({"type": "object"})),
1025            None,
1026            "a schema with no properties judges nothing"
1027        );
1028
1029        let mut obs = FieldObservation::new(64);
1030        observe_docs(&mut obs, "k", &[(0.0, json!({"seq": 1, "extra": 2}))]);
1031        let ctx: BTreeMap<String, KeyFieldContext> = [(
1032            "k".to_string(),
1033            KeyFieldContext {
1034                type_name: Some("Health".into()),
1035                declared: Some(declared),
1036                ..KeyFieldContext::default()
1037            },
1038        )]
1039        .into();
1040        let findings = judge_fields(&obs, 1.0, &ctx);
1041        let new: Vec<_> = findings
1042            .iter()
1043            .filter(|f| f.check == CheckId::FieldNew)
1044            .collect();
1045        assert_eq!(new.len(), 1, "{findings:?}");
1046        assert!(new[0].subject.ends_with("· extra"));
1047        assert!(new[0].evidence.contains("Health"), "{}", new[0].evidence);
1048        assert_eq!(new[0].citation.as_deref(), Some("RFC 08 §7"));
1049    }
1050
1051    /// The document `schemars` actually emits for an adjacently-tagged enum:
1052    /// a `oneOf` whose every branch declares both the tag and the content,
1053    /// reached through a `$ref` into `$defs`. Walking `properties` alone saw
1054    /// neither, so `value.type` and `value.value` — fields the schema
1055    /// *requires* — were reported as drift on every sample of every key
1056    /// (#384: 141 warnings in one 15s window, all of them this).
1057    #[test]
1058    fn a_tagged_enum_declares_its_variant_fields_rather_than_drifting() {
1059        let doc = json!({
1060            "$schema": "https://json-schema.org/draft/2020-12/schema",
1061            "title": "TelemetryPoint",
1062            "type": "object",
1063            "properties": {
1064                "ts_ns": {"type": "integer", "format": "uint64"},
1065                "value": {"$ref": "#/$defs/TelemetryValue"},
1066            },
1067            "required": ["ts_ns", "value"],
1068            "$defs": {
1069                "TelemetryValue": {
1070                    "description": "Typed telemetry value.",
1071                    "oneOf": [
1072                        {"type": "object",
1073                         "properties": {"type": {"const": "counter", "type": "string"},
1074                                        "value": {"format": "uint64", "type": "integer"}},
1075                         "required": ["type", "value"]},
1076                        {"type": "object",
1077                         "properties": {"type": {"const": "gauge", "type": "string"},
1078                                        "value": {"format": "double", "type": "number"}},
1079                         "required": ["type", "value"]},
1080                        {"type": "object",
1081                         "properties": {"type": {"const": "text", "type": "string"},
1082                                        "value": {"type": "string"}},
1083                         "required": ["type", "value"]},
1084                    ],
1085                },
1086            },
1087        });
1088        let declared =
1089            DeclaredPaths::from_json_schema(&doc).expect("the schema enumerates properties");
1090
1091        assert!(!judge_new("ts_ns", Some(&declared)));
1092        assert!(
1093            !judge_new("value.type", Some(&declared)),
1094            "the tag is declared by every branch"
1095        );
1096        assert!(
1097            !judge_new("value.value", Some(&declared)),
1098            "the content is declared by every branch"
1099        );
1100        // The union is not a blanket amnesty: the enum enumerated its fields,
1101        // so one it never declared is still drift.
1102        assert!(judge_new("value.unit", Some(&declared)));
1103        assert!(judge_new("extra", Some(&declared)));
1104    }
1105
1106    /// A `$ref` is the normal shape for *any* nested struct, not only an
1107    /// enum — `schemars` hoists every named type into `$defs`. Unresolved,
1108    /// the whole subtree read as undeclared.
1109    #[test]
1110    fn a_ref_into_defs_resolves_and_its_fields_are_declared() {
1111        let declared = DeclaredPaths::from_json_schema(&json!({
1112            "type": "object",
1113            "properties": {"cpu": {"$ref": "#/$defs/Cpu"}},
1114            "$defs": {
1115                "Cpu": {
1116                    "type": "object",
1117                    "properties": {
1118                        "usage": {"type": "number"},
1119                        "core": {"$ref": "#/$defs/Core"},
1120                    },
1121                },
1122                "Core": {"type": "object", "properties": {"id": {"type": "integer"}}},
1123            },
1124        }))
1125        .expect("the schema enumerates properties");
1126
1127        assert!(!judge_new("cpu.usage", Some(&declared)));
1128        assert!(
1129            !judge_new("cpu.core.id", Some(&declared)),
1130            "a $ref inside a $ref resolves too"
1131        );
1132        assert!(judge_new("cpu.missing", Some(&declared)));
1133    }
1134
1135    /// A `$ref` this walk cannot follow leaves its subtree **open**, never
1136    /// closed. Rendering "could not follow" as "undeclared" is the O4
1137    /// violation the whole check sits inside.
1138    #[test]
1139    fn an_unfollowable_ref_opens_its_subtree_rather_than_condemning_it() {
1140        for pointer in [
1141            "#/$defs/Absent",                     // dangling, same document
1142            "https://example.invalid/Thing.json", // somebody else's document
1143        ] {
1144            let declared = DeclaredPaths::from_json_schema(&json!({
1145                "type": "object",
1146                "properties": {
1147                    "seq": {"type": "number"},
1148                    "opaque": {"$ref": pointer},
1149                },
1150            }))
1151            .expect("the schema still enumerates `seq`");
1152            assert!(!judge_new("seq", Some(&declared)));
1153            assert!(
1154                !judge_new("opaque.anything.at.all", Some(&declared)),
1155                "{pointer}: an unresolvable $ref is unjudgeable, not new"
1156            );
1157        }
1158    }
1159
1160    /// A recursive type terminates, and the leg that would have looped is
1161    /// open rather than condemned.
1162    #[test]
1163    fn a_recursive_ref_terminates_and_opens_where_it_stops() {
1164        let declared = DeclaredPaths::from_json_schema(&json!({
1165            "$ref": "#/$defs/Node",
1166            "$defs": {
1167                "Node": {
1168                    "type": "object",
1169                    "properties": {
1170                        "name": {"type": "string"},
1171                        "parent": {"anyOf": [{"$ref": "#/$defs/Node"}, {"type": "null"}]},
1172                    },
1173                },
1174            },
1175        }))
1176        .expect("the schema enumerates properties");
1177
1178        assert!(!judge_new("name", Some(&declared)));
1179        assert!(!judge_new("parent", Some(&declared)));
1180        assert!(
1181            !judge_new("parent.name", Some(&declared)),
1182            "the cycle stops at `parent`, and what it could not enumerate is open"
1183        );
1184    }
1185
1186    /// `allOf` is how a flattened struct reaches the wire, and every arm's
1187    /// properties apply at once.
1188    #[test]
1189    fn all_of_arms_are_unioned() {
1190        let declared = DeclaredPaths::from_json_schema(&json!({
1191            "allOf": [
1192                {"type": "object", "properties": {"a": {"type": "number"}}},
1193                {"type": "object", "properties": {"b": {"type": "string"}}},
1194            ],
1195        }))
1196        .expect("the arms enumerate properties");
1197
1198        assert!(!judge_new("a", Some(&declared)));
1199        assert!(!judge_new("b", Some(&declared)));
1200        assert!(judge_new("c", Some(&declared)));
1201    }
1202
1203    /// A document that enumerates nothing stays unjudgeable, whichever shape
1204    /// the nothing takes — the O4 floor the walk must not lower while it
1205    /// learns to see more.
1206    #[test]
1207    fn a_document_that_enumerates_nothing_still_judges_nothing() {
1208        for doc in [
1209            json!({"type": "object"}),
1210            json!({}),
1211            json!({"$ref": "#/$defs/Absent"}),
1212            json!({"oneOf": [{"type": "string"}, {"type": "number"}]}),
1213            json!(true),
1214        ] {
1215            assert_eq!(
1216                DeclaredPaths::from_json_schema(&doc),
1217                None,
1218                "nothing enumerated, nothing judged: {doc}"
1219            );
1220        }
1221    }
1222
1223    /// The walk runs on whatever a producer replies with, so it is bounded in
1224    /// work, not only in depth: nested two-armed combinators fan out
1225    /// multiplicatively, and thirty levels of them is a **small** document —
1226    /// thirty `$defs` entries — describing a billion-node walk. The depth cap
1227    /// alone would not stop it; the node budget does. Exhausting either
1228    /// degrades to open (O4), never to a partial surface reported as whole.
1229    #[test]
1230    fn a_fanning_combinator_tree_terminates_within_its_budget() {
1231        const LEVELS: usize = 30;
1232        let mut defs = serde_json::Map::new();
1233        for level in 0..LEVELS {
1234            // Both arms point at the next level: 2^30 walks, 30 entries.
1235            let next = json!({"$ref": format!("#/$defs/L{}", level + 1)});
1236            defs.insert(format!("L{level}"), json!({"anyOf": [next.clone(), next]}));
1237        }
1238        defs.insert(
1239            format!("L{LEVELS}"),
1240            json!({"type": "object", "properties": {"leaf": {"type": "number"}}}),
1241        );
1242        let doc = json!({
1243            "type": "object",
1244            "properties": {"seq": {"type": "number"}, "deep": {"$ref": "#/$defs/L0"}},
1245            "$defs": Value::Object(defs),
1246        });
1247
1248        // Terminating at all is the assertion. Whatever it concluded about
1249        // the deep subtree, it must not have concluded a false "new" there,
1250        // and it must still have judged the shallow field it did reach.
1251        let declared = DeclaredPaths::from_json_schema(&doc).expect("`seq` is enumerated");
1252        assert!(!judge_new("seq", Some(&declared)));
1253        assert!(
1254            !judge_new("deep.leaf", Some(&declared)),
1255            "a subtree the budget could not finish is unjudgeable, not new"
1256        );
1257        assert!(
1258            judge_new("absent", Some(&declared)),
1259            "the check still works"
1260        );
1261    }
1262
1263    /// The small-domain set is total or absent: past the cap it clears and
1264    /// flags, never silently partial.
1265    #[test]
1266    fn distinct_values_are_total_or_flagged_overflowed() {
1267        let mut obs = FieldObservation::new(8);
1268        for i in 0..3 {
1269            obs.observe("k", i as f64, Some(&json!({"mode": format!("m{}", i % 2)})));
1270        }
1271        let (_, fields) = obs.iter().next().unwrap();
1272        let stats = &fields.paths["mode"];
1273        assert!(!stats.distinct_overflow);
1274        assert_eq!(stats.distinct.len(), 2);
1275
1276        let mut obs = FieldObservation::new(8);
1277        for i in 0..20 {
1278            obs.observe("k", i as f64, Some(&json!({"mode": i})));
1279        }
1280        let (_, fields) = obs.iter().next().unwrap();
1281        let stats = &fields.paths["mode"];
1282        assert!(stats.distinct_overflow, "20 values are not a small domain");
1283        assert!(
1284            stats.distinct.is_empty(),
1285            "an overflowed set is cleared, not silently partial"
1286        );
1287        // …and the numeric stats still carry the range.
1288        assert_eq!(stats.num_min, Some(0.0));
1289        assert_eq!(stats.num_max, Some(19.0));
1290        assert_eq!(stats.changes, 19);
1291    }
1292
1293    /// An undocumented sample (text, opaque bytes) is counted apart — it must
1294    /// not read as "every field absent" (O4).
1295    #[test]
1296    fn undocumented_samples_do_not_fake_a_vanish() {
1297        let mut obs = FieldObservation::new(8);
1298        obs.observe("k", 0.0, Some(&json!({"opt": 1})));
1299        for i in 1..6 {
1300            obs.observe("k", i as f64, None);
1301        }
1302        let (_, fields) = obs.iter().next().unwrap();
1303        assert_eq!(fields.documents, 1);
1304        assert_eq!(fields.undocumented, 5);
1305        assert!(
1306            judge_fields(&obs, 6.0, &BTreeMap::new())
1307                .iter()
1308                .all(|f| f.check != CheckId::FieldVanished),
1309            "five undocumented samples are five unobservables, not a vanish"
1310        );
1311    }
1312}