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}