ktstr 0.23.0

Test harness for Linux process schedulers
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
//! `KtstrTestEntry::validate` and its phase helpers, split out of
//! `entry.rs` to keep that file under its grandfathered file-size
//! ceiling while the validator stays decomposed into sub-200-line
//! phases. These are inherent methods on
//! [`crate::test_support::KtstrTestEntry`]; its fields are `pub`, so a
//! sibling-module impl has full access.

impl crate::test_support::KtstrTestEntry {
    /// Reject values that would boot a broken VM or leave assertions
    /// vacuously passing. The `#[ktstr_test]` proc macro enforces the
    /// same constraints at compile time for attribute-built entries;
    /// this method covers directly-constructed entries (library
    /// callers building `KtstrTestEntry` values to push into
    /// [`KTSTR_TESTS`](crate::test_support::KTSTR_TESTS) programmatically).
    ///
    /// Rules:
    /// - `name` must be non-empty (empty names collapse into each
    ///   other in nextest output and in sidecar lookups).
    /// - `name` must not contain `/` or `\` (path separators embed in
    ///   sidecar filenames and nextest test IDs; a separator would
    ///   create a synthetic subdirectory in sidecar output and
    ///   mangle `cargo nextest run -E 'test(name)'` filtering).
    /// - `memory_mib` must be `> 0` (a VM with zero memory cannot boot).
    /// - `duration` must be `> 0` (a zero-duration run never exercises
    ///   the scheduler and produces no telemetry).
    pub fn validate(&self) -> anyhow::Result<()> {
        self.validate_basics_and_staging()?;
        self.validate_mode_flags()?;
        self.validate_snapshots()?;
        self.validate_config_and_topology()?;
        self.validate_perf_delta_assertions()?;
        Ok(())
    }

    /// Validate name shape, memory/duration sizing, payload/host_only
    /// device conflicts, and staged_schedulers name uniqueness.
    fn validate_basics_and_staging(&self) -> anyhow::Result<()> {
        if self.name.is_empty() {
            anyhow::bail!(
                "KtstrTestEntry.name must be non-empty (empty names \
                 collide in nextest output and sidecar lookups)"
            );
        }
        if self.name.contains('/') || self.name.contains('\\') {
            anyhow::bail!(
                "KtstrTestEntry '{}' name must not contain path \
                 separators ('/' or '\\') — they embed in sidecar \
                 filenames and nextest test IDs, creating synthetic \
                 subdirectories in sidecar output and mangling \
                 nextest -E 'test(name)' filtering",
                self.name,
            );
        }
        if self.memory_mib == 0 {
            anyhow::bail!(
                "KtstrTestEntry '{}'.memory_mib must be > 0 (a VM with \
                 zero memory cannot boot)",
                self.name,
            );
        }
        if self.duration.is_zero() {
            anyhow::bail!(
                "KtstrTestEntry '{}'.duration must be > 0 (a zero-duration \
                 run never exercises the scheduler and produces no data \
                 for assertions)",
                self.name,
            );
        }
        if let Some(p) = self.payload
            && p.is_scheduler()
        {
            anyhow::bail!(
                "KtstrTestEntry '{}'.payload must be PayloadKind::Binary, \
                 not Scheduler-kind (schedulers belong in the `scheduler` \
                 slot; the `payload` slot is for userspace binaries \
                 composed under the scheduler)",
                self.name,
            );
        }
        if self.host_only && self.disk.is_some() {
            anyhow::bail!(
                "KtstrTestEntry '{}'.host_only=true with disk=Some(..) — \
                 host_only skips the VM boot that owns the virtio-blk \
                 device lifecycle, so the disk would never be attached. \
                 Drop one of host_only or disk.",
                self.name,
            );
        }
        if self.host_only && !self.networks.is_empty() {
            anyhow::bail!(
                "KtstrTestEntry '{}'.host_only=true with networks=[..] — \
                 host_only skips the VM boot that owns the virtio-net \
                 device lifecycle, so the NICs would never be attached. \
                 Drop one of host_only or networks.",
                self.name,
            );
        }
        // staged_schedulers names must (a) pass the per-name shape
        // checks (non-empty, no path separators, no NUL bytes, no
        // leading dot, not a reserved framework slot — see
        // [`crate::test_support::staged::validate_staged_scheduler_name`])
        // and (b) be unique within the set AND disjoint from the
        // boot scheduler's `name`. A collision on either axis would
        // land two distinct schedulers at the same guest path —
        // silent overwrite, the second-staged binary clobbering the
        // first OR shadowing a boot-time framework slot. The
        // boot-name seed catches the "stage all the schedulers I
        // might use" misuse (author includes the boot scheduler in
        // the staged set thinking it's required there too). Bails
        // here at validate time so the error surfaces ahead of any
        // VM boot or initramfs construction.
        let mut seen_names: std::collections::BTreeSet<&'static str> =
            std::collections::BTreeSet::new();
        seen_names.insert(self.scheduler.name);
        let staged_who = format!("KtstrTestEntry '{}'.staged_schedulers", self.name);
        for staged in self.staged_schedulers {
            crate::test_support::staged::validate_staged_scheduler_name(&staged_who, staged.name)?;
            if !seen_names.insert(staged.name) {
                if staged.name == self.scheduler.name {
                    anyhow::bail!(
                        "KtstrTestEntry '{}'.staged_schedulers cannot include \
                         the boot scheduler '{}' — the boot slot already \
                         stages it. Staged entries are the ADDITIONAL \
                         candidates the test will swap TO via \
                         Op::AttachScheduler / Op::ReplaceScheduler.",
                        self.name,
                        staged.name,
                    );
                }
                anyhow::bail!(
                    "KtstrTestEntry '{}'.staged_schedulers has duplicate \
                     Scheduler.name '{}'; each staged scheduler must have \
                     a unique name (the name maps 1:1 to the guest-side \
                     staging path)",
                    self.name,
                    staged.name,
                );
            }
        }
        Ok(())
    }

    /// Validate host_only/scheduler, performance/no-perf, and
    /// cpu_budget mode-flag combinations and the scx_bpf_error matcher
    /// gate.
    fn validate_mode_flags(&self) -> anyhow::Result<()> {
        // Defense-in-depth for the programmatic-construction path
        // (struct-literal `KtstrTestEntry { .. }` in integration tests,
        // gauntlet-rewritten entries). The macro at
        // ktstr-macros/src/lib.rs rejects `host_only = true` paired with
        // any `scheduler = ...` attribute at compile time, but
        // programmatic construction bypasses that gate. Match against
        // `SchedulerSpec::Eevdf` (the value-level marker for the
        // no-scx-scheduler placeholder) so a struct literal that sets
        // `scheduler: &SOME_REAL_SCHED` under host_only is caught while
        // the default `scheduler: &Scheduler::EEVDF` (whose binary is
        // `SchedulerSpec::Eevdf`) is accepted. The variant-based check
        // is spec-safe — unlike a pointer-identity check against
        // `&Scheduler::EEVDF`, which depends on rustc/LLVM's const-
        // deduplication of `&CONST_EXPR` materializations.
        if self.host_only
            && !matches!(
                self.scheduler.binary,
                crate::test_support::SchedulerSpec::Eevdf
            )
        {
            anyhow::bail!(
                "KtstrTestEntry '{}'.host_only=true with scheduler=&{:?} — \
                 host_only skips the VM boot that owns the scheduler \
                 lifecycle, so the declared scheduler would never attach. \
                 Drop one of host_only or scheduler; the host's \
                 currently-active scheduler (default EEVDF when none is \
                 loaded) runs the test under host_only.",
                self.name,
                self.scheduler.name,
            );
        }
        if self.performance_mode && self.no_perf_mode {
            anyhow::bail!(
                "KtstrTestEntry '{}'.performance_mode=true with \
                 no_perf_mode=true — the two flags are contradictory \
                 (\"I want pinning\" vs. \"I explicitly don't want \
                 pinning\"). Drop one of them.",
                self.name,
            );
        }
        // `cpu_budget` of zero cannot run a VM. The builder would
        // otherwise clamp it to 1 (builder.rs effective_cap), silently
        // running with a budget the author never asked for. Reject
        // explicitly — mirrors the macro's compile-time reject and the
        // memory_mib / cleanup_budget_ms zero-rejects in
        // validate_cross_attr.
        if self.cpu_budget == Some(0) {
            anyhow::bail!(
                "KtstrTestEntry '{}'.cpu_budget=Some(0) — a zero host-CPU \
                 budget cannot run a VM. Use a positive budget, or drop \
                 cpu_budget to auto-size the no-perf mask to the vCPU count.",
                self.name,
            );
        }
        // `cpu_budget` is consulted only on the no_perf_mode path
        // (builder.rs sizes the shared vCPU-thread mask from it). A
        // budget set without no_perf_mode is a silent no-op — the VM
        // runs with the default mask and the requested overcommit never
        // happens, so a contention test would quietly run un-contended.
        // Reject at validate time (nextest discovery) for the
        // programmatic-construction path; ktstr-macros enforces the same
        // gate at compile time for the `#[ktstr_test]` path.
        if self.cpu_budget.is_some() && !self.no_perf_mode {
            anyhow::bail!(
                "KtstrTestEntry '{}'.cpu_budget={:?} with no_perf_mode=false \
                 — cpu_budget sizes the no-perf vCPU-thread mask and is \
                 ignored unless no_perf_mode is set (under performance_mode \
                 vCPUs are pinned 1:1). Set no_perf_mode=true or drop \
                 cpu_budget.",
                self.name,
                self.cpu_budget,
            );
        }
        if (self.assert.expect_scx_bpf_error_contains.is_some()
            || self.assert.expect_scx_bpf_error_matches.is_some())
            && !self.expect_err
        {
            anyhow::bail!(
                "KtstrTestEntry '{}' sets an scx_bpf_error matcher \
                 (expect_scx_bpf_error_contains or expect_scx_bpf_error_matches) \
                 without expect_err = true — a reproducer matcher narrows \
                 which failure counts as the expected bug and only \
                 applies to expected-error tests. Set expect_err = true \
                 or drop the matcher.",
                self.name,
            );
        }
        if self.survives_storm && self.expect_err {
            anyhow::bail!(
                "KtstrTestEntry '{}' sets both survives_storm and expect_err — \
                 contradictory: survives_storm asserts the run passes with the \
                 scheduler alive, expect_err asserts the run fails. Pick one.",
                self.name,
            );
        }
        if self.survives_storm && self.expect_auto_repro {
            anyhow::bail!(
                "KtstrTestEntry '{}' sets both survives_storm and \
                 expect_auto_repro — contradictory: survives_storm forces a \
                 scheduler-death failure to EXIT_FAIL, expect_auto_repro \
                 inverts a crash-with-repro failure to PASS. Pick one.",
                self.name,
            );
        }
        if self.survives_storm && !self.scheduler.has_active_scheduling() {
            anyhow::bail!(
                "KtstrTestEntry '{}' sets survives_storm with no active scx \
                 scheduler — the kernel default has no scheduler to eject, so \
                 survival is vacuous. Declare a scheduler = ... or drop \
                 survives_storm.",
                self.name,
            );
        }
        Ok(())
    }

    /// Validate num_snapshots against the storage cap, host_only, and
    /// the minimum periodic-capture interval.
    fn validate_snapshots(&self) -> anyhow::Result<()> {
        // Periodic snapshots route through SnapshotBridge::store, which
        // FIFO-evicts at MAX_STORED_SNAPSHOTS. Allowing num_snapshots
        // past the cap would silently lose the earliest samples — a
        // periodic run with N=128 today would only retain
        // periodic_064..periodic_127 in the bridge.
        let max = crate::scenario::snapshot::MAX_STORED_SNAPSHOTS as u32;
        if self.num_snapshots > max {
            anyhow::bail!(
                "KtstrTestEntry '{}'.num_snapshots={} exceeds \
                 MAX_STORED_SNAPSHOTS={} — the bridge would FIFO-evict \
                 the earliest periodic samples. Lower the count or split \
                 into multiple test entries.",
                self.name,
                self.num_snapshots,
                max,
            );
        }
        if self.num_snapshots > 0 {
            // host_only skips the VM boot that owns the freeze
            // coordinator's run-loop. Without that loop there is no
            // thread to stamp `scenario_start_ns`, no thread to fire
            // `freeze_and_dispatch(FreezeMode::Capture { gate_on_exit_kind: false })` at each boundary, and no
            // `SnapshotBridge` plumbed onto a `VmResult` for the
            // test author to drain post-run. The combination is
            // unsatisfiable; reject at validate time so a
            // misconfigured entry surfaces during nextest discovery
            // rather than as silently-empty bridge results.
            if self.host_only {
                anyhow::bail!(
                    "KtstrTestEntry '{}'.host_only=true with \
                     num_snapshots={} > 0 — host_only skips the VM \
                     boot that owns the freeze coordinator's \
                     periodic-capture loop, so no snapshot would \
                     ever fire. Drop one of host_only or \
                     num_snapshots.",
                    self.name,
                    self.num_snapshots,
                );
            }
            // Refuse interval shorter than the minimum useful capture
            // cadence. Each boundary fire freezes every vCPU, walks
            // BPF maps, serialises the dump, and writes to the
            // bridge — under the FREEZE_RENDEZVOUS_TIMEOUT (30 s)
            // hard ceiling but commonly tens of milliseconds on a
            // healthy guest. An interval shorter than ~100 ms would
            // back-to-back the captures with no actual workload
            // progress between them, defeating the periodic-sampling
            // purpose. Compute the interval in nanoseconds in u128
            // to avoid overflow on long durations: the formula
            // mirrors the run-loop's
            // `compute_periodic_boundaries_ns` (10 % pre-buffer,
            // 80 % usable span, divided into N+1 equal intervals).
            let usable_span_ns = self
                .duration
                .as_nanos()
                .saturating_sub(2u128.saturating_mul(self.duration.as_nanos() / 10));
            let interval_ns = usable_span_ns / (self.num_snapshots as u128 + 1);
            const MIN_INTERVAL_NS: u128 = 100 * 1_000_000; // 100 ms
            if interval_ns < MIN_INTERVAL_NS {
                anyhow::bail!(
                    "KtstrTestEntry '{}'.num_snapshots={} with \
                     duration={:?} produces a periodic interval of \
                     {} ns ({} ms) — below the 100 ms minimum the \
                     freeze-and-capture path can sustain without \
                     back-to-back firing. Either reduce num_snapshots \
                     or extend duration so 0.8·duration / (N+1) >= 100 ms.",
                    self.name,
                    self.num_snapshots,
                    self.duration,
                    interval_ns,
                    interval_ns / 1_000_000,
                );
            }
        }
        Ok(())
    }

    /// Validate scheduler config_file_def/config_content pairing,
    /// workload-slot payload kinds, and entry/scheduler topology
    /// constraints.
    fn validate_config_and_topology(&self) -> anyhow::Result<()> {
        // Pair `scheduler.config_file_def` with `config_content`. The
        // `#[ktstr_test]` macro emits a `const _: () = assert!(...)`
        // block that catches the same mismatch at compile time for
        // attribute-built entries; this branch covers programmatic
        // construction (callers building `KtstrTestEntry` values
        // directly) and surfaces the misconfiguration before VM boot
        // rather than as a silent missing-`--config` flag.
        let scheduler_has_def = self.scheduler.config_file_def.is_some();
        let entry_has_content = self.config_content.is_some();
        if scheduler_has_def && !entry_has_content {
            anyhow::bail!(
                "KtstrTestEntry '{}'.scheduler '{}' declares \
                 `config_file_def` but the entry does not supply \
                 `config_content`; the scheduler binary expects an \
                 inline config and would launch without `--config`. \
                 Set `config = ...` on `#[ktstr_test]` or assign \
                 `config_content` directly.",
                self.name,
                self.scheduler.name,
            );
        }
        if !scheduler_has_def && entry_has_content {
            anyhow::bail!(
                "KtstrTestEntry '{}'.config_content is set but the \
                 scheduler '{}' does not declare `config_file_def`; \
                 the content would be silently dropped at dispatch. \
                 Remove `config = ...` or add \
                 `config_file_def(arg_template, guest_path)` to the \
                 scheduler.",
                self.name,
                self.scheduler.name,
            );
        }
        // Mirror the payload-slot gate for every workload entry. The
        // `workloads` slot is for userspace binaries composed with
        // the primary payload under the scheduler; a scheduler-kind
        // Payload here would be silently ignored at spawn time. The
        // narrow typo path post-`declare_scheduler!` rollout is
        // pasting [`Payload::KERNEL_DEFAULT`] (the only Scheduler-kind
        // Payload still in the prelude) into a `workloads = [...]`
        // attribute instead of the `scheduler = ...` slot.
        for (idx, w) in self.workloads.iter().enumerate() {
            if w.is_scheduler() {
                anyhow::bail!(
                    "KtstrTestEntry '{}'.workloads[{idx}] (name='{}') must be \
                     PayloadKind::Binary, not Scheduler-kind (schedulers belong \
                     in the `scheduler` slot; the `workloads` slot is for \
                     userspace binaries composed under the scheduler)",
                    self.name,
                    w.name,
                );
            }
        }
        // Reject inverted topology ranges before they silently filter
        // every gauntlet preset to zero matches. The per-entry
        // constraints gate which gauntlet presets the test author wants
        // to exercise; an inverted bound (e.g. min_numa_nodes=5 with
        // max_numa_nodes=Some(2)) would yield false on every preset.
        self.constraints
            .validate()
            .map_err(|e| anyhow::anyhow!("KtstrTestEntry '{}'.constraints: {e}", self.name))?;
        // Same for the scheduler-level constraints, which apply on top
        // of the per-entry ones. A scheduler whose declared topology
        // requirements are themselves inverted has the same silent-
        // filter pathology regardless of what test entries declare.
        self.scheduler.constraints.validate().map_err(|e| {
            anyhow::anyhow!(
                "KtstrTestEntry '{}'.scheduler '{}'.constraints: {e}",
                self.name,
                self.scheduler.name
            )
        })?;
        Ok(())
    }

    /// Validate declared `perf_delta_assertions`. Each assertion is a
    /// per-test regression gate consulted only under
    /// `perf-delta --noise-adjust` (the scalar `perf-delta` path warns that
    /// declared gates were skipped) — inert during a normal `ktstr test` run,
    /// where the in-VM evaluation never reads it. Two rules:
    ///
    /// - Every asserted `metric` must resolve in the metric registry
    ///   (`crate::stats::metric_def`). A typo'd metric name would never
    ///   match a captured field, so the gate would silently never fire:
    ///   the author believes a regression is guarded when nothing is.
    ///   Reject at validate time so the typo surfaces during nextest
    ///   discovery rather than as a phantom-passing perf-delta run.
    /// - Declaring any assertion requires `performance_mode`. The gate
    ///   tightens the noise threshold on a metric; under no-perf mode the
    ///   vCPU threads share an oversubscribed host-CPU mask, so the
    ///   metric carries scheduling noise the tightened gate would misread
    ///   (false regressions, or a real regression masked by the narrowed
    ///   band). A perf gate is only meaningful on a pinned run. Mirrors
    ///   the `cpu_budget ⇒ no_perf_mode` flag-dependency above.
    fn validate_perf_delta_assertions(&self) -> anyhow::Result<()> {
        if self.perf_delta_assertions.is_empty() {
            return Ok(());
        }
        if !self.performance_mode {
            anyhow::bail!(
                "KtstrTestEntry '{}' declares perf_delta_assertions but \
                 performance_mode=false — a declared regression gate \
                 tightens the noise threshold on a metric, which is only \
                 meaningful on a pinned (performance_mode) run. Under \
                 no-perf mode the metric carries host-CPU-oversubscription \
                 noise the gate would misread. Set performance_mode=true \
                 or drop the assertions.",
                self.name,
            );
        }
        let mut seen: std::collections::BTreeSet<(&'static str, Option<u16>)> =
            std::collections::BTreeSet::new();
        for a in self.perf_delta_assertions {
            let metric = a.metric();
            if crate::stats::metric_def(metric).is_none() {
                anyhow::bail!(
                    "KtstrTestEntry '{}'.perf_delta_assertions references \
                     unknown metric '{}' — it does not resolve in the \
                     metric registry, so the gate would never match a \
                     captured field and silently never fire. Check the \
                     name against the registry (src/stats/metric.rs \
                     METRICS).",
                    self.name,
                    metric,
                );
            }
            // A render-suppressed rate COMPONENT (the internal numerator/
            // denominator of a derived rate — e.g. total_phase_iterations)
            // keeps its registry slot for the cross-run re-pool but is never
            // emitted as a compare finding, so a gate on it can NEVER fire: a
            // guaranteed-dead gate that would pass this check on name alone.
            // Reject so the author declares the user-facing rate (e.g.
            // iteration_rate) rather than its suppressed component.
            if crate::stats::is_render_suppressed_component(metric) {
                anyhow::bail!(
                    "KtstrTestEntry '{}'.perf_delta_assertions references \
                     render-suppressed rate component '{}' — it is an internal \
                     numerator/denominator that never surfaces as a compare \
                     finding, so the gate could never fire. Declare the \
                     user-facing rate metric instead.",
                    self.name,
                    metric,
                );
            }
            // A non-finite or negative threshold silently inverts or disables
            // the gate in classify_noise (abs_gate/rel_gate are compared with
            // `>=`; NaN makes every comparison false, a negative floor makes
            // every move material). Reject both overrides.
            if let Some(pct) = a.max_regression_pct()
                && (!pct.is_finite() || pct < 0.0)
            {
                anyhow::bail!(
                    "KtstrTestEntry '{}'.perf_delta_assertions gate on '{}' has \
                     max_regression_pct={} — must be finite and >= 0 (a negative \
                     or NaN threshold silently disables or inverts the gate).",
                    self.name,
                    metric,
                    pct,
                );
            }
            if let Some(min) = a.min_abs()
                && (!min.is_finite() || min < 0.0)
            {
                anyhow::bail!(
                    "KtstrTestEntry '{}'.perf_delta_assertions gate on '{}' has \
                     min_abs={} — must be finite and >= 0 (a negative or NaN floor \
                     silently disables or inverts the gate).",
                    self.name,
                    metric,
                    min,
                );
            }
            // Duplicate (metric, phase): the compare consults declared gates via
            // a first-match `.find()`, so a second gate on the same (metric,
            // phase) is silently dropped. Reject rather than pick-one-silently.
            if !seen.insert((metric, a.phase())) {
                let scope = match a.phase() {
                    Some(k) => format!("phase {k}"),
                    None => "the aggregate value".to_string(),
                };
                anyhow::bail!(
                    "KtstrTestEntry '{}'.perf_delta_assertions declares more than \
                     one gate on metric '{}' for {} — the compare applies only the \
                     first and silently drops the rest. Merge them into one gate.",
                    self.name,
                    metric,
                    scope,
                );
            }
        }
        Ok(())
    }
}