Skip to main content

zenkey_fleet/judge/
expect.rs

1//! `expect` (#160) — one observation window, one verdict, exit-coded for CI.
2//!
3//! The shape every application test suite needs: "my producer's traffic shows
4//! up, at roughly the right rate, with valid payloads" as a one-liner whose
5//! exit code can be trusted. Trusted means three states, not two
6//! (RFC 09 §5.1 O4/O6, the `cutover`/`probe` discipline):
7//!
8//! - **0 / Met** — the expectation held.
9//! - **1 / NotMet** — it did not, *and the observation was clean enough to
10//!   say so*: either conclusive positive evidence (a sample where none may
11//!   be, a nonconformant payload, a rate above the ceiling), or a shortfall
12//!   observed with zero drops.
13//! - **2 / Impaired** — the observation cannot carry the claim: the
14//!   subscriber dropped samples under a claim that needs completeness
15//!   (absence, a rate ceiling, or a shortfall that the dropped samples could
16//!   have filled), or the session/watch failed outright.
17//!
18//! The subscriber is declared **before** the window opens — a window that
19//! starts counting before anyone listens converts "not asked" into "no".
20//!
21//! Since #227 the three-state judgement is spelled in the closed condition
22//! vocabulary ([`crate::judge::condition`]): the count and rate floors ride
23//! [`crate::judge::condition::judge_shortfall`]
24//! (`rate-below`'s rule), the rate ceiling rides
25//! [`crate::judge::condition::judge_excess`]
26//! (`rate-above`'s), and `--absent` is `silent-for` over the whole window
27//! ([`crate::judge::condition::judge_silence`]) — so
28//! `expect` and `zenctl watchdog` cannot drift about what a drop means.
29
30use std::collections::BTreeSet;
31use std::time::Duration;
32
33use crate::Result;
34
35use crate::judge::common::FINDING_CAP;
36use crate::judge::condition;
37use crate::model::decode::SchemaStore;
38use crate::model::examples::Examples;
39use crate::model::registry::SliceSet;
40use crate::report::{ExpectReport, ExpectVerdict};
41use crate::{FleetEvent, Monitor, MonitorSpec, StreamItem, Verdict};
42
43/// Which QoS each observed sample must have ridden.
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum QosCheck {
46    /// The subject's declared profile (RFC 04 §3, via the registry). A sample
47    /// whose key has no declared profile fails the check with that reason —
48    /// the assertion was "rides as declared", and nothing is declared.
49    Declared,
50    /// One named profile for everything observed.
51    Profile(zenkey::qos::QosProfile),
52}
53
54/// What must hold within the window.
55#[derive(Debug, Clone)]
56pub struct ExpectSpec {
57    /// Full wire selector to watch (the session is un-namespaced, RFC 09 §5).
58    pub selector: String,
59    /// The observation window.
60    pub within: Duration,
61    /// At least this many samples. Defaults to 1 when nothing else implies
62    /// presence; ignored under [`absent`](Self::absent).
63    pub count: Option<u64>,
64    /// Samples per second over the full window, at least.
65    pub rate_min: Option<f64>,
66    /// Samples per second over the full window, at most.
67    pub rate_max: Option<f64>,
68    /// Every observed payload must reach [`Verdict::Valid`] (#159). A payload
69    /// that *cannot* be checked (no schema served) fails the assertion with
70    /// its reason — asking for validity and getting "unknowable" is not met.
71    pub valid_payload: bool,
72    pub qos: Option<QosCheck>,
73    /// Assert silence instead: no sample may match. The only absence claim
74    /// this tool makes, and only because the verdict states its window and
75    /// observer status, and any drop forces `Impaired` (O1/O4).
76    pub absent: bool,
77}
78
79impl Default for ExpectSpec {
80    fn default() -> Self {
81        ExpectSpec {
82            selector: String::new(),
83            within: Duration::from_secs(30),
84            count: None,
85            rate_min: None,
86            rate_max: None,
87            valid_payload: false,
88            qos: None,
89            absent: false,
90        }
91    }
92}
93
94/// Run one expectation window. `Err` means the observation never stood up
95/// (session/watch failure) — callers map it to the Impaired exit, never to
96/// "not met".
97///
98/// `slices: None` means no registry was loaded: the decode pipeline then
99/// reports [`Verdict::NotValidated`]([`NoRegistry`](zenkey::schema::validate::NotValidated::NoRegistry))
100/// per sample, which `--valid` treats exactly like every other `NotValidated`
101/// reason — the assertion was validity, and "unknowable" is not met
102/// (RFC 09 §5.1 O4; #246). It is never a pass or a fail on its own.
103pub async fn run_expect(
104    fleet: &crate::Fleet<'_>,
105    slices: Option<&SliceSet>,
106    store: &SchemaStore,
107    spec: &ExpectSpec,
108) -> Result<ExpectReport> {
109    // Warm the schemas *before* anything is watched, and seal the store for
110    // the window (#337). A `--valid` window decodes per sample, and a cold
111    // store turned the first sample of each producer into a `describe` GET
112    // awaited inside the drain loop — nobody attending the bounded broadcast
113    // for the duration, so the window lost samples to its own decode and did
114    // not extend its deadline to make up for them. zenctl hands this store
115    // over cold, which is why the warming lives here and not at the call
116    // site.
117    let _sealed = if spec.valid_payload {
118        crate::model::decode::prewarm(fleet, store, slices).await;
119        Some(store.seal())
120    } else {
121        None
122    };
123
124    let monitor = Monitor::start(fleet.session(), MonitorSpec::default()).await?;
125
126    let mut events = monitor.events();
127
128    // Declared before the window opens: not-asked must never read as "no" —
129    // and a declaration that fails takes the monitor down with it (#336).
130    let monitor = monitor.watching([spec.selector.as_str()]).await?;
131    let opened = tokio::time::Instant::now();
132    let deadline = opened + spec.within;
133
134    // The existence floor: presence is implied unless silence is the claim.
135    let need = if spec.absent {
136        0
137    } else {
138        spec.count.unwrap_or(1)
139    };
140    let no_rate_bounds = spec.rate_min.is_none() && spec.rate_max.is_none();
141
142    let mut samples: u64 = 0;
143    let mut keys: BTreeSet<String> = BTreeSet::new();
144    let mut dropped: u64 = 0;
145    // Named examples, exact total: the report shows the first
146    // [`FINDING_CAP`](crate::judge::common::FINDING_CAP) and says how many there were.
147    let mut violations: Examples<String> = Examples::new(FINDING_CAP);
148    let mut ended_early = false;
149
150    // One timer for the whole window, not one per iteration (#346).
151    // `sleep_until` builds a future and registers a timer each time it
152    // is evaluated, and a `select!` in a loop evaluates it on every
153    // pass — at 100k samples/s that is 100k registrations a second for
154    // a deadline that never moves.
155    let window_over = tokio::time::sleep_until(deadline);
156    tokio::pin!(window_over);
157    loop {
158        let item = tokio::select! {
159            item = events.recv() => item,
160            () = &mut window_over => break,
161        };
162        match item {
163            Some(StreamItem::Event(FleetEvent::Sample(s))) => {
164                samples += 1;
165                keys.insert(s.key.clone());
166                if spec.absent {
167                    violations.push(format!("{}: a sample where none may be", s.key));
168                    // Conclusive — but keep draining so the report counts
169                    // the full extent of the failure within the window.
170                    continue;
171                }
172                if spec.valid_payload {
173                    let d = crate::model::decode::decode_sample(
174                        fleet,
175                        store,
176                        slices,
177                        &s.key,
178                        Some(&s.encoding),
179                        &s.payload.to_bytes(),
180                    )
181                    .await;
182                    match d.verdict {
183                        Verdict::Valid => {}
184                        Verdict::Invalid(errors) => {
185                            violations.push(format!("{}: invalid — {}", s.key, errors.join("; ")))
186                        }
187                        // Every not-validated reason — `NoRegistry`
188                        // included — rides the same arm: the user asserted
189                        // validity, and "unknowable" is not met. The reason
190                        // string keeps the two silences apart (#246).
191                        Verdict::NotValidated(reason) => {
192                            violations.push(format!("{}: validity unknowable — {reason}", s.key))
193                        }
194                    }
195                }
196                if let Some(check) = spec.qos {
197                    let against = match check {
198                        QosCheck::Profile(p) => Some(p),
199                        QosCheck::Declared => {
200                            match crate::model::facts::describe_key(fleet.base(), &s.key, slices)
201                                .facts
202                                .registration
203                            {
204                                crate::model::facts::Registration::Registered(f) => {
205                                    f.declared_qos()
206                                }
207                                _ => None,
208                            }
209                        }
210                    };
211                    match against {
212                        Some(p) if s.qos_matches(p) => {}
213                        Some(p) => violations.push(format!(
214                            "{}: did not ride {} on the wire",
215                            s.key,
216                            p.name()
217                        )),
218                        None => violations.push(format!("{}: no declared profile to ride", s.key)),
219                    }
220                }
221                // Early success: the count is in, nothing else can invalidate
222                // it (rate bounds need the full window), nothing has.
223                if no_rate_bounds && violations.total() == 0 && samples >= need && need > 0 {
224                    ended_early = true;
225                    break;
226                }
227            }
228            Some(StreamItem::Dropped(n)) => dropped += n,
229            Some(_) => continue,
230            None => break,
231        }
232    }
233    monitor.shutdown().await?;
234
235    let window = if ended_early {
236        opened.elapsed()
237    } else {
238        spec.within
239    };
240    let window_s = window.as_secs_f64();
241    let rate_hz = (spec.rate_min.is_some() || spec.rate_max.is_some())
242        .then(|| samples as f64 / spec.within.as_secs_f64());
243
244    // Judge, in the #227 condition vocabulary. `positive` marks conclusive
245    // evidence — a per-sample violation, a sample where none may be, an
246    // excess firing — which stays NotMet even under drops; the shortfalls
247    // ride `judge_shortfall`, whose drops make them unobservable instead.
248    let mut unmet: Vec<String> = Vec::new();
249    let mut positive = false;
250    let violations_total = violations.total() as u64;
251    if violations_total > 0 {
252        positive = true;
253        unmet.push(if spec.absent {
254            format!("{violations_total} sample(s) observed where none may be")
255        } else {
256            format!("{violations_total} sample(s) violated a per-sample requirement")
257        });
258    }
259    let count_short = !spec.absent && samples < need;
260    if count_short {
261        unmet.push(format!("{samples} sample(s) observed, {need} required"));
262    }
263    let mut rate_short = false;
264    if let (Some(min), Some(r)) = (spec.rate_min, rate_hz)
265        && r < min
266    {
267        rate_short = true;
268        unmet.push(format!("rate {r:.2} Hz below the {min:.2} Hz floor"));
269    }
270    if let (Some(max), Some(r)) = (spec.rate_max, rate_hz)
271        && r > max
272    {
273        positive = true;
274        unmet.push(format!("rate {r:.2} Hz above the {max:.2} Hz ceiling"));
275    }
276
277    let verdict = if unmet.is_empty() {
278        // Every claim held on its face — but a met completeness claim under
279        // drops is unobservable, never ok (O6): `--absent` is `silent-for`
280        // over the whole window, the ceiling is `rate-above` asserted quiet.
281        let met_states = [
282            spec.absent.then(|| {
283                condition::judge_silence(condition::SilenceEvidence {
284                    sample_within: false,
285                    span_observed: true,
286                    drop_free: dropped == 0,
287                })
288            }),
289            spec.rate_max
290                .map(|_| condition::judge_excess(false, dropped)),
291        ];
292        if met_states
293            .into_iter()
294            .flatten()
295            .any(|j| j.is_unobservable())
296        {
297            unmet.push(format!(
298                "{dropped} sample(s) dropped while the claim needs completeness (O6)"
299            ));
300            ExpectVerdict::Impaired
301        } else {
302            ExpectVerdict::Met
303        }
304    } else if positive {
305        ExpectVerdict::NotMet
306    } else {
307        // A pure shortfall (no positive evidence): the judge's answer folds
308        // through the documented RFC 13 mapping — an established shortfall
309        // is `NotMet`, an unobservable one `Impaired` — rather than being
310        // hand-mapped here.
311        let shortfall = condition::judge_shortfall(count_short || rate_short, dropped);
312        if shortfall.is_unobservable() {
313            unmet.push(format!(
314                "{dropped} sample(s) dropped — the shortfall may not be real (O6)"
315            ));
316        }
317        ExpectVerdict::from(shortfall)
318    };
319
320    Ok(ExpectReport {
321        selector: spec.selector.clone(),
322        window_s,
323        ended_early,
324        samples,
325        keys_seen: keys.len(),
326        dropped,
327        rate_hz,
328        violations: violations.into_vec(),
329        violations_total,
330        unmet,
331        verdict,
332    })
333}