ktstr/assert/plan.rs
1use super::*;
2
3impl AssertResult {
4 /// Empty passing result with no outcomes and default stats. Use
5 /// when a scenario completed successfully with nothing interesting
6 /// to report. Zero-allocation: `outcomes` is an empty `Vec` and
7 /// [`Self::outcome`] folds it to [`Outcome::Pass`] via the
8 /// merge identity.
9 pub fn pass() -> Self {
10 Self {
11 outcomes: vec![],
12 passes: vec![],
13 stats: Default::default(),
14 measurements: std::collections::BTreeMap::new(),
15 info_notes: vec![],
16 }
17 }
18 /// Pass result with a skip reason. Used when a scenario cannot run
19 /// under the current topology or flag combination but is not a failure.
20 /// Seeds [`Self::outcomes`] with a single [`Outcome::Skip`] carrying
21 /// the reason.
22 ///
23 /// **Skip is not Pass**: a skipped result reports `is_pass() == false`
24 /// (the outcomes vec contains a non-Pass entry). Callers that want
25 /// "not a failure" gate semantics must test
26 /// `r.is_pass() || r.is_skip()` rather than bare `r.is_pass()` —
27 /// otherwise skipped runs count as failures.
28 pub fn skip(reason: impl Into<String>) -> Self {
29 Self {
30 outcomes: vec![Outcome::Skip(AssertDetail::new(DetailKind::Skip, reason))],
31 ..Self::pass()
32 }
33 }
34 /// Failing result carrying a single [`AssertDetail`]. Mirrors
35 /// [`Self::pass`] / [`Self::skip`] for the failure axis so callers
36 /// don't hand-roll the struct-literal shape at every diagnostic-only
37 /// failure site. Seeds [`Self::outcomes`] with a single
38 /// [`Outcome::Fail`] carrying the detail.
39 pub fn fail(detail: AssertDetail) -> Self {
40 Self {
41 outcomes: vec![Outcome::Fail(detail)],
42 ..Self::pass()
43 }
44 }
45 /// Failing result carrying a single diagnostic message with
46 /// [`DetailKind::Other`]. Shortcut for the common nesting
47 /// `AssertResult::fail(AssertDetail::new(DetailKind::Other, msg))`
48 /// at call sites where the failure is a diagnostic message and
49 /// the kind is always `Other`. Named `fail_msg` rather than
50 /// `fail_other` so the call site reads "failing result with a
51 /// message" without leaking the [`DetailKind`] variant name into
52 /// the API surface; external callers that do want a specific
53 /// `kind` still reach for `AssertResult::fail` +
54 /// `AssertDetail::new(kind, msg)`.
55 pub fn fail_msg(msg: impl Into<String>) -> Self {
56 Self::fail(AssertDetail::new(DetailKind::Other, msg))
57 }
58
59 /// Inconclusive result carrying a single [`AssertDetail`].
60 /// Mirrors [`Self::pass`] / [`Self::skip`] / [`Self::fail`] for
61 /// the inconclusive axis so callers don't hand-roll the struct-
62 /// literal shape at sites that need to construct a fresh
63 /// "couldn't evaluate" envelope (the symmetric peer of
64 /// [`Self::fail`] for INSTRUMENT-derived zero-denominator
65 /// gates). Seeds [`Self::outcomes`] with a single
66 /// [`Outcome::Inconclusive`] carrying the detail. For mutating
67 /// an existing accumulator in place, use
68 /// [`Self::record_inconclusive`].
69 pub fn inconclusive(detail: AssertDetail) -> Self {
70 Self {
71 outcomes: vec![Outcome::Inconclusive(detail)],
72 ..Self::pass()
73 }
74 }
75
76 /// Inconclusive result carrying a single message-only diagnostic.
77 /// Shorthand for `AssertResult::inconclusive(AssertDetail::new(
78 /// DetailKind::Other, msg))` — mirrors [`Self::fail_msg`] for the
79 /// inconclusive axis at call sites where the operator hint is a
80 /// flat string and the structured [`DetailKind`] would always be
81 /// `Other`. Callers that need a specific kind still reach for
82 /// `AssertResult::inconclusive` + `AssertDetail::new(kind, msg)`.
83 pub fn inconclusive_msg(msg: impl Into<String>) -> Self {
84 Self::inconclusive(AssertDetail::new(DetailKind::Other, msg))
85 }
86
87 /// Atomically record a Fail outcome carrying `detail`. Replaces
88 /// the legacy two-step pattern `r.passed = false; r.details.push(d);`
89 /// — collapses the producer-defect window where the discriminant
90 /// flipped without a corresponding diagnostic. Returns `&mut Self`
91 /// for chaining.
92 ///
93 /// See [`Self::record_inconclusive`] for ratio gates whose
94 /// denominator legitimately reached zero — neither Pass nor
95 /// Fail is truthful there, and Fail-coding a "couldn't evaluate"
96 /// run loses signal in CI triage.
97 pub fn record_fail(&mut self, detail: AssertDetail) -> &mut Self {
98 self.outcomes.push(Outcome::Fail(detail));
99 self
100 }
101
102 /// Atomically record a Skip outcome carrying `reason`. Replaces
103 /// the legacy two-step pattern `r.skipped = true;
104 /// r.details.push(AssertDetail::new(DetailKind::Skip, reason));`.
105 /// Returns `&mut Self` for chaining.
106 ///
107 /// Boundary with [`Self::record_inconclusive`]: Skip = scenario
108 /// precondition unmet (the check doesn't apply — e.g. host lacks
109 /// the topology the test needs); Inconclusive = precondition met
110 /// and the check applied, but the signal was absent and the gate
111 /// couldn't conclude (e.g. a ratio with a zero denominator).
112 /// Mis-coding an Inconclusive case as Skip drops it from the
113 /// "ran but couldn't evaluate" bucket CI gates need for triage.
114 pub fn record_skip(&mut self, reason: impl Into<String>) -> &mut Self {
115 self.outcomes
116 .push(Outcome::Skip(AssertDetail::new(DetailKind::Skip, reason)));
117 self
118 }
119
120 /// Atomically record an Inconclusive outcome carrying `detail`.
121 /// Signature mirrors [`Self::record_fail`] (takes the full
122 /// [`AssertDetail`] so the producer's [`DetailKind`] flows into
123 /// the inconclusive record for filterable diagnostics — a
124 /// zero-iteration `max_migration_ratio` site emits
125 /// `DetailKind::Migration`, not a flat string). Use for ratio
126 /// gates whose INSTRUMENT-derived denominator (iteration count,
127 /// sample count, wall-clock interval) reached zero: the gate
128 /// has no signal to evaluate, neither Pass nor Fail is a
129 /// truthful verdict. Returns `&mut Self` for chaining.
130 ///
131 /// Boundary with [`Self::record_skip`]: Inconclusive = the gate
132 /// applied (preconditions met) but the signal was absent;
133 /// Skip = the gate's precondition was unmet (e.g. host lacks
134 /// the required topology) so the check did NOT apply. Boundary
135 /// with [`Self::record_fail`]: Inconclusive = denominator is
136 /// INSTRUMENT-derived (a measurement count that happened to be
137 /// zero); Fail = denominator is POLICY-derived (a configured
138 /// expectation that must hold — see the [`Outcome`] doc's
139 /// `MemPolicy::Bind` carve-out for the canonical example).
140 pub fn record_inconclusive(&mut self, detail: AssertDetail) -> &mut Self {
141 self.outcomes.push(Outcome::Inconclusive(detail));
142 self
143 }
144
145 /// Explicitly record a Pass marker. Rare — the zero-state
146 /// `AssertResult::pass()` already folds to [`Outcome::Pass`] via
147 /// the merge identity over an empty vec. Use when a test helper
148 /// wants the outcome stream to carry an explicit pass record for
149 /// per-check accounting (e.g. "this specific check ran and
150 /// passed" vs "no check ran"). Returns `&mut Self` for chaining.
151 pub fn record_pass(&mut self) -> &mut Self {
152 self.outcomes.push(Outcome::Pass);
153 self
154 }
155
156 /// Escape hatch: push a pre-folded [`Outcome`] onto the stream.
157 /// Used by helpers that compute a verdict externally (e.g.
158 /// "this branch returned `Outcome::Fail(d)`") and want to fold
159 /// it into the running [`Self::outcomes`] without re-deriving
160 /// the variant. Returns `&mut Self` for chaining.
161 pub fn record_outcome(&mut self, outcome: Outcome) -> &mut Self {
162 self.outcomes.push(outcome);
163 self
164 }
165
166 /// True iff the scenario completed without failure or
167 /// inconclusive verdict and actually ran (i.e. wasn't all-Skip).
168 /// An empty outcomes stream (the [`Self::pass`] zero-state,
169 /// which is the merge identity element) satisfies this; any
170 /// stream containing at least one real Pass marker alongside no
171 /// Fail / Inconclusive also satisfies it; an all-Skip stream
172 /// returns false (a skipped scenario didn't pass, it didn't
173 /// run); any Inconclusive returns false (a zero-denominator
174 /// ratio gate didn't pass, it couldn't evaluate).
175 ///
176 /// Mechanically: `!self.is_fail() && !self.is_inconclusive() &&
177 /// !self.is_skip()`. The three conjuncts capture "no failure
178 /// recorded", "no inconclusive verdict recorded", AND "not
179 /// vacuously satisfied by all-skip".
180 ///
181 /// Part of the `is_pass` / `is_fail` / `is_inconclusive` /
182 /// `is_skip` vocabulary uniform across the verdict surfaces:
183 /// [`AssertResult::is_pass`] /
184 /// [`crate::test_support::SidecarResult::is_pass`] /
185 /// [`Outcome::is_pass`] / `MonitorVerdict::is_pass` (in the
186 /// `monitor` module, which is `pub(crate)`) / `Verdict::is_pass`
187 /// (re-exported at [`crate::assert::Verdict`]) /
188 /// `GauntletRow::is_pass` (in the `stats` module, which is
189 /// `pub(crate)`).
190 pub fn is_pass(&self) -> bool {
191 !self.is_fail() && !self.is_inconclusive() && !self.is_skip()
192 }
193
194 /// True iff any recorded outcome is [`Outcome::Fail`]. Any fail
195 /// in the stream dominates per `Fail > Inconclusive > Pass > Skip`
196 /// precedence.
197 pub fn is_fail(&self) -> bool {
198 self.outcomes.iter().any(Outcome::is_fail)
199 }
200
201 /// True iff `outcomes` is non-empty AND every entry is
202 /// [`Outcome::Skip`]. Empty `outcomes` is the Pass identity,
203 /// NOT a vacuous Skip — `is_skip()` returns false on empty.
204 pub fn is_skip(&self) -> bool {
205 !self.outcomes.is_empty() && self.outcomes.iter().all(Outcome::is_skip)
206 }
207
208 /// True iff any recorded outcome is [`Outcome::Inconclusive`]
209 /// AND no [`Outcome::Fail`] dominates it. Mirrors the precedence
210 /// `Fail > Inconclusive > Pass > Skip`: a Fail-plus-Inconclusive
211 /// stream is `is_fail() == true` and `is_inconclusive() == false`
212 /// (the Fail wins; Inconclusive is dominated). Used by CI gates
213 /// that want to surface "couldn't evaluate" verdicts distinctly
214 /// from passes and failures.
215 pub fn is_inconclusive(&self) -> bool {
216 !self.is_fail() && self.outcomes.iter().any(Outcome::is_inconclusive)
217 }
218
219 /// Iterate every [`Outcome::Fail`]'s payload. Use to extract
220 /// failure diagnostics for rendering or stats roll-up. Does NOT
221 /// include [`Outcome::Inconclusive`] payloads —
222 /// [`Self::inconclusive_details`] is the sibling iterator for
223 /// those, and [`Self::into_anyhow_or_log`] bails only on Fail
224 /// so folding Inconclusive into this iterator would break the
225 /// "couldn't evaluate doesn't fail the run" semantic.
226 pub fn failure_details(&self) -> impl Iterator<Item = &AssertDetail> {
227 self.outcomes.iter().filter_map(|o| match o {
228 Outcome::Fail(d) => Some(d),
229 _ => None,
230 })
231 }
232
233 /// Iterate every [`Outcome::Skip`]'s payload. Use to extract
234 /// skip reasons when triaging "scenario didn't run" outcomes.
235 /// The `_details` suffix mirrors [`Self::failure_details`] /
236 /// [`Self::inconclusive_details`] — all three yield
237 /// `&AssertDetail` payloads.
238 pub fn skip_details(&self) -> impl Iterator<Item = &AssertDetail> {
239 self.outcomes.iter().filter_map(|o| match o {
240 Outcome::Skip(d) => Some(d),
241 _ => None,
242 })
243 }
244
245 /// Iterate every [`Outcome::Inconclusive`]'s payload. Use to
246 /// extract diagnostic context for zero-denominator ratio gates
247 /// or other "couldn't evaluate" verdicts when triaging.
248 /// Symmetric with [`Self::failure_details`] /
249 /// [`Self::skip_details`]; not folded into either so the
250 /// failure / skip / inconclusive surfaces remain separately
251 /// addressable. The `_details` suffix mirrors
252 /// [`Self::failure_details`] — both yield `&AssertDetail`
253 /// payloads that drive triage of material verdicts.
254 pub fn inconclusive_details(&self) -> impl Iterator<Item = &AssertDetail> {
255 self.outcomes.iter().filter_map(|o| match o {
256 Outcome::Inconclusive(d) => Some(d),
257 _ => None,
258 })
259 }
260
261 /// Terminal post_vm-callback helper: route every
262 /// [`Self::info_notes`] entry through `tracing::info!` (so
263 /// `--nocapture` + `RUST_LOG=ktstr=info` users see them, but
264 /// default-noise-level runs stay quiet) and bail on any
265 /// accumulated failure OR inconclusive verdict. Returns `Ok(())`
266 /// only on the pass / pure-skip path — idiomatic post_vm usage
267 /// chains `?` to propagate the verdict or continue.
268 ///
269 /// # Failure behavior
270 ///
271 /// Per the precedence `Fail > Inconclusive > Pass > Skip`, Fail
272 /// dominates Inconclusive: when any [`Outcome::Fail`] is recorded
273 /// the helper bails with the failure narrative, regardless of
274 /// any sibling Inconclusive outcomes. Every entry from
275 /// [`Self::failure_details`] is concatenated into the returned
276 /// `anyhow::Error` message — all failures surface, the helper
277 /// does NOT drop N-1 details when multiple claims failed.
278 ///
279 /// # Inconclusive behavior
280 ///
281 /// When no failure is present but at least one Inconclusive is
282 /// recorded (a zero-denominator ratio gate that couldn't
283 /// evaluate), the helper bails with a distinct preamble
284 /// `"N inconclusive verdict(s):"` carrying every
285 /// [`Self::inconclusive_details`] payload. This prevents the
286 /// silent-pass class of bug where a CI gate keying off
287 /// `into_anyhow_or_log().is_ok()` would treat an Inconclusive
288 /// run as green (the `is_pass()`-keyed invariant fails on
289 /// Inconclusive, so the bail surface must match). The
290 /// `"inconclusive verdict(s)"` preamble distinguishes the bail
291 /// narrative from the failure preamble `"N assertion failures:"`
292 /// so an operator triaging the log can immediately tell whether
293 /// the run failed claims or merely lacked signal to evaluate them.
294 ///
295 /// # Note ordering
296 ///
297 /// Info notes are logged BEFORE the verdict check fires, so on
298 /// a failed or inconclusive run the operator sees the
299 /// diagnostic observations that led to the verdict ALONGSIDE
300 /// the bail message in their log feed (rather than the bail
301 /// terminating before the notes surface).
302 ///
303 /// # `tracing` vs `println!`
304 ///
305 /// Notes are emitted via `tracing::info!` with target
306 /// `"ktstr::assert"` — the parent namespace of the comparator
307 /// pass-arm's more specific `"ktstr::assert::claim"` target at
308 /// [`crate::assert::claim`]. Operators set
309 /// `RUST_LOG=ktstr::assert=info` (or broader) to surface them;
310 /// `println!` would bypass the tracing subscriber and bake in
311 /// stdout-only visibility.
312 ///
313 /// # Composability
314 ///
315 /// [`crate::assert::Verdict::into_anyhow_or_log`] is a thin
316 /// wrapper for callers that hold a `Verdict` directly.
317 pub fn into_anyhow_or_log(self) -> anyhow::Result<()> {
318 for note in &self.info_notes {
319 tracing::info!(target: "ktstr::assert", "{}", note.message);
320 }
321 let failures: Vec<String> = self.failure_details().map(|d| d.message.clone()).collect();
322 if !failures.is_empty() {
323 let combined = if failures.len() == 1 {
324 failures.into_iter().next().unwrap()
325 } else {
326 let mut out = format!("{} assertion failures:\n", failures.len());
327 for (i, msg) in failures.iter().enumerate() {
328 out.push_str(&format!(" {}. {}\n", i + 1, msg));
329 }
330 out.trim_end().to_string()
331 };
332 anyhow::bail!("{}", combined);
333 }
334 let inconclusives: Vec<String> = self
335 .inconclusive_details()
336 .map(|d| d.message.clone())
337 .collect();
338 if !inconclusives.is_empty() {
339 let combined = if inconclusives.len() == 1 {
340 format!("1 inconclusive verdict: {}", inconclusives[0])
341 } else {
342 let mut out = format!("{} inconclusive verdicts:\n", inconclusives.len());
343 for (i, msg) in inconclusives.iter().enumerate() {
344 out.push_str(&format!(" {}. {}\n", i + 1, msg));
345 }
346 out.trim_end().to_string()
347 };
348 anyhow::bail!("{}", combined);
349 }
350 Ok(())
351 }
352 /// Append an informational annotation to [`Self::info_notes`].
353 /// Does NOT alter the terminal verdict ([`Self::outcome`] is unaffected) — a note
354 /// is context, not a verdict. Use to surface observed values
355 /// alongside a passing or failing result so the sidecar carries
356 /// the diagnostic context an operator needs without forcing every
357 /// test to hand-format a `format!` and push onto `details`
358 /// directly. Notes live on the structurally-separate
359 /// [`Self::info_notes`] field — sidecar consumers iterating
360 /// `details` see only failures, eliminating the prior
361 /// "forgot to filter `kind == Note`" silent-miscount class of bug.
362 pub fn note(&mut self, msg: impl Into<String>) -> &mut Self {
363 self.info_notes.push(InfoNote::new(msg));
364 self
365 }
366 /// Drop the best-effort RAW per-phase per-cgroup sample vectors
367 /// (`wake_latencies_ns`, `timer_latencies_ns`, `run_delays_ns`,
368 /// `off_cpu_pcts`) and the schbench per-phase histograms from every phase
369 /// bucket, keeping the reduced counters (`num_workers`, `total_*`,
370 /// `wake_sample_total`, the NUMA counts, the coupled gap) and the rest of the
371 /// verdict. Returns the number of samples dropped.
372 ///
373 /// Graceful-degradation lever for [`crate::vmm::guest_comms::send_test_result`]:
374 /// This is the first path to put raw per-cgroup sample vectors on the
375 /// size-limited guest bulk port, and a scenario with many step-local cgroups
376 /// over many steps accumulates one (non-merging) carrier per cgroup-step, so
377 /// the AGGREGATE can exceed the frame even though each carrier is reservoir-
378 /// capped. Rather than replace the whole AssertResult with a synthetic FAIL
379 /// (a PASS→FAIL flip + total telemetry loss), the sender drops only these
380 /// best-effort sample pools — the verdict, outcomes, and every scalar/counter
381 /// (including `wake_sample_total`, the NUMA counts, the coupled gap, and the
382 /// counter-derived metrics) survive. The wake p99 / median / CV (from
383 /// `wake_latencies_ns`) and mean / worst run-delay (from `run_delays_ns`)
384 /// re-pools DEGRADE rather than vanish: with their carrier samples gone
385 /// every carrier is empty, so `populate_run_distribution_metrics` folds the
386 /// surviving per-cgroup [`CgroupStats`] reductions worst-wins (the pre-Item-7
387 /// cross-cgroup max — a worst-cgroup proxy, not the pooled distribution).
388 /// `off_cpu_pcts` has no run-level re-pool consumer (worst_spread / off-CPU%
389 /// come from the typed `CgroupStats` merge fold, not the carrier — off-CPU%
390 /// is intrinsically per-cgroup, out of the run-level re-pool), so dropping it
391 /// loses only the per-phase off-CPU% render, not any run-level value.
392 pub(crate) fn strip_phase_cgroup_samples(&mut self) -> usize {
393 let mut dropped = 0usize;
394 for bucket in &mut self.stats.phases {
395 for pc in bucket.per_cgroup.values_mut() {
396 // Mark stripped only when this carrier ACTUALLY had samples to
397 // drop, so the render distinguishes "stripped for size" from a
398 // carrier that genuinely measured nothing (already-empty vecs
399 // stay not-stripped).
400 // schbench per-phase histograms (wakeup + request + rps, ~19 KiB each)
401 // also multiply by carrier count, so a many-carrier overflow
402 // with schbench carriers present must drop them too (a
403 // worker_count>1 schbench cgroup can emit several). Dropping
404 // schbench loses only the per-phase schbench percentiles (the
405 // derivation finds None → emits no key → the claim reads the
406 // metric as ABSENT, a loud degradation), never the verdict.
407 let schbench_samples = pc
408 .schbench
409 .as_ref()
410 .map(|s| {
411 s.wakeup.sample_count() + s.request.sample_count() + s.rps.sample_count()
412 })
413 .unwrap_or(0);
414 let had_samples = !pc.wake_latencies_ns.is_empty()
415 || !pc.timer_latencies_ns.is_empty()
416 || !pc.run_delays_ns.is_empty()
417 || !pc.off_cpu_pcts.is_empty()
418 || schbench_samples > 0;
419 dropped += pc.wake_latencies_ns.len()
420 + pc.timer_latencies_ns.len()
421 + pc.run_delays_ns.len()
422 + pc.off_cpu_pcts.len()
423 + schbench_samples as usize;
424 pc.wake_latencies_ns = Vec::new();
425 pc.timer_latencies_ns = Vec::new();
426 pc.run_delays_ns = Vec::new();
427 pc.off_cpu_pcts = Vec::new();
428 pc.schbench = None;
429 if had_samples {
430 pc.stripped = true;
431 }
432 }
433 }
434 dropped
435 }
436 /// Builder-style sibling of [`Self::note`] returning the
437 /// owned result so a scenario can chain
438 /// `AssertResult::pass().with_note("max_wchar=12345")` at
439 /// the return site. Equivalent to calling
440 /// [`Self::note`] on a mutable binding.
441 pub fn with_note(mut self, msg: impl Into<String>) -> Self {
442 self.note(msg);
443 self
444 }
445 /// Terminal verdict as a single [`Outcome`] value, aligned with
446 /// [`Self::is_pass`] / [`Self::is_fail`] /
447 /// [`Self::is_inconclusive`] / [`Self::is_skip`]:
448 ///
449 /// - any [`Outcome::Fail`] in the stream → [`Outcome::Fail`]
450 /// carrying the first Fail's payload (the LEFT operand wins
451 /// per [`Outcome::merge`]'s payload-tie semantics).
452 /// - else any [`Outcome::Inconclusive`] in the stream →
453 /// [`Outcome::Inconclusive`] carrying the first
454 /// Inconclusive's payload (the gate ran but couldn't
455 /// evaluate; per `Fail > Inconclusive > Pass > Skip` this
456 /// sits below Fail but above Pass and Skip).
457 /// - else non-empty all-[`Outcome::Skip`] → [`Outcome::Skip`]
458 /// carrying the first Skip's payload (a scenario whose only
459 /// recorded gates were skips didn't run — the terminal
460 /// verdict is Skip, not Pass).
461 /// - else (empty stream OR at least one explicit
462 /// [`Outcome::Pass`] marker alongside no Fail / Inconclusive)
463 /// → [`Outcome::Pass`] (the zero-allocation pass identity
464 /// also lands here).
465 ///
466 /// Diverges from the naive `Outcome::merge` fold over the
467 /// identity element [`Outcome::Pass`]: that fold would treat
468 /// `[Skip(d)]` as `Pass.merge(Skip(d)) = Pass` per the
469 /// `Fail > Inconclusive > Pass > Skip` precedence, contradicting
470 /// the all-Skip branch of [`Self::is_skip`]. This accessor
471 /// encodes the "empty Pass identity" / "real Pass beats Skip" /
472 /// "all-Skip is Skip terminal" distinctions the boolean
473 /// accessors enforce.
474 ///
475 /// Use [`Self::outcome_ref`] when the caller only needs to
476 /// inspect the verdict shape/payload without taking ownership —
477 /// avoids the per-call `AssertDetail::clone` this accessor
478 /// performs on the `Skip` / `Fail` arms.
479 pub fn outcome(&self) -> Outcome {
480 // Delegates to [`Self::outcome_ref`] + [`OutcomeRef::to_owned`]
481 // so the fold rule (Fail > Inconclusive > Pass > Skip with
482 // the empty-vec / all-Skip / mixed-Pass-plus-Skip branch
483 // resolution) lives in ONE place. A future change to the
484 // fold lands at `outcome_ref` and propagates here for free;
485 // the drift-guard test
486 // `assert_result_outcome_ref_matches_owned_outcome_shape`
487 // in `tests_assert.rs` was originally written to catch
488 // divergence between two parallel implementations — after
489 // this delegation it instead catches a single-source bug
490 // (e.g. fold-rule + `as_ref`/`to_owned` mapping drift) but
491 // remains load-bearing.
492 self.outcome_ref().to_owned()
493 }
494
495 /// Borrow the terminal verdict as an [`OutcomeRef`]. Same fold
496 /// semantics as [`Self::outcome`] —
497 /// `Fail > Inconclusive > Pass > Skip` precedence, empty-vec /
498 /// non-empty-all-Skip / mixed-Pass-plus-Skip branches all match
499 /// — but the `Skip(_)` / `Inconclusive(_)` / `Fail(_)` arms
500 /// borrow the source [`AssertDetail`] from `self.outcomes`
501 /// instead of cloning. Use when the caller holds the source
502 /// `AssertResult` and wants the verdict payload without the
503 /// per-call clone (formatter / sidecar emit / debug-render paths).
504 ///
505 /// Drift guard: `assert_result_outcome_ref_matches_owned_outcome_shape`
506 /// in `tests_assert.rs` pins the lockstep with [`Self::outcome`];
507 /// any divergence (e.g. a future refactor that adds a new
508 /// terminal arm here but forgets the owned accessor, or vice
509 /// versa) trips the test.
510 pub fn outcome_ref(&self) -> OutcomeRef<'_> {
511 if let Some(d) = self.failure_details().next() {
512 OutcomeRef::Fail(d)
513 } else if let Some(d) = self.inconclusive_details().next() {
514 OutcomeRef::Inconclusive(d)
515 } else if let Some(d) = self.skip_details().next() {
516 if self.outcomes.iter().all(Outcome::is_skip) {
517 OutcomeRef::Skip(d)
518 } else {
519 OutcomeRef::Pass
520 }
521 } else {
522 OutcomeRef::Pass
523 }
524 }
525 /// Fold `other` into `self`. The four parallel vecs/maps —
526 /// [`Self::outcomes`], [`Self::passes`], [`Self::info_notes`],
527 /// [`Self::measurements`] — all extend with `other`'s contents
528 /// (the three vecs concatenate; `measurements` is a `BTreeMap`
529 /// merged with plain last-write-wins on key collision, i.e.
530 /// `other`'s value overwrites `self`'s for shared keys).
531 /// Aggregate `stats` adopt the worst-case value per dimension
532 /// so the merged result represents the union of all checks
533 /// applied. The polarity-aware per-key min/max selection for
534 /// extensible scheduler metrics is a separate mechanism that
535 /// applies inside `stats.ext_metrics` only — see the loop at
536 /// the bottom of this body for the polarity registry path; the
537 /// result-level `measurements` map deliberately does not consult
538 /// the registry (it is a producer-attached typed annotation map,
539 /// not a roll-up aggregation surface).
540 ///
541 /// Terminal-verdict semantics fall out automatically per the
542 /// precedence `Fail > Inconclusive > Pass > Skip`: appending
543 /// `other.outcomes` keeps every Fail in the stream so
544 /// [`Self::outcome`]'s fold surfaces them; absent any Fail, any
545 /// Inconclusive in either side dominates Pass/Skip so a
546 /// zero-denominator gate in one branch survives the fold;
547 /// Skip survives only when both inputs were Skip-only because a
548 /// Pass or Inconclusive entry in either side beats Skip.
549 pub fn merge(&mut self, mut other: AssertResult) {
550 self.outcomes.extend(other.outcomes);
551 self.passes.extend(other.passes);
552 self.info_notes.extend(other.info_notes);
553 let s = &mut self.stats;
554 let o = &other.stats;
555 // total_workers / total_cpus are ktstr-configured TOPOLOGY counts
556 // (bounded by the guest CPU count), so plain `+=`. total_migrations /
557 // total_iterations are guest-runtime monotonic counters pooled across VM
558 // results: saturating_add so a corrupt/hostile value can't wrap to a
559 // silently-wrong run-level total (exact for every in-range value).
560 s.total_workers += o.total_workers;
561 s.total_cpus += o.total_cpus;
562 s.total_migrations = s.total_migrations.saturating_add(o.total_migrations);
563 s.total_iterations = s.total_iterations.saturating_add(o.total_iterations);
564 s.worst_spread = s.worst_spread.max(o.worst_spread);
565 s.worst_migration_ratio = s.worst_migration_ratio.max(o.worst_migration_ratio);
566 // worst_p99/median/cv wake-latency + mean/worst run-delay are no
567 // longer folded here: they are `MetricKind::Distribution`, re-pooled
568 // post-merge from the per-cgroup raw samples by
569 // `populate_run_distribution_metrics` (the pooled-distribution
570 // percentile, not a max of per-cgroup reductions).
571 // worst_cross_node_migration_ratio is no longer folded here: it is
572 // `MetricKind::WorstCrossNodeRatio`, re-pooled post-merge by
573 // `populate_run_distribution_metrics` from the per-phase NUMA carriers
574 // (the MAX per-cgroup churn ratio over the latest residency total), so the
575 // sidecar value agrees with run_metric's re-derivation.
576 // worst_wake_latency_tail_ratio is no longer folded here: it is
577 // `MetricKind::WakeLatencyTailRatio`, re-selected post-merge by
578 // `populate_run_distribution_metrics` (the max over the merged
579 // `stats.cgroups` per-cgroup p99/median ratios, floor-gated).
580 // worst_iterations_per_worker / worst_iterations_per_cpu_sec are no
581 // longer folded here: they are `MetricKind::WorstLowest`, re-selected
582 // post-merge by `populate_run_distribution_metrics` (the lowest-wins,
583 // None-aware min over the merged `stats.cgroups` counters — the same
584 // starvation semantic the deleted `fold_lowest_some` carried).
585 // Coupled fields: `worst_gap_cpu` must come from the same
586 // cgroup that posted the new worst `worst_gap_ms`.
587 if o.worst_gap_ms > s.worst_gap_ms {
588 s.worst_gap_ms = o.worst_gap_ms;
589 s.worst_gap_cpu = o.worst_gap_cpu;
590 }
591 // worst_page_locality is no longer folded here: it is
592 // `MetricKind::WorstLowest` (NumaLocal/NumaTotal), re-pooled post-merge
593 // by `populate_run_distribution_metrics` from the per-phase NUMA carriers
594 // (a measured 0.0 — all pages off-node — wins the lowest; the buggy
595 // `fold_lowest_nonzero` that skipped it as a sentinel is removed).
596 // Merge extensible metrics: take worst per key according to
597 // each metric's polarity in the MetricDef registry. For
598 // `higher_is_worse: true` the worst is max; for
599 // `higher_is_worse: false` the worst is min.
600 //
601 // Unregistered metric names fall through to
602 // [`crate::stats::infer_higher_is_worse`], which derives the
603 // polarity from name substrings (e.g. `*_iops`,
604 // `*_latency_us`). Without the inference, a payload-author
605 // throughput metric — e.g. `jobs.0.read.iops` from
606 // `OutputFormat::Json` — would fold with `max`,
607 // keeping the BETTER (higher) value across cgroups and
608 // masking a cgroup that fell behind. The inference returns a
609 // higher-is-worse default when no token matches, so genuinely
610 // unknown names still surface their max (the safer side of
611 // the regression-vs-improvement misclassification).
612 //
613 // `or_insert(*v)` rather than `or_insert(0.0)`: the old sentinel
614 // clobbered real-but-small values for min-polarity metrics on
615 // first merge, making the subsequent min comparison meaningless.
616 for (k, v) in &other.stats.ext_metrics {
617 let higher_is_worse = crate::stats::metric_def(k)
618 .map(|m| m.higher_is_worse())
619 .unwrap_or_else(|| crate::stats::infer_higher_is_worse(k));
620 let entry = self.stats.ext_metrics.entry(k.clone()).or_insert(*v);
621 *entry = if higher_is_worse {
622 entry.max(*v)
623 } else {
624 entry.min(*v)
625 };
626 }
627 // Merge `phases` per `step_index`. For matched phases on
628 // both sides, fold per-metric using `MetricKind::merge_kind`
629 // to pick the commutative or NonCommutative path. Unpaired
630 // phases (one side only) carry through verbatim — never
631 // silently dropped, per the no-silent-drops contract. The
632 // result is sorted by `step_index` for a deterministic
633 // observable order regardless of merge-arrival order.
634 //
635 // Move `other.stats.phases` out before the per-cgroup
636 // extend below (which moves the sibling `cgroups` field).
637 // After the take, `other.stats.phases` is an empty Vec —
638 // never read again because the rest of this fn references
639 // only `other.stats.cgroups` and `other.measurements`.
640 let other_phases = std::mem::take(&mut other.stats.phases);
641 if !self.stats.phases.is_empty() || !other_phases.is_empty() {
642 let other_len = other_phases.len();
643 // No-silent-drops: phase buckets have unique step_index
644 // (build_phase_buckets_with_stimulus / collect_step emit one per
645 // step_index), but fold same-step_index duplicates via merge rather
646 // than a last-wins collect so a future producer that violated the
647 // invariant DEGRADES to a merge, never a silent release-mode drop. The
648 // debug_assert still trips loudly in test/debug.
649 let mut other_by_idx: std::collections::BTreeMap<u16, PhaseBucket> =
650 std::collections::BTreeMap::new();
651 for b in other_phases {
652 match other_by_idx.remove(&b.step_index) {
653 Some(existing) => {
654 other_by_idx.insert(b.step_index, merge_matched_phase_buckets(existing, b));
655 }
656 None => {
657 other_by_idx.insert(b.step_index, b);
658 }
659 }
660 }
661 debug_assert_eq!(
662 other_by_idx.len(),
663 other_len,
664 "merged phase buckets must have unique step_index; a collision merged (not dropped)",
665 );
666 let self_buckets = std::mem::take(&mut self.stats.phases);
667 let mut merged: Vec<PhaseBucket> =
668 Vec::with_capacity(self_buckets.len() + other_by_idx.len());
669 for s_bucket in self_buckets {
670 if let Some(o_bucket) = other_by_idx.remove(&s_bucket.step_index) {
671 merged.push(merge_matched_phase_buckets(s_bucket, o_bucket));
672 } else {
673 merged.push(s_bucket);
674 }
675 }
676 merged.extend(other_by_idx.into_values());
677 merged.sort_by_key(|b| b.step_index);
678 self.stats.phases = merged;
679 }
680
681 // Append per-cgroup stats last: moving `other.stats.cgroups`
682 // here consumes `other.stats`, so every scalar/map access
683 // above goes through the `&other.stats` reference first.
684 self.stats.cgroups.extend(other.stats.cgroups);
685
686 // Fold structured measurements. Keys from `other` overwrite
687 // existing keys from `self` because the merge protocol treats
688 // the right-hand side as a more recent observation; a
689 // duplicate-key write is a producer bug (two cgroups
690 // measuring the same global metric) but the "later wins"
691 // policy keeps the result deterministic for tests pinning
692 // merge order. Producers that need additive accumulation
693 // should use `stats.ext_metrics` (which has explicit polarity
694 // semantics) rather than `measurements`.
695 for (k, v) in other.measurements {
696 self.measurements.insert(k, v);
697 }
698 }
699
700 /// Attach a structured `(key, value)` measurement to the result.
701 /// Writes into [`Self::measurements`] without altering
702 /// the terminal verdict ([`Self::outcome`]) —
703 /// pure context for stats tooling.
704 ///
705 /// Distinct from [`Self::note`]: `note` carries a free-form
706 /// `String` for operator triage; `note_value` carries a typed
707 /// `(key, NoteValue)` pair for programmatic consumption (sidecar
708 /// parsers, `perf-delta` regression dashboards). Producers
709 /// commonly call BOTH — they occupy independent buffers and
710 /// neither overwrites the other.
711 ///
712 /// Key collision policy: a second write with the same `key`
713 /// overwrites the first. The intended call site shape is "one
714 /// producer per key" (one site computes `max_wchar`, one site
715 /// computes `psi_some_total_usec`); accidental key collision
716 /// indicates a producer bug. The test
717 /// `note_value_overwrites_on_duplicate_key` pins this last-
718 /// write-wins semantics.
719 ///
720 /// ```
721 /// # use ktstr::assert::{AssertResult, NoteValue};
722 /// let mut r = AssertResult::pass();
723 /// r.note_value("max_wchar", 12345i64);
724 /// r.note_value("psi_available", true);
725 /// assert_eq!(r.measurements["max_wchar"], NoteValue::Int(12345));
726 /// assert_eq!(r.measurements["psi_available"], NoteValue::Bool(true));
727 /// ```
728 pub fn note_value(&mut self, key: impl Into<String>, value: impl Into<NoteValue>) -> &mut Self {
729 self.measurements.insert(key.into(), value.into());
730 self
731 }
732
733 /// Builder-style sibling of [`Self::note_value`] returning the
734 /// owned result so a scenario can chain
735 /// `AssertResult::pass().with_note_value("max_wchar", 12345u64)`
736 /// at the return site. Equivalent to calling [`Self::note_value`]
737 /// on a mutable binding. Mirrors [`Self::with_note`].
738 pub fn with_note_value(mut self, key: impl Into<String>, value: impl Into<NoteValue>) -> Self {
739 self.note_value(key, value);
740 self
741 }
742
743 /// Fold a sequence of [`AssertResult`]s with OR semantics: the
744 /// returned result passes iff at least one branch passes. Use
745 /// when a test author expresses "either of these two checks
746 /// suffices" — a kernel-version-fork case where one path is
747 /// expected on 6.16 and another on 7.1, or a topology probe
748 /// where any of several detection methods landing is enough.
749 ///
750 /// Outcomes:
751 /// - **At least one branch passes**: returned result is passing.
752 /// `info_notes` carries the union of every passing branch's
753 /// info_notes, each prefix-stamped with `any_of[<branch-idx>]:`
754 /// so an operator can attribute every note to the emitting
755 /// branch. The synthesized "any_of: branch N satisfied the
756 /// disjunction" arbiter annotation is appended last, bare
757 /// (it's not from any branch — it IS the disposition).
758 /// Failed-branch details and info_notes are dropped (they would
759 /// only confuse the operator with messages from the not-taken
760 /// paths). `stats` adopts the first passing branch's `stats`.
761 /// `measurements` union all passing branches' measurements
762 /// (last write wins on key collision, matching `merge`).
763 /// `outcomes` follows the first passing branch (typically
764 /// empty per the Pass identity).
765 /// - **No branch passes; at least one fails**: returned result
766 /// is failing. Every branch's recorded outcomes are re-emitted
767 /// with each payload's message prefixed by
768 /// `"any_of[<branch-idx>]: "` so an operator can identify
769 /// which branch produced which outcome. `stats` and
770 /// `measurements` adopt the FIRST branch's values (an
771 /// arbitrary choice but deterministic). A synthesized summary
772 /// record is appended last carrying the per-disposition
773 /// counts `(F failed, I inconclusive, S skipped of N branches)`.
774 /// - **No branch passes or fails; at least one is Inconclusive**:
775 /// returned result is Inconclusive (the disjunction itself
776 /// could not be evaluated — every branch either was inconclusive
777 /// or skipped, and per the lattice
778 /// `Fail > Inconclusive > Pass > Skip` Inconclusive dominates
779 /// Skip). Same re-emission + summary shape as the fail case,
780 /// but the synthesized record is [`Outcome::Inconclusive`].
781 /// Critical: without this arm, all-zero-denominator branches
782 /// would silently MISCLASSIFY as Fail, defeating the
783 /// Inconclusive primitive's purpose of preserving "couldn't
784 /// evaluate" signal.
785 /// - **All branches skipped**: returned result is Skip. Same
786 /// re-emission + summary shape, with [`Outcome::Skip`] as the
787 /// synthesized record (every alternative check's precondition
788 /// was unmet — the disjunction itself didn't run).
789 /// - **Empty input**: returned result is failing with a single
790 /// Fail outcome explaining the empty `any_of`. An empty
791 /// disjunction is logically false; this surfaces a producer
792 /// bug as a nameable failure rather than a vacuous pass.
793 ///
794 /// Doc: a trivial two-branch test with the second branch passing
795 /// and the first branch failing — pinning that the verdict
796 /// chooses the passer.
797 ///
798 /// ```
799 /// # use ktstr::assert::{AssertDetail, AssertResult, DetailKind};
800 /// let r = AssertResult::any_of([
801 /// {
802 /// let mut a = AssertResult::pass();
803 /// a.record_fail(AssertDetail::new(DetailKind::Other, "branch 0 boom"));
804 /// a
805 /// },
806 /// AssertResult::pass(),
807 /// ]);
808 /// assert!(r.is_pass());
809 /// ```
810 pub fn any_of(branches: impl IntoIterator<Item = AssertResult>) -> AssertResult {
811 let branches: Vec<AssertResult> = branches.into_iter().collect();
812 if branches.is_empty() {
813 return AssertResult::fail(AssertDetail::new(
814 DetailKind::Other,
815 "any_of: empty branch list — a disjunction of zero alternatives is logically false",
816 ));
817 }
818
819 let first_pass_idx = branches.iter().position(|b| b.is_pass());
820 if let Some(idx) = first_pass_idx {
821 // At least one branch passes. Take the first passing
822 // branch as the "chosen" narrative: keep its stats /
823 // outcomes, union measurements AND info_notes across
824 // every passing branch (failed branches' content is
825 // dropped — they would only confuse the operator with
826 // messages from not-taken paths), and prefix every
827 // surviving info_note with `any_of[<branch-idx>]:` for
828 // operator-visible provenance.
829 let mut chosen: Option<AssertResult> = None;
830 let mut union_measurements: std::collections::BTreeMap<String, NoteValue> =
831 std::collections::BTreeMap::new();
832 let mut union_info_notes: Vec<InfoNote> = Vec::new();
833 for (orig_idx, b) in branches.into_iter().enumerate() {
834 if orig_idx == idx {
835 let mut b = b;
836 let pre_notes = std::mem::take(&mut b.info_notes);
837 let pre_meas = std::mem::take(&mut b.measurements);
838 chosen = Some(b);
839 for n in pre_notes {
840 union_info_notes
841 .push(InfoNote::new(format!("any_of[{orig_idx}]: {}", n.message)));
842 }
843 for (k, v) in pre_meas {
844 union_measurements.insert(k, v);
845 }
846 } else if b.is_pass() {
847 for n in b.info_notes {
848 union_info_notes
849 .push(InfoNote::new(format!("any_of[{orig_idx}]: {}", n.message)));
850 }
851 for (k, v) in b.measurements {
852 union_measurements.insert(k, v);
853 }
854 }
855 // Failed/skipped non-chosen branches: contents are
856 // dropped (would confuse the operator with not-taken
857 // path messages).
858 }
859 let mut chosen = chosen.expect("first_pass_idx matched a branch");
860 chosen.measurements = union_measurements;
861 chosen.info_notes = union_info_notes;
862 chosen.info_notes.push(InfoNote::new(format!(
863 "any_of: branch {idx} satisfied the disjunction"
864 )));
865 chosen
866 } else {
867 // No branch passes. Re-emit every branch's outcome
868 // stream with branch-index prefixes; adopt the first
869 // branch's stats / measurements / passes (deterministic
870 // but arbitrary). Variants keep their kind discriminant
871 // so the operator narrative differentiates "this branch
872 // failed" from "this branch was inconclusive" from
873 // "this branch skipped". The synthesized summary record
874 // appended last carries the per-disposition counts AND
875 // its discriminant follows the precedence
876 // `Fail > Inconclusive > Pass > Skip`:
877 // - any Fail branch → synth Fail
878 // - else any Inconclusive → synth Inconclusive
879 // - else (all Skip) → synth Skip
880 // The Inconclusive arm is load-bearing: without it, a
881 // disjunction of zero-denominator branches would silently
882 // MISCLASSIFY as Fail (or, before that arm existed at
883 // all, surface as a misleading "all branches failed"
884 // verdict on data that simply couldn't be evaluated).
885 let total_branches = branches.len();
886 let (n_fail, n_inc, n_skip) =
887 branches
888 .iter()
889 .fold((0usize, 0usize, 0usize), |(f, i, s), b| {
890 if b.is_fail() {
891 (f + 1, i, s)
892 } else if b.is_inconclusive() {
893 (f, i + 1, s)
894 } else if b.is_skip() {
895 (f, i, s + 1)
896 } else {
897 (f, i, s)
898 }
899 });
900 let mut iter = branches.into_iter().enumerate();
901 let (_, first) = iter.next().expect("non-empty checked above");
902 let mut acc = AssertResult {
903 outcomes: Vec::new(),
904 passes: first.passes,
905 stats: first.stats,
906 measurements: first.measurements,
907 info_notes: Vec::new(),
908 };
909 fn reemit_with_prefix(
910 acc: &mut AssertResult,
911 idx: usize,
912 outcomes: Vec<Outcome>,
913 info_notes: Vec<InfoNote>,
914 ) {
915 for o in outcomes {
916 match o {
917 Outcome::Pass => acc.outcomes.push(Outcome::Pass),
918 Outcome::Fail(d) => acc.outcomes.push(Outcome::Fail(AssertDetail::new(
919 d.kind,
920 format!("any_of[{idx}]: {}", d.message),
921 ))),
922 Outcome::Inconclusive(d) => acc.outcomes.push(Outcome::Inconclusive(
923 AssertDetail::new(d.kind, format!("any_of[{idx}]: {}", d.message)),
924 )),
925 Outcome::Skip(d) => acc.outcomes.push(Outcome::Skip(AssertDetail::new(
926 d.kind,
927 format!("any_of[{idx}]: {}", d.message),
928 ))),
929 }
930 }
931 for n in info_notes {
932 acc.info_notes
933 .push(InfoNote::new(format!("any_of[{idx}]: {}", n.message)));
934 }
935 }
936 reemit_with_prefix(&mut acc, 0, first.outcomes, first.info_notes);
937 for (idx, b) in iter {
938 reemit_with_prefix(&mut acc, idx, b.outcomes, b.info_notes);
939 }
940 let summary = format!(
941 "any_of: no branch passed ({n_fail} failed, {n_inc} inconclusive, {n_skip} skipped of {total_branches} branches)"
942 );
943 let synth = if n_fail > 0 {
944 Outcome::Fail(AssertDetail::new(DetailKind::Other, summary))
945 } else if n_inc > 0 {
946 Outcome::Inconclusive(AssertDetail::new(DetailKind::Other, summary))
947 } else {
948 Outcome::Skip(AssertDetail::new(DetailKind::Skip, summary))
949 };
950 acc.outcomes.push(synth);
951 acc
952 }
953 }
954
955 /// Fold a sequence of [`AssertResult`]s with AND semantics:
956 /// equivalent to `branches.into_iter().fold(pass(),
957 /// |acc, b| { acc.merge(b); acc })`. Returns a passing result iff
958 /// every branch passes.
959 ///
960 /// Distinct from [`Self::merge`] in API shape only: `merge`
961 /// folds one external result into an existing accumulator;
962 /// `all_of` folds an iterator of branches into a fresh result.
963 /// Same semantics for `outcomes` (concatenated; Fail dominates
964 /// per `Outcome::merge` precedence), `stats` (worst-per-dimension),
965 /// `measurements` (union with last-write-wins). An empty input
966 /// yields the passing identity (`AssertResult::pass()`) — the
967 /// AND of an empty set is logically true, mirroring
968 /// `Iterator::all`.
969 ///
970 /// Use when the test reads more naturally as "every check
971 /// must hold" than as a merge chain — e.g. when the checks
972 /// are dynamically generated from a slice and the call site
973 /// would otherwise need an explicit `for` loop with `merge`.
974 ///
975 /// ```
976 /// # use ktstr::assert::{AssertDetail, AssertResult, DetailKind};
977 /// let r = AssertResult::all_of([
978 /// AssertResult::pass(),
979 /// AssertResult::pass(),
980 /// ]);
981 /// assert!(r.is_pass());
982 ///
983 /// let r = AssertResult::all_of([
984 /// AssertResult::pass(),
985 /// AssertResult::fail(AssertDetail::new(DetailKind::Other, "boom")),
986 /// ]);
987 /// assert!(r.is_fail());
988 /// ```
989 pub fn all_of(branches: impl IntoIterator<Item = AssertResult>) -> AssertResult {
990 let mut acc = AssertResult::pass();
991 for b in branches {
992 acc.merge(b);
993 }
994 acc
995 }
996}
997
998/// Worker-side assertion plan (crate-internal). Specifies which checks
999/// to run on worker reports after collection.
1000///
1001/// External users should use [`Assert`] and its `assert_cgroup()` method
1002/// instead.
1003#[derive(Clone, Debug, Default)]
1004pub(crate) struct AssertPlan {
1005 pub(crate) not_starved: bool,
1006 pub(crate) isolation: bool,
1007 pub(crate) max_gap_ms: Option<u64>,
1008 pub(crate) max_spread_pct: Option<f64>,
1009 pub(crate) max_throughput_cv: Option<f64>,
1010 pub(crate) min_work_rate: Option<f64>,
1011 pub(crate) max_p99_wake_latency_ns: Option<u64>,
1012 pub(crate) max_wake_latency_cv: Option<f64>,
1013 pub(crate) min_iteration_rate: Option<f64>,
1014 pub(crate) max_migration_ratio: Option<f64>,
1015 pub(crate) min_page_locality: Option<f64>,
1016 pub(crate) max_cross_node_migration_ratio: Option<f64>,
1017 pub(crate) max_slow_tier_ratio: Option<f64>,
1018}
1019
1020impl AssertPlan {
1021 /// Construct an empty `AssertPlan` — equivalent to `AssertPlan::default()`.
1022 /// Kept as an alias for the existing test-suite call style.
1023 #[cfg(test)]
1024 pub(crate) fn new() -> Self {
1025 Self::default()
1026 }
1027
1028 /// Run all configured checks against one cgroup's reports.
1029 ///
1030 /// `cpuset` is the expected CPU set for isolation checks. Pass `None`
1031 /// when there is no cpuset constraint (isolation check is skipped).
1032 ///
1033 /// `numa_nodes` is the NUMA node IDs covered by the cpuset (derived
1034 /// via `TestTopology::numa_nodes_for_cpuset`). Used for page locality
1035 /// and slow-tier ratio checks. Pass `None` when NUMA checks are not
1036 /// applicable.
1037 pub(crate) fn assert_cgroup(
1038 &self,
1039 reports: &[WorkerReport],
1040 cpuset: Option<&BTreeSet<usize>>,
1041 numa_nodes: Option<&BTreeSet<usize>>,
1042 ) -> AssertResult {
1043 // Per-cgroup TELEMETRY is built unconditionally — it is pure
1044 // measurement and must not be gated behind whether any worker
1045 // check is configured (see [`cgroup_stats`]). The opt-in checks
1046 // below only append fail/inconclusive outcomes onto `r`.
1047 let mut cg = cgroup_stats(reports);
1048 // NUMA page-locality telemetry: the expected-node set lives in
1049 // `numa_nodes` (cgroup_stats has no NUMA context), so compute
1050 // locality here whenever it is supplied — independent of whether
1051 // the min_page_locality CHECK is set — and store it on the
1052 // telemetry. The (locality, total, local) triple is reused by that
1053 // check below so the value is computed once.
1054 let page_locality = numa_nodes.map(|nodes| {
1055 // saturating: overflow-safe pool of per-worker per-node page counts
1056 // (a corrupt component must not wrap the page-locality numerator/denom).
1057 let mut total: u64 = 0;
1058 let mut local: u64 = 0;
1059 for w in reports {
1060 for (&node, &count) in &w.numa_pages {
1061 total = total.saturating_add(count);
1062 if nodes.contains(&node) {
1063 local = local.saturating_add(count);
1064 }
1065 }
1066 }
1067 let locality = if total > 0 {
1068 local as f64 / total as f64
1069 } else {
1070 0.0
1071 };
1072 (locality, total, local)
1073 });
1074 if let Some((locality, _, _)) = page_locality {
1075 cg.page_locality = locality;
1076 }
1077 let mut r = AssertResult::pass();
1078 r.stats = scenario_stats_for_cgroup(&cg);
1079
1080 // Opt-in CHECKS in fixed order — each only appends fail /
1081 // inconclusive outcomes onto `r`; the order they run is the order
1082 // outcomes land in `r.outcomes`, which is verdict-significant.
1083 self.eval_fairness(&mut r, &cg, reports);
1084 self.eval_isolation(&mut r, reports, cpuset);
1085 self.eval_throughput_latency(&mut r, reports);
1086 self.eval_migration_locality(&mut r, reports, page_locality);
1087 self.eval_numa_migration_slow_tier(&mut r, reports, numa_nodes);
1088 r
1089 }
1090
1091 /// Fairness CHECKS (Starved / Unfair / Stuck), in order: the
1092 /// default-threshold `not_starved` arm, then the custom
1093 /// `max_spread_pct` and `max_gap_ms` arms. Each appends onto `r`.
1094 fn eval_fairness(&self, r: &mut AssertResult, cg: &CgroupStats, reports: &[WorkerReport]) {
1095 // `not_starved`: default-threshold fairness (Starved / Unfair /
1096 // Stuck) on top of the telemetry already built above.
1097 if self.not_starved {
1098 record_default_fairness(r, cg, reports);
1099 }
1100 // Custom spread threshold — independent of `not_starved` (it gates
1101 // on its own field). Strip any default-threshold Unfair outcome
1102 // (present only when `not_starved` also ran) before re-evaluating
1103 // against the caller's limit.
1104 if let Some(spread_limit) = self.max_spread_pct {
1105 r.outcomes
1106 .retain(|o| !matches!(o, Outcome::Fail(d) if d.kind == DetailKind::Unfair));
1107 // `num_workers >= 2` matches the pre-decouple custom-spread
1108 // path. It is equivalent to the default path's
1109 // `measurable >= 2` gate here: a non-zero `spread` requires at
1110 // least two workers with measurable wall time (fewer collapses
1111 // min==max -> spread==0), so the `spread > limit` guard already
1112 // implies >= 2 measurable.
1113 // `spread` is None when off-CPU% was not measured (no
1114 // worker with positive wall time) — inconclusive, never
1115 // flagged unfair. A measured `Some(spread)` over the
1116 // limit on >= 2 workers is the real violation.
1117 if let Some(spread) = cg.spread
1118 && spread > spread_limit
1119 && cg.num_workers >= 2
1120 {
1121 r.record_fail(AssertDetail::new(
1122 DetailKind::Unfair,
1123 format!(
1124 "unfair cgroup: spread={:.0}% ({:.0}-{:.0}%) {} workers on {} cpus (threshold {:.0}%)",
1125 spread,
1126 cg.min_off_cpu_pct.unwrap_or(0.0),
1127 cg.max_off_cpu_pct.unwrap_or(0.0),
1128 cg.num_workers, cg.num_cpus, spread_limit
1129 ),
1130 ));
1131 }
1132 }
1133 // Custom gap threshold — independent of `not_starved`. Strip any
1134 // default-threshold Stuck outcome before re-evaluating.
1135 if let Some(threshold) = self.max_gap_ms {
1136 r.outcomes
1137 .retain(|o| !matches!(o, Outcome::Fail(d) if d.kind == DetailKind::Stuck));
1138 for w in reports {
1139 if w.max_gap_ms > threshold {
1140 r.record_fail(AssertDetail::new(
1141 DetailKind::Stuck,
1142 format!(
1143 "tid {} stuck {}ms on cpu{} at +{}ms (threshold {}ms)",
1144 w.tid, w.max_gap_ms, w.max_gap_cpu, w.max_gap_at_ms, threshold,
1145 ),
1146 ));
1147 }
1148 }
1149 }
1150 }
1151
1152 /// Isolation CHECK: run [`assert_isolation`] only when the
1153 /// `isolation` flag is set and a `cpuset` constraint is supplied.
1154 fn eval_isolation(
1155 &self,
1156 r: &mut AssertResult,
1157 reports: &[WorkerReport],
1158 cpuset: Option<&BTreeSet<usize>>,
1159 ) {
1160 if self.isolation
1161 && let Some(cs) = cpuset
1162 {
1163 r.merge(assert_isolation(reports, cs));
1164 }
1165 }
1166
1167 /// Throughput-parity and wake-latency / iteration-rate CHECKS, in
1168 /// order: [`assert_throughput_parity`] (gated on
1169 /// `max_throughput_cv` / `min_work_rate`) then [`assert_benchmarks`]
1170 /// (gated on `max_p99_wake_latency_ns` / `max_wake_latency_cv` /
1171 /// `min_iteration_rate`).
1172 fn eval_throughput_latency(&self, r: &mut AssertResult, reports: &[WorkerReport]) {
1173 if self.max_throughput_cv.is_some() || self.min_work_rate.is_some() {
1174 r.merge(assert_throughput_parity(
1175 reports,
1176 self.max_throughput_cv,
1177 self.min_work_rate,
1178 ));
1179 }
1180 if self.max_p99_wake_latency_ns.is_some()
1181 || self.max_wake_latency_cv.is_some()
1182 || self.min_iteration_rate.is_some()
1183 {
1184 r.merge(assert_benchmarks(
1185 reports,
1186 self.max_p99_wake_latency_ns,
1187 self.max_wake_latency_cv,
1188 self.min_iteration_rate,
1189 ));
1190 }
1191 }
1192
1193 /// Migration-ratio then page-locality CHECKS, in order. The
1194 /// locality arm reuses the `page_locality` triple computed once in
1195 /// the telemetry head so the value is not recomputed.
1196 fn eval_migration_locality(
1197 &self,
1198 r: &mut AssertResult,
1199 reports: &[WorkerReport],
1200 page_locality: Option<(f64, u64, u64)>,
1201 ) {
1202 if let Some(max_ratio) = self.max_migration_ratio {
1203 // saturating: overflow-safe pool of the per-worker migration / iteration
1204 // counters that feed the migration-ratio verdict.
1205 let total_mig: u64 = reports
1206 .iter()
1207 .map(|w| w.migration_count)
1208 .fold(0u64, u64::saturating_add);
1209 let total_iters: u64 = reports
1210 .iter()
1211 .map(|w| w.iterations)
1212 .fold(0u64, u64::saturating_add);
1213 if total_iters == 0 {
1214 r.record_inconclusive(AssertDetail::new(
1215 DetailKind::Migration,
1216 format!(
1217 "migration ratio inconclusive: 0 iterations across {} workers — \
1218 denominator is zero, ratio cannot be computed; threshold {:.4} \
1219 neither pass nor fail (was the workload able to run?)",
1220 reports.len(),
1221 max_ratio,
1222 ),
1223 ));
1224 } else {
1225 let ratio = total_mig as f64 / total_iters as f64;
1226 if ratio > max_ratio {
1227 r.record_fail(AssertDetail::new(
1228 DetailKind::Migration,
1229 format!(
1230 "migration ratio {:.4} exceeds threshold {:.4} ({} migrations / {} iterations)",
1231 ratio, max_ratio, total_mig, total_iters,
1232 ),
1233 ));
1234 }
1235 }
1236 }
1237 if let Some(min_locality) = self.min_page_locality
1238 && let Some((locality, total, local)) = page_locality
1239 {
1240 // Reuse the page-locality telemetry computed in the head (the
1241 // cgroup-wide local/total aggregate, evaluating the cgroup as a
1242 // whole rather than skipping zero-page workers or summing
1243 // misleading per-worker fractions).
1244 //
1245 // POLICY-derived denominator: this gate is only reachable when
1246 // the caller supplied a `numa_nodes` set — i.e. the test set a
1247 // NUMA policy (typically `MemPolicy::Bind`) declaring that the
1248 // workload WILL allocate pages on the expected nodes. Zero
1249 // observed pages is therefore a policy violation, not an
1250 // instrumentation gap, and stays Fail (the `0.0` locality the
1251 // head computed then fails the threshold) per the [`Outcome`]
1252 // doc's INSTRUMENT-vs-POLICY carve-out. The Inconclusive
1253 // primitive does NOT apply here — see the sibling
1254 // `max_migration_ratio` / `max_slow_tier_ratio` /
1255 // `assert_cross_node_migration` arms for the INSTRUMENT-derived
1256 // counterparts.
1257 r.merge(assert_page_locality(
1258 locality,
1259 Some(min_locality),
1260 total,
1261 local,
1262 ));
1263 }
1264 }
1265
1266 /// Cross-node migration then slow-tier CHECKS, in order. Both are
1267 /// NUMA-derived: the cross-node arm gates on
1268 /// `max_cross_node_migration_ratio`; the slow-tier arm gates on
1269 /// `max_slow_tier_ratio` and a present `numa_nodes` set.
1270 fn eval_numa_migration_slow_tier(
1271 &self,
1272 r: &mut AssertResult,
1273 reports: &[WorkerReport],
1274 numa_nodes: Option<&BTreeSet<usize>>,
1275 ) {
1276 if let Some(max_ratio) = self.max_cross_node_migration_ratio {
1277 // `vmstat_numa_pages_migrated` is the delta of the
1278 // system-wide `/proc/vmstat numa_pages_migrated` counter
1279 // captured by each worker over its own work loop. With
1280 // concurrent workers the deltas overlap heavily — every
1281 // worker observes roughly the same system-wide migration
1282 // count, so summing them inflates the numerator by the
1283 // worker count. Take the maximum delta across the cgroup
1284 // as the closest approximation of total migrations
1285 // observed during the run, then divide once by the
1286 // cgroup-wide total of allocated pages.
1287 let total_pages: u64 = reports
1288 .iter()
1289 .map(|w| {
1290 w.numa_pages
1291 .values()
1292 .copied()
1293 .fold(0u64, u64::saturating_add)
1294 })
1295 .fold(0u64, u64::saturating_add);
1296 let migrated_pages: u64 = reports
1297 .iter()
1298 .map(|w| w.vmstat_numa_pages_migrated)
1299 .max()
1300 .unwrap_or(0);
1301 r.merge(assert_cross_node_migration(
1302 migrated_pages,
1303 total_pages,
1304 Some(max_ratio),
1305 ));
1306 }
1307 if let Some(max_ratio) = self.max_slow_tier_ratio
1308 && numa_nodes.is_some()
1309 {
1310 // Skip workers with no NUMA signal (empty numa_pages or
1311 // zero total) but count them: if every worker dropped
1312 // out, the gate had no data to evaluate and previously
1313 // silent-passed. Record Inconclusive instead so a
1314 // workload that produced no NUMA allocations at all
1315 // doesn't masquerade as meeting the slow-tier ratio.
1316 let mut evaluated = 0usize;
1317 for w in reports {
1318 if w.numa_pages.is_empty() {
1319 continue;
1320 }
1321 let total: u64 = w
1322 .numa_pages
1323 .values()
1324 .copied()
1325 .fold(0u64, u64::saturating_add);
1326 if total > 0 {
1327 evaluated += 1;
1328 r.merge(assert_slow_tier_ratio(
1329 &w.numa_pages,
1330 max_ratio,
1331 total,
1332 numa_nodes,
1333 ));
1334 }
1335 }
1336 if evaluated == 0 {
1337 r.record_inconclusive(AssertDetail::new(
1338 DetailKind::SlowTier,
1339 format!(
1340 "slow-tier ratio inconclusive: no worker reported any NUMA pages \
1341 (across {} workers) — denominator is zero, ratio cannot be computed; \
1342 threshold {max_ratio:.4} neither pass nor fail \
1343 (did the workload allocate any memory?)",
1344 reports.len(),
1345 ),
1346 ));
1347 }
1348 }
1349 }
1350}
1351
1352/// Check slow-tier page ratio against threshold.
1353///
1354/// "Slow tier" nodes are NUMA nodes NOT in the cpuset's NUMA node set.
1355/// For CXL memory-only nodes, these are the nodes without CPUs.
1356pub(crate) fn assert_slow_tier_ratio(
1357 numa_pages: &BTreeMap<usize, u64>,
1358 max_ratio: f64,
1359 total_pages: u64,
1360 numa_nodes: Option<&BTreeSet<usize>>,
1361) -> AssertResult {
1362 let mut r = AssertResult::pass();
1363 let Some(cpu_nodes) = numa_nodes else {
1364 return r;
1365 };
1366 let slow_pages: u64 = numa_pages
1367 .iter()
1368 .filter(|(node, _)| !cpu_nodes.contains(node))
1369 .map(|(_, &count)| count)
1370 .fold(0u64, u64::saturating_add);
1371 let ratio = slow_pages as f64 / total_pages as f64;
1372 if ratio > max_ratio {
1373 r.record_fail(AssertDetail::new(
1374 DetailKind::SlowTier,
1375 format!(
1376 "slow-tier page ratio {ratio:.4} ({pct:.2}%) exceeds threshold {max_ratio:.4} ({thr_pct:.2}%) \
1377 ({slow_pages}/{total_pages} pages on non-CPU nodes)",
1378 pct = ratio * 100.0,
1379 thr_pct = max_ratio * 100.0,
1380 ),
1381 ));
1382 }
1383 r
1384}
1385
1386/// Check NUMA page locality against threshold.
1387///
1388/// `observed` is the fraction of pages on expected nodes (0.0-1.0).
1389/// `total_pages` and `local_pages` are included in diagnostics.
1390pub fn assert_page_locality(
1391 observed: f64,
1392 min_locality: Option<f64>,
1393 total_pages: u64,
1394 local_pages: u64,
1395) -> AssertResult {
1396 let mut r = AssertResult::pass();
1397 if let Some(threshold) = min_locality
1398 && observed < threshold
1399 {
1400 r.record_fail(AssertDetail::new(
1401 DetailKind::PageLocality,
1402 format!(
1403 "page locality {observed:.4} ({pct:.2}%) below threshold {threshold:.4} ({thr_pct:.2}%) ({local_pages}/{total_pages} pages local)",
1404 pct = observed * 100.0,
1405 thr_pct = threshold * 100.0,
1406 ),
1407 ));
1408 }
1409 r
1410}
1411
1412/// Check cross-node page migration ratio against threshold.
1413///
1414/// `migrated_pages` is the delta of `/proc/vmstat` `numa_pages_migrated`
1415/// between pre- and post-workload snapshots. `total_pages` is the total
1416/// allocated pages from numa_maps.
1417///
1418/// Inconsistent inputs (`migrated_pages > 0` while `total_pages == 0`)
1419/// fail loudly: vmstat saw migrations the workload's numa_maps did not
1420/// account for, which is either a measurement gap or an instrumentation
1421/// bug, and silently coercing the ratio to 0.0 would let the assertion
1422/// pass on data the operator should not trust.
1423///
1424/// When both inputs are zero (`migrated_pages == 0 && total_pages == 0`)
1425/// the gate records Inconclusive — the denominator is zero and the
1426/// check has no signal to evaluate; neither Pass (would silently green
1427/// a workload that produced no NUMA pages) nor Fail (no actual ratio
1428/// violation observed) is truthful.
1429pub fn assert_cross_node_migration(
1430 migrated_pages: u64,
1431 total_pages: u64,
1432 max_ratio: Option<f64>,
1433) -> AssertResult {
1434 let mut r = AssertResult::pass();
1435 if let Some(threshold) = max_ratio {
1436 if total_pages == 0 {
1437 if migrated_pages > 0 {
1438 r.record_fail(AssertDetail::new(
1439 DetailKind::CrossNodeMigration,
1440 format!(
1441 "cross-node migration inconsistent: {migrated_pages} pages migrated but 0 pages observed in numa_maps (threshold {threshold:.4})",
1442 ),
1443 ));
1444 } else {
1445 r.record_inconclusive(AssertDetail::new(
1446 DetailKind::CrossNodeMigration,
1447 format!(
1448 "cross-node migration inconclusive: 0 pages observed in numa_maps and 0 pages migrated — \
1449 denominator is zero, ratio cannot be computed; threshold {threshold:.4} \
1450 neither pass nor fail (did the workload allocate any memory?)",
1451 ),
1452 ));
1453 }
1454 return r;
1455 }
1456 let ratio = migrated_pages as f64 / total_pages as f64;
1457 if ratio > threshold {
1458 r.record_fail(AssertDetail::new(
1459 DetailKind::CrossNodeMigration,
1460 format!(
1461 "cross-node migration ratio {ratio:.4} ({pct:.2}%) exceeds threshold {threshold:.4} ({thr_pct:.2}%) ({migrated_pages}/{total_pages} pages migrated)",
1462 pct = ratio * 100.0,
1463 thr_pct = threshold * 100.0,
1464 ),
1465 ));
1466 }
1467 }
1468 r
1469}
1470
1471#[cfg(test)]
1472impl AssertPlan {
1473 pub(crate) fn check_not_starved(mut self) -> Self {
1474 self.not_starved = true;
1475 self
1476 }
1477
1478 pub(crate) fn check_isolation(mut self) -> Self {
1479 self.isolation = true;
1480 self
1481 }
1482
1483 pub(crate) fn max_gap_ms(mut self, ms: u64) -> Self {
1484 self.max_gap_ms = Some(ms);
1485 self
1486 }
1487
1488 pub(crate) fn max_spread_pct(mut self, pct: f64) -> Self {
1489 self.max_spread_pct = Some(pct);
1490 self
1491 }
1492}