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