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}