Skip to main content

zenkey_fleet/judge/
kind.rs

1//! Declared versus observed `kind` (#422): the sensor that publishes
2//! `oom_kills_total` as a gauge, and nothing could have caught it.
3//!
4//! RFC 08 §2 (v1.32) lets a subject declare what its leaf value *is* —
5//! `counter | gauge | text | bool` — and RFC 13 §3 says what a judge owes
6//! that declaration:
7//!
8//! - **Not asked** when the entry declares no `kind`. Nothing here runs for
9//!   such a key; the doctor's listen phase never calls
10//!   [`KindObservation::observe`] for it.
11//! - **Established(no)** — `kind-mismatch`, severity Error — when a
12//!   self-describing payload's `type` tag disagrees with the declared kind,
13//!   or when a `counter` decreased between two samples of one origin with
14//!   no `alive` cycle of that origin in between. The restart is the one
15//!   sanctioned reset, and it is on the wire (RFC 04 §5): the listen phase
16//!   watches the liveliness planes and reports a down-then-up through
17//!   [`KindObservation::alive_cycled`].
18//! - **Unobservable** when the payload could not be decoded, said with the
19//!   reason — one Warning per key, never folded into a pass.
20//!
21//! Per origin, never pooled: a key already names its origin, so the table
22//! is per key and the counter baseline is that key's own series. The window
23//! rides in every finding (O5/O6) — a window in which a counter did not
24//! decrease has not established that it is one, which is why there is no
25//! `Established(yes)` here at all.
26//!
27//! Pure, the house pattern of [`crate::judge::field`] and
28//! [`crate::judge::budget`]: nothing here takes a session, so the same
29//! observation judges a `.zrec` replay.
30
31use std::collections::BTreeMap;
32
33use serde_json::Value;
34use zenkey::{SliceToken, SubjectKind};
35
36use crate::judge::common::{EXPANSION_CAP, FINDING_CAP};
37use crate::model::examples::Examples;
38use crate::report::{CheckId, DoctorFinding, DoctorSeverity};
39
40/// The producer identity a liveliness token and a data key share:
41/// `(origin, producer)`, with a service origin's producer being the origin
42/// minus its `@` — exactly [`crate::bus::roster::token_identity`]'s reading
43/// of `v1/@catalog/state/alive`, so a cycle seen on the token plane lands on
44/// the keys it restarted.
45pub type ProducerId = (String, String);
46
47/// What one key's series has shown against its declared kind.
48#[derive(Debug)]
49pub struct KeyKind {
50    /// Whose series this is — the cycle bookkeeping is per producer.
51    pub producer: ProducerId,
52    pub declared: SubjectKind,
53    /// Samples that reached a verdict (decoded and read).
54    pub judged: u64,
55    /// Samples with a self-describing tag that disagreed with `declared`.
56    pub tag_mismatches: u64,
57    /// Samples whose value was not of the declared kind — a negative or
58    /// non-numeric `counter`, a non-boolean `bool`, a non-string `text`.
59    pub value_mismatches: u64,
60    /// `counter` only: decreases with no `alive` cycle in between.
61    pub decreases: u64,
62    /// Samples that could not be decoded at all — unobservable, said so.
63    pub undecoded: u64,
64    /// Up to [`EXPANSION_CAP`] mismatches, spelled out.
65    examples: Examples<String>,
66    /// `counter` only: the last value read, the next sample's baseline.
67    last: Option<f64>,
68    /// The producer's cycle generation this key's baseline belongs to.
69    generation: u64,
70}
71
72/// The per-key observation the listen phase feeds and [`judge_kind`] reads.
73#[derive(Debug, Default)]
74pub struct KindObservation {
75    keys: BTreeMap<String, KeyKind>,
76    /// How many `alive` cycles each producer has shown. A key whose baseline
77    /// is from an older generation resets on its next sample instead of
78    /// being judged against a series the restart ended — kept per producer
79    /// rather than as a one-shot mark so every key of a restarted producer
80    /// resets, not just the first one to publish (#422).
81    cycles: BTreeMap<ProducerId, u64>,
82}
83
84/// The tag a self-describing payload carries, when it does
85/// (`{"type": "<kind>", "value": …}`, RFC 08 §2 / RFC 11 §4). A tag outside
86/// the four is not a disagreement — it is a payload this rule does not read.
87fn payload_tag(doc: &Value) -> Option<SubjectKind> {
88    doc.get("type")
89        .and_then(Value::as_str)
90        .and_then(SubjectKind::from_payload_tag)
91}
92
93/// The value the declaration is about: `value` when the payload is an
94/// object carrying one, the payload itself otherwise.
95fn leaf(doc: &Value) -> &Value {
96    match doc.get("value") {
97        Some(v) if doc.is_object() => v,
98        _ => doc,
99    }
100}
101
102/// A short spelling of a JSON value for the evidence line.
103fn describe(v: &Value) -> String {
104    match v {
105        Value::Null => "null".into(),
106        Value::Bool(b) => format!("boolean {b}"),
107        Value::Number(n) => format!("number {n}"),
108        Value::String(s) if s.len() > 24 => format!("string {:?}…", &s[..24]),
109        Value::String(s) => format!("string {s:?}"),
110        Value::Array(a) => format!("array of {}", a.len()),
111        Value::Object(o) => format!("object with {} field(s)", o.len()),
112    }
113}
114
115impl KindObservation {
116    pub fn new() -> KindObservation {
117        KindObservation::default()
118    }
119
120    /// The producer `(origin, producer)` went down and came back — the
121    /// sanctioned counter reset (RFC 08 §2). A lone `NodeUp` at window start
122    /// is a token seen, not a cycle; the caller decides that.
123    pub fn alive_cycled(&mut self, origin: &str, producer: &str) {
124        *self
125            .cycles
126            .entry((origin.to_string(), producer.to_string()))
127            .or_default() += 1;
128    }
129
130    /// One `Put` sample on a key whose registry entry declares `kind`.
131    /// `doc` is the structural document, `None` when the payload could not
132    /// be decoded (counted, reported as unobservable, never judged).
133    pub fn observe(
134        &mut self,
135        key: &str,
136        origin: &str,
137        producer: &str,
138        declared: SubjectKind,
139        doc: Option<&Value>,
140    ) {
141        let producer_id = (origin.to_string(), producer.to_string());
142        let generation = self.cycles.get(&producer_id).copied().unwrap_or(0);
143        let entry = self.keys.entry(key.to_string()).or_insert_with(|| KeyKind {
144            producer: producer_id,
145            declared,
146            judged: 0,
147            tag_mismatches: 0,
148            value_mismatches: 0,
149            decreases: 0,
150            undecoded: 0,
151            examples: Examples::new(EXPANSION_CAP),
152            last: None,
153            generation,
154        });
155        let Some(doc) = doc else {
156            entry.undecoded += 1;
157            return;
158        };
159        entry.judged += 1;
160
161        // (a) The tag, when the payload self-describes. A disagreeing tag is
162        // the finding on its own; the value is not judged twice.
163        if let Some(tag) = payload_tag(doc)
164            && tag != declared
165        {
166            entry.tag_mismatches += 1;
167            entry.examples.push_with(|| {
168                format!(
169                    "payload tags itself `{}`, registry declares `{}`",
170                    tag.payload_tag(),
171                    declared.token()
172                )
173            });
174            return;
175        }
176
177        // (b) The value.
178        let v = leaf(doc);
179        match declared {
180            SubjectKind::Gauge => {
181                if v.as_f64().is_none() {
182                    entry.value_mismatches += 1;
183                    entry
184                        .examples
185                        .push_with(|| format!("gauge value is {}", describe(v)));
186                }
187            }
188            SubjectKind::Bool => {
189                if !v.is_boolean() {
190                    entry.value_mismatches += 1;
191                    entry
192                        .examples
193                        .push_with(|| format!("bool value is {}", describe(v)));
194                }
195            }
196            SubjectKind::Text => {
197                if !v.is_string() {
198                    entry.value_mismatches += 1;
199                    entry
200                        .examples
201                        .push_with(|| format!("text value is {}", describe(v)));
202                }
203            }
204            SubjectKind::Counter => {
205                let Some(n) = v.as_f64() else {
206                    entry.value_mismatches += 1;
207                    entry
208                        .examples
209                        .push_with(|| format!("counter value is {}", describe(v)));
210                    return;
211                };
212                if n < 0.0 {
213                    entry.value_mismatches += 1;
214                    entry
215                        .examples
216                        .push_with(|| format!("counter value is negative ({n})"));
217                }
218                if entry.generation != generation {
219                    // The producer cycled its `alive` token since this
220                    // key's baseline: the series restarted, and this sample
221                    // is the new baseline (RFC 08 §2, RFC 13 §3).
222                    entry.generation = generation;
223                    entry.last = Some(n);
224                    return;
225                }
226                if let Some(prev) = entry.last
227                    && n < prev
228                {
229                    entry.decreases += 1;
230                    entry.examples.push_with(|| {
231                        format!("counter decreased {prev} → {n} with no `alive` cycle in between")
232                    });
233                }
234                entry.last = Some(n);
235            }
236        }
237    }
238}
239
240impl KeyKind {
241    /// The mismatches spelled out, up to [`EXPANSION_CAP`] of them.
242    pub fn examples(&self) -> &[String] {
243        self.examples.as_slice()
244    }
245}
246
247impl KindObservation {
248    /// Every key observed, with what its series showed.
249    pub fn iter(&self) -> impl Iterator<Item = (&str, &KeyKind)> {
250        self.keys.iter().map(|(k, v)| (k.as_str(), v))
251    }
252
253    pub fn keys_seen(&self) -> usize {
254        self.keys.len()
255    }
256}
257
258/// The `kind-mismatch` findings for one listen window: one Error per key
259/// whose series disagreed with its declaration, one Warning per key whose
260/// payloads could not be judged, both capped at the doctor's per-check
261/// finding cap with the remainder counted. Filter **then** cap, like every
262/// listen check.
263pub fn judge_kind(observation: &KindObservation, window_s: f64) -> Vec<DoctorFinding> {
264    let mut findings = Vec::new();
265    let mut bad: Examples<DoctorFinding> = Examples::new(FINDING_CAP);
266    let mut unjudged: Examples<DoctorFinding> = Examples::new(FINDING_CAP);
267    for (key, k) in observation.iter() {
268        let mismatches = k.tag_mismatches + k.value_mismatches + k.decreases;
269        if mismatches > 0 {
270            bad.push_with(|| {
271                let mut parts = Vec::new();
272                if k.tag_mismatches > 0 {
273                    parts.push(format!("{} tag disagreement(s)", k.tag_mismatches));
274                }
275                if k.decreases > 0 {
276                    parts.push(format!("{} decrease(s)", k.decreases));
277                }
278                if k.value_mismatches > 0 {
279                    parts.push(format!("{} value(s) not of that kind", k.value_mismatches));
280                }
281                let examples = k.examples.as_slice().join("; ");
282                DoctorFinding {
283                    severity: DoctorSeverity::Error,
284                    check: CheckId::KindMismatch,
285                    subject: key.to_string(),
286                    evidence: format!(
287                        "declared `{}`, and {} of {} sample(s) from origin {} in {window_s:.0}s \
288                         disagree: {} — e.g. {examples}",
289                        k.declared.token(),
290                        mismatches,
291                        k.judged,
292                        k.producer.0,
293                        parts.join(", "),
294                    ),
295                    citation: Some("RFC 08 §2".into()),
296                }
297            });
298        }
299        if k.undecoded > 0 {
300            unjudged.push_with(|| DoctorFinding {
301                severity: DoctorSeverity::Warning,
302                check: CheckId::KindMismatch,
303                subject: key.to_string(),
304                evidence: format!(
305                    "kind not judged: {} payload(s) from origin {} in {window_s:.0}s could \
306                     not be decoded, so the declared `{}` is unobservable for them",
307                    k.undecoded,
308                    k.producer.0,
309                    k.declared.token(),
310                ),
311                citation: Some("RFC 13 §3".into()),
312            });
313        }
314    }
315    for (ex, tail) in [
316        (bad, "more key(s) with the same finding"),
317        (unjudged, "more key(s) whose kind was not judged"),
318    ] {
319        let more = ex.more(tail);
320        findings.extend(ex.into_vec());
321        if let Some(evidence) = more {
322            findings.push(DoctorFinding {
323                severity: DoctorSeverity::Info,
324                check: CheckId::KindMismatch,
325                subject: "fleet".into(),
326                evidence,
327                citation: None,
328            });
329        }
330    }
331    findings
332}
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337    use serde_json::json;
338
339    const KEY: &str = "v1/h-aaaaaaaaaaaa/telemetry/sysinfo/memory/oom_kills_total";
340
341    fn observe(obs: &mut KindObservation, declared: SubjectKind, doc: Value) {
342        obs.observe(KEY, "h-aaaaaaaaaaaa", "sysinfo", declared, Some(&doc));
343    }
344
345    fn mismatches(obs: &KindObservation) -> Vec<DoctorFinding> {
346        judge_kind(obs, 10.0)
347            .into_iter()
348            .filter(|f| f.severity == DoctorSeverity::Error)
349            .collect()
350    }
351
352    /// A counter that goes down is the finding, named per origin with the
353    /// window; a counter that only goes up is not a pass — it is nothing.
354    #[test]
355    fn a_decreasing_counter_is_a_finding_and_a_rising_one_is_nothing() {
356        let mut obs = KindObservation::new();
357        for n in [10, 20, 30] {
358            observe(&mut obs, SubjectKind::Counter, json!(n));
359        }
360        assert!(
361            judge_kind(&obs, 10.0).is_empty(),
362            "no Established(yes) exists"
363        );
364
365        observe(&mut obs, SubjectKind::Counter, json!(5));
366        let f = mismatches(&obs);
367        assert_eq!(f.len(), 1, "{f:?}");
368        assert_eq!(f[0].check, CheckId::KindMismatch);
369        assert_eq!(f[0].subject, KEY);
370        assert!(
371            f[0].evidence.contains("h-aaaaaaaaaaaa"),
372            "{}",
373            f[0].evidence
374        );
375        assert!(f[0].evidence.contains("10s"), "the window is stated");
376        assert!(f[0].evidence.contains("30 → 5"), "{}", f[0].evidence);
377        assert_eq!(f[0].citation.as_deref(), Some("RFC 08 §2"));
378    }
379
380    /// The restart is the one sanctioned reset (RFC 08 §2): a cycle of the
381    /// producer's `alive` token between the samples makes the drop a new
382    /// baseline, for every key of that producer, and a cycle of *another*
383    /// producer excuses nothing.
384    #[test]
385    fn a_reset_across_an_alive_cycle_is_not_a_finding() {
386        let mut obs = KindObservation::new();
387        let other = "v1/h-aaaaaaaaaaaa/telemetry/sysinfo/memory/page_faults_total";
388        observe(&mut obs, SubjectKind::Counter, json!(10));
389        obs.observe(
390            other,
391            "h-aaaaaaaaaaaa",
392            "sysinfo",
393            SubjectKind::Counter,
394            Some(&json!(7)),
395        );
396        obs.alive_cycled("h-aaaaaaaaaaaa", "sysinfo");
397        observe(&mut obs, SubjectKind::Counter, json!(0));
398        obs.observe(
399            other,
400            "h-aaaaaaaaaaaa",
401            "sysinfo",
402            SubjectKind::Counter,
403            Some(&json!(0)),
404        );
405        assert!(mismatches(&obs).is_empty(), "{:?}", judge_kind(&obs, 10.0));
406
407        // After the reset the new baseline holds: a later drop is judged.
408        observe(&mut obs, SubjectKind::Counter, json!(3));
409        observe(&mut obs, SubjectKind::Counter, json!(1));
410        assert_eq!(mismatches(&obs).len(), 1);
411
412        // Another producer's cycle is not this one's restart.
413        let mut obs = KindObservation::new();
414        observe(&mut obs, SubjectKind::Counter, json!(10));
415        obs.alive_cycled("h-aaaaaaaaaaaa", "netring");
416        observe(&mut obs, SubjectKind::Counter, json!(0));
417        assert_eq!(mismatches(&obs).len(), 1);
418    }
419
420    /// A self-describing payload's tag must agree (RFC 08 §2); `boolean` is
421    /// how `bool` is spelled on the wire (RFC 11 §4), and an unknown tag is
422    /// not a disagreement.
423    #[test]
424    fn a_disagreeing_tag_is_a_finding_and_an_agreeing_or_foreign_one_is_not() {
425        let mut obs = KindObservation::new();
426        observe(
427            &mut obs,
428            SubjectKind::Counter,
429            json!({"type": "gauge", "value": 1}),
430        );
431        let f = mismatches(&obs);
432        assert_eq!(f.len(), 1, "{f:?}");
433        assert!(
434            f[0].evidence.contains("tags itself `gauge`"),
435            "{}",
436            f[0].evidence
437        );
438
439        let mut obs = KindObservation::new();
440        observe(
441            &mut obs,
442            SubjectKind::Bool,
443            json!({"type": "boolean", "value": true}),
444        );
445        observe(
446            &mut obs,
447            SubjectKind::Bool,
448            json!({"type": "histogram", "value": true}),
449        );
450        assert!(mismatches(&obs).is_empty());
451    }
452
453    /// The value itself: a `text` that is a number, a `bool` that is a
454    /// string, a negative `counter` — bare or under `value`.
455    #[test]
456    fn a_value_not_of_the_declared_kind_is_a_finding() {
457        let mut obs = KindObservation::new();
458        observe(&mut obs, SubjectKind::Text, json!(3));
459        observe(&mut obs, SubjectKind::Text, json!({"value": "ok"}));
460        assert_eq!(mismatches(&obs).len(), 1);
461        let mut obs = KindObservation::new();
462        observe(&mut obs, SubjectKind::Bool, json!({"value": "true"}));
463        assert_eq!(mismatches(&obs).len(), 1);
464        let mut obs = KindObservation::new();
465        observe(&mut obs, SubjectKind::Counter, json!(-1));
466        assert_eq!(mismatches(&obs).len(), 1);
467        let mut obs = KindObservation::new();
468        observe(&mut obs, SubjectKind::Gauge, json!(-1.5));
469        observe(&mut obs, SubjectKind::Gauge, json!({"value": 2}));
470        assert!(mismatches(&obs).is_empty(), "a gauge is any number");
471    }
472
473    /// An undecodable payload is unobservable, said so per key as a Warning
474    /// — never a pass, never an Error (RFC 13 §3).
475    #[test]
476    fn an_undecodable_payload_is_reported_unobservable_not_passed() {
477        let mut obs = KindObservation::new();
478        obs.observe(KEY, "h-aaaaaaaaaaaa", "sysinfo", SubjectKind::Counter, None);
479        obs.observe(KEY, "h-aaaaaaaaaaaa", "sysinfo", SubjectKind::Counter, None);
480        let f = judge_kind(&obs, 10.0);
481        assert_eq!(f.len(), 1, "{f:?}");
482        assert_eq!(f[0].severity, DoctorSeverity::Warning);
483        assert_eq!(f[0].check, CheckId::KindMismatch);
484        assert!(
485            f[0].evidence.contains("kind not judged: 2 payload(s)"),
486            "{}",
487            f[0].evidence
488        );
489        assert!(f[0].evidence.contains("10s"));
490    }
491
492    /// The cap bounds the findings and the remainder counts what it hid —
493    /// the note cannot disagree with the population it summarises.
494    #[test]
495    fn the_cap_bites_with_a_counted_remainder() {
496        let mut obs = KindObservation::new();
497        for i in 0..(FINDING_CAP + 3) {
498            let key = format!("v1/h-aaaaaaaaaaaa/telemetry/sysinfo/k{i}");
499            obs.observe(
500                &key,
501                "h-aaaaaaaaaaaa",
502                "sysinfo",
503                SubjectKind::Text,
504                Some(&json!(1)),
505            );
506        }
507        let f = judge_kind(&obs, 10.0);
508        assert_eq!(f.len(), FINDING_CAP + 1, "{f:?}");
509        let tail = f.last().unwrap();
510        assert_eq!(tail.severity, DoctorSeverity::Info);
511        assert!(tail.evidence.contains("… and 3 more"), "{}", tail.evidence);
512    }
513}