ktstr/assert/
detail.rs

1use super::*;
2
3/// Category tag for an [`AssertDetail`]. Enables structural filtering
4/// (e.g. by `AssertPlan`) without matching on substrings of
5/// human-readable messages, which is fragile if wording changes.
6///
7/// Notes previously lived as a `DetailKind::Note` variant on
8/// [`AssertDetail`]; they now live on [`AssertResult::info_notes`] as
9/// [`InfoNote`] values. See [`AssertResult::note`] /
10/// [`AssertResult::with_note`] for the producer-side migration and
11/// [`InfoNote`] for the rationale (structurally-separate context
12/// stream so sidecar consumers iterating `details` count only real
13/// failures without a "forgot to filter `kind == Note`" miscount
14/// class of bug).
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
16pub enum DetailKind {
17    /// A worker made zero progress.
18    Starved,
19    /// A worker was stuck off-CPU longer than the gap threshold.
20    Stuck,
21    /// Spread between best and worst worker exceeded the fairness threshold.
22    Unfair,
23    /// A worker ran on a CPU outside its expected cpuset.
24    Isolation,
25    /// Throughput / benchmarking threshold failure (p99, CV, rate).
26    Benchmark,
27    /// Migration-ratio threshold failure (migrations per iteration).
28    Migration,
29    /// NUMA page locality threshold failure.
30    PageLocality,
31    /// Cross-node migration threshold failure.
32    CrossNodeMigration,
33    /// Slow-tier (memory tier) threshold failure.
34    SlowTier,
35    /// Monitor-subsystem anomaly (imbalance, DSQ depth, rq_clock stall).
36    /// Use one of [`DetailKind::SchedulerCrashed`] /
37    /// [`DetailKind::SchedulerExitedCleanly`] /
38    /// [`DetailKind::SchedulerDiedUnknownReason`] for scheduler-liveness failures.
39    Monitor,
40    /// Scheduler process observed to have died (via `sched_pid`
41    /// probe returning ESRCH or wait on the leader) AND the BPF
42    /// probe observed a non-clean `trace_sched_ext_exit` event
43    /// before the liveness check fired. The crash classification
44    /// covers SCX_EXIT_ERROR, SCX_EXIT_ERROR_STALL, watchdog kick,
45    /// and BPF-side error paths — every kernel exit that latches
46    /// `ktstr_err_exit_detected` in the probe BSS.
47    ///
48    /// Distinguished from [`DetailKind::SchedulerExitedCleanly`]
49    /// (`SCX_EXIT_NONE` clean teardown) so the console-dump gate
50    /// and downstream triage can tell a real crash from a benign
51    /// completion. Consumers wanting to gate on "any scheduler
52    /// exit" should match both variants via
53    /// `matches!(d.kind, SchedulerCrashed | SchedulerExitedCleanly)`.
54    SchedulerCrashed,
55    /// Scheduler process observed to have died with the probe BSS
56    /// `ktstr_err_exit_detected` latch unset — the kernel ran the
57    /// `SCX_EXIT_NONE` clean-teardown path (sysrq, explicit
58    /// unregister) without latching an error. Surfaces alongside
59    /// `SchedulerCrashed` because both are "scheduler exited"
60    /// signals; splitting them lets the operator distinguish a
61    /// benign shutdown from a real fault without re-parsing
62    /// console output.
63    SchedulerExitedCleanly,
64    /// Scheduler process observed to have died but the BPF probe
65    /// has no classification yet — either the probe never armed
66    /// for this run (no scheduler attached, host-only test) or
67    /// the poll thread has not completed a first iteration since
68    /// the prior reset. Operators triaging this variant should
69    /// check whether the probe pipeline was wired before
70    /// concluding "scheduler-exit classification is broken".
71    SchedulerDiedUnknownReason,
72    /// SCX event-counter threshold failure. An error-class
73    /// `SCX_EV_*` counter (e.g. `enq_skip_exiting`,
74    /// `enq_skip_migration_disabled`, `dispatch_local_dsq_offline`) crossed
75    /// the configured bound. Distinct from the process-liveness
76    /// variants ([`DetailKind::SchedulerCrashed`] /
77    /// [`DetailKind::SchedulerExitedCleanly`] /
78    /// [`DetailKind::SchedulerDiedUnknownReason`]) and
79    /// [`DetailKind::Monitor`] (imbalance / DSQ-depth /
80    /// rq_clock-stall): this kind flags individual event-counter
81    /// regressions surfaced by [`assert_scx_events_clean`]. The
82    /// counters themselves originate in the kernel's per-task
83    /// `scx_event_stats` (see `kernel/sched/ext.c` —
84    /// `SCX_EV_*` macros); ktstr reads aggregated deltas via
85    /// `monitor::ScxEventDeltas` and presents them to the
86    /// assertion as `(name, count)` pairs.
87    SchedulerEvent,
88    /// Temporal assertion failure on a periodic-capture
89    /// [`SampleSeries`](crate::scenario::sample::SampleSeries).
90    /// One of the seven built-in patterns
91    /// (`nondecreasing` / `strictly_increasing`, `rate_within`,
92    /// `steady_within`, `converges_to`, `always_true`,
93    /// `ratio_within`) or a per-sample scalar comparator
94    /// invoked via `.each(...)` reported a violation. The
95    /// detail message names the pattern, the offending sample
96    /// tag(s), and the observed-vs-expected values; the
97    /// stdout `--- temporal assertions ---` summary in
98    /// `test_support::output` aggregates the same kind into
99    /// per-assertion pass/fail rows.
100    Temporal,
101    /// Host-mode worker stall detected by
102    /// [`crate::scenario::host_stall`]. The polling thread
103    /// observed `Δnr_switches == 0` AND `Δsum_exec_runtime == 0`
104    /// across the configured window for a worker pid — the task
105    /// neither got picked nor preempted for at least
106    /// `STALL_WINDOW * poll_interval` ms. Distinct from
107    /// [`DetailKind::Stuck`] (worker-side report: a worker was
108    /// off-CPU longer than the in-test gap threshold): this kind
109    /// fires from the host-side polling thread when running
110    /// host-mode (no VM boot) and is the only stall signal
111    /// available in that mode.
112    WorkerStalled,
113    /// Skip notification (scenario could not run under this topology/flags).
114    Skip,
115    /// Uncategorized — falls through when a detail has no specific kind.
116    Other,
117}
118
119/// Message prefix emitted by every scenario-runner site that
120/// detects the scheduler process has died — whether through a
121/// post-ops liveness probe or an inter-step liveness check. Both
122/// paths share this single prefix as the operator-visible
123/// message format so someone grepping stderr for the canonical
124/// "scheduler process died" string hits every emission site.
125/// Structural routing (the console-dump gate in
126/// `test_support::eval`) goes through the `DetailKind::Scheduler*`
127/// variants ([`DetailKind::SchedulerCrashed`] /
128/// [`DetailKind::SchedulerExitedCleanly`] /
129/// [`DetailKind::SchedulerDiedUnknownReason`]),
130/// NOT this prefix — the prefix is a human-readability contract,
131/// not a detection mechanism. Exposed as `pub(crate)` so emitters
132/// reference the same literal; renaming the prefix is a one-site
133/// edit instead of a grep-and-hope across `scenario::*`.
134///
135/// Vocabulary history: prior versions of this module used two
136/// prefixes (`SCHED_EXITED_PREFIX` = "scheduler process exited"
137/// and `SCHED_NO_LONGER_RUNNING_PREFIX` = "scheduler process no
138/// longer running") for in-workload vs post-ops detection. The
139/// distinction carried no downstream semantics — every consumer
140/// treated both as equivalent scheduler-death signals — so the
141/// wording was unified onto "died" (shorter, matches the prior
142/// `SchedulerDied` variant name, and closes a class of "which
143/// wording does this site use?" drift bugs).
144pub(crate) const SCHED_DIED_PREFIX: &str = "scheduler process died";
145
146/// Format the scheduler-died detail message for an inter-step
147/// liveness-probe failure (the scheduler was alive after step
148/// `step_idx - 1` but ESRCH'd before step `step_idx` ran).
149///
150/// Begins with [`SCHED_DIED_PREFIX`] verbatim, followed by
151/// "unexpectedly after completing step N of M (X.Xs into test)".
152/// The prefix is the operator-visible stderr anchor (see the
153/// prefix doc); structural routing is via one of
154/// [`DetailKind::SchedulerCrashed`] /
155/// [`DetailKind::SchedulerExitedCleanly`] /
156/// [`DetailKind::SchedulerDiedUnknownReason`] on the emitted `AssertDetail`.
157/// Centralized so ops.rs and any future emitter share a single
158/// format.
159pub(crate) fn format_sched_died_after_step(
160    step_idx: usize,
161    total_steps: usize,
162    elapsed_s: f64,
163) -> String {
164    format!(
165        "{SCHED_DIED_PREFIX} unexpectedly after completing step {step_idx} of {total_steps} ({elapsed_s:.1}s into test)",
166    )
167}
168
169/// Format the scheduler-died detail message for the post-loop
170/// liveness probe (the scheduler was alive throughout the step loop
171/// but ESRCH'd after the last step completed).
172///
173/// Begins with [`SCHED_DIED_PREFIX`] verbatim; shares the prefix
174/// invariant documented on [`format_sched_died_after_step`].
175/// Structural routing is via one of [`DetailKind::SchedulerCrashed`] /
176/// [`DetailKind::SchedulerExitedCleanly`] /
177/// [`DetailKind::SchedulerDiedUnknownReason`] on the emitted detail.
178pub(crate) fn format_sched_died_after_all_steps(total_steps: usize, elapsed_s: f64) -> String {
179    format!(
180        "{SCHED_DIED_PREFIX} unexpectedly (detected after all {total_steps} steps completed, {elapsed_s:.1}s elapsed)",
181    )
182}
183
184/// Format the scheduler-died detail message for the in-step
185/// liveness probe (the scheduler ESRCH'd during a step's hold-period
186/// sleep, before the step completed).
187///
188/// Begins with [`SCHED_DIED_PREFIX`] verbatim; shares the prefix
189/// invariant documented on [`format_sched_died_after_step`].
190/// Structural routing is via one of [`DetailKind::SchedulerCrashed`] /
191/// [`DetailKind::SchedulerExitedCleanly`] /
192/// [`DetailKind::SchedulerDiedUnknownReason`] on the emitted detail. Emitted by `run_scenario` when the
193/// liveness-poll inside `run_step`'s hold sleep observes
194/// `process_alive(sched_pid) == false`, replacing the prior
195/// behavior that waited for the post-loop probe to fire (which
196/// stamped the message with the full scenario duration even when
197/// the scheduler had died seconds earlier).
198pub(crate) fn format_sched_died_during_workload(elapsed_s: f64) -> String {
199    format!("{SCHED_DIED_PREFIX} unexpectedly during workload ({elapsed_s:.1}s into test)")
200}
201
202/// Format the scheduler-died message for the guest-side `survives_storm`
203/// post-function liveness probe: the test asserted the scheduler survives, but
204/// after the test function returned it was found dead or down (the leader pid
205/// ESRCH'd, or the scx state went `disabling`/`disabled`) while still expected
206/// to run. Fires for scenarios that do NOT drive an `execute_*` in-hold probe
207/// (which records its own scheduler-death detail via the `format_sched_died_*`
208/// helpers above). Begins with [`SCHED_DIED_PREFIX`] verbatim; structural
209/// routing is via one of [`DetailKind::SchedulerCrashed`] /
210/// [`DetailKind::SchedulerExitedCleanly`] /
211/// [`DetailKind::SchedulerDiedUnknownReason`] on the emitted detail.
212pub(crate) fn format_sched_died_survives_storm() -> String {
213    format!(
214        "{SCHED_DIED_PREFIX} during a survives_storm workload (detected after the test function returned)"
215    )
216}
217
218/// A single diagnostic message from an assertion, paired with a
219/// structural [`DetailKind`] so filtering is robust to wording changes.
220///
221/// Access the message text via `detail.message`; format-string probes
222/// (`format!("{detail}")`) work via the `Display` impl. New code that
223/// needs to filter by category should match on `kind` rather than
224/// substring-match the message text — wording can change without
225/// notice but the variant tag is the structural contract.
226#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
227pub struct AssertDetail {
228    pub kind: DetailKind,
229    pub message: String,
230    /// Scenario phase the detail was emitted under. Mirrors
231    /// [`PassDetail::phase`]: `None` outside any [`PhaseGuard`] scope
232    /// (boot, BASELINE settle, non-scenario test fixtures), `Some`
233    /// when an active guard has installed a label. Carried on every
234    /// detail so consumers (auto-repro renderer, sidecar parsers)
235    /// see a uniform phase field across pass + fail records.
236    /// Producers that already know the active phase can stamp via
237    /// [`Self::with_phase`].
238    ///
239    /// [`Cow`](std::borrow::Cow)`<'static, str>` mirrors [`PassDetail::phase`] for the
240    /// same zero-allocation reason: the common case is the per-step
241    /// RAII guard's static `&'static str` label staying as
242    /// `Cow::Borrowed` (zero alloc); runtime-built `String`s become
243    /// `Cow::Owned`.
244    pub phase: Option<std::borrow::Cow<'static, str>>,
245}
246
247impl AssertDetail {
248    pub fn new(kind: DetailKind, message: impl Into<String>) -> Self {
249        Self {
250            kind,
251            message: message.into(),
252            phase: current_phase_label(),
253        }
254    }
255
256    /// Builder-style setter for [`Self::phase`]. Consumes self,
257    /// stamps the phase label, returns the updated value. Matches
258    /// the [`PassDetail::with_phase`] shape so producers can chain
259    /// `AssertDetail::new(...).with_phase(...)` uniformly across
260    /// pass and fail records.
261    #[must_use = "builder methods consume self; bind the result"]
262    pub fn with_phase(mut self, phase: impl Into<std::borrow::Cow<'static, str>>) -> Self {
263        self.phase = Some(phase.into());
264        self
265    }
266
267    /// Borrow this detail as a kind-prefixed [`std::fmt::Display`]
268    /// adapter. The default [`Display`](std::fmt::Display) impl on
269    /// `AssertDetail` writes only `message` so terminal output reads
270    /// as bare prose; structured-log consumers that want to bucket
271    /// failures by category without re-checking [`Self::kind`] reach
272    /// for this helper instead.
273    ///
274    /// Renders as `[<DetailKind variant name>] <message>` — debug-form
275    /// for the kind so the variant token is grep-stable across renames
276    /// (a regression that drops a `DetailKind` variant breaks the
277    /// match arms that produce it; the rendered token follows). Zero-
278    /// allocation: the wrapper holds a `&AssertDetail` and writes
279    /// straight into the formatter.
280    ///
281    /// ```
282    /// # use ktstr::assert::{AssertDetail, DetailKind};
283    /// let d = AssertDetail::new(DetailKind::Stuck, "tid 7 stuck 1500ms on cpu3");
284    /// assert_eq!(d.to_string(), "tid 7 stuck 1500ms on cpu3");
285    /// assert_eq!(
286    ///     d.display_with_kind().to_string(),
287    ///     "[Stuck] tid 7 stuck 1500ms on cpu3",
288    /// );
289    /// ```
290    pub fn display_with_kind(&self) -> AssertDetailWithKind<'_> {
291        AssertDetailWithKind { detail: self }
292    }
293}
294
295/// Structured record of a single passing claim — the positive
296/// counterpart to [`AssertDetail`]. Populated by [`Verdict`]'s
297/// `record_pass_unary` / `record_pass_binary` helpers at every
298/// comparator's pass arm so the auto-repro renderer (and any other
299/// consumer that wants per-claim fidelity) can iterate passes
300/// alongside fails.
301///
302/// Carries the same shape primitives every comparator naturally has
303/// at the pass site: the claim's `name`, a short `comparator`
304/// token (`"eq"`, `"ge"`, `"is_finite"`, …), the `value` that was
305/// compared (formatted via the comparator's `Display`), and an
306/// optional `expected` for binary comparators. Unary comparators
307/// (e.g. `is_finite`, `set_is_empty`) leave `expected = None`.
308///
309/// `comparator` is a **wire-canonical token** from
310/// [`COMPARATOR_VOCABULARY`], NOT a string derived from the builder
311/// method name. Operator-named comparators map to operator-canonical
312/// tokens (`eq`/`ne`/`ge`/`le`/`lt`/`gt`) regardless of whether the
313/// invoking builder method is `eq` or `at_least` — tokens are the
314/// stable wire vocabulary, methods are the ergonomic surface. A
315/// renderer that wants pretty operators can map `ge → >=` on output.
316///
317/// Container-bound comparators prefix their tokens with the
318/// container type name (`set_*`, `sequence_*`) to disambiguate same-
319/// named operations across surfaces (`contains` is ambiguous between
320/// sets and sequences, so prefix; `is_finite` is scalar-only, so
321/// bare). The prefix policy is part of the vocabulary contract.
322///
323/// `comparator` is a [`Cow`](std::borrow::Cow)`<'static, str>` so call sites passing a
324/// `&'static str` literal — the universal case for built-in
325/// comparators — pay zero allocation; runtime-built comparator
326/// labels store as `Cow::Owned`. The same `Cow` shape applies to
327/// `phase` (set by the per-step RAII guard's static label in the
328/// common case).
329///
330/// **Structurally distinct from [`AssertDetail`]**: PassDetail
331/// carries a uniform per-claim shape (every comparator emits
332/// `name + comparator + value + expected`), while AssertDetail
333/// uses a `kind: DetailKind` category enum because failure /
334/// note / warning shapes diverge. Forcing them to one mold would
335/// either lose comparator-typed slots (collapse to kind+message)
336/// or invent a Pass variant of DetailKind that doesn't carry the
337/// typed slots cleanly. Keeping them separate is a deliberate
338/// design choice, not an inconsistency.
339///
340/// Distinct from a one-line tracing log — the structured form is
341/// the data path the auto-repro renderer reads to compose the
342/// bracketed phase output that surfaces passing context alongside
343/// failing assertions. The tracing log path remains the
344/// operator-facing surface for `--nocapture` runs.
345#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
346pub struct PassDetail {
347    pub name: String,
348    pub comparator: std::borrow::Cow<'static, str>,
349    pub value: String,
350    pub expected: Option<String>,
351    /// Scenario phase the claim was made under. `None` outside any
352    /// [`PhaseGuard`] scope; `Some(label)` when the active-phase
353    /// thread-local has been installed at the scenario-driver step
354    /// loop entry. The auto-repro renderer groups passes by this
355    /// field to compose the bracketed `==== PHASE N: <label> ====`
356    /// output. [`Cow`](std::borrow::Cow)`<'static, str>` so the common case (the RAII
357    /// guard's static `&'static str` label) pays zero allocation.
358    pub phase: Option<std::borrow::Cow<'static, str>>,
359}
360
361impl PassDetail {
362    /// Construct a binary-comparator pass record (e.g. `eq`, `ge`,
363    /// `in_range`). Both `value` and `expected` are stringified via
364    /// [`std::fmt::Display`] at the call site so the struct is
365    /// `T`-agnostic on the wire. See [`COMPARATOR_VOCABULARY`] for
366    /// the full set of canonical tokens.
367    pub fn binary(
368        name: impl Into<String>,
369        comparator: impl Into<std::borrow::Cow<'static, str>>,
370        value: impl Into<String>,
371        expected: impl Into<String>,
372    ) -> Self {
373        Self {
374            name: name.into(),
375            comparator: comparator.into(),
376            value: value.into(),
377            expected: Some(expected.into()),
378            phase: current_phase_label(),
379        }
380    }
381
382    /// Construct a unary-comparator pass record (e.g. `is_finite`,
383    /// `set_is_empty`). `expected` is left None — the comparator
384    /// name alone carries the meaning. See [`COMPARATOR_VOCABULARY`]
385    /// for the full set of canonical tokens.
386    pub fn unary(
387        name: impl Into<String>,
388        comparator: impl Into<std::borrow::Cow<'static, str>>,
389        value: impl Into<String>,
390    ) -> Self {
391        Self {
392            name: name.into(),
393            comparator: comparator.into(),
394            value: value.into(),
395            expected: None,
396            phase: current_phase_label(),
397        }
398    }
399
400    /// Builder-style setter for [`Self::phase`]. Consumes self,
401    /// stamps the phase label, returns the updated value so
402    /// per-phase test fixtures and the [`PhaseGuard`] RAII helper can chain
403    /// `PassDetail::binary(...).with_phase("step_0")`.
404    /// `&'static str` literals stay `Cow::Borrowed` (zero alloc);
405    /// runtime-built `String` becomes `Cow::Owned`.
406    pub fn with_phase(mut self, phase: impl Into<std::borrow::Cow<'static, str>>) -> Self {
407        self.phase = Some(phase.into());
408        self
409    }
410}
411
412/// Wire-canonical vocabulary of `PassDetail.comparator` tokens.
413///
414/// Every comparator implementation in [`crate::assert::claim`] emits
415/// one of these tokens when it records a passing claim. The vocabulary
416/// is the **stable wire contract** — renderers, sidecar consumers,
417/// and the auto-repro pipeline match against these exact
418/// strings. A token rename in `claim.rs` without a parallel update
419/// here breaks every downstream consumer; the regression test in
420/// `tests/claim_comparator_tokens_canonical.rs` pins this.
421///
422/// One synthetic token is NOT in this vocabulary: the cap-overflow
423/// sentinel record (see [`PASSES_TRUNCATION_SENTINEL_NAME`]) carries
424/// `comparator = "truncated"` to indicate the slot is metadata, not
425/// a real claim. Renderers that filter passes by vocabulary should
426/// also handle the sentinel as a distinct category.
427///
428/// Tokens follow three style rules:
429///
430/// 1. **Operator-canonical**: comparison operators map to short
431///    operator names (`eq`, `ne`, `ge`, `le`, `lt`, `gt`) regardless
432///    of whether the builder method is `eq` or `at_least`. The
433///    vocabulary is independent of method naming.
434/// 2. **Container-prefixed**: comparators bound to a specific
435///    container type prefix their token with the container name
436///    (`set_*`, `sequence_*`) to disambiguate same-named operations
437///    across surfaces. Scalar tokens are unprefixed.
438/// 3. **Snake-case ASCII**: every token is lower-snake-case, no
439///    Unicode, no spaces — survives shell escapes, IDE regex search,
440///    and log-mining pipelines without transformation.
441///
442/// Asymmetries are intentional: `sequence_*` does not carry
443/// `subset_of` / `disjoint_from` because sequences have order and
444/// duplicates that set semantics don't model.
445///
446/// Categorization below groups by SEMANTIC AXIS (comparison /
447/// predicate / cardinality / membership / relation), not by call-
448/// site arity. Every `len_*` cardinality token (`set_len_eq` /
449/// `set_len_le` / `set_len_ge` and the sequence peers) records
450/// via the binary helper so renderer-side handling is uniform:
451/// each pass surfaces both the actual length and the expected
452/// bound. The previous unary-on-eq + binary-on-le/ge asymmetry
453/// was a micro-optimization (eq's `actual == expected` makes the
454/// actual redundant on the pass arm) that traded uniform output
455/// for one elided field. The `*_is_non_empty` and `*_is_empty`
456/// predicates remain unary by design — the comparator token IS
457/// the predicate, and there is no separate expected bound to
458/// surface. `*_is_non_empty` records the observed length
459/// (evidence the container was non-empty); `*_is_empty` records
460/// no value (the predicate token alone carries the meaning —
461/// emptiness is self-evident from the comparator).
462pub const COMPARATOR_VOCABULARY: &[&str] = &[
463    // Scalar comparisons
464    "eq",
465    "ne",
466    "ge",
467    "le",
468    "lt",
469    "gt",
470    "in_range",
471    "near_within",
472    // Scalar predicates
473    "is_finite",
474    // Set predicates (emptiness)
475    "set_is_empty",
476    "set_is_non_empty",
477    // Set cardinality
478    "set_len_eq",
479    "set_len_le",
480    "set_len_ge",
481    // Set membership / relations
482    "set_contains",
483    "subset_of",
484    "disjoint_from",
485    // Sequence predicates (emptiness)
486    "sequence_is_empty",
487    "sequence_is_non_empty",
488    // Sequence cardinality
489    "sequence_len_eq",
490    "sequence_len_le",
491    "sequence_len_ge",
492    // Sequence membership
493    "sequence_contains",
494];
495
496/// Cap on `AssertResult.passes` (and the matching truncation sentinel)
497/// so a pathological test that fires millions of claims doesn't
498/// balloon the wire-formatted result. Mirrors SnapshotBridge's
499/// `MAX_STORED_EVENTS` truncation pattern: when the cap is hit,
500/// the cap-th record is replaced with a synthetic
501/// `PassDetail { name: PASSES_TRUNCATION_SENTINEL_NAME, … }` carrying
502/// the dropped-count, and further pushes are no-ops.
503///
504/// 10_000 is comfortably above the steady-state claim count of every
505/// existing test (typical test fires <100 claims; pathological hot-
506/// loop tests in the tree fire under 5_000) while bounding the
507/// worst-case wire size to ~3 MB — well under the 16 MiB
508/// `MAX_BULK_FRAME_PAYLOAD` per vmm/bulk.rs:56.
509pub const MAX_RECORDED_PASSES: usize = 10_000;
510
511/// Sentinel `PassDetail.name` value used by the truncation record
512/// that replaces the `MAX_RECORDED_PASSES`-th slot when a test
513/// over-runs the cap. Consumers (the auto-repro renderer) match on
514/// this string to render `[N passes truncated]` instead of treating
515/// it as a real claim.
516///
517/// **Truncation-check idiom**: a caller checking
518/// `result.passes.len() == MAX_RECORDED_PASSES` MISSES the truncated
519/// state because the truncation sentinel pushes the vec to
520/// `MAX_RECORDED_PASSES + 1`. The correct check is
521/// `result.passes.last().map(|p| p.name == PASSES_TRUNCATION_SENTINEL_NAME)
522/// .unwrap_or(false)` — i.e. inspect the tail entry's name.
523pub const PASSES_TRUNCATION_SENTINEL_NAME: &str = "__ktstr_passes_truncated__";
524
525/// Comparator-string value used by the truncation sentinel record
526/// alone. Out-of-vocabulary by design — not in [`COMPARATOR_VOCABULARY`]
527/// — so the runtime debug_assert in `record_pass_inner` allows it
528/// explicitly without polluting the wire-canonical token set.
529pub const PASSES_TRUNCATION_SENTINEL_COMPARATOR: &str = "truncated";
530
531/// `Display` adapter returned by [`AssertDetail::display_with_kind`].
532/// Renders the detail as `[<kind>] <message>`. Held by reference so
533/// the helper allocates nothing on the formatting path; the lifetime
534/// is the borrow of the source `AssertDetail`.
535#[must_use = "AssertDetailWithKind only renders when formatted"]
536pub struct AssertDetailWithKind<'a> {
537    detail: &'a AssertDetail,
538}
539
540impl std::fmt::Display for AssertDetailWithKind<'_> {
541    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
542        write!(f, "[{:?}] {}", self.detail.kind, self.detail.message)
543    }
544}
545
546impl From<String> for AssertDetail {
547    /// Conversion for uncategorized messages; defaults `kind` to
548    /// [`DetailKind::Other`]. Prefer [`AssertDetail::new`] when the
549    /// detail has a meaningful category — the `DetailKind` is serialized
550    /// into the sidecar JSON and consumed by stats tooling to bucket
551    /// failures, so losing the category bucket makes post-run
552    /// categorization rely on free-text regex against `message`.
553    fn from(message: String) -> Self {
554        Self::new(DetailKind::Other, message)
555    }
556}
557
558/// Informational annotation that does NOT contribute to the failure
559/// verdict — the structural counterpart to [`AssertDetail`] for
560/// "context surfaced alongside a result" emissions. Lives in its own
561/// type (not as a `DetailKind` variant of `AssertDetail`) so the
562/// "details = failures" mental model holds at the type level:
563/// `AssertResult::details` is the failure stream, `AssertResult::info_notes`
564/// is the context stream. Producers can no longer accidentally tag a
565/// note as a failure (the prior `DetailKind::Note` variant on
566/// `AssertDetail` made misclassification a one-character bug — every
567/// sidecar consumer that read `details` needed to remember to filter
568/// `kind == Note` to count real failures, and forgetting silently
569/// misreported failure counts).
570///
571/// Carries the same `phase` field as [`AssertDetail`] so the auto-repro
572/// renderer can attribute notes to the scenario phase they were emitted
573/// under, mirroring the per-step grouping already used for failures
574/// and passes.
575///
576/// `PartialEq + Eq` mirror the derive set on [`AssertDetail`] and
577/// [`PassDetail`] so test authors can compose `AssertResult` fixtures
578/// across the three record types with uniform structural-equality
579/// affordances. Test authors should still prefer
580/// `result.info_notes.iter().any(|n| n.message.contains(...))` over
581/// `assert_eq!(result.info_notes, expected)` so pins survive note
582/// wording adjustments without churn.
583#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
584pub struct InfoNote {
585    pub message: String,
586    /// Scenario phase the note was emitted under. Mirrors
587    /// [`AssertDetail::phase`] and [`PassDetail::phase`] so the
588    /// renderer threads pass / fail / note records through one
589    /// per-phase grouping.
590    pub phase: Option<std::borrow::Cow<'static, str>>,
591}
592
593impl InfoNote {
594    pub fn new(message: impl Into<String>) -> Self {
595        Self {
596            message: message.into(),
597            phase: current_phase_label(),
598        }
599    }
600
601    /// Builder-style setter for [`Self::phase`]. Matches the
602    /// [`AssertDetail::with_phase`] shape so producers can chain
603    /// `InfoNote::new(...).with_phase(...)` uniformly with the
604    /// failure-detail builder.
605    #[must_use = "builder methods consume self; bind the result"]
606    pub fn with_phase(mut self, phase: impl Into<std::borrow::Cow<'static, str>>) -> Self {
607        self.phase = Some(phase.into());
608        self
609    }
610}
611
612impl std::fmt::Display for InfoNote {
613    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
614        f.write_str(&self.message)
615    }
616}
617
618impl From<&str> for AssertDetail {
619    /// Conversion for uncategorized messages; defaults `kind` to
620    /// [`DetailKind::Other`]. Prefer [`AssertDetail::new`] when the
621    /// detail has a meaningful category — the `DetailKind` is serialized
622    /// into the sidecar JSON and consumed by stats tooling to bucket
623    /// failures, so losing the category bucket makes post-run
624    /// categorization rely on free-text regex against `message`.
625    fn from(s: &str) -> Self {
626        Self::new(DetailKind::Other, s)
627    }
628}
629
630impl std::fmt::Display for AssertDetail {
631    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
632        f.write_str(&self.message)
633    }
634}
635
636/// Result of checking a scenario run.
637///
638/// Contains pass/fail status, human-readable detail messages, and
639/// aggregated statistics. Multiple results can be combined with
640/// [`merge()`](AssertResult::merge).
641///
642/// ```
643/// # use ktstr::assert::{AssertDetail, AssertResult, DetailKind};
644/// let mut a = AssertResult::pass();
645/// assert!(a.is_pass());
646///
647/// let mut b = AssertResult::pass();
648/// b.record_fail(AssertDetail::new(DetailKind::Starved, "worker starved"));
649///
650/// a.merge(b);
651/// assert!(a.is_fail());
652/// assert!(a.failure_details().any(|d| d.kind == DetailKind::Starved));
653/// ```
654/// Structured measurement value attached via
655/// [`AssertResult::note_value`] / [`Verdict::note_value`].
656///
657/// The variants cover every primitive shape stats tooling consumes:
658/// signed and unsigned 64-bit ints, 64-bit floats, booleans, and
659/// owned strings. A test that wants to surface "max_wchar=12345"
660/// alongside a passing IO_ACCOUNTING reachability check writes
661/// `verdict.note_value("max_wchar", 12345i64)` and downstream stats
662/// tooling reads `result.measurements["max_wchar"]` as
663/// `NoteValue::Int(12345)`.
664///
665/// Distinct from [`AssertResult::info_notes`]'s free-form
666/// [`InfoNote`] messages: an `InfoNote` carries a single human-
667/// readable string (formatted via its `Display` impl), the
668/// structured map carries typed `(key, NoteValue)` pairs for
669/// programmatic consumption (sidecar parsers, `perf-delta`,
670/// regression dashboards). Producers can call BOTH `note(msg)`
671/// and `note_value(key, val)` on the same result — they occupy
672/// independent buffers (`info_notes` vs `measurements`).
673///
674/// Conversion via the `From` impls below: any
675/// `i64`/`u64`/`f64`/`bool`/`String`/`&str` literal flows into
676/// `note_value` without explicit variant naming. Integer types
677/// narrower than 64-bit (`i32`, `u32`, etc.) need an explicit cast
678/// at the call site rather than a blanket impl, so the call site
679/// reads honestly about the value's resolution.
680///
681/// Derives `PartialEq` but NOT `Eq`: the `Float(f64)` variant holds
682/// IEEE-754 doubles where `NaN != NaN`, which violates the
683/// reflexivity requirement on `Eq`. Equality on `NoteValue` is
684/// partial-equivalence semantics for the same reason `f64` is.
685///
686/// Uses serde's externally-tagged default (no `#[serde(untagged)]`).
687/// Like [`Outcome`], NoteValue is wire-encoded as part of
688/// [`AssertResult::measurements`] via postcard's TLV transport from
689/// guest to host. Postcard is not a self-describing format and cannot
690/// decode `#[serde(untagged)]` enums (returns `WontImplement`) — pre-fix
691/// the decode silently failed when any test populated measurements
692/// before its result crossed the wire. The externally-tagged default
693/// (JSON form `{"Int": 42}` / `{"Text": "x"}`) is what postcard's
694/// externally-tagged enum decoder expects. The
695/// `assert_result_postcard_roundtrip` test pins this contract so a
696/// regression that re-adds `#[serde(untagged)]` trips at test time
697/// rather than as a silent data drop at runtime.
698#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
699pub enum NoteValue {
700    /// 64-bit signed integer — pid_t, exit codes, signed counters.
701    Int(i64),
702    /// 64-bit unsigned integer — work_units, byte counts, durations.
703    Uint(u64),
704    /// 64-bit float — ratios, rates, percentiles in microseconds.
705    Float(f64),
706    /// Boolean — completion flags, feature-detect results.
707    Bool(bool),
708    /// Owned string — categorical labels, environment tokens.
709    Text(String),
710}
711
712impl From<i64> for NoteValue {
713    fn from(v: i64) -> Self {
714        Self::Int(v)
715    }
716}
717impl From<u64> for NoteValue {
718    fn from(v: u64) -> Self {
719        Self::Uint(v)
720    }
721}
722impl From<f64> for NoteValue {
723    fn from(v: f64) -> Self {
724        Self::Float(v)
725    }
726}
727impl From<bool> for NoteValue {
728    fn from(v: bool) -> Self {
729        Self::Bool(v)
730    }
731}
732impl From<String> for NoteValue {
733    fn from(v: String) -> Self {
734        Self::Text(v)
735    }
736}
737impl From<&str> for NoteValue {
738    fn from(v: &str) -> Self {
739        Self::Text(v.to_string())
740    }
741}
742
743/// Terminal verdict for a single test scenario or merge fold —
744/// strict four-state enum that replaces the `(passed, skipped)`
745/// bool-pair encoding on [`AssertResult`].
746///
747/// Precedence under [`AssertResult::merge`]:
748/// **`Fail > Inconclusive > Pass > Skip`**.
749/// A merge that contains any `Fail` resolves to `Fail`; absent a
750/// `Fail`, any `Inconclusive` resolves to `Inconclusive`; absent
751/// both, a `Pass + Skip` mix resolves to `Pass` (Pass dominates
752/// Skip — a check that actually ran and passed overrides a
753/// sibling check whose precondition was unmet, so the merge does
754/// not falsely demote to Skip on the strength of an unrelated
755/// missing-precondition sibling). Skip-only merges stay Skip.
756/// Pass-only merges stay Pass. Inconclusive sits between Fail
757/// and Pass because "couldn't evaluate" is not a real Pass (an
758/// Inconclusive run must not satisfy `is_pass()`-keyed CI gates)
759/// but also not a hard Fail (no claim was made that the system
760/// did the wrong thing).
761///
762/// `Inconclusive` exists for ratio assertions whose denominator
763/// is an INSTRUMENT-derived measurement (iteration count, sample
764/// count, wall-clock interval) that legitimately reached zero —
765/// the gate has no signal to evaluate against. Distinguish from
766/// `Fail`: a POLICY-derived denominator (e.g. NUMA pages under
767/// `MemPolicy::Bind`, where the policy specifies pages will
768/// exist) staying at zero IS a defect signal and stays as `Fail`
769/// per the existing semantic — see `assert_page_locality` /
770/// `AssertPlan::assert_cgroup` for the policy-derived carve-out.
771///
772/// Note: Notes do NOT belong here. [`AssertResult::info_notes`]
773/// is the structurally-separate context stream; re-encoding Note
774/// as an `Outcome` variant would re-mix the failure / verdict
775/// surface with the context surface and erase the separation.
776/// Outcome is strictly terminal verdict; notes are non-verdict
777/// context.
778///
779/// `Skip`, `Inconclusive`, and `Fail` carry an [`AssertDetail`]
780/// payload so the match arm has the diagnostic in hand without
781/// re-walking `details`. `Pass` carries no payload — there is no
782/// failure to describe.
783///
784/// Outcomes are stored as [`AssertResult::outcomes`] and the
785/// [`AssertResult::outcome`] accessor folds the vec via this enum's
786/// [`Self::merge`] (identity = `Outcome::Pass`). Callers query via
787/// [`AssertResult::is_pass`] / [`AssertResult::is_fail`] /
788/// [`AssertResult::is_skip`] / [`AssertResult::is_inconclusive`]
789/// (bool checks), [`AssertResult::record_fail`] /
790/// [`AssertResult::record_skip`] / [`AssertResult::record_pass`] /
791/// [`AssertResult::record_inconclusive`] (atomic mutators), or
792/// [`AssertResult::failure_details`] / [`AssertResult::skip_details`] /
793/// [`AssertResult::inconclusive_details`] (per-variant payload
794/// iterators).
795///
796/// **Skip is not Pass**: `is_pass()` returns `false` on skip — a
797/// skipped scenario is "couldn't run", not "passed". Stats tooling
798/// and gate callers that want to count "not a failure" must test
799/// `r.is_pass() || r.is_skip()` rather than bare `r.is_pass()`.
800/// **Inconclusive is not Pass either**: `is_pass()` returns `false`
801/// when any Inconclusive is recorded, so a zero-denominator ratio
802/// gate cannot silently satisfy an `is_pass()`-keyed CI check.
803/// Uses serde's externally-tagged default (no `#[serde(tag,
804/// content)]`). Most ktstr enums adopt the adjacently-tagged
805/// `#[serde(tag = "kind", content = "data")]` style for JSON
806/// readability, but `Outcome` is uniquely wire-encoded via
807/// postcard as part of [`AssertResult`]'s TLV transport from
808/// guest to host (see
809/// `crate::test_support::output::parse_assert_result_from_drain`
810/// and `crate::test_support::test_helpers::assert_result_tlv_entry`).
811/// Postcard is not a self-describing format and cannot decode
812/// adjacently-tagged enums — pre-fix the decode silently failed and
813/// surfaced as `ERR_NO_TEST_FUNCTION_OUTPUT`. The externally-tagged
814/// default is what postcard's externally-tagged enum decoder
815/// expects. `tests_assert.rs::outcome_serde_externally_tagged_*`
816/// pins both the JSON shape and the postcard roundtrip so a
817/// refactor that re-adds adjacent tagging trips loudly at test
818/// time rather than at runtime.
819///
820/// # Wire-format stability (postcard variant index)
821///
822/// Postcard encodes externally-tagged enums by **variant index**,
823/// not variant name — the integer position in the `enum` body
824/// becomes part of the wire format. The current encoding is:
825/// `Pass=0`, `Skip=1`, `Inconclusive=2`, `Fail=3`.
826///
827/// **Append-only:** new variants MUST be added at the END of the
828/// variant list. Re-ordering, removing, or inserting a variant
829/// shifts the index of every variant after it and silently
830/// reinterprets in-flight bytes from guest payloads as a
831/// different variant on the host — the failure mode is a `Pass`
832/// reading as `Skip` (or vice versa) with no decode error.
833///
834/// Any change to the variant order or list MUST be accompanied
835/// by an update to `tests_assert.rs::outcome_serde_externally_tagged_*`
836/// (which pins the JSON shape and the postcard roundtrip) and
837/// `tests_assert.rs::outcome_postcard_variant_index_byte_sentinel`
838/// (which pins the leading variant-index byte) so a silent-shift
839/// regression trips at test time.
840#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
841pub enum Outcome {
842    // Wire-format-stable: variant indices encode into postcard
843    // bytes (Pass=0, Skip=1, Inconclusive=2, Fail=3). Append new
844    // variants ONLY at the end of this list — see the enum doc's
845    // "Wire-format stability" section for the silent-shift hazard
846    // a reorder introduces.
847    Pass,
848    Skip(AssertDetail),
849    Inconclusive(AssertDetail),
850    Fail(AssertDetail),
851}
852
853impl Outcome {
854    /// True iff `self == Outcome::Pass`.
855    ///
856    /// Part of the `is_pass` / `is_fail` / `is_inconclusive` /
857    /// `is_skip` vocabulary uniform across the verdict surfaces:
858    /// [`crate::assert::AssertResult::is_pass`] /
859    /// [`crate::test_support::SidecarResult::is_pass`] /
860    /// [`Self::is_pass`] / `MonitorVerdict::is_pass` (in the
861    /// `monitor` module, which is `pub(crate)`) / `Verdict::is_pass`
862    /// (re-exported at [`crate::assert::Verdict`]) /
863    /// `GauntletRow::is_pass` (in the `stats` module, which is
864    /// `pub(crate)`). [`OutcomeRef::is_pass`] is a borrowed-view
865    /// twin of this method on the borrowed [`OutcomeRef`] enum and
866    /// is intentionally NOT counted as a peer surface — it shares
867    /// the boolean semantic for naming parity but is a `&self`
868    /// projection over [`Outcome`], not an independent verdict
869    /// shape.
870    pub fn is_pass(&self) -> bool {
871        matches!(self, Outcome::Pass)
872    }
873
874    /// True iff `self == Outcome::Skip(_)`.
875    pub fn is_skip(&self) -> bool {
876        matches!(self, Outcome::Skip(_))
877    }
878
879    /// True iff `self == Outcome::Fail(_)`.
880    pub fn is_fail(&self) -> bool {
881        matches!(self, Outcome::Fail(_))
882    }
883
884    /// True iff `self == Outcome::Inconclusive(_)`.
885    pub fn is_inconclusive(&self) -> bool {
886        matches!(self, Outcome::Inconclusive(_))
887    }
888
889    /// Merge two outcomes per the precedence
890    /// `Fail > Inconclusive > Pass > Skip`.
891    ///
892    /// Discriminant-commutative: the merged Pass/Skip/Inconclusive/Fail
893    /// kind is the same regardless of operand order. Idempotent on
894    /// Pass (`Pass.merge(Pass) == Pass`).
895    ///
896    /// Payload semantic (NOT commutative):
897    /// - Same-variant ties (Fail+Fail, Inconclusive+Inconclusive,
898    ///   Skip+Skip): the LEFT operand's payload wins, so caller-
899    ///   controlled merge ordering produces deterministic detail
900    ///   content.
901    /// - Cross-variant Fail+{Inconclusive,Skip}: the merged outcome is
902    ///   Fail and the payload comes from whichever side carries the
903    ///   Fail (the dominated side's payload is dropped — the merged
904    ///   verdict is Fail, so the dominated narrative is irrelevant to
905    ///   the failure record).
906    /// - Cross-variant Inconclusive+{Pass,Skip}: merged outcome is
907    ///   Inconclusive and the payload comes from whichever side
908    ///   carries the Inconclusive.
909    pub fn merge(self, other: Outcome) -> Outcome {
910        use Outcome::*;
911        match (self, other) {
912            (Fail(d), _) | (_, Fail(d)) => Fail(d),
913            (Inconclusive(d), _) | (_, Inconclusive(d)) => Inconclusive(d),
914            (Pass, _) | (_, Pass) => Pass,
915            (Skip(d), Skip(_)) => Skip(d),
916        }
917    }
918
919    /// Borrow this outcome's payload as an [`OutcomeRef`]. Zero-
920    /// allocation projection — `Pass` carries no payload; `Skip`,
921    /// `Inconclusive`, and `Fail` borrow their [`AssertDetail`] in
922    /// place. Used by
923    /// the verdict-read fast path
924    /// ([`AssertResult::outcome_ref`]) and any caller that wants
925    /// to inspect the terminal verdict without cloning the
926    /// detail (e.g. error-message formatting where the detail
927    /// outlives the formatter, or sidecar emission that already
928    /// owns the source `Outcome`).
929    pub fn as_ref(&self) -> OutcomeRef<'_> {
930        match self {
931            Outcome::Pass => OutcomeRef::Pass,
932            Outcome::Skip(d) => OutcomeRef::Skip(d),
933            Outcome::Inconclusive(d) => OutcomeRef::Inconclusive(d),
934            Outcome::Fail(d) => OutcomeRef::Fail(d),
935        }
936    }
937}
938
939/// Borrowed view of an [`Outcome`]: same four discriminants but
940/// the `Skip`, `Inconclusive`, and `Fail` payloads borrow their
941/// [`AssertDetail`] in place. Returned by [`Outcome::as_ref`] and
942/// the zero-clone verdict-read fast path
943/// [`AssertResult::outcome_ref`].
944///
945/// Use when the caller wants the terminal verdict shape (or its
946/// payload) WITHOUT taking ownership — typical sites are
947/// formatter and sidecar paths that already hold the source
948/// `AssertResult` and want to avoid the per-call
949/// `AssertDetail::clone` the owned [`Outcome`] accessor performs.
950#[derive(Debug, Clone, Copy, PartialEq, Eq)]
951pub enum OutcomeRef<'a> {
952    Pass,
953    Skip(&'a AssertDetail),
954    Inconclusive(&'a AssertDetail),
955    Fail(&'a AssertDetail),
956}
957
958impl OutcomeRef<'_> {
959    /// True iff `self == OutcomeRef::Pass`. Matches the boolean
960    /// shape of [`Outcome::is_pass`] for naming parity.
961    pub fn is_pass(&self) -> bool {
962        matches!(self, OutcomeRef::Pass)
963    }
964    /// True iff `self == OutcomeRef::Skip(_)`. Matches the
965    /// boolean shape of [`Outcome::is_skip`].
966    pub fn is_skip(&self) -> bool {
967        matches!(self, OutcomeRef::Skip(_))
968    }
969    /// True iff `self == OutcomeRef::Fail(_)`. Matches the
970    /// boolean shape of [`Outcome::is_fail`].
971    pub fn is_fail(&self) -> bool {
972        matches!(self, OutcomeRef::Fail(_))
973    }
974    /// True iff `self == OutcomeRef::Inconclusive(_)`. Matches the
975    /// boolean shape of [`Outcome::is_inconclusive`].
976    pub fn is_inconclusive(&self) -> bool {
977        matches!(self, OutcomeRef::Inconclusive(_))
978    }
979    /// Promote a borrowed [`OutcomeRef`] into an owned [`Outcome`]
980    /// by cloning the borrowed [`AssertDetail`] (when present).
981    /// `OutcomeRef::Pass` carries no payload so the conversion is
982    /// allocation-free. Pairs with [`Outcome::as_ref`] for the
983    /// borrow ↔ own round-trip; [`AssertResult::outcome`] delegates
984    /// here so the fold logic stays single-sourced in
985    /// [`AssertResult::outcome_ref`] and any future fold-rule
986    /// change (e.g. a new terminal arm) lands in one place.
987    pub fn to_owned(&self) -> Outcome {
988        match self {
989            OutcomeRef::Pass => Outcome::Pass,
990            OutcomeRef::Skip(d) => Outcome::Skip((*d).clone()),
991            OutcomeRef::Inconclusive(d) => Outcome::Inconclusive((*d).clone()),
992            OutcomeRef::Fail(d) => Outcome::Fail((*d).clone()),
993        }
994    }
995}