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::registry::SliceSet;
50use crate::report::{CheckId, DoctorFinding, DoctorSeverity, FieldReport, FieldRow};
51
52pub const DEFAULT_MAX_PATHS: usize = 512;
57
58pub const DISTINCT_CAP: usize = 8;
60
61const DISTINCT_VALUE_CAP: usize = 64;
64
65pub const STUCK_TTL_FACTOR: f64 = 3.0;
69
70pub const VANISHED_MIN_ABSENT: u64 = 3;
73
74const STUCK_MIN_SEEN: u64 = 3;
77
78const DROPPED_EXAMPLE_CAP: usize = 5;
81
82pub const ROOT_PATH: &str = "$";
85
86#[derive(Debug, Clone)]
96pub struct FieldObservation {
97 max_paths: usize,
98 keys: BTreeMap<String, KeyFields>,
99 paths: usize,
100 dropped: Examples<String>,
103}
104
105#[derive(Debug, Clone, Default)]
107pub struct KeyFields {
108 pub documents: u64,
111 pub undocumented: u64,
114 pub unread: u64,
118 pub paths: BTreeMap<String, PathStats>,
120}
121
122#[derive(Debug, Clone)]
124pub struct PathStats {
125 pub seen: u64,
127 pub first_at_s: f64,
129 pub last_at_s: f64,
130 pub last_seen_sample: u64,
133 pub kinds: BTreeMap<&'static str, u64>,
135 pub changes: u64,
137 pub last_change_at_s: Option<f64>,
139 pub num_min: Option<f64>,
141 pub num_max: Option<f64>,
142 pub num_last: Option<f64>,
143 pub distinct: BTreeSet<String>,
146 pub distinct_overflow: bool,
149 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 pub fn observe_unread(&mut self, key: &str) {
224 self.keys.entry(key.to_string()).or_default().unread += 1;
225 }
226
227 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 None => self.dropped.push_with(|| format!("{key} · {path}")),
253 }
254 }
255 }
256
257 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 pub fn paths(&self) -> usize {
268 self.paths
269 }
270
271 pub fn max_paths(&self) -> usize {
272 self.max_paths
273 }
274
275 pub fn dropped_paths(&self) -> u64 {
277 self.dropped.total() as u64
278 }
279
280 pub fn dropped_examples(&self) -> &[String] {
284 self.dropped.as_slice()
285 }
286
287 pub fn undocumented(&self) -> u64 {
289 self.keys.values().map(|k| k.undocumented).sum()
290 }
291}
292
293fn 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
305pub 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#[derive(Debug, Clone, Default, PartialEq, Eq)]
340pub struct DeclaredPaths {
341 declared: BTreeSet<String>,
342 open: BTreeSet<String>,
345}
346
347impl DeclaredPaths {
348 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 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 pub fn accounts_for(&self, path: &str) -> bool {
380 if path == ROOT_PATH || self.declared.contains(path) {
381 return true;
382 }
383 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#[derive(Debug, Clone, Default)]
404pub struct KeyFieldContext {
405 pub ttl_s: Option<i64>,
408 pub type_name: Option<String>,
410 pub declared: Option<DeclaredPaths>,
412}
413
414pub 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
421pub 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
437pub fn judge_new(path: &str, declared: Option<&DeclaredPaths>) -> bool {
441 declared.is_some_and(|d| !d.accounts_for(path))
442}
443
444pub 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#[derive(Debug, Clone)]
545pub struct FieldSpec {
546 pub selector: String,
548 pub window: Duration,
550 pub max_paths: usize,
552}
553
554pub 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 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 let mut facts = crate::model::facts::FactsCache::default();
585
586 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 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 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
663pub(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 #[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 #[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 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 #[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 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 #[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 #[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 #[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 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 #[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}