zenkey_fleet/report/infer.rs
1//! `registry infer` (#225, RFC 08 §6.1 v1.34): the observation-derived
2//! registry draft as a document.
3//!
4//! The files `zenctl registry infer` writes derive from this report — the
5//! emitter walks [`InferredProducer`]s — so `--format json` and the draft on
6//! disk cannot disagree about what was inferred. Every field a
7//! [`InferredSubject`] could not establish is **absent** (RFC 13 §3 O4
8//! applied to a file, as §6.1 says), and every guess that is not a field
9//! rides as a comment string, never laundered into the TOML as a value.
10
11use serde::Serialize;
12
13use super::asked::u64_is_zero;
14
15/// What one inference run saw and what it drafted.
16#[derive(Debug, Clone, Serialize)]
17pub struct InferReport {
18 /// The selector watched, or the `.zrec` path read.
19 pub source: String,
20 /// The window asked for, seconds. Absent for a capture — nothing was
21 /// asked; the span is in the rows (O4).
22 #[serde(skip_serializing_if = "Option::is_none")]
23 pub window_s: Option<f64>,
24 /// First to last sample, seconds — the span every rate and refresh
25 /// guess is measured over.
26 pub span_s: f64,
27 pub samples: u64,
28 /// Samples the bounded observer missed (O6).
29 pub dropped: u64,
30 /// Distinct wire keys retained.
31 pub keys_seen: usize,
32 /// Keys refused at the key bound — the draft covers the retained set
33 /// only (O6).
34 pub keys_refused: u64,
35 /// Distinct origins the retained keys came from. One origin cannot
36 /// exhibit a leaf dimension; the draft says so when this is 1.
37 pub origins: usize,
38 /// Keys under the base that are not v1 keys — nothing to draft from.
39 pub unparsed_keys: u64,
40 /// Keys on a verbatim plane (`@rpc`, `@media`, `@blob`): no
41 /// `[[subject]]` surface, so not drafted.
42 pub off_plane_keys: u64,
43 /// Samples carrying no structural document (fields unobservable).
44 pub undocumented: u64,
45 /// Samples past the observation limit, never read (distinct from
46 /// `undocumented`, O6).
47 #[serde(skip_serializing_if = "u64_is_zero")]
48 pub unread: u64,
49 /// Path observations refused at the path-table bound (O6).
50 #[serde(skip_serializing_if = "u64_is_zero")]
51 pub paths_refused: u64,
52 /// Whether a registry slice set hinted the inference (var names,
53 /// service ownership). The draft comes out without one, and says so.
54 pub hinted_by_registry: bool,
55 /// Run-level caveats the reviewer must read — stated as data so they
56 /// reach json and ndjson.
57 pub caveats: Vec<String>,
58 pub producers: Vec<InferredProducer>,
59}
60
61/// One draft file: a producer (or service) and the subjects seen under it.
62#[derive(Debug, Clone, Serialize)]
63pub struct InferredProducer {
64 /// The producer base name, or the service name (its origin without the
65 /// `@`) — the draft file's stem.
66 pub name: String,
67 /// `Some("@catalog")` for a service origin (RFC 03 §1.5); the file then
68 /// gets a `[service]` header.
69 #[serde(skip_serializing_if = "Option::is_none")]
70 pub service_origin: Option<String>,
71 pub subjects: Vec<InferredSubject>,
72 pub types: Vec<InferredType>,
73}
74
75/// One inferred `[[subject]]`. Optional fields are absent when the
76/// observation could not establish them, never defaulted.
77#[derive(Debug, Clone, Serialize)]
78pub struct InferredSubject {
79 /// The inferred pattern, `{var}`s and a trailing `{rest...}` included.
80 pub path: String,
81 pub class: String,
82 pub type_name: String,
83 /// A generated-variant override, present only where two inferred paths
84 /// would collide on the default name (`cpu/usage` beside
85 /// `cpu/{v1}/usage`): the var-bearing one is then named with its vars,
86 /// as the reference registry does by hand.
87 #[serde(skip_serializing_if = "Option::is_none")]
88 pub variant: Option<String>,
89 #[serde(skip_serializing_if = "Option::is_none")]
90 pub unit: Option<String>,
91 /// Present for the alert family only — `state` under a leading `alert`
92 /// chunk — where RFC 08 §5 (v1.23) *requires* `qos = "alert"`. That is
93 /// the family's normative profile, never what was observed: observed
94 /// QoS stays a comment on every entry, this one included.
95 #[serde(skip_serializing_if = "Option::is_none")]
96 pub qos: Option<String>,
97 #[serde(skip_serializing_if = "Option::is_none")]
98 pub ttl_s: Option<i64>,
99 #[serde(skip_serializing_if = "Option::is_none")]
100 pub rate: Option<String>,
101 #[serde(skip_serializing_if = "Option::is_none")]
102 pub cardinality: Option<i64>,
103 #[serde(skip_serializing_if = "Option::is_none")]
104 pub encoding: Option<String>,
105 /// The guesses that are not fields — observed QoS, a `kind` guess, the
106 /// sibling set a `{var}` was inferred from, what could not be
107 /// established. Written above the entry as `#` lines.
108 pub comments: Vec<String>,
109 /// Distinct origins that published an expansion of this pattern.
110 pub origins: usize,
111 /// Distinct wire keys (expansions) the pattern covers.
112 pub keys: usize,
113 pub samples: u64,
114}
115
116/// One inferred payload type: a JSON Schema built from the observed
117/// field kinds (`schema` absent when no sample was structural).
118#[derive(Debug, Clone, Serialize)]
119pub struct InferredType {
120 pub name: String,
121 #[serde(skip_serializing_if = "Option::is_none")]
122 pub schema: Option<serde_json::Value>,
123 /// How many subjects bind this type (deduplicated by schema identity).
124 pub subjects: usize,
125}
126
127#[cfg(test)]
128mod tests {
129 use super::*;
130
131 /// `zenctl registry infer --format json`'s document, pinned: every
132 /// unestablished field is absent — a draft that defaulted one would be
133 /// the lie RFC 08 §6.1 forbids, in JSON instead of TOML.
134 #[test]
135 fn infer_report_json_shape_is_pinned() {
136 let report = InferReport {
137 source: "v1/**".into(),
138 window_s: Some(60.0),
139 span_s: 59.2,
140 samples: 120,
141 dropped: 0,
142 keys_seen: 2,
143 keys_refused: 0,
144 origins: 2,
145 unparsed_keys: 0,
146 off_plane_keys: 1,
147 undocumented: 0,
148 unread: 0,
149 paths_refused: 0,
150 hinted_by_registry: false,
151 caveats: vec![],
152 producers: vec![InferredProducer {
153 name: "demo".into(),
154 service_origin: None,
155 subjects: vec![
156 InferredSubject {
157 path: "cpu/{v1}/usage_percent".into(),
158 class: "telemetry".into(),
159 type_name: "DemoCpuUsagePercent".into(),
160 variant: None,
161 unit: Some("percent".into()),
162 qos: None,
163 ttl_s: None,
164 rate: None,
165 cardinality: Some(10),
166 encoding: Some("application/json".into()),
167 comments: vec!["qos observed: sampled".into()],
168 origins: 2,
169 keys: 2,
170 samples: 118,
171 },
172 InferredSubject {
173 path: "health".into(),
174 class: "state".into(),
175 type_name: "DemoHealth".into(),
176 variant: None,
177 unit: None,
178 qos: None,
179 ttl_s: None,
180 rate: None,
181 cardinality: None,
182 encoding: None,
183 comments: vec![
184 "ttl_s not established: no refresh observed within 59.2 s".into(),
185 ],
186 origins: 2,
187 keys: 2,
188 samples: 2,
189 },
190 ],
191 types: vec![InferredType {
192 name: "DemoCpuUsagePercent".into(),
193 schema: Some(serde_json::json!({"type": "number"})),
194 subjects: 1,
195 }],
196 }],
197 };
198 assert_eq!(
199 serde_json::to_value(&report).unwrap(),
200 serde_json::json!({
201 "source": "v1/**",
202 "window_s": 60.0,
203 "span_s": 59.2,
204 "samples": 120,
205 "dropped": 0,
206 "keys_seen": 2,
207 "keys_refused": 0,
208 "origins": 2,
209 "unparsed_keys": 0,
210 "off_plane_keys": 1,
211 "undocumented": 0,
212 "hinted_by_registry": false,
213 "caveats": [],
214 "producers": [{
215 "name": "demo",
216 "subjects": [
217 {
218 "path": "cpu/{v1}/usage_percent",
219 "class": "telemetry",
220 "type_name": "DemoCpuUsagePercent",
221 "unit": "percent",
222 "cardinality": 10,
223 "encoding": "application/json",
224 "comments": ["qos observed: sampled"],
225 "origins": 2,
226 "keys": 2,
227 "samples": 118,
228 },
229 {
230 "path": "health",
231 "class": "state",
232 "type_name": "DemoHealth",
233 "comments": [
234 "ttl_s not established: no refresh observed within 59.2 s",
235 ],
236 "origins": 2,
237 "keys": 2,
238 "samples": 2,
239 },
240 ],
241 "types": [{
242 "name": "DemoCpuUsagePercent",
243 "schema": {"type": "number"},
244 "subjects": 1,
245 }],
246 }],
247 }),
248 "unestablished fields are absent, never null (RFC 08 §6.1, RFC 13 §3 O4)"
249 );
250 }
251}