ktstr/assert/builder.rs
1use super::*;
2
3/// Unified assertion configuration. Carries both worker checks and
4/// monitor thresholds as a single composable type. Each `Option` field
5/// acts as an override — `None` means "inherit from parent layer".
6///
7/// Construct via [`Assert::NO_OVERRIDES`] (preferred const baseline)
8/// or [`Assert::default_checks`] (currently aliases NO_OVERRIDES);
9/// chain builder methods on either base (all builders are `const fn`
10/// except [`Assert::expect_scx_bpf_error_matches`], which compiles a
11/// regex at construction). Use the resulting `Assert` value as the
12/// `assert` field of a [`Scheduler`](crate::test_support::Scheduler)
13/// declared via [`declare_scheduler!`](crate::declare_scheduler) — the
14/// macro accepts `assert = Assert::NO_OVERRIDES.foo()`-style chains
15/// at the scheduler level. The `#[ktstr_test]` proc macro does NOT
16/// accept an `assert = …` attribute on test entries; per-field
17/// attribute shortcuts (`max_gap_ms = N`, `not_starved = true`, …)
18/// compose into the equivalent struct literal at expansion time.
19///
20/// Merge order: `Assert::default_checks()` -> `Scheduler.assert` -> per-test `assert`.
21/// `default_checks()` is `NO_OVERRIDES` — all assertions are opt-in.
22///
23/// ```
24/// # use ktstr::assert::Assert;
25/// // Scheduler opts into imbalance checking.
26/// let sched_assert = Assert::NO_OVERRIDES.max_imbalance_ratio(5.0);
27///
28/// // Merge: defaults <- scheduler <- test.
29/// let merged = Assert::default_checks()
30/// .merge(&sched_assert)
31/// .merge(&Assert::NO_OVERRIDES.max_gap_ms(5000));
32///
33/// assert_eq!(merged.not_starved, None); // not opted in
34/// assert_eq!(merged.max_imbalance_ratio, Some(5.0)); // from sched
35/// assert_eq!(merged.max_gap_ms, Some(5000)); // from test
36/// ```
37///
38/// # Serde roundtrip — covers the 20 threshold/check fields only
39///
40/// The serde derive at the struct level covers the 20 threshold +
41/// flag fields (every `Option<bool/u64/f64/u32/usize>` plus the bare
42/// `enforce_monitor_thresholds: bool`). The two reproducer-matcher
43/// fields ([`Self::expect_scx_bpf_error_contains`] and
44/// [`Self::expect_scx_bpf_error_matches`]) carry `#[serde(skip)]`
45/// because their `&'static str` shape cannot round-trip through a
46/// borrowed deserializer — see each field's doc for the rationale.
47/// Sidecar consumers comparing
48/// threshold config across runs treat reproducer matcher strings as
49/// part of the test identity (encoded by name in the sidecar key),
50/// not part of the threshold payload, so the skip is operationally
51/// transparent today.
52#[must_use = "builder methods return a new Assert; discard means config is lost"]
53#[derive(Clone, Copy, Debug, serde::Serialize, serde::Deserialize)]
54pub struct Assert {
55 // Worker checks
56 /// Enable starvation, fairness spread, and gap checks across
57 /// worker reports. `Some(true)` enables, `Some(false)` explicitly
58 /// disables (overriding any enabling merge from a lower layer),
59 /// `None` inherits from the merge parent.
60 pub not_starved: Option<bool>,
61 /// Enable per-worker CPU isolation checks (ensure workers remain
62 /// within their assigned cpuset). Same tri-state semantics as
63 /// `not_starved`.
64 pub isolation: Option<bool>,
65 /// Max per-worker scheduling gap in milliseconds. Fails the
66 /// assertion if any worker's longest off-CPU stretch exceeds this.
67 pub max_gap_ms: Option<u64>,
68 /// Max per-cgroup fairness spread as a percentage. Fails if the
69 /// range between the most- and least-served workers exceeds this
70 /// fraction of their mean.
71 pub max_spread_pct: Option<f64>,
72
73 // Throughput checks
74 /// Max coefficient of variation for work_units/cpu_time across workers.
75 /// Catches placement unfairness where some workers get less CPU than others.
76 pub max_throughput_cv: Option<f64>,
77 /// Minimum work_units per CPU-second. Catches cases where all workers
78 /// are equally slow (CV passes but absolute throughput is too low).
79 pub min_work_rate: Option<f64>,
80
81 // Benchmarking checks
82 /// Max p99 wake latency in NANOSECONDS. Fails if the pooled
83 /// p99 across every worker's `wake_latencies_ns` exceeds this.
84 ///
85 /// # Unit-name gotcha
86 ///
87 /// The threshold is `_ns`, but the paired reporting field on
88 /// [`CgroupStats::p99_wake_latency_us`] and the re-pooled run-level
89 /// `worst_p99_wake_latency_us` ext metric (a `MetricKind::Distribution`)
90 /// are MICROSECONDS. The two surfaces are intentionally split:
91 /// - the threshold uses NS for precision (typical scheduler
92 /// wake latencies are single-digit µs, so sub-µs resolution
93 /// matters for regression gates);
94 /// - the reporting fields use US for readability in
95 /// `perf-delta` / dashboard output.
96 ///
97 /// Both are computed from the same underlying
98 /// [`WorkerReport::wake_latencies_ns`] samples — see
99 /// [`assert_benchmarks`] for the threshold path and
100 /// [`assert_not_starved`] for the reporting path. A bare
101 /// comparison of `max_p99_wake_latency_ns` against
102 /// `CgroupStats::p99_wake_latency_us` is a unit-mismatch bug;
103 /// `assert_benchmarks` never does this — it consumes the raw
104 /// `wake_latencies_ns` directly — and
105 /// `assert_p99_ns_threshold_compares_against_ns_latencies` pins
106 /// that contract.
107 pub max_p99_wake_latency_ns: Option<u64>,
108 /// Max wake latency coefficient of variation. Fails if CV exceeds this.
109 pub max_wake_latency_cv: Option<f64>,
110 /// Minimum iterations per wall-clock second. Fails if any worker is below.
111 pub min_iteration_rate: Option<f64>,
112 /// Max migration ratio (migrations/iterations). Fails if any cgroup exceeds this.
113 pub max_migration_ratio: Option<f64>,
114
115 // Monitor checks
116 /// Max `nr_running` / LLC imbalance ratio observed by the monitor.
117 /// Fails if the worst sample's imbalance exceeds this.
118 pub max_imbalance_ratio: Option<f64>,
119 /// Max local DSQ depth observed by the monitor. Fails if any
120 /// sampled CPU's local DSQ grew beyond this.
121 pub max_local_dsq_depth: Option<u32>,
122 /// Treat a stall verdict from the monitor as a hard failure. Same
123 /// tri-state semantics as `not_starved`.
124 pub fail_on_stall: Option<bool>,
125 /// Minimum number of consecutive samples that must exceed the
126 /// monitor threshold before a verdict is raised. Smooths out
127 /// single-sample spikes.
128 pub sustained_samples: Option<usize>,
129 /// Max `select_cpu_fallback` rate (events/sec). Fails if the
130 /// scx event counter delta over the run exceeds this rate.
131 pub max_fallback_rate: Option<f64>,
132 /// Max `keep_last` rate (events/sec). Fails if the scx event
133 /// counter delta over the run exceeds this rate.
134 pub max_keep_last_rate: Option<f64>,
135 /// Promote monitor threshold violations from report-only to
136 /// pass/fail. When `false` (the default), the monitor still walks
137 /// every sample and records every violation in the verdict's
138 /// `details`, but the verdict's `passed` stays `true`. Tests that
139 /// want monitor violations to fail the run call
140 /// [`Self::with_monitor_defaults`], which populates each monitor
141 /// threshold from `MonitorThresholds::new()`
142 /// and sets this flag to `true`.
143 pub enforce_monitor_thresholds: bool,
144
145 // NUMA checks
146 /// Minimum fraction of pages on the expected NUMA node(s) (0.0-1.0).
147 /// Expected nodes are derived from the worker's
148 /// [`MemPolicy`](crate::workload::MemPolicy) at evaluation time.
149 /// Fails if the observed locality fraction falls below this.
150 pub min_page_locality: Option<f64>,
151 /// Maximum ratio of NUMA-node-migrated pages to total allocated
152 /// pages (0.0-1.0). Distinct from [`max_migration_ratio`](Self::max_migration_ratio)
153 /// which measures CPU migrations per iteration. Fails if the
154 /// observed migration ratio exceeds this.
155 pub max_cross_node_migration_ratio: Option<f64>,
156 /// Maximum fraction of pages on slow-tier (memory-only) NUMA nodes
157 /// (0.0-1.0). For CXL memory tiering tests: fails if more than
158 /// this fraction of pages land on memory-only nodes. Requires
159 /// `slow_tier_nodes` to be set at evaluation time.
160 pub max_slow_tier_ratio: Option<f64>,
161
162 /// Reproducer-mode literal-substring matcher for the captured
163 /// `scx_bpf_error` text. When `Some(literal)`, the eval pipeline
164 /// scans the combined scheduler log + sched_ext dump for a
165 /// substring match against `literal` and fails the test if the
166 /// substring is absent.
167 ///
168 /// Use this for the common case of pinning an exact error
169 /// fragment like `apply_cell_config returned -EINVAL` without
170 /// having to escape regex metacharacters. For pattern matching
171 /// with anchors / character classes / wildcards, use
172 /// [`Self::expect_scx_bpf_error_matches`] instead — the two
173 /// fields are orthogonal and can both be set (both must match).
174 ///
175 /// Requires the entry's `expect_err = true` — a reproducer
176 /// matcher only fires on expected-error tests; setting this on
177 /// a passing test would assert "the test passed AND contained
178 /// this error text," which is contradictory. The eval-time
179 /// validation rejects that combination with a clear diagnostic.
180 ///
181 /// Stored as `&'static str` so the const-fn `Self::merge`
182 /// composes without cloning. Empty strings are rejected at
183 /// evaluation (an empty literal would silently match every
184 /// message and turn this assertion into a no-op).
185 /// `#[serde(skip)]` because the field's `&'static str` shape
186 /// cannot round-trip through a borrowed deserializer (no source-
187 /// string lifetime to bind to). Reproducer matcher strings are
188 /// test-author static literals carried in the test definition
189 /// itself, not per-run data the sidecar needs to roundtrip — so
190 /// skipping them on the wire keeps the JSON shape clean without
191 /// losing any sidecar-consumer functionality. Skipped fields
192 /// default to `None` on deserialize per `Option::default()`.
193 ///
194 /// The `Option<Cow<'static, str>>` alternative that WOULD
195 /// roundtrip is rejected because it cascade-breaks
196 /// `Scheduler::assert`'s const-fn (`Cow` has a heap destructor,
197 /// which breaks the const-fn assignment path used by
198 /// `declare_scheduler!` macro statics). A future decomposition
199 /// into a `ReproducerMatchers` sub-config could revisit this if
200 /// sidecar-loaded test definitions ever need the strings to
201 /// survive end-to-end.
202 #[serde(skip)]
203 pub expect_scx_bpf_error_contains: Option<&'static str>,
204
205 /// Reproducer-mode regex matcher for the captured `scx_bpf_error`
206 /// text. When `Some(pattern)`, the eval pipeline compiles the
207 /// pattern via the `regex` crate, scans the combined scheduler
208 /// log + sched_ext dump, and fails the test if the regex does
209 /// not match anywhere in the corpus.
210 ///
211 /// The pattern is a full regex — special characters
212 /// (`. * + ? ( ) [ ] { } | ^ $ \`) carry their regex meaning.
213 /// For literal-substring matching, prefer
214 /// [`Self::expect_scx_bpf_error_contains`] to avoid escape
215 /// footguns. The captured corpus is the multi-line concatenation
216 /// of the scheduler log and `--- sched_ext dump ---`; the regex
217 /// crate's default flags apply: `^` and `$` anchor to the start /
218 /// end of the WHOLE corpus (not individual lines), and `.` does
219 /// NOT cross `\n`. Opt in to line-level anchoring with `(?m)`
220 /// (e.g. `(?m)^apply_cell_config$`) and to newline-spanning
221 /// `.` with `(?s)`. A bare `apply_cell_config` matches the
222 /// token anywhere in the corpus.
223 ///
224 /// Requires the entry's `expect_err = true` — same rationale
225 /// as [`Self::expect_scx_bpf_error_contains`]. Patterns are
226 /// validated at construction: empty literals, invalid regex
227 /// syntax, and any pattern satisfying `is_match("")` all
228 /// panic via the [`Self::expect_scx_bpf_error_matches`]
229 /// builder. The `is_match("")` predicate catches two
230 /// no-op classes with one check: patterns that match every
231 /// position (e.g. `a?`, `.*`, `(?:)`) trivially pass against
232 /// any corpus, and patterns that match only the empty string
233 /// (e.g. `^$`) trivially fail against any non-empty corpus —
234 /// real captured scheduler-output corpora are non-empty, so
235 /// both classes are equally no-op pins. Bare `\b` (word
236 /// boundary) slips the gate because the empty string
237 /// contains no word characters; see the builder docstring
238 /// for the operator-direction.
239 /// `#[serde(skip)]` for the same reason as
240 /// [`Self::expect_scx_bpf_error_contains`] above: `&'static str`
241 /// doesn't roundtrip + the matcher pattern is test-definition
242 /// data, not sidecar-roundtrip data. Skipped fields default to
243 /// `None` on deserialize.
244 #[serde(skip)]
245 pub expect_scx_bpf_error_matches: Option<&'static str>,
246}
247
248impl Assert {
249 /// Human-readable multi-line dump of every threshold field. Each
250 /// field renders as ` name: value` (`none` when the option is
251 /// `None`, i.e. inherited or unset). Used by
252 /// `cargo ktstr show-thresholds <test>` to expose the resolved
253 /// merged `Assert` (`default_checks().merge(&entry.scheduler.assert).
254 /// merge(&entry.assert)`) without forcing the operator to read
255 /// the Debug impl or source. Output is a sequence of indented
256 /// `row` lines ending with a newline; the caller owns any
257 /// outer section header (the `show-thresholds` CLI already
258 /// prints `Test: ...` / `Scheduler: ...` / `Resolved assertion
259 /// thresholds:` lines above the threshold block, and
260 /// `format_human` emits no outer header of its own so it does
261 /// not duplicate that banner).
262 pub fn format_human(&self) -> String {
263 use std::fmt::Write;
264 let mut out = String::new();
265 fn row<T: std::fmt::Display>(out: &mut String, name: &str, v: &Option<T>) {
266 match v {
267 Some(x) => writeln!(out, " {name:<38}: {x}").unwrap(),
268 None => writeln!(out, " {name:<38}: none").unwrap(),
269 }
270 }
271 row(&mut out, "not_starved", &self.not_starved);
272 row(&mut out, "isolation", &self.isolation);
273 row(&mut out, "max_gap_ms", &self.max_gap_ms);
274 row(&mut out, "max_spread_pct", &self.max_spread_pct);
275 row(&mut out, "max_throughput_cv", &self.max_throughput_cv);
276 row(&mut out, "min_work_rate", &self.min_work_rate);
277 row(
278 &mut out,
279 "max_p99_wake_latency_ns",
280 &self.max_p99_wake_latency_ns,
281 );
282 row(&mut out, "max_wake_latency_cv", &self.max_wake_latency_cv);
283 row(&mut out, "min_iteration_rate", &self.min_iteration_rate);
284 row(&mut out, "max_migration_ratio", &self.max_migration_ratio);
285 row(&mut out, "max_imbalance_ratio", &self.max_imbalance_ratio);
286 row(&mut out, "max_local_dsq_depth", &self.max_local_dsq_depth);
287 row(&mut out, "fail_on_stall", &self.fail_on_stall);
288 row(&mut out, "sustained_samples", &self.sustained_samples);
289 row(&mut out, "max_fallback_rate", &self.max_fallback_rate);
290 row(&mut out, "max_keep_last_rate", &self.max_keep_last_rate);
291 row(&mut out, "min_page_locality", &self.min_page_locality);
292 row(
293 &mut out,
294 "max_cross_node_migration_ratio",
295 &self.max_cross_node_migration_ratio,
296 );
297 row(&mut out, "max_slow_tier_ratio", &self.max_slow_tier_ratio);
298 row(
299 &mut out,
300 "expect_scx_bpf_error_contains",
301 &self.expect_scx_bpf_error_contains,
302 );
303 row(
304 &mut out,
305 "expect_scx_bpf_error_matches",
306 &self.expect_scx_bpf_error_matches,
307 );
308 out
309 }
310
311 /// Identity element for [`Assert::merge`]: every Option field is
312 /// `None` and `enforce_monitor_thresholds` is `false`, so neither
313 /// side of a merge with `NO_OVERRIDES` is altered.
314 /// Identical to the value returned by [`Self::default_checks`] —
315 /// the const is for spread-into-struct-literal composition, the
316 /// const fn is the method-style entry point.
317 pub const NO_OVERRIDES: Assert = Assert {
318 not_starved: None,
319 isolation: None,
320 max_gap_ms: None,
321 max_spread_pct: None,
322 max_throughput_cv: None,
323 min_work_rate: None,
324 max_p99_wake_latency_ns: None,
325 max_wake_latency_cv: None,
326 min_iteration_rate: None,
327 max_migration_ratio: None,
328 max_imbalance_ratio: None,
329 max_local_dsq_depth: None,
330 fail_on_stall: None,
331 sustained_samples: None,
332 max_fallback_rate: None,
333 max_keep_last_rate: None,
334 enforce_monitor_thresholds: false,
335 min_page_locality: None,
336 max_cross_node_migration_ratio: None,
337 max_slow_tier_ratio: None,
338 expect_scx_bpf_error_contains: None,
339 expect_scx_bpf_error_matches: None,
340 };
341
342 /// Baseline of the runtime merge chain
343 /// `default_checks().merge(&scheduler.assert).merge(&entry.assert)`.
344 ///
345 /// All checks are off by default — tests opt in to the assertions
346 /// they care about via scheduler-level or per-test `Assert`
347 /// overrides.
348 ///
349 /// For spread-into-struct-literal composition
350 /// (`Assert { not_starved: Some(true), ..Assert::NO_OVERRIDES }`)
351 /// use the equivalent const [`Self::NO_OVERRIDES`]; this const fn
352 /// is the method-style entry point that pairs with `.verdict()`
353 /// and the builder setters.
354 pub const fn default_checks() -> Assert {
355 Self::NO_OVERRIDES
356 }
357
358 /// Build a fresh [`Verdict`] under this `Assert`'s threshold
359 /// config. The returned accumulator carries no claim records; call
360 /// the typed `claim_<field>` methods generated by
361 /// [`#[derive(Claim)]`](ktstr_macros::Claim) on stats structs as
362 /// `stats.claim_<field>(&mut verdict)`, or use the
363 /// [`claim!`](crate::claim) macro on a local/expression, then
364 /// call [`Verdict::into_result`] to produce the final
365 /// [`AssertResult`].
366 ///
367 /// This is the entry point of the pointwise-claim API. The
368 /// `Assert` itself remains pure threshold config and stays
369 /// `Copy`; per-test claims accumulate on the returned `Verdict`,
370 /// which owns its own buffers (details, stats).
371 ///
372 /// ```
373 /// # use ktstr::assert::Assert;
374 /// let r = Assert::default_checks().verdict().into_result();
375 /// assert!(r.is_pass(), "no claims means passing verdict");
376 /// ```
377 pub fn verdict(self) -> Verdict {
378 Verdict::with_assert(self)
379 }
380
381 pub const fn check_not_starved(mut self) -> Self {
382 self.not_starved = Some(true);
383 self
384 }
385
386 pub const fn check_isolation(mut self) -> Self {
387 self.isolation = Some(true);
388 self
389 }
390
391 pub const fn max_gap_ms(mut self, ms: u64) -> Self {
392 self.max_gap_ms = Some(ms);
393 self
394 }
395
396 pub const fn max_spread_pct(mut self, pct: f64) -> Self {
397 self.max_spread_pct = Some(pct);
398 self
399 }
400
401 pub const fn max_throughput_cv(mut self, v: f64) -> Self {
402 self.max_throughput_cv = Some(v);
403 self
404 }
405
406 pub const fn min_work_rate(mut self, v: f64) -> Self {
407 self.min_work_rate = Some(v);
408 self
409 }
410
411 pub const fn max_p99_wake_latency_ns(mut self, v: u64) -> Self {
412 self.max_p99_wake_latency_ns = Some(v);
413 self
414 }
415
416 pub const fn max_wake_latency_cv(mut self, v: f64) -> Self {
417 self.max_wake_latency_cv = Some(v);
418 self
419 }
420
421 pub const fn min_iteration_rate(mut self, v: f64) -> Self {
422 self.min_iteration_rate = Some(v);
423 self
424 }
425
426 pub const fn max_migration_ratio(mut self, v: f64) -> Self {
427 self.max_migration_ratio = Some(v);
428 self
429 }
430
431 pub const fn max_imbalance_ratio(mut self, v: f64) -> Self {
432 self.max_imbalance_ratio = Some(v);
433 self
434 }
435
436 pub const fn max_local_dsq_depth(mut self, v: u32) -> Self {
437 self.max_local_dsq_depth = Some(v);
438 self
439 }
440
441 /// Control whether a monitor stall verdict fails the assertion.
442 pub const fn fail_on_stall(mut self, v: bool) -> Self {
443 self.fail_on_stall = Some(v);
444 self
445 }
446
447 /// Set the number of consecutive over-threshold samples required
448 /// before the monitor raises a verdict.
449 pub const fn sustained_samples(mut self, v: usize) -> Self {
450 self.sustained_samples = Some(v);
451 self
452 }
453
454 pub const fn max_fallback_rate(mut self, v: f64) -> Self {
455 self.max_fallback_rate = Some(v);
456 self
457 }
458
459 pub const fn max_keep_last_rate(mut self, v: f64) -> Self {
460 self.max_keep_last_rate = Some(v);
461 self
462 }
463
464 pub const fn min_page_locality(mut self, v: f64) -> Self {
465 self.min_page_locality = Some(v);
466 self
467 }
468
469 pub const fn max_cross_node_migration_ratio(mut self, v: f64) -> Self {
470 self.max_cross_node_migration_ratio = Some(v);
471 self
472 }
473
474 pub const fn max_slow_tier_ratio(mut self, v: f64) -> Self {
475 self.max_slow_tier_ratio = Some(v);
476 self
477 }
478
479 /// True when any worker-level check field is `Some`. A pure query on
480 /// the configured checks — it no longer gates per-cgroup telemetry
481 /// (telemetry is built unconditionally per handle in
482 /// [`crate::scenario`]'s collect path; worker-check assertions are the
483 /// only opt-in part). Retained as public API for callers composing an
484 /// `Assert` who need to know whether any worker check is set.
485 pub const fn has_worker_checks(&self) -> bool {
486 self.not_starved.is_some()
487 || self.isolation.is_some()
488 || self.max_gap_ms.is_some()
489 || self.max_spread_pct.is_some()
490 || self.max_throughput_cv.is_some()
491 || self.min_work_rate.is_some()
492 || self.max_p99_wake_latency_ns.is_some()
493 || self.max_wake_latency_cv.is_some()
494 || self.min_iteration_rate.is_some()
495 || self.max_migration_ratio.is_some()
496 || self.min_page_locality.is_some()
497 || self.max_cross_node_migration_ratio.is_some()
498 || self.max_slow_tier_ratio.is_some()
499 }
500
501 /// Merge `other` on top of `self`. Each `Some` field in `other`
502 /// overrides the corresponding field in `self`; `None` fields
503 /// inherit from `self`.
504 ///
505 /// [`Assert::NO_OVERRIDES`] is the two-sided identity:
506 /// `x.merge(&NO_OVERRIDES)` and `NO_OVERRIDES.merge(&x)` both yield
507 /// `x`. The runtime composes scheduler- and test-level overrides as
508 /// `Assert::default_checks().merge(&scheduler.assert).merge(&test.assert)`,
509 /// so a `NO_OVERRIDES` at either override layer leaves the defaults
510 /// untouched -- which means "no override," not "no checks."
511 pub const fn merge(&self, other: &Assert) -> Assert {
512 // `Option::or` is not yet const-stable, so each field expands
513 // a match rather than calling `other.x.or(self.x)`. Keep it
514 // this way until `const fn` can call `Option::or`; at that
515 // point the 21 match blocks collapse to 21 `.or()` calls.
516 Assert {
517 not_starved: match other.not_starved {
518 Some(v) => Some(v),
519 None => self.not_starved,
520 },
521 isolation: match other.isolation {
522 Some(v) => Some(v),
523 None => self.isolation,
524 },
525 max_gap_ms: match other.max_gap_ms {
526 Some(v) => Some(v),
527 None => self.max_gap_ms,
528 },
529 max_spread_pct: match other.max_spread_pct {
530 Some(v) => Some(v),
531 None => self.max_spread_pct,
532 },
533 max_throughput_cv: match other.max_throughput_cv {
534 Some(v) => Some(v),
535 None => self.max_throughput_cv,
536 },
537 min_work_rate: match other.min_work_rate {
538 Some(v) => Some(v),
539 None => self.min_work_rate,
540 },
541 max_p99_wake_latency_ns: match other.max_p99_wake_latency_ns {
542 Some(v) => Some(v),
543 None => self.max_p99_wake_latency_ns,
544 },
545 max_wake_latency_cv: match other.max_wake_latency_cv {
546 Some(v) => Some(v),
547 None => self.max_wake_latency_cv,
548 },
549 min_iteration_rate: match other.min_iteration_rate {
550 Some(v) => Some(v),
551 None => self.min_iteration_rate,
552 },
553 max_migration_ratio: match other.max_migration_ratio {
554 Some(v) => Some(v),
555 None => self.max_migration_ratio,
556 },
557 max_imbalance_ratio: match other.max_imbalance_ratio {
558 Some(v) => Some(v),
559 None => self.max_imbalance_ratio,
560 },
561 max_local_dsq_depth: match other.max_local_dsq_depth {
562 Some(v) => Some(v),
563 None => self.max_local_dsq_depth,
564 },
565 fail_on_stall: match other.fail_on_stall {
566 Some(v) => Some(v),
567 None => self.fail_on_stall,
568 },
569 sustained_samples: match other.sustained_samples {
570 Some(v) => Some(v),
571 None => self.sustained_samples,
572 },
573 max_fallback_rate: match other.max_fallback_rate {
574 Some(v) => Some(v),
575 None => self.max_fallback_rate,
576 },
577 max_keep_last_rate: match other.max_keep_last_rate {
578 Some(v) => Some(v),
579 None => self.max_keep_last_rate,
580 },
581 enforce_monitor_thresholds: self.enforce_monitor_thresholds
582 || other.enforce_monitor_thresholds,
583 min_page_locality: match other.min_page_locality {
584 Some(v) => Some(v),
585 None => self.min_page_locality,
586 },
587 max_cross_node_migration_ratio: match other.max_cross_node_migration_ratio {
588 Some(v) => Some(v),
589 None => self.max_cross_node_migration_ratio,
590 },
591 max_slow_tier_ratio: match other.max_slow_tier_ratio {
592 Some(v) => Some(v),
593 None => self.max_slow_tier_ratio,
594 },
595 expect_scx_bpf_error_contains: match other.expect_scx_bpf_error_contains {
596 Some(v) => Some(v),
597 None => self.expect_scx_bpf_error_contains,
598 },
599 expect_scx_bpf_error_matches: match other.expect_scx_bpf_error_matches {
600 Some(v) => Some(v),
601 None => self.expect_scx_bpf_error_matches,
602 },
603 }
604 }
605
606 /// Extract an `AssertPlan` for worker-side checks.
607 pub(crate) fn worker_plan(&self) -> AssertPlan {
608 AssertPlan {
609 not_starved: self.not_starved.unwrap_or(false),
610 isolation: self.isolation.unwrap_or(false),
611 max_gap_ms: self.max_gap_ms,
612 max_spread_pct: self.max_spread_pct,
613 max_throughput_cv: self.max_throughput_cv,
614 min_work_rate: self.min_work_rate,
615 max_p99_wake_latency_ns: self.max_p99_wake_latency_ns,
616 max_wake_latency_cv: self.max_wake_latency_cv,
617 min_iteration_rate: self.min_iteration_rate,
618 max_migration_ratio: self.max_migration_ratio,
619 min_page_locality: self.min_page_locality,
620 max_cross_node_migration_ratio: self.max_cross_node_migration_ratio,
621 max_slow_tier_ratio: self.max_slow_tier_ratio,
622 }
623 }
624
625 /// Run the configured worker checks against one cgroup's reports.
626 ///
627 /// `cpuset` is the CPU set for isolation checks. `numa_nodes` is
628 /// the NUMA node IDs covered by the cpuset (for page locality and
629 /// slow-tier checks). Derive via
630 /// [`TestTopology::numa_nodes_for_cpuset`](crate::topology::TestTopology::numa_nodes_for_cpuset).
631 pub fn assert_cgroup(
632 &self,
633 reports: &[crate::workload::WorkerReport],
634 cpuset: Option<&BTreeSet<usize>>,
635 ) -> AssertResult {
636 self.worker_plan().assert_cgroup(reports, cpuset, None)
637 }
638
639 /// Run worker checks with explicit NUMA node set for page locality.
640 pub fn assert_cgroup_with_numa(
641 &self,
642 reports: &[crate::workload::WorkerReport],
643 cpuset: Option<&BTreeSet<usize>>,
644 numa_nodes: Option<&BTreeSet<usize>>,
645 ) -> AssertResult {
646 self.worker_plan()
647 .assert_cgroup(reports, cpuset, numa_nodes)
648 }
649
650 /// Run NUMA page locality check.
651 ///
652 /// `observed` is the fraction of pages on expected nodes (0.0-1.0).
653 /// `total_pages` and `local_pages` are for diagnostics.
654 pub fn assert_page_locality(
655 &self,
656 observed: f64,
657 total_pages: u64,
658 local_pages: u64,
659 ) -> AssertResult {
660 assert_page_locality(observed, self.min_page_locality, total_pages, local_pages)
661 }
662
663 /// Run cross-node migration ratio check.
664 ///
665 /// `migrated_pages` is the `/proc/vmstat` `numa_pages_migrated` delta.
666 /// `total_pages` is total allocated pages from numa_maps.
667 pub fn assert_cross_node_migration(
668 &self,
669 migrated_pages: u64,
670 total_pages: u64,
671 ) -> AssertResult {
672 assert_cross_node_migration(
673 migrated_pages,
674 total_pages,
675 self.max_cross_node_migration_ratio,
676 )
677 }
678
679 /// Extract `MonitorThresholds` for monitor-side evaluation.
680 pub(crate) fn has_monitor_thresholds(&self) -> bool {
681 self.max_imbalance_ratio.is_some()
682 || self.max_local_dsq_depth.is_some()
683 || self.fail_on_stall.is_some()
684 || self.sustained_samples.is_some()
685 || self.max_fallback_rate.is_some()
686 || self.max_keep_last_rate.is_some()
687 }
688
689 pub(crate) fn monitor_thresholds(&self) -> crate::monitor::MonitorThresholds {
690 use crate::monitor::MonitorThresholds;
691 let d = MonitorThresholds::new();
692 MonitorThresholds {
693 max_imbalance_ratio: self.max_imbalance_ratio.unwrap_or(d.max_imbalance_ratio),
694 max_local_dsq_depth: self.max_local_dsq_depth.unwrap_or(d.max_local_dsq_depth),
695 fail_on_stall: self.fail_on_stall.unwrap_or(d.fail_on_stall),
696 sustained_samples: self.sustained_samples.unwrap_or(d.sustained_samples),
697 max_fallback_rate: self.max_fallback_rate.unwrap_or(d.max_fallback_rate),
698 max_keep_last_rate: self.max_keep_last_rate.unwrap_or(d.max_keep_last_rate),
699 enforce: self.enforce_monitor_thresholds,
700 }
701 }
702
703 /// Opt into pass/fail enforcement for monitor thresholds. Without
704 /// this call, monitor violations are reported in the verdict's
705 /// `details` but do not fail the test. With it, any monitor
706 /// threshold violation fails the test.
707 ///
708 /// Also populates any unset monitor-threshold field with the
709 /// canonical default from `MonitorThresholds::new()`
710 /// — so a test that only cares about `max_keep_last_rate` can chain
711 /// `.max_keep_last_rate(N).with_monitor_defaults()` and get the
712 /// other five enforced at their canonical defaults.
713 pub const fn with_monitor_defaults(mut self) -> Self {
714 use crate::monitor::MonitorThresholds;
715 let d = MonitorThresholds::new();
716 if self.max_imbalance_ratio.is_none() {
717 self.max_imbalance_ratio = Some(d.max_imbalance_ratio);
718 }
719 if self.max_local_dsq_depth.is_none() {
720 self.max_local_dsq_depth = Some(d.max_local_dsq_depth);
721 }
722 if self.fail_on_stall.is_none() {
723 self.fail_on_stall = Some(d.fail_on_stall);
724 }
725 if self.sustained_samples.is_none() {
726 self.sustained_samples = Some(d.sustained_samples);
727 }
728 if self.max_fallback_rate.is_none() {
729 self.max_fallback_rate = Some(d.max_fallback_rate);
730 }
731 if self.max_keep_last_rate.is_none() {
732 self.max_keep_last_rate = Some(d.max_keep_last_rate);
733 }
734 self.enforce_monitor_thresholds = true;
735 self
736 }
737
738 /// Const-fn builder for [`Self::expect_scx_bpf_error_contains`].
739 /// Chains with the other const-fn setters so a scheduler-def or
740 /// per-test assertion block can compose
741 /// `Assert::NO_OVERRIDES.expect_scx_bpf_error_contains(...).check_not_starved()`.
742 ///
743 /// Empty strings panic at construction (an empty literal would
744 /// silently match every message and turn this assertion into a
745 /// no-op); pass a non-empty fragment that should appear in the
746 /// expected `scx_bpf_error` message.
747 ///
748 /// # Panics
749 /// When `literal` is empty.
750 #[must_use = "builder methods consume self; bind the result"]
751 pub const fn expect_scx_bpf_error_contains(mut self, literal: &'static str) -> Self {
752 assert!(
753 !literal.is_empty(),
754 "Assert::expect_scx_bpf_error_contains: literal must be non-empty",
755 );
756 self.expect_scx_bpf_error_contains = Some(literal);
757 self
758 }
759
760 /// Builder for [`Self::expect_scx_bpf_error_matches`]. The
761 /// pattern is a regex; special characters retain their regex
762 /// meaning. For literal-substring matching, prefer
763 /// [`Self::expect_scx_bpf_error_contains`] to avoid escape
764 /// footguns.
765 ///
766 /// Validates the pattern at construction: rejects empty
767 /// patterns, rejects invalid regex syntax, and rejects any
768 /// pattern that satisfies `is_match("")`. The empty-string
769 /// match predicate catches two related no-op classes:
770 /// patterns that match every position (e.g. `a?`, `.*`,
771 /// `(?:)`) trivially pass against any corpus, and patterns
772 /// that match only the empty string (e.g. `^$`) trivially
773 /// fail against any non-empty corpus — every real captured
774 /// scheduler-output corpus is non-empty, so the latter is
775 /// equally a no-op pin in practice. Both are useless;
776 /// `is_match("")` catches both with one check.
777 ///
778 /// Bare `\b` (word boundary) slips this gate because the
779 /// empty string contains no word characters, so `\b` finds
780 /// no transition and `is_match("")` returns false; yet `\b`
781 /// matches the first word boundary in any real corpus,
782 /// turning a bare-`\b` pin into a vacuous "any non-empty
783 /// log passes" assertion. Use a substring of the expected
784 /// error text rather than a standalone boundary assertion.
785 /// All other documented assertions (`\A`, `\z`, `^`, `$`,
786 /// `\B`) match the empty string at position 0 and ARE
787 /// caught by the gate.
788 ///
789 /// Unlike the sibling [`Self::expect_scx_bpf_error_contains`]
790 /// (which is `const fn`), this builder is non-const because
791 /// the construction-time regex compilation requires heap
792 /// allocation. Callers needing a const builder for a regex
793 /// matcher must build the `Assert` via struct literal —
794 /// the evaluator's defense-in-depth catches invalid syntax
795 /// reached via that bypass at first evaluation, but the
796 /// vacuous-pattern gate only fires on the builder path.
797 ///
798 /// # Panics
799 /// When `pattern` is empty, is invalid regex syntax, or
800 /// matches the empty string.
801 #[must_use = "builder methods consume self; bind the result"]
802 pub fn expect_scx_bpf_error_matches(mut self, pattern: &'static str) -> Self {
803 assert!(
804 !pattern.is_empty(),
805 "Assert::expect_scx_bpf_error_matches: pattern must be non-empty",
806 );
807 let compiled = regex::Regex::new(pattern).unwrap_or_else(|e| {
808 panic!(
809 "Assert::expect_scx_bpf_error_matches: pattern {pattern:?} is not valid regex: {e}",
810 )
811 });
812 assert!(
813 !compiled.is_match(""),
814 "Assert::expect_scx_bpf_error_matches: pattern {pattern:?} matches the empty \
815 string (e.g. `a?`, `.*`, `(?:)`, `^$`); such patterns vacuously match any \
816 corpus and turn the matcher into a no-op — use a meaningful pattern that \
817 requires at least one character",
818 );
819 self.expect_scx_bpf_error_matches = Some(pattern);
820 self
821 }
822
823 /// Evaluate the reproducer-mode `scx_bpf_error` matchers against
824 /// the captured text corpus. Returns an empty Vec when no matcher
825 /// is configured or when every configured matcher matches; returns
826 /// a non-empty Vec of `AssertDetail` entries on failure.
827 ///
828 /// Each configured matcher contributes at most one detail. Both
829 /// fields can be set simultaneously (`AND` semantics — both must
830 /// match).
831 ///
832 /// Preconditions enforced by the evaluator:
833 /// 1. `expect_err = true` must be set when either matcher is
834 /// configured. Setting the matcher on a passing-test contract
835 /// is a misuse — surfaced with an `expect_err = true` reminder
836 /// diagnostic.
837 /// 2. The regex pattern in
838 /// [`Self::expect_scx_bpf_error_matches`] must compile via
839 /// the `regex` crate. Invalid syntax surfaces as a diagnostic
840 /// naming the pattern and the compile error.
841 ///
842 /// `captured_text` is the concatenation of the raw scheduler-log
843 /// stream (the bulk-port merged `SchedLog` frames, or the test
844 /// process `output` fallback when no frames arrived) and the
845 /// `--- sched_ext dump ---` extract — both surfaces where
846 /// `scx_bpf_error` printk lands. The matcher sees the WHOLE
847 /// stream, not the marker-extracted section; lines outside the
848 /// `SCHED_OUTPUT_START..SCHED_OUTPUT_END` markers are included.
849 pub fn evaluate_scx_bpf_error_match(
850 &self,
851 captured_text: &str,
852 expect_err: bool,
853 ) -> Vec<AssertDetail> {
854 let mut details = Vec::new();
855 if self.expect_scx_bpf_error_contains.is_none()
856 && self.expect_scx_bpf_error_matches.is_none()
857 {
858 return details;
859 }
860 if !expect_err {
861 details.push(AssertDetail::new(
862 DetailKind::Other,
863 "expect_scx_bpf_error_contains or expect_scx_bpf_error_matches \
864 requires expect_err = true on the test entry — the matcher narrows \
865 which failure counts as the expected bug, and only applies to \
866 expected-error tests; set #[ktstr_test(expect_err = true, ...)] or \
867 drop the matcher",
868 ));
869 return details;
870 }
871 // Truncate at a UTF-8 char boundary at or below 400 bytes so
872 // the excerpt length stays within the "up to 400 bytes follow:"
873 // budget. `chars().take(400)` would count codepoints instead,
874 // and a multi-byte corpus would exceed the byte budget — the
875 // boundary walk steps back to the nearest char start.
876 let excerpt = || -> &str {
877 let len = captured_text.len();
878 if len <= 400 {
879 captured_text
880 } else {
881 let mut end = 400;
882 while end > 0 && !captured_text.is_char_boundary(end) {
883 end -= 1;
884 }
885 &captured_text[..end]
886 }
887 };
888 if let Some(literal) = self.expect_scx_bpf_error_contains
889 && !captured_text.contains(literal)
890 {
891 details.push(AssertDetail::new(
892 DetailKind::Other,
893 format!(
894 "expect_scx_bpf_error_contains({literal:?}): substring not found \
895 in the scheduler log + sched_ext dump corpus (the expected bug \
896 did not fire, or its message text changed). Captured corpus \
897 {} bytes; up to 400 bytes follow:\n{}",
898 captured_text.len(),
899 excerpt(),
900 ),
901 ));
902 }
903 if let Some(pattern) = self.expect_scx_bpf_error_matches {
904 match regex::Regex::new(pattern) {
905 Ok(re) => {
906 if !re.is_match(captured_text) {
907 details.push(AssertDetail::new(
908 DetailKind::Other,
909 format!(
910 "expect_scx_bpf_error_matches({pattern:?}): regex did \
911 not match the scheduler log + sched_ext dump corpus \
912 (the expected bug did not fire, or its message text \
913 changed). Captured corpus {} bytes; up to 400 bytes \
914 follow:\n{}",
915 captured_text.len(),
916 excerpt(),
917 ),
918 ));
919 }
920 }
921 Err(e) => {
922 details.push(AssertDetail::new(
923 DetailKind::Other,
924 format!(
925 "expect_scx_bpf_error_matches({pattern:?}): regex \
926 compilation failed: {e}. Fix the pattern at the test \
927 declaration site — the matcher cannot evaluate against an \
928 invalid pattern",
929 ),
930 ));
931 }
932 }
933 }
934 details
935 }
936}