1use 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
53pub const DEFAULT_MAX_PATHS: usize = 512;
58
59pub const DISTINCT_CAP: usize = 8;
61
62const DISTINCT_VALUE_CAP: usize = 64;
65
66pub const STUCK_TTL_FACTOR: f64 = 3.0;
70
71pub const VANISHED_MIN_ABSENT: u64 = 3;
74
75const STUCK_MIN_SEEN: u64 = 3;
78
79const DROPPED_EXAMPLE_CAP: usize = 5;
82
83pub const ROOT_PATH: &str = "$";
86
87const SCHEMA_DEPTH_CAP: usize = 32;
91
92const SCHEMA_NODE_BUDGET: usize = 10_000;
104
105#[derive(Debug, Clone)]
115pub struct FieldObservation {
116 max_paths: usize,
117 keys: BTreeMap<String, KeyFields>,
118 paths: usize,
119 dropped: Examples<String>,
122}
123
124#[derive(Debug, Clone, Default)]
126pub struct KeyFields {
127 pub documents: u64,
130 pub undocumented: u64,
133 pub unread: u64,
137 pub paths: BTreeMap<String, PathStats>,
139}
140
141#[derive(Debug, Clone)]
143pub struct PathStats {
144 pub seen: u64,
146 pub first_at_s: f64,
148 pub last_at_s: f64,
149 pub last_seen_sample: u64,
152 pub kinds: BTreeMap<&'static str, u64>,
154 pub changes: u64,
156 pub last_change_at_s: Option<f64>,
158 pub num_min: Option<f64>,
160 pub num_max: Option<f64>,
161 pub num_last: Option<f64>,
162 pub distinct: BTreeSet<String>,
165 pub distinct_overflow: bool,
168 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 pub fn observe_unread(&mut self, key: &str) {
243 self.keys.entry(key.to_string()).or_default().unread += 1;
244 }
245
246 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 None => self.dropped.push_with(|| format!("{key} · {path}")),
272 }
273 }
274 }
275
276 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 pub fn paths(&self) -> usize {
287 self.paths
288 }
289
290 pub fn max_paths(&self) -> usize {
291 self.max_paths
292 }
293
294 pub fn dropped_paths(&self) -> u64 {
296 self.dropped.total() as u64
297 }
298
299 pub fn dropped_examples(&self) -> &[String] {
303 self.dropped.as_slice()
304 }
305
306 pub fn undocumented(&self) -> u64 {
308 self.keys.values().map(|k| k.undocumented).sum()
309 }
310}
311
312fn 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
324pub 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#[derive(Debug, Clone, Default, PartialEq, Eq)]
360pub struct DeclaredPaths {
361 declared: BTreeSet<String>,
362 open: BTreeSet<String>,
365}
366
367impl DeclaredPaths {
368 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 pub fn accounts_for(&self, path: &str) -> bool {
408 if path == ROOT_PATH || self.declared.contains(path) {
409 return true;
410 }
411 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
426struct Walk<'d> {
430 root: &'d Value,
431 visiting: BTreeSet<String>,
432 budget: usize,
433 root_open: bool,
436}
437
438impl Walk<'_> {
439 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 fn node(&mut self, node: &Value, prefix: &str, depth: usize, out: &mut DeclaredPaths) {
452 if depth > SCHEMA_DEPTH_CAP || self.budget == 0 {
455 self.open(prefix, out);
456 return;
457 }
458 self.budget -= 1;
459
460 let Some(obj) = node.as_object() else {
464 return;
465 };
466
467 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 _ => self.open(prefix, out),
482 }
483 }
484
485 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 if !described && obj.get("type").and_then(Value::as_str) == Some("object") {
515 self.open(prefix, out);
516 }
517 }
518}
519
520#[derive(Debug, Clone, Default)]
526pub struct KeyFieldContext {
527 pub ttl_s: Option<i64>,
530 pub type_name: Option<String>,
532 pub declared: Option<DeclaredPaths>,
534}
535
536pub 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
543pub 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
559pub fn judge_new(path: &str, declared: Option<&DeclaredPaths>) -> bool {
563 declared.is_some_and(|d| !d.accounts_for(path))
564}
565
566pub 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#[derive(Debug, Clone)]
667pub struct FieldSpec {
668 pub selector: String,
670 pub window: Duration,
672 pub max_paths: usize,
674}
675
676pub 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 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 let mut facts = crate::model::facts::FactsCache::default();
707
708 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 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 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
785pub(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 #[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 #[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 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 #[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 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 #[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 #[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 #[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 assert!(judge_new("value.unit", Some(&declared)));
1095 assert!(judge_new("extra", Some(&declared)));
1096 }
1097
1098 #[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 #[test]
1131 fn an_unfollowable_ref_opens_its_subtree_rather_than_condemning_it() {
1132 for pointer in [
1133 "#/$defs/Absent", "https://example.invalid/Thing.json", ] {
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 #[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 #[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 #[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 #[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 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 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 #[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 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 #[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}