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 — **deferred to a later zengui
34//! window** — the Inspector field table with per-field sparklines through
35//! the existing `series.rs`/`spark.rs` gap-drawing. This chunk ships the
36//! engine and zenctl halves only.
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::registry::SliceSet;
50use crate::report::{CheckId, DoctorFinding, DoctorSeverity, FieldReport, FieldRow};
51
52/// Default bound on the per-path table, across every key the window sees. A
53/// high-cardinality document can blow a path table the way a `{var}` family
54/// blows a key table (#221), so the cap and its cost are reported like every
55/// other bound (RFC 09 §5.1 O6).
56pub const DEFAULT_MAX_PATHS: usize = 512;
57
58/// How many distinct values a path may show and still count as small-domain.
59pub const DISTINCT_CAP: usize = 8;
60
61/// A value longer than this cannot be a small-domain member — tracking a set
62/// of megabyte blobs would move the memory bound into the values.
63const DISTINCT_VALUE_CAP: usize = 64;
64
65/// How long an unchanged span must be, relative to the declared `ttl_s`,
66/// before `field-stuck` fires. One ttl unchanged is a slow sensor; three is
67/// three consecutive refresh deadlines carrying the same number.
68pub const STUCK_TTL_FACTOR: f64 = 3.0;
69
70/// How many consecutive trailing document samples a seen path must be absent
71/// from before `field-vanished` fires — one missing sample is jitter.
72pub const VANISHED_MIN_ABSENT: u64 = 3;
73
74/// A stuck path must have been observed at least this often — "the key kept
75/// publishing" is part of the finding's meaning.
76const STUCK_MIN_SEEN: u64 = 3;
77
78/// Dropped-path examples carried by the bound report (O6 names, not just
79/// counts — enough to recognise the document that exploded).
80const DROPPED_EXAMPLE_CAP: usize = 5;
81
82/// The dotted path of a document root that is not an object (a bare scalar
83/// or array payload) — one field, named like `jq`'s root.
84pub const ROOT_PATH: &str = "$";
85
86// ─── the observation ────────────────────────────────────────────────────────
87
88/// Bounded per-(key, dotted-path) statistics over one window.
89///
90/// Fed structural values as they ride; judged afterwards by the pure
91/// functions below. The bound is over the *total* path population across
92/// keys, and every path refused for the bound is counted and exemplified —
93/// a table that silently stops growing is indistinguishable from a document
94/// that stopped changing (O6).
95#[derive(Debug, Clone)]
96pub struct FieldObservation {
97    max_paths: usize,
98    keys: BTreeMap<String, KeyFields>,
99    paths: usize,
100    /// Refused path observations: the count *and* the names, in one
101    /// collector, so they cannot drift apart (O6).
102    dropped: Examples<String>,
103}
104
105/// One key's document samples and the paths inside them.
106#[derive(Debug, Clone, Default)]
107pub struct KeyFields {
108    /// Samples that carried a structural document (the population every
109    /// presence ratio is against).
110    pub documents: u64,
111    /// Samples that carried none (plain text, opaque bytes) — fields are
112    /// unobservable for them, which is stated, not folded into absence (O4).
113    pub undocumented: u64,
114    /// Samples whose payload was past [`crate::OBSERVE_LIMIT`] and therefore never
115    /// read. **Not** `undocumented`: "we did not look" is not "there was
116    /// nothing to see" (RFC 09 §5.1 O4).
117    pub unread: u64,
118    /// Per dotted path, the stats.
119    pub paths: BTreeMap<String, PathStats>,
120}
121
122/// What one dotted path did across one key's window.
123#[derive(Debug, Clone)]
124pub struct PathStats {
125    /// Document samples in which the path was present.
126    pub seen: u64,
127    /// Window-relative seconds of first / last presence.
128    pub first_at_s: f64,
129    pub last_at_s: f64,
130    /// The key's document-sample index at last presence — what "absent since"
131    /// is measured against.
132    pub last_seen_sample: u64,
133    /// JSON kind → occurrences (type stability: one entry is stable).
134    pub kinds: BTreeMap<&'static str, u64>,
135    /// Times the value differed from the previous observation of this path.
136    pub changes: u64,
137    /// Window-relative seconds of the last change; `None` = never changed.
138    pub last_change_at_s: Option<f64>,
139    /// Numeric min/max/last, when the path carried numbers.
140    pub num_min: Option<f64>,
141    pub num_max: Option<f64>,
142    pub num_last: Option<f64>,
143    /// Small-domain distinct values (canonical JSON), until the domain
144    /// overflows [`DISTINCT_CAP`].
145    pub distinct: BTreeSet<String>,
146    /// The domain outgrew the cap (or carried values too large to track) —
147    /// the set above is then cleared, not silently partial.
148    pub distinct_overflow: bool,
149    /// Fingerprint of the last observed value, for change detection.
150    last_fingerprint: Option<u64>,
151}
152
153impl PathStats {
154    fn new(at_s: f64, sample: u64) -> PathStats {
155        PathStats {
156            seen: 0,
157            first_at_s: at_s,
158            last_at_s: at_s,
159            last_seen_sample: sample,
160            kinds: BTreeMap::new(),
161            changes: 0,
162            last_change_at_s: None,
163            num_min: None,
164            num_max: None,
165            num_last: None,
166            distinct: BTreeSet::new(),
167            distinct_overflow: false,
168            last_fingerprint: None,
169        }
170    }
171
172    fn observe(&mut self, at_s: f64, sample: u64, value: &Value) {
173        self.seen += 1;
174        self.last_at_s = at_s;
175        self.last_seen_sample = sample;
176        *self.kinds.entry(kind_of(value)).or_default() += 1;
177        let canonical = serde_json::to_string(value).unwrap_or_default();
178        let fingerprint = {
179            let mut h = std::collections::hash_map::DefaultHasher::new();
180            canonical.hash(&mut h);
181            h.finish()
182        };
183        if let Some(prev) = self.last_fingerprint
184            && prev != fingerprint
185        {
186            self.changes += 1;
187            self.last_change_at_s = Some(at_s);
188        }
189        self.last_fingerprint = Some(fingerprint);
190        if let Some(n) = value.as_f64() {
191            self.num_min = Some(self.num_min.map_or(n, |m| m.min(n)));
192            self.num_max = Some(self.num_max.map_or(n, |m| m.max(n)));
193            self.num_last = Some(n);
194        }
195        if !self.distinct_overflow {
196            if canonical.len() > DISTINCT_VALUE_CAP {
197                self.distinct_overflow = true;
198                self.distinct.clear();
199            } else {
200                self.distinct.insert(canonical);
201                if self.distinct.len() > DISTINCT_CAP {
202                    self.distinct_overflow = true;
203                    self.distinct.clear();
204                }
205            }
206        }
207    }
208}
209
210impl FieldObservation {
211    pub fn new(max_paths: usize) -> FieldObservation {
212        FieldObservation {
213            max_paths: max_paths.max(1),
214            keys: BTreeMap::new(),
215            paths: 0,
216            dropped: Examples::new(DROPPED_EXAMPLE_CAP),
217        }
218    }
219
220    /// Feed one sample. `doc` is the structural value when the payload
221    /// carried one ([`crate::model::decode::structural_value`]); `None` counts the
222    /// sample as undocumented rather than pretending its fields were absent.
223    pub fn observe_unread(&mut self, key: &str) {
224        self.keys.entry(key.to_string()).or_default().unread += 1;
225    }
226
227    /// Samples skipped because their payload was too large to read.
228    pub fn unread(&self) -> u64 {
229        self.keys.values().map(|k| k.unread).sum()
230    }
231
232    pub fn observe(&mut self, key: &str, at_s: f64, doc: Option<&Value>) {
233        let entry = self.keys.entry(key.to_string()).or_default();
234        let Some(doc) = doc else {
235            entry.undocumented += 1;
236            return;
237        };
238        entry.documents += 1;
239        let sample = entry.documents;
240        let mut leaves = Vec::new();
241        flatten(doc, &mut leaves);
242        for (path, value) in leaves {
243            match entry.paths.get_mut(&path) {
244                Some(stats) => stats.observe(at_s, sample, value),
245                None if self.paths < self.max_paths => {
246                    let mut stats = PathStats::new(at_s, sample);
247                    stats.observe(at_s, sample, value);
248                    entry.paths.insert(path, stats);
249                    self.paths += 1;
250                }
251                // The bound: refused, counted, exemplified — never silent.
252                None => self.dropped.push_with(|| format!("{key} · {path}")),
253            }
254        }
255    }
256
257    /// Per-key observations, for the judges and the report rows.
258    pub fn iter(&self) -> impl Iterator<Item = (&str, &KeyFields)> {
259        self.keys.iter().map(|(k, v)| (k.as_str(), v))
260    }
261
262    pub fn keys_seen(&self) -> usize {
263        self.keys.len()
264    }
265
266    /// Distinct (key, path) pairs currently tracked.
267    pub fn paths(&self) -> usize {
268        self.paths
269    }
270
271    pub fn max_paths(&self) -> usize {
272        self.max_paths
273    }
274
275    /// Path observations refused to stay within the bound (RFC 09 §5.1 O6).
276    pub fn dropped_paths(&self) -> u64 {
277        self.dropped.total() as u64
278    }
279
280    /// Up to a handful of `key · path` names among the refused (the cap is
281    /// `DROPPED_EXAMPLE_CAP` — enough to recognise the document that
282    /// exploded, without pasting the population).
283    pub fn dropped_examples(&self) -> &[String] {
284        self.dropped.as_slice()
285    }
286
287    /// Samples that carried no structural document, across every key.
288    pub fn undocumented(&self) -> u64 {
289        self.keys.values().map(|k| k.undocumented).sum()
290    }
291}
292
293/// One observed value's JSON kind, for the type-stability count.
294fn kind_of(v: &Value) -> &'static str {
295    match v {
296        Value::Null => "null",
297        Value::Bool(_) => "bool",
298        Value::Number(_) => "number",
299        Value::String(_) => "string",
300        Value::Array(_) => "array",
301        Value::Object(_) => "object",
302    }
303}
304
305/// Flatten a structural document into dotted leaf paths. Objects recurse
306/// (`a.b.c`); arrays and scalars are leaves — indexing into arrays would
307/// mint a path per element and hand the cardinality problem a wildcard. A
308/// non-object root is the single leaf [`ROOT_PATH`]; an empty object is its
309/// own leaf (a present-but-empty subtree is presence, not absence).
310pub fn flatten<'v>(doc: &'v Value, out: &mut Vec<(String, &'v Value)>) {
311    fn walk<'v>(prefix: &str, v: &'v Value, out: &mut Vec<(String, &'v Value)>) {
312        match v {
313            Value::Object(map) if !map.is_empty() => {
314                for (name, child) in map {
315                    let path = if prefix.is_empty() {
316                        name.clone()
317                    } else {
318                        format!("{prefix}.{name}")
319                    };
320                    walk(&path, child, out);
321                }
322            }
323            leaf => out.push(if prefix.is_empty() {
324                (ROOT_PATH.to_string(), leaf)
325            } else {
326                (prefix.to_string(), leaf)
327            }),
328        }
329    }
330    walk("", doc, out);
331}
332
333// ─── the declared-path surface (field-new's other half) ─────────────────────
334
335/// The dotted paths a served JSON Schema declares, with the subtrees it
336/// leaves free-form. `field-new` is judgeable only against this: a schema
337/// kind this build cannot enumerate (protobuf, CDR) or a document with no
338/// `properties` yields `None`, and no finding — unjudgeable is not new (O4).
339#[derive(Debug, Clone, Default, PartialEq, Eq)]
340pub struct DeclaredPaths {
341    declared: BTreeSet<String>,
342    /// Prefixes whose subschema enumerates nothing — anything below is
343    /// unjudgeable rather than undeclared.
344    open: BTreeSet<String>,
345}
346
347impl DeclaredPaths {
348    /// Walk a JSON Schema document's `properties` tree. `None` when the root
349    /// enumerates no properties at all — then the whole document is
350    /// unjudgeable at field granularity.
351    pub fn from_json_schema(doc: &Value) -> Option<DeclaredPaths> {
352        let root = doc.get("properties")?.as_object()?;
353        let mut out = DeclaredPaths::default();
354        fn walk(prefix: &str, props: &serde_json::Map<String, Value>, out: &mut DeclaredPaths) {
355            for (name, sub) in props {
356                let path = if prefix.is_empty() {
357                    name.clone()
358                } else {
359                    format!("{prefix}.{name}")
360                };
361                match sub.get("properties").and_then(Value::as_object) {
362                    Some(nested) => walk(&path, nested, out),
363                    // An object subschema with no enumerated properties is a
364                    // free-form subtree: everything under it is unjudgeable.
365                    None if sub.get("type").and_then(Value::as_str) == Some("object") => {
366                        out.open.insert(path.clone());
367                    }
368                    None => {}
369                }
370                out.declared.insert(path);
371            }
372        }
373        walk("", root, &mut out);
374        Some(out)
375    }
376
377    /// Whether the schema accounts for this path — declared, or under a
378    /// free-form subtree it deliberately left open.
379    pub fn accounts_for(&self, path: &str) -> bool {
380        if path == ROOT_PATH || self.declared.contains(path) {
381            return true;
382        }
383        // Any ancestor being open makes the path unjudgeable, not new.
384        let mut prefix = String::new();
385        for chunk in path.split('.') {
386            if !prefix.is_empty() {
387                prefix.push('.');
388            }
389            prefix.push_str(chunk);
390            if self.open.contains(&prefix) {
391                return true;
392            }
393        }
394        false
395    }
396}
397
398// ─── the judges (pure — the #227/#221 house pattern) ────────────────────────
399
400/// What the judges may know about one key beyond its stats. Everything is
401/// optional, and every `None` suppresses the finding that needed it rather
402/// than guessing (O4).
403#[derive(Debug, Clone, Default)]
404pub struct KeyFieldContext {
405    /// The subject's declared `ttl_s`, when the key refined to a registered
406    /// subject — what `field-stuck` is long *relative to*.
407    pub ttl_s: Option<i64>,
408    /// The registered type name, for the `field-new` evidence line.
409    pub type_name: Option<String>,
410    /// The served schema's declared paths, when derivable.
411    pub declared: Option<DeclaredPaths>,
412}
413
414/// `field-vanished`: SEEN, then absent from at least [`VANISHED_MIN_ABSENT`]
415/// consecutive trailing document samples. A path never seen is not vanished
416/// — that would be rendering "not asked" as "no" (O4).
417pub fn judge_vanished(stats: &PathStats, key_documents: u64) -> bool {
418    stats.seen > 0 && key_documents.saturating_sub(stats.last_seen_sample) >= VANISHED_MIN_ABSENT
419}
420
421/// `field-stuck`: a purely numeric path, zero changes, observed at least
422/// `STUCK_MIN_SEEN` times across a span of at least [`STUCK_TTL_FACTOR`] ×
423/// the declared `ttl_s`. No declared ttl, no finding: there is nothing to be
424/// long relative to (O4) — and the numeric requirement is what keeps a
425/// constant-by-design hostname or enum out of the noise.
426pub fn judge_stuck(stats: &PathStats, ttl_s: Option<i64>) -> bool {
427    let Some(ttl) = ttl_s.filter(|t| *t > 0) else {
428        return false;
429    };
430    stats.changes == 0
431        && stats.seen >= STUCK_MIN_SEEN
432        && stats.kinds.len() == 1
433        && stats.kinds.contains_key("number")
434        && (stats.last_at_s - stats.first_at_s) >= STUCK_TTL_FACTOR * ttl as f64
435}
436
437/// `field-new`: the served schema enumerates its properties and this path is
438/// not among them (nor under a free-form subtree). With no declared surface
439/// there is no finding — unjudgeable is not new (O4).
440pub fn judge_new(path: &str, declared: Option<&DeclaredPaths>) -> bool {
441    declared.is_some_and(|d| !d.accounts_for(path))
442}
443
444/// Judge a whole observation into doctor findings, capped per check the way
445/// the doctor caps its listen findings. `ctx` supplies what is known per key;
446/// a key it does not name gets the empty context (everything unjudgeable).
447pub fn judge_fields(
448    obs: &FieldObservation,
449    window_s: f64,
450    ctx: &BTreeMap<String, KeyFieldContext>,
451) -> Vec<DoctorFinding> {
452    let empty = KeyFieldContext::default();
453
454    let mut vanished = Examples::new(FINDING_CAP);
455
456    let mut stuck = Examples::new(FINDING_CAP);
457
458    let mut new = Examples::new(FINDING_CAP);
459
460    for (key, fields) in obs.iter() {
461        let c = ctx.get(key).unwrap_or(&empty);
462        for (path, stats) in &fields.paths {
463            if judge_vanished(stats, fields.documents) {
464                vanished.push_with(|| DoctorFinding {
465                    severity: DoctorSeverity::Warning,
466                    check: CheckId::FieldVanished,
467                    subject: format!("{key} · {path}"),
468                    evidence: format!(
469                        "present in {} of {} document sample(s) in {window_s:.0}s, absent \
470                         from the last {} — seen, then gone; a schema that declares it \
471                         optional reads Valid without it by construction",
472                        stats.seen,
473                        fields.documents,
474                        fields.documents - stats.last_seen_sample
475                    ),
476                    citation: None,
477                });
478            }
479            if judge_stuck(stats, c.ttl_s) {
480                let ttl = c.ttl_s.unwrap_or(0);
481                stuck.push_with(|| DoctorFinding {
482                    severity: DoctorSeverity::Warning,
483                    check: CheckId::FieldStuck,
484                    subject: format!("{key} · {path}"),
485                    evidence: format!(
486                        "value {} unchanged across {} sample(s) spanning {:.1}s — at least \
487                         {STUCK_TTL_FACTOR:.0}× the declared ttl_s {ttl}s — while the key \
488                         kept publishing. An observation over this {window_s:.0}s window, \
489                         not a verdict: a constant-by-design field always reads this way",
490                        stats
491                            .num_last
492                            .map(|n| n.to_string())
493                            .unwrap_or_else(|| "?".into()),
494                        stats.seen,
495                        stats.last_at_s - stats.first_at_s,
496                    ),
497                    citation: Some("RFC 04 §1.2".into()),
498                });
499            }
500            if judge_new(path, c.declared.as_ref()) {
501                new.push_with(|| DoctorFinding {
502                    severity: DoctorSeverity::Warning,
503                    check: CheckId::FieldNew,
504                    subject: format!("{key} · {path}"),
505                    evidence: format!(
506                        "present in {} of {} document sample(s) but never declared by the \
507                         served schema{} — schema drift at field granularity",
508                        stats.seen,
509                        fields.documents,
510                        c.type_name
511                            .as_deref()
512                            .map(|t| format!(" for {t}"))
513                            .unwrap_or_default()
514                    ),
515                    citation: Some("RFC 08 §7".into()),
516                });
517            }
518        }
519    }
520    let mut findings = Vec::new();
521    for (check, hits) in [
522        (CheckId::FieldVanished, vanished),
523        (CheckId::FieldStuck, stuck),
524        (CheckId::FieldNew, new),
525    ] {
526        let more = hits.more("more path(s) with the same finding");
527        findings.extend(hits.into_vec());
528        if let Some(evidence) = more {
529            findings.push(DoctorFinding {
530                severity: DoctorSeverity::Info,
531                check,
532                subject: "fleet".into(),
533                evidence,
534                citation: None,
535            });
536        }
537    }
538    findings
539}
540
541// ─── the window runner (zenctl field) ───────────────────────────────────────
542
543/// What a `zenctl field` run watches, and its bounds.
544#[derive(Debug, Clone)]
545pub struct FieldSpec {
546    /// Full wire selector to watch (the session is un-namespaced, RFC 09 §5).
547    pub selector: String,
548    /// The observation window.
549    pub window: Duration,
550    /// The per-path table bound (RFC 09 §5.1 O6).
551    pub max_paths: usize,
552}
553
554/// Watch one selector for the window and report per-path statistics plus the
555/// three findings. The subscriber is declared **before** the window opens
556/// (O4). `slices: None` means no registry was loaded: declared `ttl_s` and
557/// type names are then unknown, `field-stuck` and `field-new` are
558/// unjudgeable, and the report says so rather than reading clean (O4; #246).
559pub async fn run_field(
560    fleet: &crate::Fleet<'_>,
561    slices: Option<&SliceSet>,
562    store: &SchemaStore,
563    spec: &FieldSpec,
564) -> Result<FieldReport> {
565    use crate::{FleetEvent, StreamItem};
566
567    let (session, base) = (fleet.session(), fleet.base());
568
569    let monitor = crate::Monitor::start(session, crate::MonitorSpec::default()).await?;
570
571    let mut events = monitor.events();
572
573    // Declared before the window opens: not-asked must never read as "no" —
574    // and a declaration that fails takes the monitor down with it (#336).
575    let monitor = monitor.watching([spec.selector.as_str()]).await?;
576    let opened = tokio::time::Instant::now();
577    let deadline = opened + spec.window;
578
579    let mut obs = FieldObservation::new(spec.max_paths);
580    let mut samples: u64 = 0;
581    let mut dropped: u64 = 0;
582    // Bounded (#107): one projection per distinct key, LRU past the bound,
583    // evictions counted into the report (O6).
584    let mut facts = crate::model::facts::FactsCache::default();
585
586    // One timer for the whole window, not one per iteration (#346).
587    // `sleep_until` builds a future and registers a timer each time it
588    // is evaluated, and a `select!` in a loop evaluates it on every
589    // pass — at 100k samples/s that is 100k registrations a second for
590    // a deadline that never moves.
591    let window_over = tokio::time::sleep_until(deadline);
592    tokio::pin!(window_over);
593    loop {
594        let item = tokio::select! {
595            item = events.recv() => item,
596            () = &mut window_over => break,
597        };
598        match item {
599            Some(StreamItem::Event(FleetEvent::Sample(s))) => {
600                samples += 1;
601                // Bounded, and the skip is counted rather than read as an
602                // absent document (#346).
603                let bytes = s.payload.to_bytes();
604                if bytes.len() > crate::model::decode::OBSERVE_LIMIT {
605                    obs.observe_unread(&s.key);
606                } else {
607                    let doc = crate::model::decode::structural_value(&bytes);
608                    obs.observe(&s.key, opened.elapsed().as_secs_f64(), doc.as_ref());
609                }
610                facts.ensure(base, &s.key, slices);
611            }
612            Some(StreamItem::Dropped(n)) => dropped += n,
613            Some(_) => continue,
614            None => break,
615        }
616    }
617    monitor.shutdown().await?;
618
619    let window_s = spec.window.as_secs_f64();
620    let ctx = field_context(session, store, slices, &facts).await;
621    let findings = judge_fields(&obs, window_s, &ctx);
622
623    let mut rows = Vec::new();
624    for (key, fields) in obs.iter() {
625        for (path, stats) in &fields.paths {
626            rows.push(FieldRow {
627                key: key.to_string(),
628                path: path.clone(),
629                seen: stats.seen,
630                documents: fields.documents,
631                kinds: stats.kinds.keys().map(|k| k.to_string()).collect(),
632                changes: stats.changes,
633                last_change_s: stats.last_change_at_s,
634                min: stats.num_min,
635                max: stats.num_max,
636                last: stats.num_last,
637                // `None` = the domain outgrew the cap; `Some` is total.
638                values: (!stats.distinct_overflow)
639                    .then(|| stats.distinct.iter().cloned().collect()),
640            });
641        }
642    }
643
644    Ok(FieldReport {
645        selector: spec.selector.clone(),
646        window_s,
647        samples,
648        keys_seen: obs.keys_seen(),
649        dropped,
650        undocumented: obs.undocumented(),
651        unread: obs.unread(),
652        registry_loaded: slices.is_some(),
653        paths: obs.paths(),
654        max_paths: obs.max_paths(),
655        paths_dropped: obs.dropped_paths(),
656        paths_dropped_examples: obs.dropped_examples().to_vec(),
657        facts_evicted: facts.evicted(),
658        rows,
659        findings,
660    })
661}
662
663/// Build the per-key judge context: declared `ttl_s`/type from the resolved
664/// facts, declared paths from each producer's served schema (fetched through
665/// the store's ordinary cache — one `describe` per producer, not per key).
666pub(crate) async fn field_context(
667    session: &Session,
668    store: &SchemaStore,
669    slices: Option<&SliceSet>,
670    facts: &crate::model::facts::FactsCache,
671) -> BTreeMap<String, KeyFieldContext> {
672    let mut declared_cache: BTreeMap<(String, String), Option<DeclaredPaths>> = BTreeMap::new();
673
674    let mut ctx = BTreeMap::new();
675
676    for (key, f) in facts.iter() {
677        let mut c = KeyFieldContext::default();
678        if let crate::model::facts::Registration::Registered(sf) = &f.registration {
679            c.ttl_s = sf.ttl_s;
680            c.type_name = Some(sf.type_name.clone());
681            if let Some(producer) = producer_of(f, slices)
682                && !sf.type_name.is_empty()
683            {
684                let cache_key = (producer.clone(), sf.type_name.clone());
685                if !declared_cache.contains_key(&cache_key) {
686                    let declared = store
687                        .schema_for(session, &producer, &sf.type_name)
688                        .await
689                        .and_then(|schema| {
690                            schema
691                                .json_document()
692                                .and_then(DeclaredPaths::from_json_schema)
693                        });
694                    declared_cache.insert(cache_key.clone(), declared);
695                }
696                c.declared = declared_cache.get(&cache_key).cloned().flatten();
697            }
698        }
699        ctx.insert(key.to_string(), c);
700    }
701    ctx
702}
703
704#[cfg(test)]
705mod tests {
706    use super::*;
707    use serde_json::json;
708
709    fn observe_docs(obs: &mut FieldObservation, key: &str, docs: &[(f64, Value)]) {
710        for (at, doc) in docs {
711            obs.observe(key, *at, Some(doc));
712        }
713    }
714
715    /// Objects flatten to dotted leaves; arrays and scalars are leaves; a
716    /// non-object root is the single `$` field.
717    #[test]
718    fn flattening_recurses_objects_and_stops_at_arrays() {
719        let doc = json!({"a": {"b": 1, "c": [1, 2]}, "d": "x", "e": {}});
720        let mut leaves = Vec::new();
721        flatten(&doc, &mut leaves);
722        let paths: Vec<&str> = leaves.iter().map(|(p, _)| p.as_str()).collect();
723        assert_eq!(paths, ["a.b", "a.c", "d", "e"]);
724
725        let scalar = json!(42.0);
726        let mut leaves = Vec::new();
727        flatten(&scalar, &mut leaves);
728        assert_eq!(leaves.len(), 1);
729        assert_eq!(leaves[0].0, ROOT_PATH);
730    }
731
732    /// The acceptance bound: the path table refuses past its cap, counts
733    /// every refusal, and names examples — never a silent truncation (O6).
734    #[test]
735    fn the_path_table_is_bounded_and_reports_what_it_dropped() {
736        let mut obs = FieldObservation::new(4);
737        let wide: serde_json::Map<String, Value> =
738            (0..20).map(|i| (format!("f{i:02}"), json!(i))).collect();
739        obs.observe("k", 0.0, Some(&Value::Object(wide)));
740        assert_eq!(obs.paths(), 4, "the bound holds");
741        assert_eq!(obs.dropped_paths(), 16, "every refusal is counted");
742        assert!(
743            obs.dropped_examples().iter().any(|e| e.contains("k · f04")),
744            "refused paths are named: {:?}",
745            obs.dropped_examples()
746        );
747        // A tracked path keeps updating even while the table is full.
748        obs.observe("k", 1.0, Some(&json!({"f00": 9})));
749        let (_, fields) = obs.iter().next().unwrap();
750        assert_eq!(fields.paths["f00"].seen, 2);
751    }
752
753    /// Vanished needs SEEN then absent: a path never observed is not a
754    /// vanished path — "not asked" never renders as "no" (O4).
755    #[test]
756    fn vanished_needs_seen_then_absent() {
757        let mut obs = FieldObservation::new(64);
758        let with = json!({"seq": 1, "opt": true});
759        let without = json!({"seq": 2});
760        observe_docs(
761            &mut obs,
762            "k",
763            &[
764                (0.0, with),
765                (1.0, without.clone()),
766                (2.0, without.clone()),
767                (3.0, without.clone()),
768                (4.0, without),
769            ],
770        );
771        let (_, fields) = obs.iter().next().unwrap();
772        assert!(judge_vanished(&fields.paths["opt"], fields.documents));
773        assert!(
774            !judge_vanished(&fields.paths["seq"], fields.documents),
775            "a path present in the last sample has not vanished"
776        );
777        let findings = judge_fields(&obs, 5.0, &BTreeMap::new());
778        let vanished: Vec<_> = findings
779            .iter()
780            .filter(|f| f.check == CheckId::FieldVanished)
781            .collect();
782        assert_eq!(vanished.len(), 1, "{findings:?}");
783        assert!(vanished[0].subject.ends_with("· opt"));
784        assert!(
785            vanished[0].evidence.contains("1 of 5"),
786            "presence is counted: {}",
787            vanished[0].evidence
788        );
789        // One missing sample is jitter, not a vanish.
790        let mut obs = FieldObservation::new(64);
791        observe_docs(
792            &mut obs,
793            "k",
794            &[
795                (0.0, json!({"opt": 1})),
796                (1.0, json!({})),
797                (2.0, json!({"opt": 1})),
798            ],
799        );
800        assert!(
801            judge_fields(&obs, 3.0, &BTreeMap::new())
802                .iter()
803                .all(|f| f.check != CheckId::FieldVanished)
804        );
805    }
806
807    /// Stuck is numeric, ttl-relative, and never a verdict without a declared
808    /// ttl: a frozen number across ≥3×ttl fires, a changing one does not, a
809    /// constant string (hostname) does not, and no ttl means nothing to be
810    /// long relative to (O4).
811    #[test]
812    fn stuck_is_numeric_ttl_relative_and_suppressed_without_a_ttl() {
813        let mut obs = FieldObservation::new(64);
814        let docs: Vec<(f64, Value)> = (0..8)
815            .map(|i| {
816                (
817                    i as f64,
818                    json!({"temperature_c": 21.5, "seq": i, "host": "web-1"}),
819                )
820            })
821            .collect();
822        observe_docs(&mut obs, "k", &docs);
823        let (_, fields) = obs.iter().next().unwrap();
824        assert!(judge_stuck(&fields.paths["temperature_c"], Some(1)));
825        assert!(
826            !judge_stuck(&fields.paths["seq"], Some(1)),
827            "a changing numeric is not stuck"
828        );
829        assert!(
830            !judge_stuck(&fields.paths["host"], Some(1)),
831            "a constant string is constant by design, not stuck"
832        );
833        assert!(
834            !judge_stuck(&fields.paths["temperature_c"], None),
835            "no declared ttl_s: nothing to be long relative to (O4)"
836        );
837        assert!(
838            !judge_stuck(&fields.paths["temperature_c"], Some(10)),
839            "a 7s span is not long relative to a 10s ttl"
840        );
841
842        let ctx: BTreeMap<String, KeyFieldContext> = [(
843            "k".to_string(),
844            KeyFieldContext {
845                ttl_s: Some(1),
846                ..KeyFieldContext::default()
847            },
848        )]
849        .into();
850        let findings = judge_fields(&obs, 8.0, &ctx);
851        let stuck: Vec<_> = findings
852            .iter()
853            .filter(|f| f.check == CheckId::FieldStuck)
854            .collect();
855        assert_eq!(stuck.len(), 1, "{findings:?}");
856        assert!(stuck[0].subject.ends_with("· temperature_c"));
857        assert!(stuck[0].evidence.contains("21.5"), "{}", stuck[0].evidence);
858        assert!(
859            stuck[0].evidence.contains("not a verdict"),
860            "stuck is an observation with a stated window: {}",
861            stuck[0].evidence
862        );
863        assert!(
864            stuck[0].evidence.contains("ttl_s 1s"),
865            "the ttl it is relative to is stated: {}",
866            stuck[0].evidence
867        );
868    }
869
870    /// `field-new` is judged only against a schema that enumerates its
871    /// properties: an undeclared path fires, a declared one does not, a
872    /// free-form subtree is unjudgeable, and no schema means no finding (O4).
873    #[test]
874    fn new_is_judged_only_against_a_declaring_schema() {
875        let declared = DeclaredPaths::from_json_schema(&json!({
876            "type": "object",
877            "properties": {
878                "seq": {"type": "number"},
879                "nested": {"type": "object", "properties": {"x": {"type": "number"}}},
880                "freeform": {"type": "object"},
881            },
882        }))
883        .expect("the schema enumerates properties");
884        assert!(!judge_new("seq", Some(&declared)));
885        assert!(!judge_new("nested.x", Some(&declared)));
886        assert!(judge_new("extra", Some(&declared)));
887        assert!(judge_new("nested.y", Some(&declared)));
888        assert!(
889            !judge_new("freeform.anything.at.all", Some(&declared)),
890            "a free-form subtree is unjudgeable, not new"
891        );
892        assert!(!judge_new("extra", None), "no schema, no finding (O4)");
893        assert_eq!(
894            DeclaredPaths::from_json_schema(&json!({"type": "object"})),
895            None,
896            "a schema with no properties judges nothing"
897        );
898
899        let mut obs = FieldObservation::new(64);
900        observe_docs(&mut obs, "k", &[(0.0, json!({"seq": 1, "extra": 2}))]);
901        let ctx: BTreeMap<String, KeyFieldContext> = [(
902            "k".to_string(),
903            KeyFieldContext {
904                type_name: Some("Health".into()),
905                declared: Some(declared),
906                ..KeyFieldContext::default()
907            },
908        )]
909        .into();
910        let findings = judge_fields(&obs, 1.0, &ctx);
911        let new: Vec<_> = findings
912            .iter()
913            .filter(|f| f.check == CheckId::FieldNew)
914            .collect();
915        assert_eq!(new.len(), 1, "{findings:?}");
916        assert!(new[0].subject.ends_with("· extra"));
917        assert!(new[0].evidence.contains("Health"), "{}", new[0].evidence);
918        assert_eq!(new[0].citation.as_deref(), Some("RFC 08 §7"));
919    }
920
921    /// The small-domain set is total or absent: past the cap it clears and
922    /// flags, never silently partial.
923    #[test]
924    fn distinct_values_are_total_or_flagged_overflowed() {
925        let mut obs = FieldObservation::new(8);
926        for i in 0..3 {
927            obs.observe("k", i as f64, Some(&json!({"mode": format!("m{}", i % 2)})));
928        }
929        let (_, fields) = obs.iter().next().unwrap();
930        let stats = &fields.paths["mode"];
931        assert!(!stats.distinct_overflow);
932        assert_eq!(stats.distinct.len(), 2);
933
934        let mut obs = FieldObservation::new(8);
935        for i in 0..20 {
936            obs.observe("k", i as f64, Some(&json!({"mode": i})));
937        }
938        let (_, fields) = obs.iter().next().unwrap();
939        let stats = &fields.paths["mode"];
940        assert!(stats.distinct_overflow, "20 values are not a small domain");
941        assert!(
942            stats.distinct.is_empty(),
943            "an overflowed set is cleared, not silently partial"
944        );
945        // …and the numeric stats still carry the range.
946        assert_eq!(stats.num_min, Some(0.0));
947        assert_eq!(stats.num_max, Some(19.0));
948        assert_eq!(stats.changes, 19);
949    }
950
951    /// An undocumented sample (text, opaque bytes) is counted apart — it must
952    /// not read as "every field absent" (O4).
953    #[test]
954    fn undocumented_samples_do_not_fake_a_vanish() {
955        let mut obs = FieldObservation::new(8);
956        obs.observe("k", 0.0, Some(&json!({"opt": 1})));
957        for i in 1..6 {
958            obs.observe("k", i as f64, None);
959        }
960        let (_, fields) = obs.iter().next().unwrap();
961        assert_eq!(fields.documents, 1);
962        assert_eq!(fields.undocumented, 5);
963        assert!(
964            judge_fields(&obs, 6.0, &BTreeMap::new())
965                .iter()
966                .all(|f| f.check != CheckId::FieldVanished),
967            "five undocumented samples are five unobservables, not a vanish"
968        );
969    }
970}