ktstr/test_support/
payload.rs

1//! Generalized test payload — scheduler or binary workload.
2//!
3//! [`Payload`] is the primitive that `#[ktstr_test]` consumes for both
4//! the scheduler slot and the optional binary/workload slots. A
5//! payload's [`PayloadKind`] determines how it's launched: a
6//! [`Scheduler`] reference invokes the
7//! existing scheduler-spawn path; a bare binary name spawns the binary
8//! via the runtime [`PayloadRun`](crate::scenario::payload_run::PayloadRun)
9//! builder.
10//!
11//! The constants this module exposes — particularly
12//! [`Payload::KERNEL_DEFAULT`] — are used as the default scheduler
13//! slot when no `scheduler = ...` attribute is supplied on a
14//! `#[ktstr_test]`. `KERNEL_DEFAULT` wraps whatever scheduler the
15//! running kernel selects when no sched_ext scheduler is attached
16//! (EEVDF on Linux 6.6+) and surfaces on the wire as
17//! `"kernel_default"`.
18//!
19//! [`KtstrTestEntry`](crate::test_support::KtstrTestEntry) carries
20//! `payload` and `workloads` fields populated by the `#[ktstr_test]`
21//! macro's `payload = ...` and `workloads = [...]` attributes.
22
23use crate::test_support::Scheduler;
24
25// ---------------------------------------------------------------------------
26// Payload + PayloadKind
27// ---------------------------------------------------------------------------
28
29/// A test payload — either a scheduler or a userspace binary to run
30/// inside the guest VM.
31///
32/// `Payload` unifies the two launch modes under one `#[ktstr_test]`
33/// attribute surface: tests declare `scheduler = SOME_SCHED` for
34/// scheduler-centric runs, `payload = SOME_BIN` for binary runs, or
35/// both with `workloads = [...]` to compose binaries under a
36/// scheduler. See [`PayloadKind`] for the two variants.
37///
38/// Use [`Payload::KERNEL_DEFAULT`] as the default scheduler
39/// placeholder when a test doesn't attach a sched_ext scheduler —
40/// it wraps the kernel's default scheduler (EEVDF on Linux 6.6+)
41/// via [`Scheduler::EEVDF`].
42///
43/// `Payload` intentionally does NOT implement [`serde::Serialize`] /
44/// [`serde::Deserialize`]. It is a compile-time-static definition that
45/// references `&'static Scheduler` and `&'static [&'static str]`
46/// slices — lifetimes that serialization cannot round-trip. Runtime
47/// telemetry (per-payload metrics, exit codes, names) is serialized
48/// via [`PayloadMetrics`] and [`Metric`] instead; those own their
49/// data.
50///
51/// `#[non_exhaustive]` reserves the right to add fields without
52/// breaking downstream code. Out-of-crate callers cannot construct
53/// `Payload` via struct literal — use the const-fn constructors
54/// ([`Payload::new`], [`Payload::binary`]) or the
55/// `#[derive(Payload)]` macro, which routes through [`Payload::new`]
56/// under the hood.
57#[derive(Clone, Copy)]
58#[non_exhaustive]
59pub struct Payload {
60    /// Short, stable name used in logs and sidecar records.
61    pub name: &'static str,
62    /// Launch kind — scheduler reference or binary name.
63    pub kind: PayloadKind,
64    /// How the framework extracts metrics from the payload's
65    /// stdout, with stderr fallback when stdout yields no metrics.
66    /// See [`OutputFormat`] for the per-variant contract and
67    /// `scenario::payload_run` for the fallback mechanics.
68    pub output: OutputFormat,
69    /// Default CLI args appended when this payload runs. Test bodies
70    /// can extend via `.arg(...)` or replace via `.clear_args()` +
71    /// `.arg(...)` on the runtime builder.
72    pub default_args: &'static [&'static str],
73    /// Author-declared default checks evaluated against extracted
74    /// [`PayloadMetrics`]. Payloads that need exit-code gating
75    /// should include [`MetricCheck::ExitCodeEq(0)`](MetricCheck::ExitCodeEq)
76    /// here; the runtime evaluates `ExitCodeEq` as a pre-pass
77    /// before metric checks.
78    pub default_checks: &'static [MetricCheck],
79    /// Declared metric hints — polarity, unit. Unhinted metrics
80    /// extracted from output land as [`Polarity::Unknown`].
81    pub metrics: &'static [MetricHint],
82    /// Host-side file specs resolved at runtime. Each entry is
83    /// resolved through the framework's include-file pipeline — the
84    /// same resolver used by CLI `-i` / `--include-files` arguments:
85    /// bare names are searched in the host's `PATH`, explicit paths
86    /// (absolute, relative, or containing `/`) must exist on the
87    /// host, and directories are walked recursively. The entry's
88    /// scheduler / payload / workloads / extra_include_files are
89    /// aggregated at test time via
90    /// [`KtstrTestEntry::all_include_files`](crate::test_support::KtstrTestEntry::all_include_files)
91    /// and resolved through the same pipeline the `ktstr shell -i`
92    /// path uses. Populate via the
93    /// `#[include_files("helper", ...)]` attribute on
94    /// `#[derive(Payload)]` or by spelling the array in the struct
95    /// literal.
96    pub include_files: &'static [&'static str],
97    /// When `true`, the payload's spawn path does NOT place the
98    /// child into its own process group via
99    /// `CommandExt::process_group(0)`. The child inherits the
100    /// parent ktstr process's pgid. Default (`false`) keeps the
101    /// existing "fresh pgrp → killpg-reaches-descendants" model
102    /// — see `src/scenario/payload_run.rs::build_command`.
103    ///
104    /// Opt-in for tty-dependent binaries: a shell-like tool that
105    /// uses the controlling terminal's foreground process group
106    /// for signal delivery (job-control signals, SIGHUP on tty
107    /// close) reads a fresh pgrp as "no job control", which
108    /// breaks interactive shells and `less`-style readers.
109    /// Payloads that need tty job-control semantics set this
110    /// true so they stay in the parent's pgrp and keep the
111    /// inherited controlling-terminal association.
112    ///
113    /// Trade-off on the `true` branch: multi-process payloads
114    /// can no longer be killed via `killpg(child_pid, SIGKILL)`
115    /// because the child is not a pgrp leader; the kill path
116    /// falls back to single-pid `kill(pid, SIGKILL)` and any
117    /// descendants that the payload forks must either react to
118    /// SIGHUP / pipe close or run the risk of orphaning. Most
119    /// payloads should leave this `false`.
120    pub uses_parent_pgrp: bool,
121    /// When `Some`, the listed flag names form an allowlist that
122    /// `Op::RunPayload` validation checks against at scenario-
123    /// execution time (inside `apply_ops`, before the payload
124    /// spawn) — any user-supplied `--flag` whose name is not in
125    /// the allowlist produces an error surfaced through the step
126    /// executor, surfacing typos as loud errors instead of silent
127    /// no-ops that only manifest as "feature didn't activate" in
128    /// the test output.
129    ///
130    /// `None` (default) disables validation — the payload accepts
131    /// arbitrary flag sets. Use `None` for payloads that wrap
132    /// binaries with open-ended flag surfaces (stress-ng, fio,
133    /// schbench) where enumerating every accepted flag is either
134    /// impossible or high-churn.
135    ///
136    /// `Some(&[])` is legal but rarely intended: it rejects EVERY
137    /// long flag, including ones the wrapped binary legitimately
138    /// accepts. Use `None` for "no validation" and a non-empty
139    /// slice for "validate against this allowlist" — an empty
140    /// slice means "only positional args and short flags are
141    /// acceptable", which is almost never what a Payload author
142    /// wants.
143    ///
144    /// Flag names in the slice are bare (no leading `--`) and
145    /// match the syntax of `Op::RunPayload`'s per-flag slot.
146    pub known_flags: Option<&'static [&'static str]>,
147}
148
149impl std::fmt::Debug for Payload {
150    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
151        // Manual impl renders the payload's identity + slice counts
152        // rather than the full struct (which would expand every
153        // nested Scheduler/Sysctl/CgroupPath into a multi-screen
154        // dump on every trace line). Scheduler IS Debug — the
155        // derived impl would just be too verbose for the routine
156        // tracing call sites this type appears in.
157        f.debug_struct("Payload")
158            .field("name", &self.name)
159            .field("kind", &self.kind)
160            .field("output", &self.output)
161            .field("default_args_len", &self.default_args.len())
162            .field("default_checks_len", &self.default_checks.len())
163            .field("metrics_len", &self.metrics.len())
164            .finish()
165    }
166}
167
168/// How a payload is launched inside the guest.
169///
170/// Two variants — scheduler and binary — map to the two launch paths
171/// in the runtime. "Kernel default" (EEVDF) is represented as
172/// `Scheduler(&Scheduler::EEVDF)` rather than a dedicated variant
173/// because [`Scheduler`] already carries the no-userspace-binary
174/// taxonomy via its own `binary: SchedulerSpec` field.
175#[derive(Clone, Copy)]
176pub enum PayloadKind {
177    /// Wraps an existing [`Scheduler`] definition. The scheduler's
178    /// own `binary: SchedulerSpec` carries the Eevdf/Discover/Path/
179    /// KernelBuiltin taxonomy — no duplication at the Payload level.
180    Scheduler(&'static Scheduler),
181    /// Bare userspace binary looked up by name in the guest. Not a
182    /// scheduler — runs as a workload under whatever scheduler the
183    /// test declares.
184    ///
185    /// # How the binary reaches the guest
186    ///
187    /// The stored `&'static str` is the executable name passed to
188    /// `std::process::Command::new` inside the guest (see
189    /// [`PayloadRun::run`](crate::scenario::payload_run::PayloadRun::run)),
190    /// which resolves it against the guest's `PATH`. The framework
191    /// resolves binaries through the include-file pipeline — for
192    /// `#[ktstr_test]` entries via declarative `include_files` /
193    /// `extra_include_files`, or via `-i` on `ktstr shell`.
194    ///
195    /// Supply a binary through the framework's include-file
196    /// pipeline. The pipeline is wired up to the `shell` subcommand
197    /// of both `ktstr` and `cargo ktstr` through the repeatable
198    /// `-i` / `--include-files` flag. Each `-i` argument accepts:
199    ///
200    /// - an explicit path (absolute, relative, or containing `/`) —
201    ///   must exist on the host;
202    /// - a bare name — searched in `PATH` on the host;
203    /// - a directory — walked recursively, preserving structure under
204    ///   `/include-files/<dirname>/...` in the guest.
205    ///
206    /// Every regular file ends up at `/include-files/<name>` (or
207    /// deeper for directory walks). Dynamically-linked ELFs pull in
208    /// their `DT_NEEDED` shared libraries automatically; the guest
209    /// init prepends every `/include-files/*` subdirectory containing
210    /// an executable to `PATH`, so a binary packaged with `-i` is
211    /// runnable by bare name from a test body.
212    ///
213    /// Example — launch a shell VM with `fio` available by bare name:
214    ///
215    /// ```sh
216    /// cargo ktstr shell -i fio --exec "fio --version"
217    /// ```
218    ///
219    /// The `fio` binary is resolved against the host's `PATH`, copied
220    /// to `/include-files/fio` in the guest, exposed on the guest
221    /// `PATH`, and spawnable as `fio` from any guest-side process.
222    ///
223    /// # `#[ktstr_test]` entries
224    ///
225    /// Declarative `include_files` on `#[derive(Payload)]` and
226    /// `extra_include_files` on `#[ktstr_test]` handle binary
227    /// packaging automatically — no CLI `-i` and no bespoke harness
228    /// needed.
229    ///
230    /// # Scheduler config files
231    ///
232    /// Scheduler-kind payloads that set
233    /// [`Scheduler`]'s `config_file`
234    /// field get automatic packaging: the config file is placed at
235    /// `/include-files/{filename}` without a `-i` flag — the field
236    /// is the source the harness reads.
237    ///
238    /// # Binary-kind packaging
239    ///
240    /// Payloads built via `#[derive(Payload)]` get automatic binary
241    /// packaging: the derive macro prepends the `binary = "..."`
242    /// spec to the emitted `include_files` slice, so the spawn
243    /// target is packaged into the guest without requiring a
244    /// separate `#[include_files("...")]` entry. Auxiliary files
245    /// the payload needs (helpers, config files, fixtures) still
246    /// go on `#[include_files(...)]` — the derive only injects the
247    /// primary binary.
248    ///
249    /// Payloads constructed manually via struct literal (rather
250    /// than the derive) do not get this auto-injection: the
251    /// harness does not derive `include_files` from the
252    /// `PayloadKind::Binary(name)` at aggregation time. Manual
253    /// constructions must list the binary in
254    /// [`Payload::include_files`](Payload::include_files)
255    /// themselves, or declare it on
256    /// [`extra_include_files`](crate::test_support::KtstrTestEntry::extra_include_files)
257    /// at the `#[ktstr_test]` level. A binary referenced at spawn
258    /// time but neither auto-injected nor listed as an include is
259    /// expected to already be present in the guest filesystem
260    /// (e.g. a standard `busybox` applet on the base image);
261    /// otherwise the omission surfaces as `ENOENT` at `exec` time
262    /// inside the guest.
263    ///
264    /// # Fork / kill semantics
265    ///
266    /// A binary-kind payload is spawned in its own process group via
267    /// `CommandExt::process_group(0)` in
268    /// [`build_command`](crate::scenario::payload_run) so the
269    /// framework can reach every descendant the binary forks. Direct consequences for test
270    /// authors:
271    ///
272    /// - `std::process::Child::kill()` only targets the direct child
273    ///   — a `fork()`ed descendant (stress-ng worker, fio `--numjobs`,
274    ///   schbench worker mode, pipeline subshells under `sh -c`)
275    ///   survives. Never call `child.kill()` directly on a payload
276    ///   `Child`; the handle's `kill()` wrapper fans out SIGKILL to
277    ///   the whole process group via `killpg`.
278    /// - [`PayloadHandle::kill`](crate::scenario::payload_run::PayloadHandle::kill),
279    ///   [`PayloadHandle::wait`](crate::scenario::payload_run::PayloadHandle::wait)
280    ///   cleanup, and the panic-safety Drop arm all route through
281    ///   `kill_payload_process_group`, which issues `killpg(pgid,
282    ///   SIGKILL)` followed by a single-pid SIGKILL fallback so
283    ///   descendants and the leader both exit. This is the only kill
284    ///   path test authors need.
285    /// - Pipe drainers (stdout / stderr reader threads) block on EOF,
286    ///   which only arrives after every descendant holding the
287    ///   write ends closes them. A bare `child.kill()` leaves the
288    ///   descendants holding the pipes open and
289    ///   `wait_and_capture` hangs
290    ///   forever — motivating the `killpg` requirement.
291    Binary(&'static str),
292}
293
294impl std::fmt::Debug for PayloadKind {
295    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
296        // Manual impl renders variant + identity summary (just the
297        // scheduler/binary name) rather than the full Scheduler
298        // struct that the derived Debug would expand. Trace lines
299        // that print a PayloadKind want one-line attribution, not a
300        // multi-screen scheduler config dump.
301        match self {
302            PayloadKind::Scheduler(s) => f.debug_tuple("Scheduler").field(&s.name).finish(),
303            PayloadKind::Binary(name) => f.debug_tuple("Binary").field(name).finish(),
304        }
305    }
306}
307
308impl Payload {
309    /// Placeholder payload that wraps the current kernel-default
310    /// scheduler — [`Scheduler::EEVDF`] on Linux 6.6+ (the "no scx
311    /// scheduler attached" case). Used as the default value of the
312    /// `scheduler` slot on
313    /// [`KtstrTestEntry`](crate::test_support::KtstrTestEntry) so
314    /// tests without an explicit `scheduler = ...` attribute still
315    /// get a valid, non-optional reference. Wire name is
316    /// `"kernel_default"` — the Rust const and the serialized form
317    /// agree, so the const describes what it selects for (the
318    /// kernel's default) rather than naming a specific scheduler that
319    /// a future kernel release could replace.
320    ///
321    /// ## `kernel_default` vs `eevdf` in sidecars
322    ///
323    /// `KERNEL_DEFAULT.name` is `"kernel_default"` (the intent-level
324    /// label), while `KERNEL_DEFAULT.scheduler_name()` returns
325    /// `"eevdf"` (the inner [`Scheduler::EEVDF`]'s `.name`). The two
326    /// names answer different questions:
327    ///
328    /// - `"kernel_default"` answers "what did the test author select?"
329    ///   — a future kernel release replacing EEVDF keeps this label
330    ///   stable, so an in-memory match on author intent survives
331    ///   kernel upgrades.
332    /// - `"eevdf"` answers "what scheduler actually ran?" — the
333    ///   concrete scheduling class in effect.
334    ///
335    /// **Sidecar serialization reads the scheduler-slot `Scheduler`
336    /// directly.** The `SidecarResult.scheduler` field
337    /// (src/test_support/sidecar/mod.rs) is populated by reading
338    /// `entry.scheduler.name` — a field access on the
339    /// `&'static Scheduler` held in the entry's scheduler slot, with
340    /// no Payload indirection. When the scheduler slot holds
341    /// `&Scheduler::EEVDF` (the framework default), the sidecar
342    /// records `"eevdf"`. The outer `KERNEL_DEFAULT.name`
343    /// (`"kernel_default"`) is NOT written to the sidecar — it stays
344    /// in-memory only, used by logs, `#[ktstr_test]`-declaration
345    /// lookups, and `Payload::display_name()` on the payload-slot
346    /// surface. Cross-kernel-version comparisons via sidecar
347    /// `scheduler` therefore see `"eevdf"` today and whatever future
348    /// scheduling class replaces EEVDF tomorrow; author-intent
349    /// filtering on `"kernel_default"` requires consulting the
350    /// in-memory `Payload::name` directly, not the sidecar.
351    pub const KERNEL_DEFAULT: Payload = Payload::new(
352        "kernel_default",
353        PayloadKind::Scheduler(&Scheduler::EEVDF),
354        OutputFormat::ExitCode,
355        &[],
356        &[],
357        &[],
358        &[],
359        false,
360        None,
361    );
362
363    /// Short, human-readable name for logging and sidecar output.
364    pub const fn display_name(&self) -> &'static str {
365        self.name
366    }
367
368    /// Return the inner [`Scheduler`] reference when this payload
369    /// wraps one. Returns `None` for [`PayloadKind::Binary`].
370    pub const fn as_scheduler(&self) -> Option<&'static Scheduler> {
371        match self.kind {
372            PayloadKind::Scheduler(s) => Some(s),
373            PayloadKind::Binary(_) => None,
374        }
375    }
376
377    /// True when this payload wraps a [`Scheduler`] (scheduler
378    /// slot). False for binary payloads.
379    pub const fn is_scheduler(&self) -> bool {
380        matches!(self.kind, PayloadKind::Scheduler(_))
381    }
382
383    /// Primary const constructor for a [`Payload`].
384    ///
385    /// Takes every field by position so the `#[derive(Payload)]`
386    /// macro can emit a single call instead of a struct-literal.
387    /// `#[non_exhaustive]` on the struct prevents out-of-crate
388    /// struct-literal construction; this constructor — defined in
389    /// the same crate as `Payload` — is not subject to that
390    /// restriction, so the macro-expanded tokens that reach
391    /// downstream crates compile cleanly.
392    ///
393    /// For one-field constructions prefer [`Payload::binary`] — it
394    /// calls into this helper and pins the non-identity fields to
395    /// the exit-code-only defaults.
396    #[allow(clippy::too_many_arguments)]
397    pub const fn new(
398        name: &'static str,
399        kind: PayloadKind,
400        output: OutputFormat,
401        default_args: &'static [&'static str],
402        default_checks: &'static [MetricCheck],
403        metrics: &'static [MetricHint],
404        include_files: &'static [&'static str],
405        uses_parent_pgrp: bool,
406        known_flags: Option<&'static [&'static str]>,
407    ) -> Payload {
408        Payload {
409            name,
410            kind,
411            output,
412            default_args,
413            default_checks,
414            metrics,
415            include_files,
416            uses_parent_pgrp,
417            known_flags,
418        }
419    }
420
421    /// Minimal const constructor for a binary-kind [`Payload`]. Fills
422    /// the non-identity fields with the exit-code-only defaults — no
423    /// CLI args, no author-declared checks, no metric hints, and
424    /// [`OutputFormat::ExitCode`] — so a `#[ktstr_test]` entry or a
425    /// direct unit test can declare a runnable binary with one line
426    /// instead of spelling out the full struct literal.
427    ///
428    /// The `binary` string is the executable name passed to
429    /// `std::process::Command::new` inside the guest. Supply it to
430    /// the guest via `-i` / `--include-files` for CLI invocations or
431    /// pre-install it in the initramfs for `#[ktstr_test]` entries —
432    /// see [`PayloadKind::Binary`] for the full packaging contract.
433    pub const fn binary(name: &'static str, binary: &'static str) -> Payload {
434        Payload::new(
435            name,
436            PayloadKind::Binary(binary),
437            OutputFormat::ExitCode,
438            &[],
439            &[],
440            &[],
441            &[],
442            false,
443            None,
444        )
445    }
446
447    // -----------------------------------------------------------------
448    // Scheduler-slot forwarding accessors
449    //
450    // These methods let every site that consumed `entry.scheduler:
451    // &Scheduler` read the equivalent field off `entry.scheduler:
452    // &Payload` without the caller having to unwrap
453    // `as_scheduler()`. For a scheduler-kind payload the accessor
454    // forwards to the inner `Scheduler`. For a binary-kind payload
455    // the accessor returns a sensible default — usually the empty
456    // slice or the no-op value — matching the semantics a binary
457    // payload in the scheduler slot should carry (no sysctls, no
458    // kargs, no scheduler-specific CLI flags).
459    //
460    // The binary-kind branch is not "best effort": a binary payload
461    // in the scheduler slot is a valid configuration (pure userspace
462    // test under the kernel default scheduler), and every accessor
463    // below returns exactly what that scenario should see.
464    // -----------------------------------------------------------------
465
466    /// The scheduler's display name.
467    ///
468    /// Returns a compile-time-fixed LABEL, not a runtime reflection
469    /// of the scheduling class the live kernel is actually running.
470    /// A sidecar written on a kernel whose default is a successor
471    /// scheduling class still records whatever string this method
472    /// returns — the label comes from the `Payload` / inner
473    /// `Scheduler` definition, nothing queries `/proc` or the live
474    /// policy. Consumers that need to know the running kernel's
475    /// scheduling class must cross-reference the sidecar's
476    /// `host.kernel_release` with kernel-version-to-scheduler
477    /// knowledge maintained outside the sidecar.
478    ///
479    /// Branch behavior:
480    /// - `PayloadKind::Scheduler(s)` → `s.name` — the label attached
481    ///   to that specific scheduler, e.g. `"eevdf"` for
482    ///   [`Scheduler::EEVDF`] or `"scx_rusty"` for a scx_*
483    ///   scheduler. This is what scheduler-kind payloads (including
484    ///   `Payload::KERNEL_DEFAULT`, which wraps [`Scheduler::EEVDF`])
485    ///   surface.
486    /// - `PayloadKind::Binary(_)` → `"kernel_default"` — a binary
487    ///   payload runs under whatever scheduler the test declares
488    ///   elsewhere (or the kernel default if it declares none), so
489    ///   the binary-kind payload carries no scheduler identity of
490    ///   its own. The returned string is a LABEL ("test author did
491    ///   not pin a scheduler here"), NOT a statement about which
492    ///   scheduling class the VM actually ran under — the live
493    ///   kernel may be running EEVDF, a successor class, or an scx
494    ///   scheduler the binary's test harness attached separately;
495    ///   `scheduler_name()` does not observe any of that. Only a
496    ///   scheduler-kind payload explicitly wrapping
497    ///   [`Scheduler::EEVDF`] returns the `"eevdf"` label; every
498    ///   binary-kind payload returns `"kernel_default"` regardless
499    ///   of what class is running.
500    pub const fn scheduler_name(&self) -> &'static str {
501        match self.kind {
502            PayloadKind::Scheduler(s) => s.name,
503            PayloadKind::Binary(_) => "kernel_default",
504        }
505    }
506
507    /// The scheduler's binary spec when scheduler-kind; `None` for
508    /// binary-kind payloads. Consumers that dispatch on the
509    /// `SchedulerSpec` variant (e.g. `KernelBuiltin { enable, disable }`
510    /// hook invocation) use this rather than the `scheduler_name`
511    /// shortcut.
512    pub const fn scheduler_binary(&self) -> Option<&'static crate::test_support::SchedulerSpec> {
513        match self.kind {
514            PayloadKind::Scheduler(s) => Some(&s.binary),
515            PayloadKind::Binary(_) => None,
516        }
517    }
518
519    /// True when this payload drives an active scheduling policy
520    /// (anything other than the kernel default EEVDF). Forwards to
521    /// `SchedulerSpec::has_active_scheduling` for scheduler-kind
522    /// payloads; binary-kind payloads always return `false` — a
523    /// binary runs under whatever scheduler the test declares, and
524    /// does not itself impose one.
525    ///
526    /// Returns `true` for `KernelBuiltin` scheduler-kind payloads.
527    /// See [`Self::has_bpf_scheduler`] for the narrower gate that
528    /// excludes them.
529    pub const fn has_active_scheduling(&self) -> bool {
530        match self.kind {
531            PayloadKind::Scheduler(s) => s.binary.has_active_scheduling(),
532            PayloadKind::Binary(_) => false,
533        }
534    }
535
536    /// True when this payload drives a userspace BPF scheduler
537    /// binary. Forwards to `SchedulerSpec::has_bpf_scheduler` for
538    /// scheduler-kind payloads; binary-kind payloads always return
539    /// `false`.
540    ///
541    /// Distinct from [`Self::has_active_scheduling`]: this excludes
542    /// `KernelBuiltin` scheduler-kind payloads (in-kernel policy,
543    /// no userspace BPF binary). Use whenever the caller assumes a
544    /// BPF binary is attached — verifier_stats wiring, BPF-attach
545    /// monitor thresholds, or auto-repro probe gating.
546    pub const fn has_bpf_scheduler(&self) -> bool {
547        match self.kind {
548            PayloadKind::Scheduler(s) => s.binary.has_bpf_scheduler(),
549            PayloadKind::Binary(_) => false,
550        }
551    }
552
553    /// Guest sysctls applied before the scheduler starts. Empty slice
554    /// for binary-kind payloads.
555    pub const fn sysctls(&self) -> &'static [crate::test_support::Sysctl] {
556        match self.kind {
557            PayloadKind::Scheduler(s) => s.sysctls,
558            PayloadKind::Binary(_) => &[],
559        }
560    }
561
562    /// Extra guest kernel command-line arguments appended when
563    /// booting the VM. Empty slice for binary-kind payloads.
564    pub const fn kargs(&self) -> &'static [&'static str] {
565        match self.kind {
566            PayloadKind::Scheduler(s) => s.kargs,
567            PayloadKind::Binary(_) => &[],
568        }
569    }
570
571    /// Scheduler CLI args prepended before per-test `extra_sched_args`.
572    /// Empty slice for binary-kind payloads.
573    pub const fn sched_args(&self) -> &'static [&'static str] {
574        match self.kind {
575            PayloadKind::Scheduler(s) => s.sched_args,
576            PayloadKind::Binary(_) => &[],
577        }
578    }
579
580    /// Cgroup parent path. `None` for binary-kind payloads and for
581    /// scheduler-kind payloads that did not set one.
582    pub const fn cgroup_parent(&self) -> Option<crate::test_support::CgroupPath> {
583        match self.kind {
584            PayloadKind::Scheduler(s) => s.cgroup_parent,
585            PayloadKind::Binary(_) => None,
586        }
587    }
588
589    /// Host-side path to the scheduler config file. `None` for
590    /// binary-kind payloads and for scheduler-kind payloads that
591    /// did not set one.
592    pub const fn config_file(&self) -> Option<&'static str> {
593        match self.kind {
594            PayloadKind::Scheduler(s) => s.config_file,
595            PayloadKind::Binary(_) => None,
596        }
597    }
598
599    /// Inline config file definition. `None` for binary-kind payloads
600    /// and for scheduler-kind payloads that did not set one.
601    pub const fn config_file_def(&self) -> Option<(&'static str, &'static str)> {
602        match self.kind {
603            PayloadKind::Scheduler(s) => s.config_file_def,
604            PayloadKind::Binary(_) => None,
605        }
606    }
607
608    /// Scheduler-wide assertion overrides. For binary-kind payloads
609    /// returns `Assert::NO_OVERRIDES` — the default identity value
610    /// merge that leaves per-entry assertions untouched.
611    pub const fn assert(&self) -> &'static crate::assert::Assert {
612        match self.kind {
613            PayloadKind::Scheduler(s) => &s.assert,
614            PayloadKind::Binary(_) => &crate::assert::Assert::NO_OVERRIDES,
615        }
616    }
617
618    /// Default VM topology for this payload. Scheduler-kind payloads
619    /// expose the topology declared on the inner `Scheduler` so tests
620    /// that inherit from the scheduler slot stay consistent with the
621    /// rest of the scheduler's test surface; binary-kind payloads
622    /// return a minimal placeholder
623    /// ([`Topology::DEFAULT_FOR_PAYLOAD`](crate::test_support::Topology::DEFAULT_FOR_PAYLOAD))
624    /// — a pure binary workload has no scheduler-level topology
625    /// opinion, so per-entry `#[ktstr_test(...)]` overrides are what
626    /// actually drive the VM shape.
627    pub const fn topology(&self) -> crate::test_support::Topology {
628        match self.kind {
629            PayloadKind::Scheduler(s) => s.topology,
630            PayloadKind::Binary(_) => crate::test_support::Topology::DEFAULT_FOR_PAYLOAD,
631        }
632    }
633
634    /// Gauntlet topology constraints. Scheduler-kind payloads forward
635    /// to the inner `Scheduler::constraints`; binary-kind payloads
636    /// return `TopologyConstraints::DEFAULT`.
637    pub const fn constraints(&self) -> crate::test_support::TopologyConstraints {
638        match self.kind {
639            PayloadKind::Scheduler(s) => s.constraints,
640            PayloadKind::Binary(_) => crate::test_support::TopologyConstraints::DEFAULT,
641        }
642    }
643}
644
645// ---------------------------------------------------------------------------
646// OutputFormat
647// ---------------------------------------------------------------------------
648
649/// How the framework extracts metrics from a payload's output.
650///
651/// `ExitCode` records only the exit code; no text parsing. `Json`
652/// finds a JSON document region and walks numeric leaves into
653/// [`Metric`] values.
654///
655/// For `Json`, extraction is stdout-primary with a stderr fallback:
656/// the extractor runs first against stdout, and only when that
657/// yields an empty metric set AND stderr is non-empty does it retry
658/// against stderr. Well-behaved binaries keep stdout canonical;
659/// payloads that emit structured output only on stderr (schbench's
660/// `show_latencies` → `fprintf(stderr, ...)`) still parse. The
661/// streams are never merged. `ExitCode` produces no metrics from
662/// either stream — `extract_metrics` is invoked (the control flow
663/// is variant-agnostic for simplicity) but the `ExitCode` arm
664/// returns `Ok(vec![])` immediately, so the stderr fallback runs
665/// and also returns empty. Observable behavior: exit code only, no
666/// metrics.
667#[derive(Debug, Clone, Copy)]
668pub enum OutputFormat {
669    /// Pass/fail from exit code alone. Stdout is archived for
670    /// debugging but not parsed. `extract_metrics` is still invoked
671    /// in the evaluate pipeline (variant-agnostic control flow) but
672    /// returns `Ok(vec![])` immediately for this variant; the
673    /// stderr fallback runs too and also returns empty. Observable
674    /// behavior: no metrics extracted regardless of stream content.
675    ExitCode,
676    /// Parse the primary stream (stdout, or stderr on fallback) as
677    /// JSON: find the JSON region within mixed output, extract
678    /// numeric leaves as metrics keyed by dotted path (e.g.
679    /// `jobs.0.read.iops`).
680    Json,
681}
682
683// ---------------------------------------------------------------------------
684// Polarity, MetricCheck, Metric
685// ---------------------------------------------------------------------------
686
687/// Regression direction for a metric.
688///
689/// Used by `cargo ktstr test-stats` to classify deltas between runs.
690/// Declared explicitly on [`MetricHint`]; unhinted metrics default to
691/// [`Polarity::Unknown`] and are recorded without regression
692/// classification.
693#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
694pub enum Polarity {
695    /// Bigger is better (throughput, IOPS, bogo_ops/sec). Regression
696    /// = decrease from baseline.
697    HigherBetter,
698    /// Smaller is better (latency percentiles, error rates).
699    /// Regression = increase from baseline.
700    LowerBetter,
701    /// A target value that the metric should hover near. Regression
702    /// = absolute distance exceeds a threshold, symmetric in either
703    /// direction. The inner `f64` MUST be finite (not NaN/inf);
704    /// construct via [`Polarity::target`], which enforces this at
705    /// runtime in both debug and release.
706    TargetValue(f64),
707    /// Direction not declared; the metric is recorded but not
708    /// classified as regression-relevant. This is the CONSERVATIVE
709    /// default for an UNCLASSIFIED metric: the comparison path treats
710    /// it as higher-is-worse (see `MetricDef::higher_is_worse` /
711    /// `classify_direction`) so a real regression in a metric someone
712    /// forgot to classify is still caught rather than silently ignored.
713    Unknown,
714    /// Deliberately directionless: the metric is recorded and DISPLAYED
715    /// in comparisons but is NEVER classified as a regression or
716    /// improvement and NEVER affects the exit code. Distinct from
717    /// [`Polarity::Unknown`] (the conservative higher-is-worse default
718    /// for *unclassified* metrics): `Informational` is the explicit
719    /// "this counter has no good/bad direction" choice — e.g. wakeup /
720    /// context-switch / yield counts, where more is neither inherently
721    /// better nor worse. `classify_direction` returns `None` for it.
722    Informational,
723}
724
725impl Polarity {
726    /// Map the legacy `higher_is_worse: bool` used by
727    /// `MetricDef` to a `Polarity`.
728    ///
729    /// The sense is INVERSE: `true` (bigger values are regressions)
730    /// maps to [`Polarity::LowerBetter`] (we want the metric to go
731    /// down); `false` maps to [`Polarity::HigherBetter`].
732    pub const fn from_higher_is_worse(higher_is_worse: bool) -> Polarity {
733        if higher_is_worse {
734            Polarity::LowerBetter
735        } else {
736            Polarity::HigherBetter
737        }
738    }
739
740    /// The regression direction of this polarity: `Some(true)` = an INCREASE is
741    /// a regression (`LowerBetter` / `TargetValue` / the conservative `Unknown`),
742    /// `Some(false)` = a DECREASE is a regression (`HigherBetter`), `None` =
743    /// directionless (`Informational`, never gates). The single source of truth
744    /// that `MetricDef::classify_direction` delegates to.
745    pub const fn classify_direction(&self) -> Option<bool> {
746        match self {
747            Polarity::Informational => None,
748            Polarity::HigherBetter => Some(false),
749            Polarity::LowerBetter | Polarity::TargetValue(_) | Polarity::Unknown => Some(true),
750        }
751    }
752
753    /// Construct a [`Polarity::TargetValue`] from a finite `target`.
754    ///
755    /// # Panics
756    ///
757    /// Panics in both debug and release builds when `target` is not
758    /// finite (`NaN`, `+inf`, `-inf`). Non-finite values produce
759    /// incorrect regression verdicts in the comparison pipeline, so
760    /// the check runs unconditionally rather than via
761    /// `debug_assert!`.
762    pub fn target(target: f64) -> Polarity {
763        assert!(
764            target.is_finite(),
765            "Polarity::TargetValue target must be finite, got {target}"
766        );
767        Polarity::TargetValue(target)
768    }
769}
770
771/// Payload-author metric declaration: polarity + display unit.
772///
773/// Attached to a [`Payload`] via the `metrics` field. Metrics
774/// extracted from output are looked up against this table by name to
775/// set their [`Polarity`] and [`Metric::unit`]. Unmatched metrics
776/// land with `Polarity::Unknown` and an empty unit string.
777#[derive(Debug, Clone, Copy)]
778pub struct MetricHint {
779    /// Dotted-path metric name (e.g. `jobs.0.read.iops`).
780    pub name: &'static str,
781    /// Regression direction for this metric.
782    pub polarity: Polarity,
783    /// Human-readable unit for display (e.g. `iops`, `ns`). Empty
784    /// string means "no unit"; matches the sentinel used by
785    /// `MetricDef`.
786    pub unit: &'static str,
787}
788
789/// Assertion check evaluated against an extracted
790/// [`PayloadMetrics`] (or the exit code for
791/// [`MetricCheck::ExitCodeEq`]).
792#[derive(Debug, Clone, Copy)]
793pub enum MetricCheck {
794    /// Fail when the named metric is below `value`.
795    Min { metric: &'static str, value: f64 },
796    /// Fail when the named metric exceeds `value`.
797    Max { metric: &'static str, value: f64 },
798    /// Fail when the named metric is outside `[lo, hi]`.
799    Range {
800        metric: &'static str,
801        lo: f64,
802        hi: f64,
803    },
804    /// Fail when the named metric is missing from the extracted set.
805    Exists(&'static str),
806    /// Fail when the payload's exit code is not equal to `expected`.
807    ExitCodeEq(i32),
808}
809
810impl MetricCheck {
811    /// Fail when the named metric is below `value`. Missing metric
812    /// fails loudly per the evaluation pipeline's missing-metric
813    /// contract.
814    pub const fn min(metric: &'static str, value: f64) -> MetricCheck {
815        MetricCheck::Min { metric, value }
816    }
817
818    /// Fail when the named metric exceeds `value`. Missing metric
819    /// fails loudly.
820    pub const fn max(metric: &'static str, value: f64) -> MetricCheck {
821        MetricCheck::Max { metric, value }
822    }
823
824    /// Fail when the named metric falls outside `[lo, hi]` (inclusive
825    /// on both ends). Missing metric fails loudly.
826    ///
827    /// # Panics
828    ///
829    /// Panics at construction when `lo > hi` — a reversed-bounds
830    /// range describes an empty interval that no finite metric can
831    /// satisfy, almost certainly a user error rather than an
832    /// intentional always-fails check. Failing at the constructor
833    /// surfaces the typo at the call site instead of letting the
834    /// evaluator run an unsatisfiable check against every probe
835    /// value. NaN bounds also trip this gate because `lo <= hi`
836    /// is false for any NaN argument.
837    pub const fn range(metric: &'static str, lo: f64, hi: f64) -> MetricCheck {
838        assert!(
839            lo <= hi,
840            "MetricCheck::range: lo must be <= hi (reversed bounds are an empty interval)"
841        );
842        MetricCheck::Range { metric, lo, hi }
843    }
844
845    /// Fail when the named metric is absent from the extracted set.
846    /// Presence-only — the metric value can be any finite number,
847    /// including zero or negative.
848    pub const fn exists(metric: &'static str) -> MetricCheck {
849        MetricCheck::Exists(metric)
850    }
851
852    /// Fail when the payload's exit code differs from `expected`.
853    /// Evaluated before metric-path checks so a mis-exited binary
854    /// reports the exit-code mismatch rather than chained
855    /// missing-metric failures.
856    pub const fn exit_code_eq(expected: i32) -> MetricCheck {
857        MetricCheck::ExitCodeEq(expected)
858    }
859
860    /// Typed sibling of [`Self::min`] — a typo-proof `BuiltinMetric` instead of a
861    /// registry-name string. `const` (via `BuiltinMetric::wire_name`) so it
862    /// composes in `const` payload-check tables exactly like [`Self::min`]. Use
863    /// this for a registered built-in metric; keep [`Self::min`] for the dynamic
864    /// keyspace (dotted JSON-leaf paths from [`OutputFormat::Json`], scheduler-runtime keys).
865    ///
866    /// ```
867    /// use ktstr::prelude::*;
868    /// // The typed checks compose into a `const` table — exactly the
869    /// // `&'static [MetricCheck]` a `Payload` carries in `default_checks`:
870    /// const CHECKS: &[MetricCheck] = &[
871    ///     MetricCheck::min_builtin(BuiltinMetric::TaobenchTotalQps, 1000.0),
872    ///     MetricCheck::exists_builtin(BuiltinMetric::SchbenchLoopCount),
873    /// ];
874    /// assert_eq!(CHECKS.len(), 2);
875    /// ```
876    pub const fn min_builtin(metric: crate::stats::BuiltinMetric, value: f64) -> MetricCheck {
877        MetricCheck::Min {
878            metric: metric.wire_name(),
879            value,
880        }
881    }
882
883    /// Typed sibling of [`Self::max`] — see [`Self::min_builtin`].
884    pub const fn max_builtin(metric: crate::stats::BuiltinMetric, value: f64) -> MetricCheck {
885        MetricCheck::Max {
886            metric: metric.wire_name(),
887            value,
888        }
889    }
890
891    /// Typed sibling of [`Self::range`] — see [`Self::min_builtin`]. Same
892    /// reversed-bounds construction panic as [`Self::range`].
893    pub const fn range_builtin(
894        metric: crate::stats::BuiltinMetric,
895        lo: f64,
896        hi: f64,
897    ) -> MetricCheck {
898        assert!(
899            lo <= hi,
900            "MetricCheck::range_builtin: lo must be <= hi (reversed bounds are an empty interval)"
901        );
902        MetricCheck::Range {
903            metric: metric.wire_name(),
904            lo,
905            hi,
906        }
907    }
908
909    /// Typed sibling of [`Self::exists`] — see [`Self::min_builtin`].
910    pub const fn exists_builtin(metric: crate::stats::BuiltinMetric) -> MetricCheck {
911        MetricCheck::Exists(metric.wire_name())
912    }
913}
914
915/// Which of the payload's output streams a [`Metric`] was extracted
916/// from.
917///
918/// Captures WHERE the bytes came from (payload stdout vs stderr).
919/// This matters for diagnosing "surprise metrics" in post-run
920/// analysis: a metric tagged [`Self::Stderr`] signals a payload
921/// whose structured output landed on the diagnostic stream —
922/// well-behaved payloads keep stdout canonical per the
923/// [`OutputFormat`] doc contract, so a stderr tag is a review hint
924/// ("is this payload misconfigured, or did the fallback
925/// intentionally pick it up?").
926///
927/// Populated by the extraction pipeline in
928/// [`crate::scenario::payload_run`]: the stdout-primary branch
929/// stamps [`Stdout`](Self::Stdout), the stderr-fallback branch
930/// stamps [`Stderr`](Self::Stderr). The streams are never merged;
931/// one or the other produces the metric set, and that identity
932/// propagates through [`Metric::stream`].
933///
934/// Status: persisted on the sidecar for future review-tooling
935/// (CI dashboards, `cargo ktstr stats`-style filters); not yet
936/// consumed by `perf-delta` or any automated pipeline. The
937/// field is wired end-to-end from the payload-pipeline to the
938/// sidecar JSON today so that downstream review tools can start
939/// filtering on it without a schema change — but no production
940/// consumer reads it yet.
941#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
942// `#[non_exhaustive]`: kept so a future probe-authored stream source
943// (e.g. a BPF-map reader) can land without a sidecar wire-format
944// migration, and so downstream matches must retain a `_ =>` arm. The
945// forward-compat guarantee is independent of the current variant count
946// — do not strip this on the grounds that the enum has only two
947// variants today.
948#[non_exhaustive]
949pub enum MetricStream {
950    /// Extracted from the payload's stdout (the happy path for
951    /// fio / stress-ng / most benchmark tools).
952    Stdout,
953    /// Extracted from the payload's stderr via the stderr-fallback
954    /// contract (for payloads that emit structured summaries to
955    /// stderr — e.g. schbench's `show_latencies` →
956    /// `fprintf(stderr, ...)`).
957    Stderr,
958}
959
960/// A single extracted metric from a payload's output.
961///
962/// Populated by the extraction pipeline after the payload exits.
963/// Sidecar serialization carries these alongside the pass/fail
964/// verdict so test-stats can classify regressions across runs.
965#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
966pub struct Metric {
967    /// Dotted-path name matching the JSON leaf.
968    pub name: String,
969    /// Numeric value.
970    pub value: f64,
971    /// Regression direction, copied from the matching
972    /// [`MetricHint`] or left as [`Polarity::Unknown`] when no hint
973    /// matches.
974    pub polarity: Polarity,
975    /// Display unit string; empty when no unit was declared.
976    pub unit: String,
977    /// Which of the payload's output streams the metric was read
978    /// from — stdout on the happy path, stderr under the
979    /// stderr-fallback contract. See [`MetricStream`] for the
980    /// "well-behaved payloads keep stdout canonical" review hint.
981    pub stream: MetricStream,
982}
983
984/// All metrics extracted from a single payload run plus the process
985/// exit code.
986///
987/// Each concurrent payload (primary or workload, foreground or
988/// background) produces one `PayloadMetrics` value. Sidecar stores
989/// these as a `Vec<PayloadMetrics>` so per-payload provenance is
990/// preserved across composed tests. Payload identity (name and
991/// cgroup placement) is carried by the enclosing sidecar record —
992/// not by `PayloadMetrics` itself, which holds only the extracted
993/// metrics and exit code.
994///
995/// The `payload_index` field stamps every per-invocation emission
996/// (one `PayloadMetrics` value per `.run()` / `.wait()` / `.kill()` /
997/// `.try_wait()` call) with a monotonically increasing per-process
998/// counter — assigned at emit time inside the guest VM.
999#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1000pub struct PayloadMetrics {
1001    /// Per-invocation index assigned at emit time. Monotonically
1002    /// increasing within a single guest VM process, starting at 0.
1003    pub payload_index: usize,
1004    /// Extracted metrics. Empty when [`OutputFormat::ExitCode`] is
1005    /// used or when JSON parsing found no numeric leaves.
1006    pub metrics: Vec<Metric>,
1007    /// Process exit code (0 = success). Used by
1008    /// [`MetricCheck::ExitCodeEq`] in the check
1009    /// evaluation pre-pass.
1010    pub exit_code: i32,
1011}
1012
1013impl PayloadMetrics {
1014    /// Look up a metric by exact name. Returns `None` when the
1015    /// metric is not in the set.
1016    pub fn get(&self, name: &str) -> Option<f64> {
1017        self.metrics
1018            .iter()
1019            .find(|m| m.name == name)
1020            .map(|m| m.value)
1021    }
1022}
1023
1024#[cfg(test)]
1025mod tests {
1026    use super::*;
1027
1028    #[test]
1029    fn metric_check_builtin_constructors_carry_the_wire_name() {
1030        use crate::stats::BuiltinMetric;
1031        // The typed const constructors store the same wire name as the &str
1032        // form, so a built-in payload check is typo-proof without changing the
1033        // stored key. (MetricCheck is Copy, not PartialEq, so match on the arm.)
1034        match MetricCheck::min_builtin(BuiltinMetric::TaobenchTotalQps, 1000.0) {
1035            MetricCheck::Min { metric, value } => {
1036                assert_eq!(metric, "taobench_total_qps");
1037                assert_eq!(value, 1000.0);
1038            }
1039            other => panic!("min_builtin must produce Min, got {other:?}"),
1040        }
1041        match MetricCheck::max_builtin(BuiltinMetric::TaobenchSlowQps, 5.0) {
1042            MetricCheck::Max { metric, value } => {
1043                assert_eq!(metric, "taobench_slow_qps");
1044                assert_eq!(value, 5.0);
1045            }
1046            other => panic!("max_builtin must produce Max, got {other:?}"),
1047        }
1048        match MetricCheck::range_builtin(BuiltinMetric::WakeupP99LatencyUs, 1.0, 2.0) {
1049            MetricCheck::Range { metric, lo, hi } => {
1050                assert_eq!(metric, "wakeup_p99_latency_us");
1051                assert_eq!((lo, hi), (1.0, 2.0));
1052            }
1053            other => panic!("range_builtin must produce Range, got {other:?}"),
1054        }
1055        match MetricCheck::exists_builtin(BuiltinMetric::SchbenchLoopCount) {
1056            MetricCheck::Exists(metric) => assert_eq!(metric, "schbench_loop_count"),
1057            other => panic!("exists_builtin must produce Exists, got {other:?}"),
1058        }
1059    }
1060
1061    #[test]
1062    fn metric_check_builtin_constructors_compose_into_a_const_default_checks_table() {
1063        use crate::stats::BuiltinMetric;
1064        // The *_builtin constructors are `const fn`, so a typed check table is a
1065        // plain `const` `&'static [MetricCheck]` — exactly the type of
1066        // `Payload::default_checks`. This pins const-composition (the property the
1067        // `const fn` buys) and documents the intended usage: a typo-proof
1068        // default_checks table built from BuiltinMetric variants, not bare strings.
1069        const CHECKS: &[MetricCheck] = &[
1070            MetricCheck::min_builtin(BuiltinMetric::TaobenchTotalQps, 1000.0),
1071            MetricCheck::max_builtin(BuiltinMetric::TaobenchSlowQps, 5.0),
1072            MetricCheck::range_builtin(BuiltinMetric::WakeupP99LatencyUs, 1.0, 2.0),
1073            MetricCheck::exists_builtin(BuiltinMetric::SchbenchLoopCount),
1074        ];
1075        // Same wire names the string constructors would store, in declaration order.
1076        assert_eq!(CHECKS.len(), 4);
1077        assert!(matches!(
1078            CHECKS[0],
1079            MetricCheck::Min {
1080                metric: "taobench_total_qps",
1081                ..
1082            }
1083        ));
1084        assert!(matches!(
1085            CHECKS[1],
1086            MetricCheck::Max {
1087                metric: "taobench_slow_qps",
1088                ..
1089            }
1090        ));
1091        assert!(matches!(
1092            CHECKS[2],
1093            MetricCheck::Range {
1094                metric: "wakeup_p99_latency_us",
1095                ..
1096            }
1097        ));
1098        assert!(matches!(
1099            CHECKS[3],
1100            MetricCheck::Exists("schbench_loop_count")
1101        ));
1102
1103        // The typed table is a `&'static [MetricCheck]` (the const's lifetime is
1104        // elided) — it drops straight into a Payload's default_checks slot (the
1105        // composition the const fn enables).
1106        const _: &[MetricCheck] = CHECKS;
1107    }
1108
1109    #[test]
1110    fn payload_kernel_default_const_is_scheduler_kind() {
1111        assert!(matches!(
1112            Payload::KERNEL_DEFAULT.kind,
1113            PayloadKind::Scheduler(_)
1114        ));
1115        assert_eq!(Payload::KERNEL_DEFAULT.display_name(), "kernel_default");
1116        assert!(matches!(
1117            Payload::KERNEL_DEFAULT.output,
1118            OutputFormat::ExitCode
1119        ));
1120        assert!(Payload::KERNEL_DEFAULT.default_args.is_empty());
1121        assert!(Payload::KERNEL_DEFAULT.default_checks.is_empty());
1122        assert!(Payload::KERNEL_DEFAULT.metrics.is_empty());
1123    }
1124
1125    #[test]
1126    fn payload_kernel_default_wraps_scheduler_eevdf() {
1127        match Payload::KERNEL_DEFAULT.kind {
1128            PayloadKind::Scheduler(s) => {
1129                assert_eq!(s.name, Scheduler::EEVDF.name);
1130            }
1131            PayloadKind::Binary(_) => panic!("EEVDF should be Scheduler-kind, got Binary"),
1132        }
1133    }
1134
1135    /// [`Payload::binary`] fills a binary-kind [`Payload`] with the
1136    /// exit-code-only defaults — empty `default_args`,
1137    /// `default_checks`, `metrics`, and `OutputFormat::ExitCode`.
1138    /// Evaluated in a `const` block so any future drift that makes
1139    /// the constructor non-const surfaces here at compile time; the
1140    /// runtime assertions pin the field-level defaults so a
1141    /// drive-by change (e.g. flipping `output` to `Json`) reshapes
1142    /// every `Payload::binary(…)` call site visibly.
1143    #[test]
1144    fn payload_binary_const_constructor_shape() {
1145        const P: Payload = Payload::binary("fio_payload", "fio");
1146        assert_eq!(P.name, "fio_payload");
1147        assert!(matches!(P.kind, PayloadKind::Binary("fio")));
1148        assert!(matches!(P.output, OutputFormat::ExitCode));
1149        assert!(P.default_args.is_empty());
1150        assert!(P.default_checks.is_empty());
1151        assert!(P.metrics.is_empty());
1152        assert!(!P.is_scheduler());
1153        assert!(P.as_scheduler().is_none());
1154    }
1155
1156    #[test]
1157    fn check_constructors() {
1158        assert!(matches!(
1159            MetricCheck::min("x", 1.0),
1160            MetricCheck::Min { .. }
1161        ));
1162        assert!(matches!(
1163            MetricCheck::max("x", 1.0),
1164            MetricCheck::Max { .. }
1165        ));
1166        assert!(matches!(
1167            MetricCheck::range("x", 1.0, 2.0),
1168            MetricCheck::Range { .. }
1169        ));
1170        assert!(matches!(MetricCheck::exists("x"), MetricCheck::Exists("x")));
1171        assert!(matches!(
1172            MetricCheck::exit_code_eq(0),
1173            MetricCheck::ExitCodeEq(0)
1174        ));
1175    }
1176
1177    #[test]
1178    fn metric_set_get_returns_value() {
1179        let pm = PayloadMetrics {
1180            payload_index: 0,
1181            metrics: vec![Metric {
1182                name: "iops".to_string(),
1183                value: 1000.0,
1184                polarity: Polarity::HigherBetter,
1185                unit: "iops".to_string(),
1186                stream: MetricStream::Stdout,
1187            }],
1188            exit_code: 0,
1189        };
1190        assert_eq!(pm.get("iops"), Some(1000.0));
1191        assert_eq!(pm.get("missing"), None);
1192    }
1193
1194    #[test]
1195    fn polarity_target_value_carries_data() {
1196        let p = Polarity::TargetValue(42.0);
1197        match p {
1198            Polarity::TargetValue(v) => assert_eq!(v, 42.0),
1199            _ => panic!("expected TargetValue variant"),
1200        }
1201    }
1202
1203    /// Wire-format round-trip for every [`MetricStream`] variant.
1204    /// Pins the serde representation so a sidecar written by one
1205    /// version of ktstr deserializes under another — a silent wire
1206    /// change (rename, internal tag, numeric encoding) would
1207    /// surface here, not as a missing-field error on every
1208    /// existing sidecar.
1209    #[test]
1210    fn metric_stream_serde_round_trip() {
1211        for s in [MetricStream::Stdout, MetricStream::Stderr] {
1212            let js = serde_json::to_string(&s).expect("serialize");
1213            let de: MetricStream = serde_json::from_str(&js).expect("deserialize");
1214            assert_eq!(
1215                de, s,
1216                "MetricStream::{s:?} wire format must round-trip \
1217                 identically; serialized as {js}, deserialized to \
1218                 {de:?}",
1219            );
1220        }
1221    }
1222
1223    #[test]
1224    fn polarity_serde_round_trip() {
1225        for p in [
1226            Polarity::HigherBetter,
1227            Polarity::LowerBetter,
1228            Polarity::TargetValue(2.78),
1229            Polarity::Unknown,
1230        ] {
1231            let js = serde_json::to_string(&p).unwrap();
1232            let de: Polarity = serde_json::from_str(&js).unwrap();
1233            assert_eq!(de, p);
1234        }
1235    }
1236
1237    // PayloadKind::Binary construction + pattern match.
1238    #[test]
1239    fn payload_kind_binary_construction_and_match() {
1240        const FIO: Payload = Payload {
1241            name: "fio",
1242            kind: PayloadKind::Binary("fio"),
1243            output: OutputFormat::Json,
1244            default_args: &[],
1245            default_checks: &[],
1246            metrics: &[],
1247            include_files: &[],
1248            uses_parent_pgrp: false,
1249            known_flags: None,
1250        };
1251        match FIO.kind {
1252            PayloadKind::Binary(name) => assert_eq!(name, "fio"),
1253            PayloadKind::Scheduler(_) => panic!("expected Binary, got Scheduler"),
1254        }
1255        assert!(!FIO.is_scheduler());
1256        assert!(FIO.as_scheduler().is_none());
1257    }
1258
1259    // Const bindings verify const-fn actually works in const context.
1260    const _MIN: MetricCheck = MetricCheck::min("x", 1.0);
1261    const _MAX: MetricCheck = MetricCheck::max("x", 2.0);
1262    const _RANGE: MetricCheck = MetricCheck::range("x", 1.0, 2.0);
1263    const _EXISTS: MetricCheck = MetricCheck::exists("x");
1264    const _EXIT: MetricCheck = MetricCheck::exit_code_eq(0);
1265    const _KERNEL_DEFAULT_REF: &Payload = &Payload::KERNEL_DEFAULT;
1266    const _KERNEL_DEFAULT_IS_SCHED: bool = Payload::KERNEL_DEFAULT.is_scheduler();
1267    const _KERNEL_DEFAULT_DISPLAY: &str = Payload::KERNEL_DEFAULT.display_name();
1268
1269    // Proves an arbitrary `Payload` (not just `Payload::KERNEL_DEFAULT`) is
1270    // const-constructible via struct literal — the #[derive(Payload)]
1271    // proc-macro emits exactly this shape.
1272    const _PAYLOAD_CONST_BUILD: Payload = Payload {
1273        name: "fio",
1274        kind: PayloadKind::Binary("fio"),
1275        output: OutputFormat::Json,
1276        default_args: &["--output-format=json"],
1277        default_checks: &[MetricCheck::exit_code_eq(0)],
1278        metrics: &[MetricHint {
1279            name: "jobs.0.read.iops",
1280            polarity: Polarity::HigherBetter,
1281            unit: "iops",
1282        }],
1283        include_files: &[],
1284        uses_parent_pgrp: false,
1285        known_flags: None,
1286    };
1287
1288    #[test]
1289    fn const_bindings_are_usable() {
1290        assert!(matches!(_MIN, MetricCheck::Min { .. }));
1291        assert!(matches!(_MAX, MetricCheck::Max { .. }));
1292        assert!(matches!(_RANGE, MetricCheck::Range { .. }));
1293        assert!(matches!(_EXISTS, MetricCheck::Exists("x")));
1294        assert!(matches!(_EXIT, MetricCheck::ExitCodeEq(0)));
1295        assert_eq!(_KERNEL_DEFAULT_REF.name, "kernel_default");
1296        const { assert!(_KERNEL_DEFAULT_IS_SCHED) };
1297        assert_eq!(_KERNEL_DEFAULT_DISPLAY, "kernel_default");
1298    }
1299
1300    // from_higher_is_worse helper.
1301    #[test]
1302    fn polarity_from_higher_is_worse_flips_sense() {
1303        assert_eq!(Polarity::from_higher_is_worse(true), Polarity::LowerBetter);
1304        assert_eq!(
1305            Polarity::from_higher_is_worse(false),
1306            Polarity::HigherBetter
1307        );
1308    }
1309
1310    /// Round-trip bool → Polarity → bool for HigherBetter /
1311    /// LowerBetter yields the identity. Pins the "inverse sense"
1312    /// contract documented on `MetricDef::higher_is_worse` and
1313    /// `Polarity::from_higher_is_worse` so a future polarity
1314    /// refactor can't accidentally flip one direction without the
1315    /// other and silently break delta-classification downstream.
1316    ///
1317    /// The test synthesizes a throw-away `MetricDef` for each
1318    /// polarity because the production `METRICS` table's entries
1319    /// live in `stats.rs` and are test-only not importable from
1320    /// here — constructing the struct literal directly keeps the
1321    /// round-trip self-contained.
1322    #[test]
1323    fn higher_is_worse_polarity_round_trip() {
1324        use crate::stats::{MetricDef, MetricKind};
1325
1326        // true (higher-is-worse) → LowerBetter → true.
1327        let m = MetricDef {
1328            name: "t",
1329            polarity: Polarity::from_higher_is_worse(true),
1330            kind: MetricKind::Counter,
1331            default_abs: 0.0,
1332            default_rel: 0.0,
1333            display_unit: "",
1334            accessor: |_| None,
1335        };
1336        assert_eq!(m.polarity, Polarity::LowerBetter);
1337        assert!(m.higher_is_worse(), "LowerBetter → higher_is_worse = true");
1338
1339        // false (higher-is-better) → HigherBetter → false.
1340        let m = MetricDef {
1341            name: "f",
1342            polarity: Polarity::from_higher_is_worse(false),
1343            kind: MetricKind::Counter,
1344            default_abs: 0.0,
1345            default_rel: 0.0,
1346            display_unit: "",
1347            accessor: |_| None,
1348        };
1349        assert_eq!(m.polarity, Polarity::HigherBetter);
1350        assert!(
1351            !m.higher_is_worse(),
1352            "HigherBetter → higher_is_worse = false"
1353        );
1354    }
1355
1356    /// `MetricDef::higher_is_worse` is total over every `Polarity`
1357    /// variant — the current implementation lumps `LowerBetter`,
1358    /// `TargetValue`, and `Unknown` all into `true`. Pinned so a
1359    /// subtle change (e.g. TargetValue → its own category) doesn't
1360    /// silently flip regression direction for every test using
1361    /// target metrics.
1362    #[test]
1363    fn higher_is_worse_covers_all_polarity_variants() {
1364        use crate::stats::{MetricDef, MetricKind};
1365        fn make(p: Polarity) -> MetricDef {
1366            MetricDef {
1367                name: "x",
1368                polarity: p,
1369                kind: MetricKind::Counter,
1370                default_abs: 0.0,
1371                default_rel: 0.0,
1372                display_unit: "",
1373                accessor: |_| None,
1374            }
1375        }
1376        assert!(!make(Polarity::HigherBetter).higher_is_worse());
1377        assert!(make(Polarity::LowerBetter).higher_is_worse());
1378        assert!(make(Polarity::TargetValue(42.0)).higher_is_worse());
1379        assert!(make(Polarity::Unknown).higher_is_worse());
1380    }
1381
1382    #[test]
1383    fn polarity_target_accepts_finite() {
1384        let p = Polarity::target(0.5);
1385        assert_eq!(p, Polarity::TargetValue(0.5));
1386    }
1387
1388    /// `Polarity::target(NaN)` must panic in release too — non-finite
1389    /// target values produce silent incorrect regression verdicts in
1390    /// `compare_rows`, so the gate is a runtime `assert!` (not
1391    /// `debug_assert!`). Pins that a release build won't silently
1392    /// let NaN slip through.
1393    #[test]
1394    #[should_panic(expected = "Polarity::TargetValue target must be finite")]
1395    fn polarity_target_rejects_nan_panics() {
1396        let _ = Polarity::target(f64::NAN);
1397    }
1398
1399    /// `Polarity::target(+inf)` panics symmetrically with NaN.
1400    /// `compare_rows` would otherwise produce inf-vs-finite verdicts
1401    /// that depend on IEEE-754 infinity arithmetic rather than
1402    /// meaningful regression direction.
1403    #[test]
1404    #[should_panic(expected = "Polarity::TargetValue target must be finite")]
1405    fn polarity_target_rejects_positive_infinity_panics() {
1406        let _ = Polarity::target(f64::INFINITY);
1407    }
1408
1409    /// `Polarity::target(-inf)` ditto.
1410    #[test]
1411    #[should_panic(expected = "Polarity::TargetValue target must be finite")]
1412    fn polarity_target_rejects_negative_infinity_panics() {
1413        let _ = Polarity::target(f64::NEG_INFINITY);
1414    }
1415
1416    /// `Polarity::TargetValue(NaN)` — which bypasses the
1417    /// `Polarity::target` constructor's runtime assert when a hand-
1418    /// built struct literal is used — serializes to
1419    /// `{"TargetValue":null}` via serde_json because
1420    /// `serde_json::Number::from_f64` returns `None` on non-finite
1421    /// values and the default serializer falls back to `null`.
1422    /// The resulting document does NOT round-trip: deserialization
1423    /// fails because `null` can't satisfy the inner `f64` slot.
1424    /// So NaN cannot survive a sidecar write + read pair, even
1425    /// though the write step silently coerces it. Pins both halves
1426    /// of this asymmetric guard so a future serde-attribute change
1427    /// (e.g. `serialize_with = "serialize_nan_as_zero"`) or a
1428    /// custom deserializer gets surfaced here.
1429    #[test]
1430    fn polarity_target_nan_serializes_as_null_and_fails_to_round_trip() {
1431        let p = Polarity::TargetValue(f64::NAN);
1432        let s = serde_json::to_string(&p).expect("NaN→null serialization is the current behavior");
1433        assert_eq!(s, "{\"TargetValue\":null}");
1434        assert!(
1435            serde_json::from_str::<Polarity>(&s).is_err(),
1436            "the null-coerced round-trip must fail to deserialize so a NaN written \
1437             by an un-guarded producer cannot silently re-enter a run",
1438        );
1439    }
1440
1441    /// Raw `NaN` / `Infinity` tokens are not valid JSON, so a
1442    /// sidecar file hand-edited (or emitted by a non-serde writer)
1443    /// to contain them will be rejected at parse time. Pairs with
1444    /// the null-round-trip test above.
1445    #[test]
1446    fn polarity_target_nan_cannot_deserialize_from_non_json_literals() {
1447        assert!(serde_json::from_str::<Polarity>("{\"TargetValue\":NaN}").is_err());
1448        assert!(serde_json::from_str::<Polarity>("{\"TargetValue\":Infinity}").is_err());
1449        assert!(serde_json::from_str::<Polarity>("{\"TargetValue\":-Infinity}").is_err());
1450    }
1451
1452    /// `MetricCheck::range(metric, lo, hi)` panics when `lo > hi`.
1453    /// A reversed-bounds range describes an empty interval that no
1454    /// finite metric can satisfy — almost always a user error.
1455    /// Failing loudly at the constructor surfaces the typo at the
1456    /// call site rather than letting the evaluator run an
1457    /// unsatisfiable check against every probe value.
1458    #[test]
1459    #[should_panic(expected = "lo must be <= hi")]
1460    fn check_range_reversed_bounds_panics_at_construction() {
1461        let _ = MetricCheck::range("iops", 100.0, 50.0);
1462    }
1463
1464    /// `MetricCheck::range_builtin` carries the same reversed-bounds construction
1465    /// panic as [`MetricCheck::range`] (the typed sibling shares the `lo <= hi`
1466    /// assert), so the typed payload-check path fails just as loudly on a
1467    /// reversed range as the string form.
1468    #[test]
1469    #[should_panic(expected = "lo must be <= hi")]
1470    fn check_range_builtin_reversed_bounds_panics_at_construction() {
1471        let _ = MetricCheck::range_builtin(
1472            crate::stats::BuiltinMetric::WakeupP99LatencyUs,
1473            100.0,
1474            50.0,
1475        );
1476    }
1477
1478    /// Equal bounds (`lo == hi`) describe a single-point interval —
1479    /// allowed; the metric must equal that exact value. Pins the
1480    /// `<=` (not `<`) gate in the constructor.
1481    #[test]
1482    fn check_range_equal_bounds_construct() {
1483        let r = MetricCheck::range("iops", 50.0, 50.0);
1484        match r {
1485            MetricCheck::Range { metric, lo, hi } => {
1486                assert_eq!(metric, "iops");
1487                assert_eq!(lo, 50.0);
1488                assert_eq!(hi, 50.0);
1489            }
1490            _ => panic!("expected Range variant"),
1491        }
1492    }
1493
1494    /// NaN as either bound trips the `lo <= hi` gate (NaN comparisons
1495    /// always return false), so the constructor panics. Prevents an
1496    /// always-fails check from slipping into the evaluator pipeline.
1497    #[test]
1498    #[should_panic(expected = "lo must be <= hi")]
1499    fn check_range_nan_lo_panics() {
1500        let _ = MetricCheck::range("iops", f64::NAN, 50.0);
1501    }
1502
1503    #[test]
1504    #[should_panic(expected = "lo must be <= hi")]
1505    fn check_range_nan_hi_panics() {
1506        let _ = MetricCheck::range("iops", 50.0, f64::NAN);
1507    }
1508
1509    // Debug + helper method surface.
1510    #[test]
1511    fn payload_debug_renders_identity_fields() {
1512        let s = format!("{:?}", Payload::KERNEL_DEFAULT);
1513        assert!(s.contains("Payload"), "debug output: {s}");
1514        assert!(s.contains("eevdf"), "debug output: {s}");
1515        assert!(
1516            s.contains("kind: Scheduler(\"eevdf\")"),
1517            "debug output: {s}"
1518        );
1519    }
1520
1521    #[test]
1522    fn payload_kind_debug_renders_variant_and_identity() {
1523        let binary = PayloadKind::Binary("fio");
1524        let s = format!("{binary:?}");
1525        assert!(s.contains("Binary"), "debug output: {s}");
1526        assert!(s.contains("fio"), "debug output: {s}");
1527
1528        let sched = Payload::KERNEL_DEFAULT.kind;
1529        let s = format!("{sched:?}");
1530        assert!(s.contains("Scheduler"), "debug output: {s}");
1531        assert!(s.contains("eevdf"), "debug output: {s}");
1532    }
1533
1534    #[test]
1535    fn output_format_derive_debug_clone_copy() {
1536        // Debug must name the variant (mirrors
1537        // `payload_kind_debug_renders_variant_and_identity`).
1538        let s = format!("{:?}", OutputFormat::Json);
1539        assert!(s.contains("Json"), "Debug must name the variant: {s}");
1540
1541        // Copy semantics observably: after `let b = a;` the original
1542        // `a` is still usable (a move would make this fail to compile),
1543        // and both render identically.
1544        let a = OutputFormat::Json;
1545        let b = a; // Copy, not move.
1546        assert_eq!(
1547            format!("{a:?}"),
1548            format!("{b:?}"),
1549            "copied value must Debug-render identically to the original",
1550        );
1551    }
1552
1553    #[test]
1554    fn as_scheduler_extracts_ref_for_scheduler_kind() {
1555        let s = Payload::KERNEL_DEFAULT
1556            .as_scheduler()
1557            .expect("Scheduler kind");
1558        assert_eq!(s.name, "eevdf");
1559    }
1560
1561    #[test]
1562    fn payload_clone_preserves_identity() {
1563        let a = Payload::KERNEL_DEFAULT;
1564        assert_eq!(a.name, Payload::KERNEL_DEFAULT.name);
1565        assert_eq!(a.is_scheduler(), Payload::KERNEL_DEFAULT.is_scheduler());
1566        assert_eq!(a.as_scheduler().map(|s| s.name), Some("eevdf"));
1567    }
1568
1569    /// `PayloadMetrics` postcard wire-format roundtrip. The type ships
1570    /// guest→host inside `MSG_TYPE_PAYLOAD_METRICS` and the
1571    /// postcard codec rejects any nested type carrying
1572    /// `#[serde(untagged)]` or `#[serde(tag, content)]` at encode
1573    /// time (`WontImplement`) — a regression introduced by a
1574    /// contributor adding adjacent tagging to `Metric` or any
1575    /// downstream field would surface as
1576    /// `ERR_NO_TEST_FUNCTION_OUTPUT` on the host without this pin.
1577    /// Sister inventory: see comment block at src/vmm/wire.rs.
1578    #[test]
1579    fn payload_metrics_postcard_roundtrip() {
1580        let original = PayloadMetrics {
1581            payload_index: 7,
1582            metrics: vec![
1583                Metric {
1584                    name: "tput".to_string(),
1585                    value: 12345.6,
1586                    polarity: Polarity::HigherBetter,
1587                    unit: "ops/s".to_string(),
1588                    stream: MetricStream::Stdout,
1589                },
1590                Metric {
1591                    name: "lat_p99".to_string(),
1592                    value: 0.025,
1593                    polarity: Polarity::LowerBetter,
1594                    unit: "s".to_string(),
1595                    stream: MetricStream::Stderr,
1596                },
1597            ],
1598            exit_code: 0,
1599        };
1600        let bytes = postcard::to_allocvec(&original).expect("encode");
1601        let back: PayloadMetrics = postcard::from_bytes(&bytes).expect("decode");
1602        assert_eq!(back.payload_index, original.payload_index);
1603        assert_eq!(back.exit_code, original.exit_code);
1604        assert_eq!(back.metrics.len(), original.metrics.len());
1605        for (a, b) in original.metrics.iter().zip(back.metrics.iter()) {
1606            assert_eq!(a.name, b.name);
1607            assert_eq!(a.value, b.value);
1608        }
1609    }
1610}