Skip to main content

zenkey_fleet/judge/
budget.rs

1//! Key-population budgets (#221): the declared `cardinality` bound joined to
2//! what a bounded observation actually saw.
3//!
4//! Every `{var}` subject has declared a `cardinality` since v1.0 — RFC 08 §2
5//! makes the field mandatory on any pattern with a variable, and RFC 04 §1.2
6//! makes an unbudgeted population-keyed subject a registry-review reject —
7//! and until this module nothing ever compared the declaration to reality.
8//! The join is deliberately engine-side: the doctor's
9//! `cardinality-over-declared` check, `zenctl topic list --budget` and
10//! zengui's tree badge (#221, `zengui/src/budget.rs`) all read the same
11//! numbers (#400).
12//!
13//! The honesty rules are the substance (RFC 09 §5.1):
14//!
15//! - Observed **over** declared is a finding. Observed **under** declared is
16//!   **not** — a bounded window proves a lower bound on the population,
17//!   never the population, and an idle host declares nothing wrong (O4/O6).
18//! - `{path...}` rest-variable families are unbounded by construction and
19//!   are **exempt and say so** — the RFC 08 §6.1 (v1.20) shape: an
20//!   exemption renders as "exempt: rest-variable", never as a silent skip
21//!   and never as a pass.
22//! - Every number states its window and scopes (O5).
23
24use std::collections::{BTreeMap, BTreeSet};
25
26use crate::SliceSet;
27use crate::judge::common::EXPANSION_CAP;
28use crate::model::examples::Examples;
29use crate::model::facts::{KeyFacts, KeyShape, OriginKind};
30use crate::report::{BudgetCell, BudgetWindow, TopicList};
31
32/// Observed expansions of every `{var}` subject family, grouped
33/// per origin — RFC 04 §1's table bounds cardinality *per producer*, so one
34/// origin exceeding the bound is conclusive on its own and two origins'
35/// mounts are never summed into a fake violation.
36#[derive(Debug, Clone, Default)]
37pub struct BudgetObservation {
38    /// (slice name, declared subject path) → origin → distinct concrete keys.
39    families: BTreeMap<(String, String), BTreeMap<String, BTreeSet<String>>>,
40}
41
42impl BudgetObservation {
43    /// Group observed concrete wire keys into `{var}` subject families.
44    ///
45    /// `keys` is whatever population the caller holds — the stats table /
46    /// key-tree snapshot of a monitor window, or the doctor listen phase's
47    /// key cache. Keys that do not parse, refine, or land on a variable
48    /// pattern contribute nothing here (they have their own checks).
49    pub fn observe<'a>(
50        base: &str,
51        slices: &SliceSet,
52        keys: impl IntoIterator<Item = &'a str>,
53    ) -> BudgetObservation {
54        let mut families: BTreeMap<(String, String), BTreeMap<String, BTreeSet<String>>> =
55            BTreeMap::new();
56        for key in keys {
57            let facts = KeyFacts::project(base, key);
58            let KeyShape::V1(v) = &facts.shape else {
59                continue;
60            };
61            if !v.class_kind.is_data_class() {
62                continue;
63            }
64            // A service origin omits the producer chunk (RFC 03 §1.5); its
65            // slice is found by the origin it serves.
66            let producer = match v.origin_kind {
67                OriginKind::Host => v.producer.clone(),
68                OriginKind::Service => slices.by_service_origin(&v.origin).map(|s| s.name.clone()),
69            };
70            let Some(producer) = producer else {
71                continue;
72            };
73            let tail: Vec<&str> = v.subject.iter().map(String::as_str).collect();
74            let Some((decl, _)) = slices.refine(&producer, &v.class, &tail) else {
75                continue;
76            };
77            if !decl.path.contains('{') {
78                continue; // a literal subject's population is 1 by construction
79            }
80            families
81                .entry((producer, decl.path.clone()))
82                .or_default()
83                .entry(v.origin.clone())
84                .or_default()
85                .insert(key.to_string());
86        }
87        BudgetObservation { families }
88    }
89
90    /// One family's per-origin expansions, when anything was observed.
91    pub fn family(
92        &self,
93        producer: &str,
94        path: &str,
95    ) -> Option<&BTreeMap<String, BTreeSet<String>>> {
96        self.families.get(&(producer.to_string(), path.to_string()))
97    }
98}
99
100/// Join an observation onto a `topic list` report: every `{var}` row gets a
101/// [`BudgetCell`], the list gets the [`BudgetWindow`] coverage statement.
102///
103/// Literal rows and ledger rows get no cell — their population is fixed by
104/// construction, and an empty cell claims nothing (which is not a pass).
105pub fn join_budget(list: &mut TopicList, obs: &BudgetObservation, window: BudgetWindow) {
106    for row in &mut list.subjects {
107        if row.deprecated || !row.path.contains('{') {
108            continue;
109        }
110        let empty = BTreeMap::new();
111        let origins = obs.family(&row.producer, &row.path).unwrap_or(&empty);
112        let observed: usize = origins.values().map(BTreeSet::len).sum();
113        let (worst_origin, worst_observed) = origins
114            .iter()
115            .max_by_key(|(_, keys)| keys.len())
116            .map(|(o, keys)| (Some(o.clone()), keys.len()))
117            .unwrap_or((None, 0));
118        let examples = worst_origin
119            .as_ref()
120            .and_then(|o| origins.get(o))
121            .map(|keys| {
122                let mut ex = Examples::new(EXPANSION_CAP);
123                for key in keys {
124                    ex.push_with(|| key.clone());
125                }
126                ex.into_vec()
127            })
128            .unwrap_or_default();
129        let exempt = row
130            .path
131            .contains("...")
132            .then(|| "rest-variable".to_string());
133        let over = exempt.is_none()
134            && row
135                .cardinality
136                .is_some_and(|declared| worst_observed as i64 > declared);
137        row.budget = Some(BudgetCell {
138            declared: row.cardinality,
139            observed,
140            origins: origins.len(),
141            worst_origin,
142            worst_observed,
143            exempt,
144            over,
145            examples,
146        });
147    }
148    list.budget = Some(window);
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154
155    const SLICE: &str = r#"
156        [registry]
157        version = "1.0"
158        app = "t"
159        convention = 1
160        [producer]
161        name = "sysinfo"
162        [[subject]]
163        path = "disk/{mount}/used"
164        class = "telemetry"
165        type = "Point"
166        cardinality = 16
167        [[subject]]
168        path = "health"
169        class = "state"
170        type = "Health"
171    "#;
172
173    #[test]
174    fn observation_groups_per_origin_and_skips_literals() {
175        let slices = SliceSet::from_toml_for_tests(SLICE);
176        let keys = [
177            "v1/h-aaaaaaaaaaaa/telemetry/sysinfo/disk/root/used",
178            "v1/h-aaaaaaaaaaaa/telemetry/sysinfo/disk/var/used",
179            "v1/h-bbbbbbbbbbbb/telemetry/sysinfo/disk/root/used",
180            // A literal subject and an unregistered key contribute nothing.
181            "v1/h-aaaaaaaaaaaa/state/sysinfo/health",
182            "v1/h-aaaaaaaaaaaa/telemetry/sysinfo/not/registered",
183        ];
184        let obs = BudgetObservation::observe("", &slices, keys);
185        let fam = obs.family("sysinfo", "disk/{mount}/used").unwrap();
186        assert_eq!(fam.len(), 2, "two origins expanded the family");
187        assert_eq!(fam["h-aaaaaaaaaaaa"].len(), 2);
188        assert_eq!(fam["h-bbbbbbbbbbbb"].len(), 1);
189        assert!(
190            obs.family("sysinfo", "health").is_none(),
191            "literals excluded"
192        );
193    }
194}