Skip to main content

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}