Skip to main content

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}