zenkey_fleet/report/field.rs
1//! The field plane (#223): per-path observations inside a payload — what
2//! moved, what never did, and which paths the cap turned away.
3
4use super::asked::u64_is_zero;
5use super::doctor::DoctorFinding;
6use serde::Serialize;
7
8/// One dotted path's statistics over a `zenctl field` window (#223) — the
9/// per-field arrival story validation cannot tell.
10#[derive(Debug, Clone, Serialize)]
11pub struct FieldRow {
12 /// The concrete wire key the path was observed under.
13 pub key: String,
14 /// The dotted path inside the structural value (`$` = a non-object root).
15 pub path: String,
16 /// Document samples in which the path was present.
17 pub seen: u64,
18 /// The key's document samples — the presence ratio's denominator.
19 pub documents: u64,
20 /// JSON kinds observed (one entry = type-stable).
21 pub kinds: Vec<String>,
22 /// Times the value differed from its previous observation.
23 pub changes: u64,
24 /// Window-relative seconds of the last change; absent = never changed
25 /// within the window (which the stated window scopes — not "never").
26 #[serde(skip_serializing_if = "Option::is_none")]
27 pub last_change_s: Option<f64>,
28 /// Numeric min/max/last, present only when the path carried numbers.
29 #[serde(skip_serializing_if = "Option::is_none")]
30 pub min: Option<f64>,
31 #[serde(skip_serializing_if = "Option::is_none")]
32 pub max: Option<f64>,
33 #[serde(skip_serializing_if = "Option::is_none")]
34 pub last: Option<f64>,
35 /// Small-domain distinct values, total when present; absent = the domain
36 /// outgrew the cap (stated, never silently partial).
37 #[serde(skip_serializing_if = "Option::is_none")]
38 pub values: Option<Vec<String>>,
39}
40
41/// The `zenctl field` report (#223): bounded per-path statistics over one
42/// window, plus the `field-vanished`/`field-stuck`/`field-new` findings.
43/// Every bound states its cost (RFC 09 §5.1 O6), and "not asked" — no
44/// registry, no served schema, no structural document — never reads as "no"
45/// (O4).
46#[derive(Debug, Clone, Serialize)]
47pub struct FieldReport {
48 pub selector: String,
49 pub window_s: f64,
50 pub samples: u64,
51 pub keys_seen: usize,
52 /// Samples the bounded observer missed (O6).
53 pub dropped: u64,
54 /// Samples carrying no structural document — fields unobservable for
55 /// them, counted apart from absence (O4).
56 pub undocumented: u64,
57 /// Samples whose payload was past the observation limit and therefore
58 /// never read — distinct from `undocumented`, which means the payload was
59 /// read and carried no document (RFC 09 §5.1 O6).
60 #[serde(skip_serializing_if = "u64_is_zero")]
61 pub unread: u64,
62 /// Whether a registry was loaded: without one, declared `ttl_s` and type
63 /// names are unknown and `field-stuck`/`field-new` are unjudgeable.
64 pub registry_loaded: bool,
65 /// Distinct (key, path) pairs tracked, against the bound they ran under.
66 pub paths: usize,
67 pub max_paths: usize,
68 /// Path observations refused to stay within the bound (O6) — the table
69 /// never truncates silently.
70 pub paths_dropped: u64,
71 /// Up to a handful of `key · path` names among the refused.
72 #[serde(skip_serializing_if = "Vec::is_empty")]
73 pub paths_dropped_examples: Vec<String>,
74 /// Key projections the bounded facts cache (#107) retired during the
75 /// window — non-zero means the declared-ttl/type context covers the
76 /// retained keys only (RFC 09 §5.1 O6). Absent when zero.
77 #[serde(skip_serializing_if = "u64_is_zero", default)]
78 pub facts_evicted: u64,
79 pub rows: Vec<FieldRow>,
80 pub findings: Vec<DoctorFinding>,
81}
82
83#[cfg(test)]
84mod tests {
85 use super::*;
86
87 /// Same contract again for `zenctl field --format json` (#223): the
88 /// document changes only deliberately, and every "not asked" is an
89 /// absent field, never a null or a zero (RFC 09 §5.1 O4).
90 #[test]
91 fn field_report_json_shape_is_pinned() {
92 let report = FieldReport {
93 selector: "v1/*/state/demo/health".into(),
94 window_s: 30.0,
95 samples: 40,
96 keys_seen: 1,
97 dropped: 0,
98 undocumented: 2,
99 unread: 0,
100 registry_loaded: true,
101 paths: 2,
102 max_paths: 512,
103 paths_dropped: 0,
104 paths_dropped_examples: vec![],
105 facts_evicted: 0,
106 rows: vec![FieldRow {
107 key: "v1/h-3fa9c2d41b7e/state/demo/health".into(),
108 path: "temperature_c".into(),
109 seen: 38,
110 documents: 38,
111 kinds: vec!["number".into()],
112 changes: 0,
113 last_change_s: None,
114 min: Some(21.5),
115 max: Some(21.5),
116 last: Some(21.5),
117 values: Some(vec!["21.5".into()]),
118 }],
119 findings: vec![],
120 };
121 let json = serde_json::to_value(&report).unwrap();
122 assert_eq!(
123 json,
124 serde_json::json!({
125 "selector": "v1/*/state/demo/health",
126 "window_s": 30.0,
127 "samples": 40,
128 "keys_seen": 1,
129 "dropped": 0,
130 "undocumented": 2,
131 "registry_loaded": true,
132 "paths": 2,
133 "max_paths": 512,
134 "paths_dropped": 0,
135 "rows": [{
136 "key": "v1/h-3fa9c2d41b7e/state/demo/health",
137 "path": "temperature_c",
138 "seen": 38,
139 "documents": 38,
140 "kinds": ["number"],
141 "changes": 0,
142 "min": 21.5,
143 "max": 21.5,
144 "last": 21.5,
145 "values": ["21.5"],
146 }],
147 "findings": [],
148 }),
149 "`last_change_s` and dropped-path examples are absent when there \
150 is nothing to say, never null (O4); `values` absent would mean \
151 the domain overflowed the cap"
152 );
153 }
154}