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 all_integral: bool,
166 pub distinct: BTreeSet<String>,
169 pub distinct_overflow: bool,
172 last_fingerprint: Option<u64>,
174}
175
176impl PathStats {
177 fn new(at_s: f64, sample: u64) -> PathStats {
178 PathStats {
179 seen: 0,
180 first_at_s: at_s,
181 last_at_s: at_s,
182 last_seen_sample: sample,
183 kinds: BTreeMap::new(),
184 changes: 0,
185 last_change_at_s: None,
186 num_min: None,
187 num_max: None,
188 num_last: None,
189 all_integral: true,
190 distinct: BTreeSet::new(),
191 distinct_overflow: false,
192 last_fingerprint: None,
193 }
194 }
195
196 fn observe(&mut self, at_s: f64, sample: u64, value: &Value) {
197 self.seen += 1;
198 self.last_at_s = at_s;
199 self.last_seen_sample = sample;
200 *self.kinds.entry(kind_of(value)).or_default() += 1;
201 let canonical = serde_json::to_string(value).unwrap_or_default();
202 let fingerprint = {
203 let mut h = std::collections::hash_map::DefaultHasher::new();
204 canonical.hash(&mut h);
205 h.finish()
206 };
207 if let Some(prev) = self.last_fingerprint
208 && prev != fingerprint
209 {
210 self.changes += 1;
211 self.last_change_at_s = Some(at_s);
212 }
213 self.last_fingerprint = Some(fingerprint);
214 if let Some(n) = value.as_f64() {
215 self.num_min = Some(self.num_min.map_or(n, |m| m.min(n)));
216 self.num_max = Some(self.num_max.map_or(n, |m| m.max(n)));
217 self.num_last = Some(n);
218 if n.fract() != 0.0 {
219 self.all_integral = false;
220 }
221 }
222 if !self.distinct_overflow {
223 if canonical.len() > DISTINCT_VALUE_CAP {
224 self.distinct_overflow = true;
225 self.distinct.clear();
226 } else {
227 self.distinct.insert(canonical);
228 if self.distinct.len() > DISTINCT_CAP {
229 self.distinct_overflow = true;
230 self.distinct.clear();
231 }
232 }
233 }
234 }
235}
236
237impl FieldObservation {
238 pub fn new(max_paths: usize) -> FieldObservation {
239 FieldObservation {
240 max_paths: max_paths.max(1),
241 keys: BTreeMap::new(),
242 paths: 0,
243 dropped: Examples::new(DROPPED_EXAMPLE_CAP),
244 }
245 }
246
247 pub fn observe_unread(&mut self, key: &str) {
251 self.keys.entry(key.to_string()).or_default().unread += 1;
252 }
253
254 pub fn unread(&self) -> u64 {
256 self.keys.values().map(|k| k.unread).sum()
257 }
258
259 pub fn observe(&mut self, key: &str, at_s: f64, doc: Option<&Value>) {
260 let entry = self.keys.entry(key.to_string()).or_default();
261 let Some(doc) = doc else {
262 entry.undocumented += 1;
263 return;
264 };
265 entry.documents += 1;
266 let sample = entry.documents;
267 let mut leaves = Vec::new();
268 flatten(doc, &mut leaves);
269 for (path, value) in leaves {
270 match entry.paths.get_mut(&path) {
271 Some(stats) => stats.observe(at_s, sample, value),
272 None if self.paths < self.max_paths => {
273 let mut stats = PathStats::new(at_s, sample);
274 stats.observe(at_s, sample, value);
275 entry.paths.insert(path, stats);
276 self.paths += 1;
277 }
278 None => self.dropped.push_with(|| format!("{key} · {path}")),
280 }
281 }
282 }
283
284 pub fn iter(&self) -> impl Iterator<Item = (&str, &KeyFields)> {
286 self.keys.iter().map(|(k, v)| (k.as_str(), v))
287 }
288
289 pub fn keys_seen(&self) -> usize {
290 self.keys.len()
291 }
292
293 pub fn paths(&self) -> usize {
295 self.paths
296 }
297
298 pub fn max_paths(&self) -> usize {
299 self.max_paths
300 }
301
302 pub fn dropped_paths(&self) -> u64 {
304 self.dropped.total() as u64
305 }
306
307 pub fn dropped_examples(&self) -> &[String] {
311 self.dropped.as_slice()
312 }
313
314 pub fn undocumented(&self) -> u64 {
316 self.keys.values().map(|k| k.undocumented).sum()
317 }
318}
319
320fn kind_of(v: &Value) -> &'static str {
322 match v {
323 Value::Null => "null",
324 Value::Bool(_) => "bool",
325 Value::Number(_) => "number",
326 Value::String(_) => "string",
327 Value::Array(_) => "array",
328 Value::Object(_) => "object",
329 }
330}
331
332pub fn flatten<'v>(doc: &'v Value, out: &mut Vec<(String, &'v Value)>) {
338 fn walk<'v>(prefix: &str, v: &'v Value, out: &mut Vec<(String, &'v Value)>) {
339 match v {
340 Value::Object(map) if !map.is_empty() => {
341 for (name, child) in map {
342 let path = if prefix.is_empty() {
343 name.clone()
344 } else {
345 format!("{prefix}.{name}")
346 };
347 walk(&path, child, out);
348 }
349 }
350 leaf => out.push(if prefix.is_empty() {
351 (ROOT_PATH.to_string(), leaf)
352 } else {
353 (prefix.to_string(), leaf)
354 }),
355 }
356 }
357 walk("", doc, out);
358}
359
360#[derive(Debug, Clone, Default, PartialEq, Eq)]
368pub struct DeclaredPaths {
369 declared: BTreeSet<String>,
370 open: BTreeSet<String>,
373}
374
375impl DeclaredPaths {
376 pub fn from_json_schema(doc: &Value) -> Option<DeclaredPaths> {
402 let mut out = DeclaredPaths::default();
403 let mut walk = Walk {
404 root: doc,
405 visiting: BTreeSet::new(),
406 budget: SCHEMA_NODE_BUDGET,
407 root_open: false,
408 };
409 walk.node(doc, "", 0, &mut out);
410 (!walk.root_open && !out.declared.is_empty()).then_some(out)
411 }
412
413 pub fn accounts_for(&self, path: &str) -> bool {
416 if path == ROOT_PATH || self.declared.contains(path) {
417 return true;
418 }
419 let mut prefix = String::new();
421 for chunk in path.split('.') {
422 if !prefix.is_empty() {
423 prefix.push('.');
424 }
425 prefix.push_str(chunk);
426 if self.open.contains(&prefix) {
427 return true;
428 }
429 }
430 false
431 }
432}
433
434struct Walk<'d> {
438 root: &'d Value,
439 visiting: BTreeSet<String>,
440 budget: usize,
441 root_open: bool,
444}
445
446impl Walk<'_> {
447 fn open(&mut self, prefix: &str, out: &mut DeclaredPaths) {
450 if prefix.is_empty() {
451 self.root_open = true;
452 } else {
453 out.open.insert(prefix.to_string());
454 }
455 }
456
457 fn node(&mut self, node: &Value, prefix: &str, depth: usize, out: &mut DeclaredPaths) {
460 if depth > SCHEMA_DEPTH_CAP || self.budget == 0 {
463 self.open(prefix, out);
464 return;
465 }
466 self.budget -= 1;
467
468 let Some(obj) = node.as_object() else {
472 return;
473 };
474
475 let mut described = false;
478
479 if let Some(pointer) = obj.get("$ref").and_then(Value::as_str) {
480 match resolve_ref(self.root, pointer) {
481 Some(target) if !self.visiting.contains(pointer) => {
482 self.visiting.insert(pointer.to_string());
483 self.node(target, prefix, depth + 1, out);
484 self.visiting.remove(pointer);
485 described = true;
486 }
487 _ => self.open(prefix, out),
490 }
491 }
492
493 for combinator in COMBINATORS {
499 if let Some(arms) = obj.get(combinator).and_then(Value::as_array) {
500 for arm in arms {
501 self.node(arm, prefix, depth + 1, out);
502 }
503 described = true;
504 }
505 }
506
507 if let Some(props) = obj.get("properties").and_then(Value::as_object) {
508 for (name, child) in props {
509 let path = if prefix.is_empty() {
510 name.clone()
511 } else {
512 format!("{prefix}.{name}")
513 };
514 self.node(child, &path, depth + 1, out);
515 out.declared.insert(path);
516 }
517 described = true;
518 }
519
520 if !described && obj.get("type").and_then(Value::as_str) == Some("object") {
523 self.open(prefix, out);
524 }
525 }
526}
527
528#[derive(Debug, Clone, Default)]
534pub struct KeyFieldContext {
535 pub ttl_s: Option<i64>,
538 pub type_name: Option<String>,
540 pub declared: Option<DeclaredPaths>,
542}
543
544pub fn judge_vanished(stats: &PathStats, key_documents: u64) -> bool {
548 stats.seen > 0 && key_documents.saturating_sub(stats.last_seen_sample) >= VANISHED_MIN_ABSENT
549}
550
551pub fn judge_stuck(stats: &PathStats, ttl_s: Option<i64>) -> bool {
557 let Some(ttl) = ttl_s.filter(|t| *t > 0) else {
558 return false;
559 };
560 stats.changes == 0
561 && stats.seen >= STUCK_MIN_SEEN
562 && stats.kinds.len() == 1
563 && stats.kinds.contains_key("number")
564 && (stats.last_at_s - stats.first_at_s) >= STUCK_TTL_FACTOR * ttl as f64
565}
566
567pub fn judge_new(path: &str, declared: Option<&DeclaredPaths>) -> bool {
571 declared.is_some_and(|d| !d.accounts_for(path))
572}
573
574pub fn judge_fields(
578 obs: &FieldObservation,
579 window_s: f64,
580 ctx: &BTreeMap<String, KeyFieldContext>,
581) -> Vec<DoctorFinding> {
582 let empty = KeyFieldContext::default();
583
584 let mut vanished = Examples::new(FINDING_CAP);
585
586 let mut stuck = Examples::new(FINDING_CAP);
587
588 let mut new = Examples::new(FINDING_CAP);
589
590 for (key, fields) in obs.iter() {
591 let c = ctx.get(key).unwrap_or(&empty);
592 for (path, stats) in &fields.paths {
593 if judge_vanished(stats, fields.documents) {
594 vanished.push_with(|| DoctorFinding {
595 severity: DoctorSeverity::Warning,
596 check: CheckId::FieldVanished,
597 subject: format!("{key} · {path}"),
598 evidence: format!(
599 "present in {} of {} document sample(s) in {window_s:.0}s, absent \
600 from the last {} — seen, then gone; a schema that declares it \
601 optional reads Valid without it by construction",
602 stats.seen,
603 fields.documents,
604 fields.documents - stats.last_seen_sample
605 ),
606 citation: None,
607 });
608 }
609 if judge_stuck(stats, c.ttl_s) {
610 let ttl = c.ttl_s.unwrap_or(0);
611 stuck.push_with(|| DoctorFinding {
612 severity: DoctorSeverity::Warning,
613 check: CheckId::FieldStuck,
614 subject: format!("{key} · {path}"),
615 evidence: format!(
616 "value {} unchanged across {} sample(s) spanning {:.1}s — at least \
617 {STUCK_TTL_FACTOR:.0}× the declared ttl_s {ttl}s — while the key \
618 kept publishing. An observation over this {window_s:.0}s window, \
619 not a verdict: a constant-by-design field always reads this way",
620 stats
621 .num_last
622 .map(|n| n.to_string())
623 .unwrap_or_else(|| "?".into()),
624 stats.seen,
625 stats.last_at_s - stats.first_at_s,
626 ),
627 citation: Some("RFC 04 §1.2".into()),
628 });
629 }
630 if judge_new(path, c.declared.as_ref()) {
631 new.push_with(|| DoctorFinding {
632 severity: DoctorSeverity::Warning,
633 check: CheckId::FieldNew,
634 subject: format!("{key} · {path}"),
635 evidence: format!(
636 "present in {} of {} document sample(s) but never declared by the \
637 served schema{} — schema drift at field granularity",
638 stats.seen,
639 fields.documents,
640 c.type_name
641 .as_deref()
642 .map(|t| format!(" for {t}"))
643 .unwrap_or_default()
644 ),
645 citation: Some("RFC 08 §7".into()),
646 });
647 }
648 }
649 }
650 let mut findings = Vec::new();
651 for (check, hits) in [
652 (CheckId::FieldVanished, vanished),
653 (CheckId::FieldStuck, stuck),
654 (CheckId::FieldNew, new),
655 ] {
656 let more = hits.more("more path(s) with the same finding");
657 findings.extend(hits.into_vec());
658 if let Some(evidence) = more {
659 findings.push(DoctorFinding {
660 severity: DoctorSeverity::Info,
661 check,
662 subject: "fleet".into(),
663 evidence,
664 citation: None,
665 });
666 }
667 }
668 findings
669}
670
671#[derive(Debug, Clone)]
675pub struct FieldSpec {
676 pub selector: String,
678 pub window: Duration,
680 pub max_paths: usize,
682}
683
684pub async fn run_field(
690 fleet: &crate::Fleet<'_>,
691 slices: Option<&SliceSet>,
692 store: &SchemaStore,
693 spec: &FieldSpec,
694) -> Result<FieldReport> {
695 use crate::{FleetEvent, StreamItem};
696
697 let (session, base) = (fleet.session(), fleet.base());
698
699 let monitor = crate::Monitor::start(session, crate::MonitorSpec::default()).await?;
700
701 let mut events = monitor.events();
702
703 let monitor = monitor.watching([spec.selector.as_str()]).await?;
706 let opened = tokio::time::Instant::now();
707 let deadline = opened + spec.window;
708
709 let mut obs = FieldObservation::new(spec.max_paths);
710 let mut samples: u64 = 0;
711 let mut dropped: u64 = 0;
712 let mut facts = crate::model::facts::FactsCache::default();
715
716 let window_over = tokio::time::sleep_until(deadline);
722 tokio::pin!(window_over);
723 loop {
724 let item = tokio::select! {
725 item = events.recv() => item,
726 () = &mut window_over => break,
727 };
728 match item {
729 Some(StreamItem::Event(FleetEvent::Sample(s))) => {
730 samples += 1;
731 let bytes = s.payload.to_bytes();
734 if bytes.len() > crate::model::decode::OBSERVE_LIMIT {
735 obs.observe_unread(&s.key);
736 } else {
737 let doc = crate::model::decode::structural_value(&bytes);
738 obs.observe(&s.key, opened.elapsed().as_secs_f64(), doc.as_ref());
739 }
740 facts.ensure(base, &s.key, slices);
741 }
742 Some(StreamItem::Dropped(n)) => dropped += n,
743 Some(_) => continue,
744 None => break,
745 }
746 }
747 monitor.shutdown().await?;
748
749 let window_s = spec.window.as_secs_f64();
750 let ctx = field_context(session, store, slices, &facts).await;
751 let findings = judge_fields(&obs, window_s, &ctx);
752
753 let mut rows = Vec::new();
754 for (key, fields) in obs.iter() {
755 for (path, stats) in &fields.paths {
756 rows.push(FieldRow {
757 key: key.to_string(),
758 path: path.clone(),
759 seen: stats.seen,
760 documents: fields.documents,
761 kinds: stats.kinds.keys().map(|k| k.to_string()).collect(),
762 changes: stats.changes,
763 last_change_s: stats.last_change_at_s,
764 min: stats.num_min,
765 max: stats.num_max,
766 last: stats.num_last,
767 values: (!stats.distinct_overflow)
769 .then(|| stats.distinct.iter().cloned().collect()),
770 });
771 }
772 }
773
774 Ok(FieldReport {
775 selector: spec.selector.clone(),
776 window_s,
777 samples,
778 keys_seen: obs.keys_seen(),
779 dropped,
780 undocumented: obs.undocumented(),
781 unread: obs.unread(),
782 registry_loaded: slices.is_some(),
783 paths: obs.paths(),
784 max_paths: obs.max_paths(),
785 paths_dropped: obs.dropped_paths(),
786 paths_dropped_examples: obs.dropped_examples().to_vec(),
787 facts_evicted: facts.evicted(),
788 rows,
789 findings,
790 })
791}
792
793pub(crate) async fn field_context(
797 session: &Session,
798 store: &SchemaStore,
799 slices: Option<&SliceSet>,
800 facts: &crate::model::facts::FactsCache,
801) -> BTreeMap<String, KeyFieldContext> {
802 let mut declared_cache: BTreeMap<(String, String), Option<DeclaredPaths>> = BTreeMap::new();
803
804 let mut ctx = BTreeMap::new();
805
806 for (key, f) in facts.iter() {
807 let mut c = KeyFieldContext::default();
808 if let crate::model::facts::Registration::Registered(sf) = &f.registration {
809 c.ttl_s = sf.ttl_s;
810 c.type_name = Some(sf.type_name.clone());
811 if let Some(producer) = producer_of(f, slices)
812 && !sf.type_name.is_empty()
813 {
814 let cache_key = (producer.clone(), sf.type_name.clone());
815 if !declared_cache.contains_key(&cache_key) {
816 let declared = store
817 .schema_for(session, &producer, &sf.type_name)
818 .await
819 .and_then(|schema| {
820 schema
821 .json_document()
822 .and_then(DeclaredPaths::from_json_schema)
823 });
824 declared_cache.insert(cache_key.clone(), declared);
825 }
826 c.declared = declared_cache.get(&cache_key).cloned().flatten();
827 }
828 }
829 ctx.insert(key.to_string(), c);
830 }
831 ctx
832}
833
834#[cfg(test)]
835mod tests {
836 use super::*;
837 use serde_json::json;
838
839 fn observe_docs(obs: &mut FieldObservation, key: &str, docs: &[(f64, Value)]) {
840 for (at, doc) in docs {
841 obs.observe(key, *at, Some(doc));
842 }
843 }
844
845 #[test]
848 fn flattening_recurses_objects_and_stops_at_arrays() {
849 let doc = json!({"a": {"b": 1, "c": [1, 2]}, "d": "x", "e": {}});
850 let mut leaves = Vec::new();
851 flatten(&doc, &mut leaves);
852 let paths: Vec<&str> = leaves.iter().map(|(p, _)| p.as_str()).collect();
853 assert_eq!(paths, ["a.b", "a.c", "d", "e"]);
854
855 let scalar = json!(42.0);
856 let mut leaves = Vec::new();
857 flatten(&scalar, &mut leaves);
858 assert_eq!(leaves.len(), 1);
859 assert_eq!(leaves[0].0, ROOT_PATH);
860 }
861
862 #[test]
865 fn the_path_table_is_bounded_and_reports_what_it_dropped() {
866 let mut obs = FieldObservation::new(4);
867 let wide: serde_json::Map<String, Value> =
868 (0..20).map(|i| (format!("f{i:02}"), json!(i))).collect();
869 obs.observe("k", 0.0, Some(&Value::Object(wide)));
870 assert_eq!(obs.paths(), 4, "the bound holds");
871 assert_eq!(obs.dropped_paths(), 16, "every refusal is counted");
872 assert!(
873 obs.dropped_examples().iter().any(|e| e.contains("k · f04")),
874 "refused paths are named: {:?}",
875 obs.dropped_examples()
876 );
877 obs.observe("k", 1.0, Some(&json!({"f00": 9})));
879 let (_, fields) = obs.iter().next().unwrap();
880 assert_eq!(fields.paths["f00"].seen, 2);
881 }
882
883 #[test]
886 fn vanished_needs_seen_then_absent() {
887 let mut obs = FieldObservation::new(64);
888 let with = json!({"seq": 1, "opt": true});
889 let without = json!({"seq": 2});
890 observe_docs(
891 &mut obs,
892 "k",
893 &[
894 (0.0, with),
895 (1.0, without.clone()),
896 (2.0, without.clone()),
897 (3.0, without.clone()),
898 (4.0, without),
899 ],
900 );
901 let (_, fields) = obs.iter().next().unwrap();
902 assert!(judge_vanished(&fields.paths["opt"], fields.documents));
903 assert!(
904 !judge_vanished(&fields.paths["seq"], fields.documents),
905 "a path present in the last sample has not vanished"
906 );
907 let findings = judge_fields(&obs, 5.0, &BTreeMap::new());
908 let vanished: Vec<_> = findings
909 .iter()
910 .filter(|f| f.check == CheckId::FieldVanished)
911 .collect();
912 assert_eq!(vanished.len(), 1, "{findings:?}");
913 assert!(vanished[0].subject.ends_with("· opt"));
914 assert!(
915 vanished[0].evidence.contains("1 of 5"),
916 "presence is counted: {}",
917 vanished[0].evidence
918 );
919 let mut obs = FieldObservation::new(64);
921 observe_docs(
922 &mut obs,
923 "k",
924 &[
925 (0.0, json!({"opt": 1})),
926 (1.0, json!({})),
927 (2.0, json!({"opt": 1})),
928 ],
929 );
930 assert!(
931 judge_fields(&obs, 3.0, &BTreeMap::new())
932 .iter()
933 .all(|f| f.check != CheckId::FieldVanished)
934 );
935 }
936
937 #[test]
942 fn stuck_is_numeric_ttl_relative_and_suppressed_without_a_ttl() {
943 let mut obs = FieldObservation::new(64);
944 let docs: Vec<(f64, Value)> = (0..8)
945 .map(|i| {
946 (
947 i as f64,
948 json!({"temperature_c": 21.5, "seq": i, "host": "web-1"}),
949 )
950 })
951 .collect();
952 observe_docs(&mut obs, "k", &docs);
953 let (_, fields) = obs.iter().next().unwrap();
954 assert!(judge_stuck(&fields.paths["temperature_c"], Some(1)));
955 assert!(
956 !judge_stuck(&fields.paths["seq"], Some(1)),
957 "a changing numeric is not stuck"
958 );
959 assert!(
960 !judge_stuck(&fields.paths["host"], Some(1)),
961 "a constant string is constant by design, not stuck"
962 );
963 assert!(
964 !judge_stuck(&fields.paths["temperature_c"], None),
965 "no declared ttl_s: nothing to be long relative to (O4)"
966 );
967 assert!(
968 !judge_stuck(&fields.paths["temperature_c"], Some(10)),
969 "a 7s span is not long relative to a 10s ttl"
970 );
971
972 let ctx: BTreeMap<String, KeyFieldContext> = [(
973 "k".to_string(),
974 KeyFieldContext {
975 ttl_s: Some(1),
976 ..KeyFieldContext::default()
977 },
978 )]
979 .into();
980 let findings = judge_fields(&obs, 8.0, &ctx);
981 let stuck: Vec<_> = findings
982 .iter()
983 .filter(|f| f.check == CheckId::FieldStuck)
984 .collect();
985 assert_eq!(stuck.len(), 1, "{findings:?}");
986 assert!(stuck[0].subject.ends_with("· temperature_c"));
987 assert!(stuck[0].evidence.contains("21.5"), "{}", stuck[0].evidence);
988 assert!(
989 stuck[0].evidence.contains("not a verdict"),
990 "stuck is an observation with a stated window: {}",
991 stuck[0].evidence
992 );
993 assert!(
994 stuck[0].evidence.contains("ttl_s 1s"),
995 "the ttl it is relative to is stated: {}",
996 stuck[0].evidence
997 );
998 }
999
1000 #[test]
1004 fn new_is_judged_only_against_a_declaring_schema() {
1005 let declared = DeclaredPaths::from_json_schema(&json!({
1006 "type": "object",
1007 "properties": {
1008 "seq": {"type": "number"},
1009 "nested": {"type": "object", "properties": {"x": {"type": "number"}}},
1010 "freeform": {"type": "object"},
1011 },
1012 }))
1013 .expect("the schema enumerates properties");
1014 assert!(!judge_new("seq", Some(&declared)));
1015 assert!(!judge_new("nested.x", Some(&declared)));
1016 assert!(judge_new("extra", Some(&declared)));
1017 assert!(judge_new("nested.y", Some(&declared)));
1018 assert!(
1019 !judge_new("freeform.anything.at.all", Some(&declared)),
1020 "a free-form subtree is unjudgeable, not new"
1021 );
1022 assert!(!judge_new("extra", None), "no schema, no finding (O4)");
1023 assert_eq!(
1024 DeclaredPaths::from_json_schema(&json!({"type": "object"})),
1025 None,
1026 "a schema with no properties judges nothing"
1027 );
1028
1029 let mut obs = FieldObservation::new(64);
1030 observe_docs(&mut obs, "k", &[(0.0, json!({"seq": 1, "extra": 2}))]);
1031 let ctx: BTreeMap<String, KeyFieldContext> = [(
1032 "k".to_string(),
1033 KeyFieldContext {
1034 type_name: Some("Health".into()),
1035 declared: Some(declared),
1036 ..KeyFieldContext::default()
1037 },
1038 )]
1039 .into();
1040 let findings = judge_fields(&obs, 1.0, &ctx);
1041 let new: Vec<_> = findings
1042 .iter()
1043 .filter(|f| f.check == CheckId::FieldNew)
1044 .collect();
1045 assert_eq!(new.len(), 1, "{findings:?}");
1046 assert!(new[0].subject.ends_with("· extra"));
1047 assert!(new[0].evidence.contains("Health"), "{}", new[0].evidence);
1048 assert_eq!(new[0].citation.as_deref(), Some("RFC 08 §7"));
1049 }
1050
1051 #[test]
1058 fn a_tagged_enum_declares_its_variant_fields_rather_than_drifting() {
1059 let doc = json!({
1060 "$schema": "https://json-schema.org/draft/2020-12/schema",
1061 "title": "TelemetryPoint",
1062 "type": "object",
1063 "properties": {
1064 "ts_ns": {"type": "integer", "format": "uint64"},
1065 "value": {"$ref": "#/$defs/TelemetryValue"},
1066 },
1067 "required": ["ts_ns", "value"],
1068 "$defs": {
1069 "TelemetryValue": {
1070 "description": "Typed telemetry value.",
1071 "oneOf": [
1072 {"type": "object",
1073 "properties": {"type": {"const": "counter", "type": "string"},
1074 "value": {"format": "uint64", "type": "integer"}},
1075 "required": ["type", "value"]},
1076 {"type": "object",
1077 "properties": {"type": {"const": "gauge", "type": "string"},
1078 "value": {"format": "double", "type": "number"}},
1079 "required": ["type", "value"]},
1080 {"type": "object",
1081 "properties": {"type": {"const": "text", "type": "string"},
1082 "value": {"type": "string"}},
1083 "required": ["type", "value"]},
1084 ],
1085 },
1086 },
1087 });
1088 let declared =
1089 DeclaredPaths::from_json_schema(&doc).expect("the schema enumerates properties");
1090
1091 assert!(!judge_new("ts_ns", Some(&declared)));
1092 assert!(
1093 !judge_new("value.type", Some(&declared)),
1094 "the tag is declared by every branch"
1095 );
1096 assert!(
1097 !judge_new("value.value", Some(&declared)),
1098 "the content is declared by every branch"
1099 );
1100 assert!(judge_new("value.unit", Some(&declared)));
1103 assert!(judge_new("extra", Some(&declared)));
1104 }
1105
1106 #[test]
1110 fn a_ref_into_defs_resolves_and_its_fields_are_declared() {
1111 let declared = DeclaredPaths::from_json_schema(&json!({
1112 "type": "object",
1113 "properties": {"cpu": {"$ref": "#/$defs/Cpu"}},
1114 "$defs": {
1115 "Cpu": {
1116 "type": "object",
1117 "properties": {
1118 "usage": {"type": "number"},
1119 "core": {"$ref": "#/$defs/Core"},
1120 },
1121 },
1122 "Core": {"type": "object", "properties": {"id": {"type": "integer"}}},
1123 },
1124 }))
1125 .expect("the schema enumerates properties");
1126
1127 assert!(!judge_new("cpu.usage", Some(&declared)));
1128 assert!(
1129 !judge_new("cpu.core.id", Some(&declared)),
1130 "a $ref inside a $ref resolves too"
1131 );
1132 assert!(judge_new("cpu.missing", Some(&declared)));
1133 }
1134
1135 #[test]
1139 fn an_unfollowable_ref_opens_its_subtree_rather_than_condemning_it() {
1140 for pointer in [
1141 "#/$defs/Absent", "https://example.invalid/Thing.json", ] {
1144 let declared = DeclaredPaths::from_json_schema(&json!({
1145 "type": "object",
1146 "properties": {
1147 "seq": {"type": "number"},
1148 "opaque": {"$ref": pointer},
1149 },
1150 }))
1151 .expect("the schema still enumerates `seq`");
1152 assert!(!judge_new("seq", Some(&declared)));
1153 assert!(
1154 !judge_new("opaque.anything.at.all", Some(&declared)),
1155 "{pointer}: an unresolvable $ref is unjudgeable, not new"
1156 );
1157 }
1158 }
1159
1160 #[test]
1163 fn a_recursive_ref_terminates_and_opens_where_it_stops() {
1164 let declared = DeclaredPaths::from_json_schema(&json!({
1165 "$ref": "#/$defs/Node",
1166 "$defs": {
1167 "Node": {
1168 "type": "object",
1169 "properties": {
1170 "name": {"type": "string"},
1171 "parent": {"anyOf": [{"$ref": "#/$defs/Node"}, {"type": "null"}]},
1172 },
1173 },
1174 },
1175 }))
1176 .expect("the schema enumerates properties");
1177
1178 assert!(!judge_new("name", Some(&declared)));
1179 assert!(!judge_new("parent", Some(&declared)));
1180 assert!(
1181 !judge_new("parent.name", Some(&declared)),
1182 "the cycle stops at `parent`, and what it could not enumerate is open"
1183 );
1184 }
1185
1186 #[test]
1189 fn all_of_arms_are_unioned() {
1190 let declared = DeclaredPaths::from_json_schema(&json!({
1191 "allOf": [
1192 {"type": "object", "properties": {"a": {"type": "number"}}},
1193 {"type": "object", "properties": {"b": {"type": "string"}}},
1194 ],
1195 }))
1196 .expect("the arms enumerate properties");
1197
1198 assert!(!judge_new("a", Some(&declared)));
1199 assert!(!judge_new("b", Some(&declared)));
1200 assert!(judge_new("c", Some(&declared)));
1201 }
1202
1203 #[test]
1207 fn a_document_that_enumerates_nothing_still_judges_nothing() {
1208 for doc in [
1209 json!({"type": "object"}),
1210 json!({}),
1211 json!({"$ref": "#/$defs/Absent"}),
1212 json!({"oneOf": [{"type": "string"}, {"type": "number"}]}),
1213 json!(true),
1214 ] {
1215 assert_eq!(
1216 DeclaredPaths::from_json_schema(&doc),
1217 None,
1218 "nothing enumerated, nothing judged: {doc}"
1219 );
1220 }
1221 }
1222
1223 #[test]
1230 fn a_fanning_combinator_tree_terminates_within_its_budget() {
1231 const LEVELS: usize = 30;
1232 let mut defs = serde_json::Map::new();
1233 for level in 0..LEVELS {
1234 let next = json!({"$ref": format!("#/$defs/L{}", level + 1)});
1236 defs.insert(format!("L{level}"), json!({"anyOf": [next.clone(), next]}));
1237 }
1238 defs.insert(
1239 format!("L{LEVELS}"),
1240 json!({"type": "object", "properties": {"leaf": {"type": "number"}}}),
1241 );
1242 let doc = json!({
1243 "type": "object",
1244 "properties": {"seq": {"type": "number"}, "deep": {"$ref": "#/$defs/L0"}},
1245 "$defs": Value::Object(defs),
1246 });
1247
1248 let declared = DeclaredPaths::from_json_schema(&doc).expect("`seq` is enumerated");
1252 assert!(!judge_new("seq", Some(&declared)));
1253 assert!(
1254 !judge_new("deep.leaf", Some(&declared)),
1255 "a subtree the budget could not finish is unjudgeable, not new"
1256 );
1257 assert!(
1258 judge_new("absent", Some(&declared)),
1259 "the check still works"
1260 );
1261 }
1262
1263 #[test]
1266 fn distinct_values_are_total_or_flagged_overflowed() {
1267 let mut obs = FieldObservation::new(8);
1268 for i in 0..3 {
1269 obs.observe("k", i as f64, Some(&json!({"mode": format!("m{}", i % 2)})));
1270 }
1271 let (_, fields) = obs.iter().next().unwrap();
1272 let stats = &fields.paths["mode"];
1273 assert!(!stats.distinct_overflow);
1274 assert_eq!(stats.distinct.len(), 2);
1275
1276 let mut obs = FieldObservation::new(8);
1277 for i in 0..20 {
1278 obs.observe("k", i as f64, Some(&json!({"mode": i})));
1279 }
1280 let (_, fields) = obs.iter().next().unwrap();
1281 let stats = &fields.paths["mode"];
1282 assert!(stats.distinct_overflow, "20 values are not a small domain");
1283 assert!(
1284 stats.distinct.is_empty(),
1285 "an overflowed set is cleared, not silently partial"
1286 );
1287 assert_eq!(stats.num_min, Some(0.0));
1289 assert_eq!(stats.num_max, Some(19.0));
1290 assert_eq!(stats.changes, 19);
1291 }
1292
1293 #[test]
1296 fn undocumented_samples_do_not_fake_a_vanish() {
1297 let mut obs = FieldObservation::new(8);
1298 obs.observe("k", 0.0, Some(&json!({"opt": 1})));
1299 for i in 1..6 {
1300 obs.observe("k", i as f64, None);
1301 }
1302 let (_, fields) = obs.iter().next().unwrap();
1303 assert_eq!(fields.documents, 1);
1304 assert_eq!(fields.undocumented, 5);
1305 assert!(
1306 judge_fields(&obs, 6.0, &BTreeMap::new())
1307 .iter()
1308 .all(|f| f.check != CheckId::FieldVanished),
1309 "five undocumented samples are five unobservables, not a vanish"
1310 );
1311 }
1312}