Skip to main content

rch_common/
storm_control.rs

1//! Multi-agent load fairness and storm-control modeling
2//! (bd-session-history-remediation-ocv9i.10.4).
3//!
4//! The core product promise is that many concurrent agents must not saturate the
5//! local machine or leave work in an ambiguous queued/running state. Unit tests
6//! for the individual queue/selection pieces are not enough: we need a scenario
7//! that simulates a real swarm and proves the scheduler, admission, queue,
8//! fallback policy, and observability stay coherent under contention.
9//!
10//! This module is the deterministic, CI-runnable heart of that proof. It does
11//! **not** reimplement the scheduler's decision logic — it DRIVES the real
12//! contract primitives so the mock-worker E2E exercises the same code the daemon
13//! and CLI use:
14//!   - [`crate::job_identity`] for the local-wrapper / remote-build identity and
15//!     admission marking,
16//!   - [`crate::queue_contract`] for the definite start-state contract and the
17//!     attach/cancel guidance every not-started job must carry.
18//!
19//! What is *modeled* here (because it lives in the bin-only `rchd` crate and
20//! cannot be imported) is the worker pool: slot accounting, the eligibility
21//! gate (a worker that is temporarily bypassed / admin-disabled /
22//! capability-inadmissible is never selected), the fairness weighting
23//! (`speed / (1 + recent_selections)`, mirroring `selection::select_fair_fastest`),
24//! the bounded FIFO queue with a wait timeout, and the fail-open / proof
25//! fallback policy. The simulation is a deterministic discrete-event loop — no
26//! threads, no wall clock, no randomness — so the same inputs always produce the
27//! same [`StormRun`].
28//!
29//! The output is a stream of [`SmokeProfileEvent`] JSONL records (reusing the
30//! smoke profile's event schema, extended with the load fields) plus a
31//! [`StormSummary`] of regression statistics. The [invariant checkers](check_all_invariants)
32//! analyze that event stream and are the actual regression guard: they run
33//! against a SIMULATED storm in CI *and* against a REAL daemon's events from a
34//! `--load` self-test run, so the same invariants gate both.
35
36use std::cmp::Reverse;
37use std::collections::{BTreeMap, BinaryHeap, VecDeque};
38
39use serde::{Deserialize, Serialize};
40
41use crate::fleet_smoke_profile::{SmokeProfileEvent, SmokeScenario};
42use crate::job_identity::{JobIdentity, RemoteBuildId};
43use crate::queue_contract::{
44    AdmissionState, QueueContractResponse, QueueOptions, WaitResult, resolve_queue_contract,
45};
46
47/// Stable placement/fallback decision tokens carried in
48/// [`SmokeProfileEvent::fallback_decision`]. Append-only: the invariant checkers,
49/// dashboards, and the validation matrix key off these exact strings.
50pub mod decision {
51    /// The job was admitted to a worker and ran remotely.
52    pub const REMOTE: &str = "remote";
53    /// The job was queued (not yet started) — an intermediate decision.
54    pub const QUEUED: &str = "queued";
55    /// No remote capacity (queue full or no eligible worker); a fail-open job ran
56    /// locally instead.
57    pub const LOCAL_FALLBACK: &str = "local_fallback";
58    /// The job waited in the queue past the wait timeout and then ran locally.
59    pub const QUEUE_TIMEOUT_FALLBACK: &str = "queue_timeout_fallback";
60    /// A proof (strict-remote) job refused local fallback when no remote was
61    /// available — the correct fail-closed behavior.
62    pub const PROOF_REFUSED: &str = "proof_refused";
63    /// The job was cancelled before it started.
64    pub const CANCELLED: &str = "cancelled";
65}
66
67/// Stable lifecycle `event` tokens emitted for load/storm jobs (a superset of the
68/// smoke profile's planned/started/passed/failed/skipped/refused vocabulary).
69pub mod event {
70    /// The job entered the queue.
71    pub const QUEUED: &str = "queued";
72    /// The job was admitted and began executing on a worker.
73    pub const STARTED: &str = "started";
74    /// The job completed remotely.
75    pub const PASSED: &str = "passed";
76    /// The job ran locally (fail-open fallback).
77    pub const FELL_BACK: &str = "fell_back";
78    /// The job refused local fallback (proof mode, fail-closed).
79    pub const REFUSED: &str = "refused";
80    /// The job was cancelled before it started.
81    pub const CANCELLED: &str = "cancelled";
82}
83
84/// Reason code surfaced when a proof (strict-remote) job refuses local fallback.
85/// Mirrors the proof-refusal taxonomy used by the smoke profile's proof-mode
86/// scenario (`RCH-E301` family); kept as a token so the checker and dashboards
87/// can recognise it without a hard dependency on the error catalog enum.
88pub const PROOF_REFUSAL_REASON: &str = "RCH-E301";
89
90/// Live scheduler eligibility of a worker, flattened from `rchd`'s two-axis
91/// (admin-intent × eligibility) model. Only [`Self::Healthy`] and
92/// [`Self::Degraded`] are schedulable — mirroring `WorkerState::is_schedulable`.
93#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
94#[serde(rename_all = "snake_case")]
95pub enum WorkerEligibility {
96    /// Healthy and schedulable.
97    Healthy,
98    /// Degraded but still schedulable.
99    Degraded,
100    /// Transiently bypassed by the circuit breaker — not schedulable.
101    TemporaryBypass,
102    /// Operator-disabled (admin intent) — not schedulable.
103    AdminDisabled,
104    /// Missing a required capability/runtime — not schedulable.
105    CapabilityInadmissible,
106}
107
108impl WorkerEligibility {
109    /// Whether a worker in this state may receive new work.
110    #[must_use]
111    pub const fn is_schedulable(self) -> bool {
112        matches!(self, Self::Healthy | Self::Degraded)
113    }
114
115    /// Stable token for diagnostics.
116    #[must_use]
117    pub const fn as_str(self) -> &'static str {
118        match self {
119            Self::Healthy => "healthy",
120            Self::Degraded => "degraded",
121            Self::TemporaryBypass => "temporary_bypass",
122            Self::AdminDisabled => "admin_disabled",
123            Self::CapabilityInadmissible => "capability_inadmissible",
124        }
125    }
126}
127
128/// A worker in the simulated fleet.
129#[derive(Debug, Clone, PartialEq)]
130pub struct StormWorker {
131    /// Stable worker id.
132    pub id: String,
133    /// Total concurrent slots.
134    pub total_slots: u32,
135    /// SpeedScore used in fairness weighting (higher = faster).
136    pub speed: f64,
137    /// Live eligibility.
138    pub eligibility: WorkerEligibility,
139}
140
141impl StormWorker {
142    /// A healthy worker with the given id, slots, and speed.
143    #[must_use]
144    pub fn healthy(id: impl Into<String>, total_slots: u32, speed: f64) -> Self {
145        Self {
146            id: id.into(),
147            total_slots,
148            speed,
149            eligibility: WorkerEligibility::Healthy,
150        }
151    }
152
153    /// The same worker with a different eligibility (for building adversarial
154    /// fleets where some workers must never receive work).
155    #[must_use]
156    pub fn with_eligibility(mut self, eligibility: WorkerEligibility) -> Self {
157        self.eligibility = eligibility;
158        self
159    }
160}
161
162/// Per-job fallback policy, mirroring the placement controls
163/// (`RCH_REQUIRE_REMOTE` / `RCH_FORCE_REMOTE` / default fail-open).
164#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
165#[serde(rename_all = "snake_case")]
166pub enum JobPolicy {
167    /// Default: attempt remote, fall back to local when remote is unavailable.
168    FailOpen,
169    /// Always attempt remote but still fall back to local (distinct from proof).
170    ForceRemote,
171    /// Strict remote (proof): refuse local fallback — fail closed.
172    Proof,
173}
174
175impl JobPolicy {
176    /// Whether this policy permits a local fallback.
177    #[must_use]
178    pub const fn allows_local_fallback(self) -> bool {
179        matches!(self, Self::FailOpen | Self::ForceRemote)
180    }
181}
182
183/// The kind of compilation command (only affects the `command_fingerprint`).
184#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
185#[serde(rename_all = "snake_case")]
186pub enum JobKind {
187    /// `cargo build`-like.
188    Build,
189    /// `cargo test`-like.
190    Test,
191    /// `cargo check`-like.
192    Check,
193}
194
195impl JobKind {
196    /// Redacted command fingerprint for the JSONL event.
197    #[must_use]
198    pub const fn fingerprint(self) -> &'static str {
199        match self {
200            Self::Build => "cargo build",
201            Self::Test => "cargo test",
202            Self::Check => "cargo check",
203        }
204    }
205}
206
207/// One agent's compilation job submitted into the storm.
208#[derive(Debug, Clone, PartialEq)]
209pub struct StormJob {
210    /// Virtual remote runtime in milliseconds.
211    pub runtime_ms: u64,
212    /// Slots the job needs.
213    pub slots: u32,
214    /// Project root (used for cache/affinity attribution and varied fixtures).
215    pub project_root: String,
216    /// Fallback policy.
217    pub policy: JobPolicy,
218    /// Command kind.
219    pub kind: JobKind,
220    /// If the job ends up queued, cancel it before it starts (exercises the
221    /// cancel-before-start path) instead of waiting for a slot.
222    pub cancel_when_queued: bool,
223}
224
225impl StormJob {
226    /// A fail-open build job needing `slots` slots for `runtime_ms`.
227    #[must_use]
228    pub fn build(runtime_ms: u64, slots: u32, project_root: impl Into<String>) -> Self {
229        Self {
230            runtime_ms,
231            slots,
232            project_root: project_root.into(),
233            policy: JobPolicy::FailOpen,
234            kind: JobKind::Build,
235            cancel_when_queued: false,
236        }
237    }
238
239    /// Override the policy.
240    #[must_use]
241    pub fn with_policy(mut self, policy: JobPolicy) -> Self {
242        self.policy = policy;
243        self
244    }
245
246    /// Override the kind.
247    #[must_use]
248    pub fn with_kind(mut self, kind: JobKind) -> Self {
249        self.kind = kind;
250        self
251    }
252
253    /// Mark the job to cancel-when-queued.
254    #[must_use]
255    pub fn cancelling(mut self) -> Self {
256        self.cancel_when_queued = true;
257        self
258    }
259}
260
261/// Tunables for the storm run.
262#[derive(Debug, Clone, PartialEq, Eq)]
263pub struct StormConfig {
264    /// Correlates every event of this run.
265    pub run_id: String,
266    /// Owning bead id.
267    pub bead_id: String,
268    /// Maximum queued jobs before a fail-open job falls back to local.
269    pub max_queue_depth: usize,
270    /// Maximum milliseconds a job waits in the queue before timing out.
271    pub queue_timeout_ms: u64,
272    /// Fairness lookback window: how many recent placements count toward a
273    /// worker's recent-selection penalty.
274    pub fairness_window: usize,
275    /// Delay before a `cancel_when_queued` job is cancelled, once queued.
276    pub cancel_delay_ms: u64,
277}
278
279impl StormConfig {
280    /// A reasonable default storm config for the given run/bead ids.
281    #[must_use]
282    pub fn new(run_id: impl Into<String>, bead_id: impl Into<String>) -> Self {
283        Self {
284            run_id: run_id.into(),
285            bead_id: bead_id.into(),
286            max_queue_depth: 64,
287            queue_timeout_ms: 30_000,
288            fairness_window: 16,
289            cancel_delay_ms: 5,
290        }
291    }
292}
293
294/// Regression statistics for one storm run — enough to detect future scheduler
295/// regressions without re-deriving them from the raw event stream.
296#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
297pub struct StormSummary {
298    /// Total jobs submitted.
299    pub total_jobs: u32,
300    /// Jobs that ran remotely to completion.
301    pub remote_successes: u32,
302    /// Jobs that fell back to local execution (fail-open).
303    pub local_fallbacks: u32,
304    /// Proof jobs that refused local fallback (fail-closed).
305    pub proof_refusals: u32,
306    /// Jobs whose queue wait exceeded the timeout.
307    pub queue_timeouts: u32,
308    /// Jobs cancelled before they started.
309    pub cancellations: u32,
310    /// 95th-percentile queue wait, milliseconds.
311    pub p95_queue_wait_ms: u64,
312    /// 95th-percentile end-to-end time, milliseconds.
313    pub p95_end_to_end_ms: u64,
314    /// Per-worker slot utilization fraction over the run's makespan, by id.
315    pub per_worker_slot_utilization: BTreeMap<String, f64>,
316}
317
318/// The full result of a simulated storm: the JSONL event stream and the summary.
319#[derive(Debug, Clone)]
320pub struct StormRun {
321    /// Every emitted JSONL event, in chronological (then submission) order.
322    pub events: Vec<SmokeProfileEvent>,
323    /// Aggregate statistics.
324    pub summary: StormSummary,
325}
326
327impl StormRun {
328    /// Serialize the event stream as one JSON object per line (JSONL).
329    ///
330    /// # Errors
331    /// Returns a [`serde_json::Error`] if any event fails to serialize.
332    pub fn to_jsonl(&self) -> Result<String, serde_json::Error> {
333        let mut out = String::new();
334        for ev in &self.events {
335            out.push_str(&serde_json::to_string(ev)?);
336            out.push('\n');
337        }
338        Ok(out)
339    }
340}
341
342// ===========================================================================
343// Discrete-event simulator
344// ===========================================================================
345
346#[derive(Debug, Clone, Copy)]
347enum Disposition {
348    Remote { remote_id: RemoteBuildId },
349    LocalFallback,
350    QueueTimeoutFallback,
351    ProofRefused,
352    Cancelled,
353}
354
355#[derive(Debug)]
356struct JobState {
357    local_id: String,
358    enqueued_at: Option<u64>,
359    queue_wait_ms: u64,
360    started_at: Option<u64>,
361    disposition: Option<Disposition>,
362}
363
364#[derive(Debug, Clone, Copy, PartialEq, Eq)]
365enum EvKind {
366    Arrival(usize),
367    Completion {
368        worker: usize,
369        slots: u32,
370        job: usize,
371    },
372    Timeout(usize),
373    Cancel(usize),
374}
375
376#[derive(Debug, Clone, Copy, PartialEq, Eq)]
377struct Ev {
378    time: u64,
379    seq: u64,
380    kind: EvKind,
381}
382
383// Min-heap ordering by (time, seq): earliest event first, deterministic ties.
384impl Ord for Ev {
385    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
386        self.time.cmp(&other.time).then(self.seq.cmp(&other.seq))
387    }
388}
389impl PartialOrd for Ev {
390    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
391        Some(self.cmp(other))
392    }
393}
394
395/// Run a deterministic, virtual-time simulation of a multi-agent storm.
396///
397/// Jobs all arrive at virtual time 0 (a true storm). Each attempts placement on
398/// the fairest eligible worker with capacity; if none, it queues (bounded) or
399/// falls back per its policy. Completions free slots and admit queued jobs FIFO;
400/// queued jobs honor the wait timeout and cancellation. Every job reaches exactly
401/// one terminal disposition — there is no "stuck wrapper".
402#[must_use]
403pub fn simulate_storm(workers: &[StormWorker], jobs: &[StormJob], cfg: &StormConfig) -> StormRun {
404    let mut sim = Sim::new(workers, jobs, cfg);
405    sim.run();
406    sim.finish()
407}
408
409struct Sim<'a> {
410    workers: &'a [StormWorker],
411    jobs: &'a [StormJob],
412    cfg: &'a StormConfig,
413    used: Vec<u32>,
414    busy_slot_ms: Vec<u128>,
415    selection_log: VecDeque<usize>,
416    state: Vec<JobState>,
417    queue: VecDeque<usize>,
418    heap: BinaryHeap<Reverse<Ev>>,
419    events: Vec<SmokeProfileEvent>,
420    next_remote_id: RemoteBuildId,
421    next_seq: u64,
422    clock: u64,
423    makespan: u64,
424    queue_timeouts: u32,
425}
426
427impl<'a> Sim<'a> {
428    fn new(workers: &'a [StormWorker], jobs: &'a [StormJob], cfg: &'a StormConfig) -> Self {
429        // Deterministic local ids by index (not the uuid-based mint) so the run
430        // is reproducible; they keep the real `rchw-` prefix.
431        let state = (0..jobs.len())
432            .map(|i| JobState {
433                local_id: format!("{}{i:04}", crate::job_identity::LOCAL_WRAPPER_ID_PREFIX),
434                enqueued_at: None,
435                queue_wait_ms: 0,
436                started_at: None,
437                disposition: None,
438            })
439            .collect();
440        let mut heap = BinaryHeap::new();
441        // Arrivals at t=0, sequenced by submission order.
442        for (i, _) in jobs.iter().enumerate() {
443            heap.push(Reverse(Ev {
444                time: 0,
445                seq: i as u64,
446                kind: EvKind::Arrival(i),
447            }));
448        }
449        Self {
450            workers,
451            jobs,
452            cfg,
453            used: vec![0; workers.len()],
454            busy_slot_ms: vec![0; workers.len()],
455            selection_log: VecDeque::new(),
456            state,
457            queue: VecDeque::new(),
458            heap,
459            events: Vec::new(),
460            next_remote_id: 1,
461            next_seq: jobs.len() as u64,
462            clock: 0,
463            makespan: 0,
464            queue_timeouts: 0,
465        }
466    }
467
468    fn seq(&mut self) -> u64 {
469        let s = self.next_seq;
470        self.next_seq += 1;
471        s
472    }
473
474    fn run(&mut self) {
475        while let Some(Reverse(ev)) = self.heap.pop() {
476            self.clock = ev.time;
477            match ev.kind {
478                EvKind::Arrival(job) => self.on_arrival(job),
479                EvKind::Completion { worker, slots, job } => self.on_completion(worker, slots, job),
480                EvKind::Timeout(job) => self.on_timeout(job),
481                EvKind::Cancel(job) => self.on_cancel(job),
482            }
483        }
484        // Safety net: any straggler still queued (e.g. head-of-line never fit)
485        // is resolved as a timeout fallback so no wrapper is ever left dangling.
486        let stragglers: Vec<usize> = self.queue.drain(..).collect();
487        for job in stragglers {
488            if self.state[job].disposition.is_none() {
489                self.dispose_timeout(job);
490            }
491        }
492    }
493
494    /// Fairest eligible worker with capacity for `slots`, or `None`.
495    /// Weight = speed / (1 + recent_selections), highest wins; ties by index.
496    fn try_select(&self, slots: u32) -> Option<usize> {
497        let mut best: Option<(usize, f64)> = None;
498        for (i, w) in self.workers.iter().enumerate() {
499            if !w.eligibility.is_schedulable() {
500                continue;
501            }
502            if w.total_slots < slots {
503                continue; // capacity-impossible on this worker
504            }
505            if w.total_slots - self.used[i] < slots {
506                continue; // busy right now
507            }
508            let recent = self.selection_log.iter().filter(|&&x| x == i).count();
509            let weight = w.speed / (1.0 + recent as f64);
510            match best {
511                Some((_, bw)) if weight <= bw => {}
512                _ => best = Some((i, weight)),
513            }
514        }
515        best.map(|(i, _)| i)
516    }
517
518    /// Whether ANY eligible worker could ever fit `slots` (capacity feasibility).
519    fn ever_feasible(&self, slots: u32) -> bool {
520        self.workers
521            .iter()
522            .any(|w| w.eligibility.is_schedulable() && w.total_slots >= slots)
523    }
524
525    fn queue_depth_u32(&self) -> u32 {
526        u32::try_from(self.queue.len()).unwrap_or(u32::MAX)
527    }
528
529    fn on_arrival(&mut self, job: usize) {
530        let spec = &self.jobs[job];
531        if !self.ever_feasible(spec.slots) {
532            // No eligible worker can ever hold this job: rejected before
533            // admission. Fail-open runs local; proof refuses.
534            if spec.policy.allows_local_fallback() {
535                self.dispose_local_fallback(job, decision::LOCAL_FALLBACK);
536            } else {
537                self.dispose_proof_refused(job);
538            }
539            return;
540        }
541        if let Some(w) = self.try_select(spec.slots) {
542            self.place(job, w);
543            return;
544        }
545        // Busy fleet: consult the real queue contract for proof refusal vs queue.
546        if !spec.policy.allows_local_fallback() {
547            // Strict (proof) refuses to queue — fail closed.
548            self.dispose_proof_refused(job);
549            return;
550        }
551        if self.queue.len() >= self.cfg.max_queue_depth {
552            // Bounded queue is full: fail open to local rather than grow
553            // unboundedly — the storm-control backpressure guarantee.
554            self.dispose_local_fallback(job, decision::LOCAL_FALLBACK);
555            return;
556        }
557        self.enqueue(job);
558    }
559
560    fn enqueue(&mut self, job: usize) {
561        self.state[job].enqueued_at = Some(self.clock);
562        self.queue.push_back(job);
563        let depth = self.queue_depth_u32();
564        // The real queue contract: a busy fleet with no wait yields a definite,
565        // reattachable not-started job carrying attach/cancel guidance.
566        let guidance = queued_guidance(&self.state[job].local_id);
567        self.emit(
568            job,
569            event::QUEUED,
570            "queued",
571            decision::QUEUED,
572            None,
573            None,
574            Some(depth),
575            0,
576            Some(guidance),
577            None,
578        );
579        // Schedule timeout and (if applicable) cancellation.
580        let timeout_at = self.clock + self.cfg.queue_timeout_ms;
581        let s = self.seq();
582        self.heap.push(Reverse(Ev {
583            time: timeout_at,
584            seq: s,
585            kind: EvKind::Timeout(job),
586        }));
587        if self.jobs[job].cancel_when_queued {
588            let cancel_at = self.clock + self.cfg.cancel_delay_ms;
589            let s = self.seq();
590            self.heap.push(Reverse(Ev {
591                time: cancel_at,
592                seq: s,
593                kind: EvKind::Cancel(job),
594            }));
595        }
596    }
597
598    fn place(&mut self, job: usize, worker: usize) {
599        let spec = &self.jobs[job];
600        let slots = spec.slots;
601        let runtime = spec.runtime_ms;
602        self.used[worker] += slots;
603        self.busy_slot_ms[worker] += u128::from(slots) * u128::from(runtime);
604        self.selection_log.push_back(worker);
605        while self.selection_log.len() > self.cfg.fairness_window {
606            self.selection_log.pop_front();
607        }
608        let wait = self.state[job]
609            .enqueued_at
610            .map_or(0, |e| self.clock.saturating_sub(e));
611        self.state[job].queue_wait_ms = wait;
612        self.state[job].started_at = Some(self.clock);
613
614        let remote_id = self.next_remote_id;
615        self.next_remote_id += 1;
616        let mut identity = JobIdentity::new_local();
617        identity.local_wrapper_id = self.state[job].local_id.clone();
618        identity.admit(remote_id);
619        self.state[job].disposition = Some(Disposition::Remote { remote_id });
620
621        let worker_id = self.workers[worker].id.clone();
622        let depth = self.queue_depth_u32();
623        // `started` event: selected worker, both job ids, queue depth.
624        self.emit(
625            job,
626            event::STARTED,
627            "run",
628            decision::REMOTE,
629            Some(worker_id.clone()),
630            Some(worker_id.clone()),
631            Some(depth),
632            0,
633            Some("admitted and running".to_string()),
634            Some(remote_id),
635        );
636        let finish = self.clock + runtime;
637        self.makespan = self.makespan.max(finish);
638        let s = self.seq();
639        self.heap.push(Reverse(Ev {
640            time: finish,
641            seq: s,
642            kind: EvKind::Completion { worker, slots, job },
643        }));
644    }
645
646    fn on_completion(&mut self, worker: usize, slots: u32, job: usize) {
647        self.used[worker] = self.used[worker].saturating_sub(slots);
648        let runtime = self.jobs[job].runtime_ms;
649        let worker_id = self.workers[worker].id.clone();
650        let remote_id = match self.state[job].disposition {
651            Some(Disposition::Remote { remote_id }) => Some(remote_id),
652            _ => None,
653        };
654        // Terminal remote success.
655        self.emit(
656            job,
657            event::PASSED,
658            "ok",
659            decision::REMOTE,
660            Some(worker_id.clone()),
661            Some(worker_id),
662            None,
663            runtime,
664            Some("completed remotely".to_string()),
665            remote_id,
666        );
667        // Freed capacity: admit queued jobs FIFO until the head cannot fit.
668        self.drain_queue();
669    }
670
671    fn drain_queue(&mut self) {
672        while let Some(&job) = self.queue.front() {
673            // Already disposed (timed out / cancelled at this same instant)?
674            if self.state[job].disposition.is_some() {
675                self.queue.pop_front();
676                continue;
677            }
678            let slots = self.jobs[job].slots;
679            if let Some(w) = self.try_select(slots) {
680                self.queue.pop_front();
681                self.place(job, w);
682            } else {
683                break; // head-of-line: nothing fits yet
684            }
685        }
686    }
687
688    fn on_timeout(&mut self, job: usize) {
689        if self.state[job].disposition.is_some() || self.state[job].started_at.is_some() {
690            return; // already resolved
691        }
692        // Remove from queue if still present.
693        self.remove_from_queue(job);
694        self.dispose_timeout(job);
695    }
696
697    fn on_cancel(&mut self, job: usize) {
698        if self.state[job].disposition.is_some() || self.state[job].started_at.is_some() {
699            return;
700        }
701        self.remove_from_queue(job);
702        self.dispose_cancelled(job);
703    }
704
705    fn remove_from_queue(&mut self, job: usize) {
706        if let Some(pos) = self.queue.iter().position(|&j| j == job) {
707            self.queue.remove(pos);
708        }
709    }
710
711    fn dispose_timeout(&mut self, job: usize) {
712        self.queue_timeouts += 1;
713        let wait = self.state[job]
714            .enqueued_at
715            .map_or(self.cfg.queue_timeout_ms, |e| {
716                (self.clock.saturating_sub(e)).min(self.cfg.queue_timeout_ms)
717            });
718        self.state[job].queue_wait_ms = wait;
719        self.state[job].disposition = Some(Disposition::QueueTimeoutFallback);
720        let guidance = queue_timeout_guidance(&self.state[job].local_id, self.cfg.queue_timeout_ms);
721        self.emit(
722            job,
723            event::FELL_BACK,
724            "local",
725            decision::QUEUE_TIMEOUT_FALLBACK,
726            None,
727            None,
728            None,
729            self.jobs[job].runtime_ms,
730            Some(guidance),
731            None,
732        );
733    }
734
735    fn dispose_local_fallback(&mut self, job: usize, decision_tok: &str) {
736        self.state[job].disposition = Some(Disposition::LocalFallback);
737        let guidance = local_fallback_guidance();
738        self.emit(
739            job,
740            event::FELL_BACK,
741            "local",
742            decision_tok,
743            None,
744            None,
745            None,
746            self.jobs[job].runtime_ms,
747            Some(guidance),
748            None,
749        );
750    }
751
752    fn dispose_proof_refused(&mut self, job: usize) {
753        self.state[job].disposition = Some(Disposition::ProofRefused);
754        self.emit(
755            job,
756            event::REFUSED,
757            "refused",
758            decision::PROOF_REFUSED,
759            None,
760            None,
761            None,
762            0,
763            Some(
764                "proof mode requires immediate remote admission; refused local fallback"
765                    .to_string(),
766            ),
767            None,
768        );
769        // Tag the refusal reason code on the just-emitted event.
770        if let Some(ev) = self.events.last_mut() {
771            ev.reason_code = Some(PROOF_REFUSAL_REASON.to_string());
772        }
773    }
774
775    fn dispose_cancelled(&mut self, job: usize) {
776        let wait = self.state[job]
777            .enqueued_at
778            .map_or(0, |e| self.clock.saturating_sub(e));
779        self.state[job].queue_wait_ms = wait;
780        self.state[job].disposition = Some(Disposition::Cancelled);
781        let guidance = cancel_guidance(&self.state[job].local_id);
782        self.emit(
783            job,
784            event::CANCELLED,
785            "cancelled",
786            decision::CANCELLED,
787            None,
788            None,
789            None,
790            0,
791            Some(guidance),
792            None,
793        );
794    }
795
796    #[allow(clippy::too_many_arguments)] // one param per JSONL field the event carries
797    fn emit(
798        &mut self,
799        job: usize,
800        event_tok: &str,
801        status_tok: &str,
802        decision_tok: &str,
803        worker_id: Option<String>,
804        selected_worker: Option<String>,
805        queue_depth: Option<u32>,
806        duration_ms: u64,
807        detail: Option<String>,
808        remote_id: Option<RemoteBuildId>,
809    ) {
810        let ev = make_load_event(
811            &self.cfg.run_id,
812            &self.cfg.bead_id,
813            self.jobs[job].kind,
814            &self.state[job].local_id,
815            event_tok,
816            status_tok,
817            decision_tok,
818            worker_id,
819            selected_worker,
820            queue_depth,
821            duration_ms,
822            detail,
823            remote_id,
824        );
825        self.events.push(ev);
826    }
827
828    fn finish(self) -> StormRun {
829        let mut remote_successes = 0u32;
830        let mut local_fallbacks = 0u32;
831        let mut proof_refusals = 0u32;
832        let mut cancellations = 0u32;
833        let mut queue_waits = Vec::new();
834        let mut end_to_ends = Vec::new();
835
836        for (i, st) in self.state.iter().enumerate() {
837            let runtime = self.jobs[i].runtime_ms;
838            queue_waits.push(st.queue_wait_ms);
839            match st.disposition {
840                Some(Disposition::Remote { .. }) => {
841                    remote_successes += 1;
842                    end_to_ends.push(st.queue_wait_ms + runtime);
843                }
844                Some(Disposition::LocalFallback) => {
845                    local_fallbacks += 1;
846                    end_to_ends.push(st.queue_wait_ms + runtime);
847                }
848                Some(Disposition::QueueTimeoutFallback) => {
849                    local_fallbacks += 1;
850                    end_to_ends.push(st.queue_wait_ms + runtime);
851                }
852                Some(Disposition::ProofRefused) => {
853                    proof_refusals += 1;
854                    end_to_ends.push(st.queue_wait_ms);
855                }
856                Some(Disposition::Cancelled) => {
857                    cancellations += 1;
858                    end_to_ends.push(st.queue_wait_ms);
859                }
860                None => {
861                    // Should be impossible (the safety net resolves stragglers),
862                    // but count an end-to-end so the percentiles stay sane.
863                    end_to_ends.push(st.queue_wait_ms);
864                }
865            }
866        }
867
868        let makespan = self.makespan.max(1);
869        let mut per_worker_slot_utilization = BTreeMap::new();
870        for (i, w) in self.workers.iter().enumerate() {
871            let capacity = u128::from(w.total_slots) * u128::from(makespan);
872            #[allow(clippy::cast_precision_loss)]
873            let util = if capacity == 0 {
874                0.0
875            } else {
876                self.busy_slot_ms[i] as f64 / capacity as f64
877            };
878            per_worker_slot_utilization.insert(w.id.clone(), util);
879        }
880
881        let summary = StormSummary {
882            total_jobs: u32::try_from(self.jobs.len()).unwrap_or(u32::MAX),
883            remote_successes,
884            local_fallbacks,
885            proof_refusals,
886            queue_timeouts: self.queue_timeouts,
887            cancellations,
888            p95_queue_wait_ms: percentile(&mut queue_waits, 95),
889            p95_end_to_end_ms: percentile(&mut end_to_ends, 95),
890            per_worker_slot_utilization,
891        };
892        StormRun {
893            events: self.events,
894            summary,
895        }
896    }
897}
898
899/// The `p`-th percentile of `values` (nearest-rank), 0 if empty. Sorts in place.
900#[must_use]
901fn percentile(values: &mut [u64], p: u64) -> u64 {
902    if values.is_empty() {
903        return 0;
904    }
905    values.sort_unstable();
906    // nearest-rank: rank = ceil(p/100 * n), 1-based.
907    let n = values.len() as u64;
908    let rank = (p * n).div_ceil(100).max(1);
909    let idx = (rank - 1).min(n - 1) as usize;
910    values[idx]
911}
912
913// ===========================================================================
914// Shared event / guidance construction (used by BOTH the discrete-event
915// simulator and the live recorder so the two emit byte-identical event shapes
916// for the same logical disposition — that is what lets the same invariant
917// checkers gate a SIMULATED storm in CI and a REAL `--load` self-test run).
918// ===========================================================================
919
920/// Build one [`SmokeProfileEvent`] for a load/storm job, carrying every load
921/// field. Shared by the simulator's `Sim::emit` and [`build_live_storm_run`].
922#[allow(clippy::too_many_arguments)] // one param per JSONL field the event carries
923fn make_load_event(
924    run_id: &str,
925    bead_id: &str,
926    kind: JobKind,
927    local_id: &str,
928    event_tok: &str,
929    status_tok: &str,
930    decision_tok: &str,
931    worker_id: Option<String>,
932    selected_worker: Option<String>,
933    queue_depth: Option<u32>,
934    duration_ms: u64,
935    detail: Option<String>,
936    remote_id: Option<RemoteBuildId>,
937) -> SmokeProfileEvent {
938    let mut ev = SmokeProfileEvent::started(
939        run_id.to_string(),
940        bead_id.to_string(),
941        worker_id,
942        SmokeScenario::LoadStormControl,
943    );
944    ev.event = event_tok.to_string();
945    ev.status = status_tok.to_string();
946    ev.duration_ms = duration_ms;
947    ev.command_fingerprint = Some(kind.fingerprint().to_string());
948    ev = ev
949        .with_job_ids(Some(local_id.to_string()), remote_id)
950        .with_selected_worker(selected_worker)
951        .with_fallback_decision(decision_tok);
952    if let Some(d) = queue_depth {
953        ev = ev.with_queue_depth(d);
954    }
955    if let Some(d) = detail {
956        ev = ev.with_detail(d);
957    }
958    ev
959}
960
961/// Attach/cancel guidance for a job that is queued and not yet started — the
962/// real busy-fleet queue contract (definite, reattachable not-started job).
963fn queued_guidance(local_id: &str) -> String {
964    QueueContractResponse::build(
965        &resolve_queue_contract(&AdmissionState::Queued, &QueueOptions::default(), None),
966        Some(local_id.to_string()),
967    )
968    .render()
969}
970
971/// Guidance for a fail-open job that never reached a worker (fleet at capacity)
972/// and ran locally instead.
973fn local_fallback_guidance() -> String {
974    QueueContractResponse::build(
975        &resolve_queue_contract(
976            &AdmissionState::RejectedBeforeAdmission(
977                "fleet at capacity; ran locally (fail-open)".to_string(),
978            ),
979            &QueueOptions::default(),
980            None,
981        ),
982        None,
983    )
984    .detail
985}
986
987/// Guidance for a job that waited past the queue wait-timeout and fell back.
988fn queue_timeout_guidance(local_id: &str, queue_timeout_ms: u64) -> String {
989    QueueContractResponse::build(
990        &resolve_queue_contract(
991            &AdmissionState::Queued,
992            &QueueOptions {
993                wait: true,
994                wait_timeout_secs: Some(queue_timeout_ms / 1000),
995                ..QueueOptions::default()
996            },
997            Some(WaitResult::TimedOut),
998        ),
999        Some(local_id.to_string()),
1000    )
1001    .render()
1002}
1003
1004/// Guidance for a job cancelled before it started.
1005fn cancel_guidance(local_id: &str) -> String {
1006    QueueContractResponse::build(
1007        &resolve_queue_contract(
1008            &AdmissionState::Queued,
1009            &QueueOptions {
1010                wait: true,
1011                ..QueueOptions::default()
1012            },
1013            Some(WaitResult::CancelledBeforeStart),
1014        ),
1015        Some(local_id.to_string()),
1016    )
1017    .render()
1018}
1019
1020// ===========================================================================
1021// Live recorder: real canary outcomes -> StormRun
1022// ===========================================================================
1023//
1024// The deterministic simulator above proves the SCHEDULER LOGIC in CI. The
1025// `rch self-test --smoke --load` consumer instead launches a bounded swarm of
1026// REAL tiny canary builds on a deployed fleet, observes how each one actually
1027// resolved, and feeds those observations here. [`build_live_storm_run`] turns
1028// the observed outcomes into the exact same [`StormRun`] shape (JSONL event
1029// stream + [`StormSummary`]) the simulator produces, so the *same five*
1030// [`check_all_invariants`] gate a live run. The recorder owns the event
1031// vocabulary and summary accounting; the consumer owns only the real execution.
1032
1033/// The terminal disposition of one observed live canary job. Mirrors the
1034/// simulator's internal `Disposition` but is the public input the live consumer
1035/// reports for each real build.
1036#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1037#[serde(rename_all = "snake_case")]
1038pub enum LiveDisposition {
1039    /// The job was admitted to a worker and ran remotely to completion.
1040    Remote,
1041    /// No remote capacity; a fail-open job ran locally instead.
1042    LocalFallback,
1043    /// The job waited past the queue wait-timeout and then ran locally.
1044    QueueTimeoutFallback,
1045    /// A proof (strict-remote) job refused local fallback (fail-closed).
1046    ProofRefused,
1047    /// The job was cancelled before it started.
1048    Cancelled,
1049}
1050
1051impl LiveDisposition {
1052    /// Whether this disposition admitted the job to a worker.
1053    #[must_use]
1054    pub const fn is_remote(self) -> bool {
1055        matches!(self, Self::Remote)
1056    }
1057
1058    /// Whether this disposition counts as a local fallback in the summary.
1059    #[must_use]
1060    pub const fn is_local_fallback(self) -> bool {
1061        matches!(self, Self::LocalFallback | Self::QueueTimeoutFallback)
1062    }
1063}
1064
1065/// One observed REAL canary outcome from a live `--load` self-test run.
1066///
1067/// The consumer populates one of these per concurrent canary build it launched,
1068/// recording how the build actually resolved (which worker the daemon selected,
1069/// the remote build id it was assigned, whether it queued/fell back/was
1070/// refused/cancelled, and the measured timings).
1071#[derive(Debug, Clone, PartialEq)]
1072pub struct LiveJobOutcome {
1073    /// Local wrapper id (`rchw-…`) the hook minted for this job.
1074    pub local_job_id: String,
1075    /// Remote build id the daemon assigned once admitted; `None` if the job
1076    /// never reached a worker.
1077    pub remote_job_id: Option<RemoteBuildId>,
1078    /// The worker the scheduler selected; `None` for fallback/refusal/cancel.
1079    pub selected_worker: Option<String>,
1080    /// Slots the job requested (for slot-utilization accounting).
1081    pub slots: u32,
1082    /// Command kind (only affects the redacted command fingerprint).
1083    pub kind: JobKind,
1084    /// How the job actually resolved.
1085    pub disposition: LiveDisposition,
1086    /// Whether the job spent time in the queue before resolving (drives an
1087    /// intermediate `queued` event carrying attach/cancel guidance).
1088    pub queued: bool,
1089    /// Measured queue wait, milliseconds.
1090    pub queue_wait_ms: u64,
1091    /// Measured remote/local runtime, milliseconds.
1092    pub runtime_ms: u64,
1093    /// Queue depth observed when the job resolved, if known.
1094    pub queue_depth: Option<u32>,
1095}
1096
1097impl LiveJobOutcome {
1098    /// A remote-success outcome: admitted to `worker` with `remote_id`, ran for
1099    /// `runtime_ms`.
1100    #[must_use]
1101    pub fn remote(
1102        local_job_id: impl Into<String>,
1103        worker: impl Into<String>,
1104        remote_id: RemoteBuildId,
1105        slots: u32,
1106        runtime_ms: u64,
1107    ) -> Self {
1108        Self {
1109            local_job_id: local_job_id.into(),
1110            remote_job_id: Some(remote_id),
1111            selected_worker: Some(worker.into()),
1112            slots,
1113            kind: JobKind::Check,
1114            disposition: LiveDisposition::Remote,
1115            queued: false,
1116            queue_wait_ms: 0,
1117            runtime_ms,
1118            queue_depth: None,
1119        }
1120    }
1121
1122    /// A local-fallback outcome: no remote capacity, ran locally for `runtime_ms`.
1123    #[must_use]
1124    pub fn local_fallback(local_job_id: impl Into<String>, slots: u32, runtime_ms: u64) -> Self {
1125        Self {
1126            local_job_id: local_job_id.into(),
1127            remote_job_id: None,
1128            selected_worker: None,
1129            slots,
1130            kind: JobKind::Check,
1131            disposition: LiveDisposition::LocalFallback,
1132            queued: false,
1133            queue_wait_ms: 0,
1134            runtime_ms,
1135            queue_depth: None,
1136        }
1137    }
1138
1139    /// Override the command kind.
1140    #[must_use]
1141    pub fn with_kind(mut self, kind: JobKind) -> Self {
1142        self.kind = kind;
1143        self
1144    }
1145
1146    /// Mark the job as having queued, with the measured wait and depth.
1147    #[must_use]
1148    pub fn queued_for(mut self, wait_ms: u64, depth: Option<u32>) -> Self {
1149        self.queued = true;
1150        self.queue_wait_ms = wait_ms;
1151        self.queue_depth = depth;
1152        self
1153    }
1154
1155    /// Override the disposition (for fallback/refusal/cancel outcomes).
1156    #[must_use]
1157    pub fn with_disposition(mut self, disposition: LiveDisposition) -> Self {
1158        self.disposition = disposition;
1159        self
1160    }
1161}
1162
1163/// Build a [`StormRun`] (JSONL event stream + [`StormSummary`]) from a set of
1164/// observed REAL canary outcomes, using the exact event vocabulary and summary
1165/// accounting as the simulator. The resulting run can be fed straight into
1166/// [`check_all_invariants`].
1167///
1168/// `workers` is the live fleet description (ids, slots, eligibility) so the
1169/// fairness and ineligible-selection invariants — and per-worker slot
1170/// utilization — can be computed against the real fleet shape.
1171#[must_use]
1172pub fn build_live_storm_run(
1173    cfg: &StormConfig,
1174    workers: &[StormWorker],
1175    outcomes: &[LiveJobOutcome],
1176) -> StormRun {
1177    let mut events = Vec::new();
1178    for o in outcomes {
1179        // A job that waited emits an intermediate `queued` event carrying the
1180        // real attach/cancel guidance, so the no-stuck-wrapper invariant has a
1181        // definite, reattachable record even before the terminal event.
1182        if o.queued {
1183            events.push(make_load_event(
1184                &cfg.run_id,
1185                &cfg.bead_id,
1186                o.kind,
1187                &o.local_job_id,
1188                event::QUEUED,
1189                "queued",
1190                decision::QUEUED,
1191                None,
1192                None,
1193                o.queue_depth,
1194                0,
1195                Some(queued_guidance(&o.local_job_id)),
1196                None,
1197            ));
1198        }
1199        match o.disposition {
1200            LiveDisposition::Remote => {
1201                events.push(make_load_event(
1202                    &cfg.run_id,
1203                    &cfg.bead_id,
1204                    o.kind,
1205                    &o.local_job_id,
1206                    event::STARTED,
1207                    "run",
1208                    decision::REMOTE,
1209                    o.selected_worker.clone(),
1210                    o.selected_worker.clone(),
1211                    o.queue_depth,
1212                    0,
1213                    Some("admitted and running".to_string()),
1214                    o.remote_job_id,
1215                ));
1216                events.push(make_load_event(
1217                    &cfg.run_id,
1218                    &cfg.bead_id,
1219                    o.kind,
1220                    &o.local_job_id,
1221                    event::PASSED,
1222                    "ok",
1223                    decision::REMOTE,
1224                    o.selected_worker.clone(),
1225                    o.selected_worker.clone(),
1226                    None,
1227                    o.runtime_ms,
1228                    Some("completed remotely".to_string()),
1229                    o.remote_job_id,
1230                ));
1231            }
1232            LiveDisposition::LocalFallback => {
1233                events.push(make_load_event(
1234                    &cfg.run_id,
1235                    &cfg.bead_id,
1236                    o.kind,
1237                    &o.local_job_id,
1238                    event::FELL_BACK,
1239                    "local",
1240                    decision::LOCAL_FALLBACK,
1241                    None,
1242                    None,
1243                    None,
1244                    o.runtime_ms,
1245                    Some(local_fallback_guidance()),
1246                    None,
1247                ));
1248            }
1249            LiveDisposition::QueueTimeoutFallback => {
1250                events.push(make_load_event(
1251                    &cfg.run_id,
1252                    &cfg.bead_id,
1253                    o.kind,
1254                    &o.local_job_id,
1255                    event::FELL_BACK,
1256                    "local",
1257                    decision::QUEUE_TIMEOUT_FALLBACK,
1258                    None,
1259                    None,
1260                    None,
1261                    o.runtime_ms,
1262                    Some(queue_timeout_guidance(
1263                        &o.local_job_id,
1264                        cfg.queue_timeout_ms,
1265                    )),
1266                    None,
1267                ));
1268            }
1269            LiveDisposition::ProofRefused => {
1270                let mut ev = make_load_event(
1271                    &cfg.run_id,
1272                    &cfg.bead_id,
1273                    o.kind,
1274                    &o.local_job_id,
1275                    event::REFUSED,
1276                    "refused",
1277                    decision::PROOF_REFUSED,
1278                    None,
1279                    None,
1280                    None,
1281                    0,
1282                    Some(
1283                        "proof mode requires immediate remote admission; refused local fallback"
1284                            .to_string(),
1285                    ),
1286                    None,
1287                );
1288                ev.reason_code = Some(PROOF_REFUSAL_REASON.to_string());
1289                events.push(ev);
1290            }
1291            LiveDisposition::Cancelled => {
1292                events.push(make_load_event(
1293                    &cfg.run_id,
1294                    &cfg.bead_id,
1295                    o.kind,
1296                    &o.local_job_id,
1297                    event::CANCELLED,
1298                    "cancelled",
1299                    decision::CANCELLED,
1300                    None,
1301                    None,
1302                    None,
1303                    0,
1304                    Some(cancel_guidance(&o.local_job_id)),
1305                    None,
1306                ));
1307            }
1308        }
1309    }
1310    let summary = summarize_live(workers, outcomes);
1311    StormRun { events, summary }
1312}
1313
1314/// Compute the [`StormSummary`] for a live run, mirroring the simulator's
1315/// `Sim::finish` accounting.
1316fn summarize_live(workers: &[StormWorker], outcomes: &[LiveJobOutcome]) -> StormSummary {
1317    let mut remote_successes = 0u32;
1318    let mut local_fallbacks = 0u32;
1319    let mut proof_refusals = 0u32;
1320    let mut queue_timeouts = 0u32;
1321    let mut cancellations = 0u32;
1322    let mut queue_waits = Vec::with_capacity(outcomes.len());
1323    let mut end_to_ends = Vec::with_capacity(outcomes.len());
1324    let mut busy_slot_ms: BTreeMap<&str, u128> = BTreeMap::new();
1325    let mut makespan = 0u64;
1326
1327    for o in outcomes {
1328        queue_waits.push(o.queue_wait_ms);
1329        let end_to_end = o.queue_wait_ms.saturating_add(o.runtime_ms);
1330        match o.disposition {
1331            LiveDisposition::Remote => {
1332                remote_successes += 1;
1333                end_to_ends.push(end_to_end);
1334                makespan = makespan.max(end_to_end);
1335                if let Some(w) = o.selected_worker.as_deref() {
1336                    *busy_slot_ms.entry(w).or_default() +=
1337                        u128::from(o.slots) * u128::from(o.runtime_ms);
1338                }
1339            }
1340            LiveDisposition::LocalFallback => {
1341                local_fallbacks += 1;
1342                end_to_ends.push(end_to_end);
1343                makespan = makespan.max(end_to_end);
1344            }
1345            LiveDisposition::QueueTimeoutFallback => {
1346                local_fallbacks += 1;
1347                queue_timeouts += 1;
1348                end_to_ends.push(end_to_end);
1349                makespan = makespan.max(end_to_end);
1350            }
1351            LiveDisposition::ProofRefused => {
1352                proof_refusals += 1;
1353                end_to_ends.push(o.queue_wait_ms);
1354            }
1355            LiveDisposition::Cancelled => {
1356                cancellations += 1;
1357                end_to_ends.push(o.queue_wait_ms);
1358            }
1359        }
1360    }
1361
1362    let makespan = makespan.max(1);
1363    let mut per_worker_slot_utilization = BTreeMap::new();
1364    for w in workers {
1365        let capacity = u128::from(w.total_slots) * u128::from(makespan);
1366        let busy = busy_slot_ms.get(w.id.as_str()).copied().unwrap_or(0);
1367        #[allow(clippy::cast_precision_loss)]
1368        let util = if capacity == 0 {
1369            0.0
1370        } else {
1371            busy as f64 / capacity as f64
1372        };
1373        per_worker_slot_utilization.insert(w.id.clone(), util);
1374    }
1375
1376    StormSummary {
1377        total_jobs: u32::try_from(outcomes.len()).unwrap_or(u32::MAX),
1378        remote_successes,
1379        local_fallbacks,
1380        proof_refusals,
1381        queue_timeouts,
1382        cancellations,
1383        p95_queue_wait_ms: percentile(&mut queue_waits, 95),
1384        p95_end_to_end_ms: percentile(&mut end_to_ends, 95),
1385        per_worker_slot_utilization,
1386    }
1387}
1388
1389// ===========================================================================
1390// Invariant checkers
1391// ===========================================================================
1392
1393/// The verdict of a single storm-control invariant check.
1394#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1395pub struct InvariantReport {
1396    /// Stable invariant name/token.
1397    pub name: String,
1398    /// Whether the invariant held.
1399    pub passed: bool,
1400    /// Human/agent-facing summary.
1401    pub detail: String,
1402    /// Specific violations (empty when `passed`).
1403    pub violations: Vec<String>,
1404}
1405
1406impl InvariantReport {
1407    fn pass(name: &str, detail: impl Into<String>) -> Self {
1408        Self {
1409            name: name.to_string(),
1410            passed: true,
1411            detail: detail.into(),
1412            violations: Vec::new(),
1413        }
1414    }
1415    fn fail(name: &str, detail: impl Into<String>, violations: Vec<String>) -> Self {
1416        Self {
1417            name: name.to_string(),
1418            passed: false,
1419            detail: detail.into(),
1420            violations,
1421        }
1422    }
1423}
1424
1425/// Invariant: no eligible worker should sit idle while another is overloaded —
1426/// the busiest worker's share of remote placements must not exceed `tolerance`×
1427/// its fair share (1/n of schedulable workers). Single-worker fleets trivially
1428/// pass.
1429#[must_use]
1430pub fn check_load_fairness(
1431    events: &[SmokeProfileEvent],
1432    workers: &[StormWorker],
1433    tolerance: f64,
1434) -> InvariantReport {
1435    const NAME: &str = "load_fairness";
1436    let schedulable: Vec<&StormWorker> = workers
1437        .iter()
1438        .filter(|w| w.eligibility.is_schedulable())
1439        .collect();
1440    if schedulable.len() <= 1 {
1441        return InvariantReport::pass(NAME, "single schedulable worker; fairness trivial");
1442    }
1443    let mut counts: BTreeMap<&str, u32> = BTreeMap::new();
1444    for w in &schedulable {
1445        counts.insert(w.id.as_str(), 0);
1446    }
1447    let mut total = 0u32;
1448    for ev in events {
1449        if ev.event == event::STARTED
1450            && let Some(sel) = ev.selected_worker.as_deref()
1451            && let Some(c) = counts.get_mut(sel)
1452        {
1453            *c += 1;
1454            total += 1;
1455        }
1456    }
1457    if total == 0 {
1458        return InvariantReport::pass(NAME, "no remote placements to spread");
1459    }
1460    let fair_share = f64::from(total) / schedulable.len() as f64;
1461    let cap = fair_share * tolerance;
1462    let mut violations = Vec::new();
1463    for (id, c) in &counts {
1464        if f64::from(*c) > cap {
1465            violations.push(format!(
1466                "worker {id} took {c} of {total} placements (cap {cap:.1}, fair share {fair_share:.1})"
1467            ));
1468        }
1469    }
1470    if violations.is_empty() {
1471        InvariantReport::pass(
1472            NAME,
1473            format!("{total} placements spread within {tolerance:.2}× fair share"),
1474        )
1475    } else {
1476        InvariantReport::fail(NAME, "load not spread fairly", violations)
1477    }
1478}
1479
1480/// Invariant: every admitted job gets a UNIQUE remote build id (no two jobs share
1481/// a remote job id across the run).
1482#[must_use]
1483pub fn check_no_duplicate_remote_job_ids(events: &[SmokeProfileEvent]) -> InvariantReport {
1484    const NAME: &str = "no_duplicate_remote_job_ids";
1485    // Map remote_job_id -> set of distinct local_job_ids that claimed it.
1486    let mut owners: BTreeMap<u64, std::collections::BTreeSet<String>> = BTreeMap::new();
1487    for ev in events {
1488        if let Some(rid) = ev.remote_job_id {
1489            let local = ev.local_job_id.clone().unwrap_or_default();
1490            owners.entry(rid).or_default().insert(local);
1491        }
1492    }
1493    let mut violations = Vec::new();
1494    for (rid, locals) in &owners {
1495        if locals.len() > 1 {
1496            violations.push(format!(
1497                "remote job id {rid} shared by local jobs {locals:?}"
1498            ));
1499        }
1500    }
1501    if violations.is_empty() {
1502        InvariantReport::pass(
1503            NAME,
1504            format!("{} distinct remote job ids, none shared", owners.len()),
1505        )
1506    } else {
1507        InvariantReport::fail(NAME, "remote job id collision", violations)
1508    }
1509}
1510
1511/// Invariant: the fleet must not degenerate into an unbounded local-fallback
1512/// storm. The fraction of jobs that fell back to local must not exceed
1513/// `max_fallback_ratio`. Proof refusals and cancellations are not fallbacks.
1514#[must_use]
1515pub fn check_no_unbounded_local_fallback_storm(
1516    events: &[SmokeProfileEvent],
1517    summary: &StormSummary,
1518    max_fallback_ratio: f64,
1519) -> InvariantReport {
1520    const NAME: &str = "no_unbounded_local_fallback_storm";
1521    let _ = events;
1522    if summary.total_jobs == 0 {
1523        return InvariantReport::pass(NAME, "no jobs");
1524    }
1525    let ratio = f64::from(summary.local_fallbacks) / f64::from(summary.total_jobs);
1526    if ratio <= max_fallback_ratio {
1527        InvariantReport::pass(
1528            NAME,
1529            format!(
1530                "{}/{} jobs fell back to local ({:.1}% <= {:.1}% cap)",
1531                summary.local_fallbacks,
1532                summary.total_jobs,
1533                ratio * 100.0,
1534                max_fallback_ratio * 100.0
1535            ),
1536        )
1537    } else {
1538        InvariantReport::fail(
1539            NAME,
1540            "local fallback storm: too many jobs bypassed the fleet",
1541            vec![format!(
1542                "{}/{} fell back ({:.1}% > {:.1}% cap)",
1543                summary.local_fallbacks,
1544                summary.total_jobs,
1545                ratio * 100.0,
1546                max_fallback_ratio * 100.0
1547            )],
1548        )
1549    }
1550}
1551
1552/// Invariant: no agent is left with a "maybe running somewhere" wrapper. Every
1553/// job that did not start remotely (fell back, timed out, was cancelled, or
1554/// queued-not-started) must carry non-empty attach/cancel guidance in `detail`,
1555/// and every job must reach a definite terminal event.
1556#[must_use]
1557pub fn check_attach_cancel_guidance(events: &[SmokeProfileEvent]) -> InvariantReport {
1558    const NAME: &str = "attach_cancel_guidance";
1559    use std::collections::BTreeSet;
1560    let terminal = [
1561        event::PASSED,
1562        event::FELL_BACK,
1563        event::REFUSED,
1564        event::CANCELLED,
1565    ];
1566    let not_started_terminal = [event::FELL_BACK, event::CANCELLED];
1567    let mut all_jobs: BTreeSet<String> = BTreeSet::new();
1568    let mut terminated: BTreeSet<String> = BTreeSet::new();
1569    let mut violations = Vec::new();
1570    for ev in events {
1571        let Some(local) = ev.local_job_id.clone() else {
1572            continue;
1573        };
1574        all_jobs.insert(local.clone());
1575        if terminal.contains(&ev.event.as_str()) {
1576            terminated.insert(local.clone());
1577        }
1578        // Any not-started terminal (and the intermediate queued event) must
1579        // carry guidance.
1580        if not_started_terminal.contains(&ev.event.as_str()) || ev.event == event::QUEUED {
1581            let has_guidance = ev.detail.as_deref().is_some_and(|d| !d.trim().is_empty());
1582            if !has_guidance {
1583                violations.push(format!(
1584                    "job {local} event '{}' lacks attach/cancel guidance",
1585                    ev.event
1586                ));
1587            }
1588        }
1589    }
1590    for job in all_jobs.difference(&terminated) {
1591        violations.push(format!(
1592            "job {job} never reached a terminal event (stuck wrapper)"
1593        ));
1594    }
1595    if violations.is_empty() {
1596        InvariantReport::pass(
1597            NAME,
1598            format!("{} jobs all terminated with guidance", terminated.len()),
1599        )
1600    } else {
1601        InvariantReport::fail(NAME, "stuck wrapper or missing guidance", violations)
1602    }
1603}
1604
1605/// Invariant: a worker that is temporarily bypassed, admin-disabled, or
1606/// capability-inadmissible must NEVER be selected for work.
1607#[must_use]
1608pub fn check_no_ineligible_worker_selected(
1609    events: &[SmokeProfileEvent],
1610    workers: &[StormWorker],
1611) -> InvariantReport {
1612    const NAME: &str = "no_ineligible_worker_selected";
1613    let ineligible: BTreeMap<&str, WorkerEligibility> = workers
1614        .iter()
1615        .filter(|w| !w.eligibility.is_schedulable())
1616        .map(|w| (w.id.as_str(), w.eligibility))
1617        .collect();
1618    let mut violations = Vec::new();
1619    for ev in events {
1620        // A worker is "given work" if it is selected or the job started on it.
1621        for candidate in [ev.selected_worker.as_deref(), ev.worker_id.as_deref()]
1622            .into_iter()
1623            .flatten()
1624        {
1625            if let Some(elig) = ineligible.get(candidate)
1626                && (ev.event == event::STARTED || ev.event == event::PASSED)
1627            {
1628                violations.push(format!(
1629                    "ineligible worker {candidate} ({}) received work on event '{}' (job {:?})",
1630                    elig.as_str(),
1631                    ev.event,
1632                    ev.local_job_id
1633                ));
1634            }
1635        }
1636    }
1637    if violations.is_empty() {
1638        InvariantReport::pass(
1639            NAME,
1640            format!("{} ineligible worker(s) received no work", ineligible.len()),
1641        )
1642    } else {
1643        InvariantReport::fail(NAME, "ineligible worker received work", violations)
1644    }
1645}
1646
1647/// Run all five storm-control invariants with the given tolerances.
1648#[must_use]
1649pub fn check_all_invariants(
1650    run: &StormRun,
1651    workers: &[StormWorker],
1652    fairness_tolerance: f64,
1653    max_fallback_ratio: f64,
1654) -> Vec<InvariantReport> {
1655    vec![
1656        check_load_fairness(&run.events, workers, fairness_tolerance),
1657        check_no_duplicate_remote_job_ids(&run.events),
1658        check_no_unbounded_local_fallback_storm(&run.events, &run.summary, max_fallback_ratio),
1659        check_attach_cancel_guidance(&run.events),
1660        check_no_ineligible_worker_selected(&run.events, workers),
1661    ]
1662}
1663
1664/// Whether every invariant in a report set held.
1665#[must_use]
1666pub fn all_passed(reports: &[InvariantReport]) -> bool {
1667    reports.iter().all(|r| r.passed)
1668}
1669
1670#[cfg(test)]
1671mod tests {
1672    use super::*;
1673
1674    fn cfg() -> StormConfig {
1675        StormConfig::new("storm-run-1", "bd-session-history-remediation-ocv9i.10.4")
1676    }
1677
1678    fn healthy_fleet() -> Vec<StormWorker> {
1679        vec![
1680            StormWorker::healthy("w1", 4, 100.0),
1681            StormWorker::healthy("w2", 4, 100.0),
1682            StormWorker::healthy("w3", 4, 100.0),
1683        ]
1684    }
1685
1686    fn build_jobs(n: usize, runtime: u64, slots: u32) -> Vec<StormJob> {
1687        (0..n)
1688            .map(|i| StormJob::build(runtime, slots, format!("/data/projects/p{}", i % 4)))
1689            .collect()
1690    }
1691
1692    #[test]
1693    fn percentile_nearest_rank() {
1694        let mut v = vec![10, 20, 30, 40, 50, 60, 70, 80, 90, 100];
1695        assert_eq!(percentile(&mut v, 95), 100);
1696        let mut v2 = vec![5];
1697        assert_eq!(percentile(&mut v2, 95), 5);
1698        let mut empty: Vec<u64> = vec![];
1699        assert_eq!(percentile(&mut empty, 95), 0);
1700        let mut v3 = vec![1, 2, 3, 4];
1701        assert_eq!(percentile(&mut v3, 50), 2); // ceil(0.5*4)=2 -> idx 1
1702    }
1703
1704    #[test]
1705    fn healthy_storm_upholds_all_invariants() {
1706        let workers = healthy_fleet();
1707        // 24 jobs, fleet capacity 12 slots: guaranteed contention + queueing.
1708        let jobs = build_jobs(24, 1000, 1);
1709        let run = simulate_storm(&workers, &jobs, &cfg());
1710        let reports = check_all_invariants(&run, &workers, 1.5, 0.25);
1711        for r in &reports {
1712            assert!(r.passed, "invariant {} failed: {:?}", r.name, r.violations);
1713        }
1714        assert_eq!(run.summary.total_jobs, 24);
1715        // Every job completed remotely (capacity recycles via the queue).
1716        assert_eq!(run.summary.remote_successes, 24);
1717        assert_eq!(run.summary.local_fallbacks, 0);
1718    }
1719
1720    #[test]
1721    fn fairness_spreads_load_across_homogeneous_fleet() {
1722        let workers = healthy_fleet();
1723        let jobs = build_jobs(30, 500, 1);
1724        let run = simulate_storm(&workers, &jobs, &cfg());
1725        let report = check_load_fairness(&run.events, &workers, 1.4);
1726        assert!(report.passed, "fairness: {:?}", report.violations);
1727        // Each worker should get a meaningful share (none starved).
1728        for w in &workers {
1729            let util = run.summary.per_worker_slot_utilization[&w.id];
1730            assert!(util > 0.0, "worker {} starved (util 0)", w.id);
1731        }
1732    }
1733
1734    #[test]
1735    fn unique_remote_job_ids_under_storm() {
1736        let workers = healthy_fleet();
1737        let jobs = build_jobs(50, 300, 1);
1738        let run = simulate_storm(&workers, &jobs, &cfg());
1739        let report = check_no_duplicate_remote_job_ids(&run.events);
1740        assert!(report.passed, "{:?}", report.violations);
1741    }
1742
1743    #[test]
1744    fn queue_full_falls_back_locally_but_not_unboundedly() {
1745        let workers = vec![StormWorker::healthy("only", 1, 100.0)];
1746        // Tiny queue: most jobs must fall back, but fail-open keeps the wrapper
1747        // definite. The storm-control bound is the *cap*, which we set high here
1748        // to assert the fallbacks happen and are accounted, then assert the bound
1749        // catches an over-fallback configuration below.
1750        let mut c = cfg();
1751        c.max_queue_depth = 2;
1752        c.queue_timeout_ms = 10; // short so queued jobs time out fast
1753        let jobs = build_jobs(20, 1000, 1);
1754        let run = simulate_storm(&workers, &jobs, &c);
1755        assert_eq!(run.summary.total_jobs, 20);
1756        // Some ran remotely, the rest fell back; nothing is stuck.
1757        let guidance = check_attach_cancel_guidance(&run.events);
1758        assert!(guidance.passed, "{:?}", guidance.violations);
1759        let total_resolved = run.summary.remote_successes
1760            + run.summary.local_fallbacks
1761            + run.summary.proof_refusals
1762            + run.summary.cancellations;
1763        assert_eq!(total_resolved, 20, "every job must resolve");
1764        // With a deliberately low cap the storm check must FAIL (detects the
1765        // over-fallback condition) — proving the checker is not vacuous.
1766        let strict = check_no_unbounded_local_fallback_storm(&run.events, &run.summary, 0.10);
1767        assert!(!strict.passed, "low cap should flag the fallback storm");
1768    }
1769
1770    #[test]
1771    fn proof_jobs_refuse_rather_than_fall_back() {
1772        // Single slot, many proof jobs: all but the first few must refuse, never
1773        // fall back to local.
1774        let workers = vec![StormWorker::healthy("only", 1, 100.0)];
1775        let jobs: Vec<StormJob> = (0..10)
1776            .map(|i| StormJob::build(1000, 1, format!("/p{i}")).with_policy(JobPolicy::Proof))
1777            .collect();
1778        let run = simulate_storm(&workers, &jobs, &cfg());
1779        assert_eq!(run.summary.local_fallbacks, 0, "proof must never fall back");
1780        assert!(run.summary.proof_refusals >= 9, "most proof jobs refuse");
1781        // Refusals carry the proof reason code.
1782        let refused: Vec<_> = run
1783            .events
1784            .iter()
1785            .filter(|e| e.event == event::REFUSED)
1786            .collect();
1787        assert!(!refused.is_empty());
1788        assert!(
1789            refused
1790                .iter()
1791                .all(|e| e.reason_code.as_deref() == Some(PROOF_REFUSAL_REASON))
1792        );
1793        assert!(check_no_unbounded_local_fallback_storm(&run.events, &run.summary, 0.0).passed);
1794    }
1795
1796    #[test]
1797    fn ineligible_workers_never_receive_work() {
1798        let workers = vec![
1799            StormWorker::healthy("good", 2, 100.0),
1800            StormWorker::healthy("bypassed", 8, 200.0)
1801                .with_eligibility(WorkerEligibility::TemporaryBypass),
1802            StormWorker::healthy("disabled", 8, 200.0)
1803                .with_eligibility(WorkerEligibility::AdminDisabled),
1804            StormWorker::healthy("incapable", 8, 200.0)
1805                .with_eligibility(WorkerEligibility::CapabilityInadmissible),
1806        ];
1807        let jobs = build_jobs(20, 500, 1);
1808        let run = simulate_storm(&workers, &jobs, &cfg());
1809        let report = check_no_ineligible_worker_selected(&run.events, &workers);
1810        assert!(report.passed, "{:?}", report.violations);
1811        // Confirm only "good" ever appears as a selected worker.
1812        for ev in &run.events {
1813            if let Some(sel) = &ev.selected_worker {
1814                assert_eq!(sel, "good", "only the eligible worker may be selected");
1815            }
1816        }
1817        // All work landed on the one eligible worker.
1818        assert_eq!(run.summary.per_worker_slot_utilization["bypassed"], 0.0);
1819        assert_eq!(run.summary.per_worker_slot_utilization["disabled"], 0.0);
1820        assert_eq!(run.summary.per_worker_slot_utilization["incapable"], 0.0);
1821    }
1822
1823    #[test]
1824    fn cancellation_before_start_is_clean() {
1825        let workers = vec![StormWorker::healthy("only", 1, 100.0)];
1826        let mut jobs = build_jobs(6, 1000, 1);
1827        // Mark the later (necessarily-queued) jobs to cancel.
1828        for j in jobs.iter_mut().skip(2) {
1829            j.cancel_when_queued = true;
1830        }
1831        let mut c = cfg();
1832        c.cancel_delay_ms = 5;
1833        c.queue_timeout_ms = 100_000;
1834        let run = simulate_storm(&workers, &jobs, &c);
1835        assert!(run.summary.cancellations >= 1, "some jobs cancel");
1836        let guidance = check_attach_cancel_guidance(&run.events);
1837        assert!(guidance.passed, "{:?}", guidance.violations);
1838        // Cancelled events carry guidance.
1839        let cancelled: Vec<_> = run
1840            .events
1841            .iter()
1842            .filter(|e| e.event == event::CANCELLED)
1843            .collect();
1844        assert!(!cancelled.is_empty());
1845        assert!(
1846            cancelled
1847                .iter()
1848                .all(|e| e.detail.as_deref().is_some_and(|d| !d.is_empty()))
1849        );
1850    }
1851
1852    #[test]
1853    fn jsonl_carries_every_required_field() {
1854        let workers = healthy_fleet();
1855        let jobs = build_jobs(12, 500, 1);
1856        let run = simulate_storm(&workers, &jobs, &cfg());
1857        let jsonl = run.to_jsonl().expect("serialize");
1858        assert!(!jsonl.is_empty());
1859        // The started events must expose the load fields.
1860        let started: Vec<&SmokeProfileEvent> = run
1861            .events
1862            .iter()
1863            .filter(|e| e.event == event::STARTED)
1864            .collect();
1865        assert!(!started.is_empty());
1866        for ev in &started {
1867            assert!(ev.local_job_id.is_some(), "local_job_id present");
1868            assert!(ev.remote_job_id.is_some(), "remote_job_id present");
1869            assert!(ev.selected_worker.is_some(), "selected_worker present");
1870            assert!(ev.queue_depth.is_some(), "queue_depth present");
1871            assert!(ev.fallback_decision.is_some(), "fallback_decision present");
1872            assert_eq!(ev.bead_id, "bd-session-history-remediation-ocv9i.10.4");
1873        }
1874        // Each JSONL line is a valid object with run_id + scenario.
1875        for line in jsonl.lines() {
1876            let v: serde_json::Value = serde_json::from_str(line).expect("valid json line");
1877            assert_eq!(v["run_id"], "storm-run-1");
1878            assert_eq!(v["scenario"], "load_storm_control");
1879        }
1880    }
1881
1882    #[test]
1883    fn detector_catches_duplicate_remote_ids() {
1884        // Hand-craft an event stream with a duplicated remote id.
1885        let mk = |local: &str, rid: u64| {
1886            SmokeProfileEvent::started("r", "b", Some("w".into()), SmokeScenario::LoadStormControl)
1887                .with_job_ids(Some(local.into()), Some(rid))
1888        };
1889        let events = vec![mk("rchw-0", 7), mk("rchw-1", 7)];
1890        let report = check_no_duplicate_remote_job_ids(&events);
1891        assert!(!report.passed);
1892        assert_eq!(report.violations.len(), 1);
1893    }
1894
1895    #[test]
1896    fn detector_catches_ineligible_selection() {
1897        let workers = vec![
1898            StormWorker::healthy("good", 2, 100.0),
1899            StormWorker::healthy("disabled", 2, 100.0)
1900                .with_eligibility(WorkerEligibility::AdminDisabled),
1901        ];
1902        // A forged event that selected the disabled worker.
1903        let mut bad = SmokeProfileEvent::started(
1904            "r",
1905            "b",
1906            Some("disabled".into()),
1907            SmokeScenario::LoadStormControl,
1908        );
1909        bad.event = event::STARTED.to_string();
1910        let bad = bad.with_selected_worker(Some("disabled".into()));
1911        let report = check_no_ineligible_worker_selected(&[bad], &workers);
1912        assert!(!report.passed);
1913    }
1914
1915    #[test]
1916    fn detector_catches_stuck_wrapper() {
1917        // A started event with no terminal event => stuck wrapper.
1918        let ev =
1919            SmokeProfileEvent::started("r", "b", Some("w".into()), SmokeScenario::LoadStormControl)
1920                .with_job_ids(Some("rchw-stuck".into()), Some(1));
1921        let report = check_attach_cancel_guidance(&[ev]);
1922        assert!(!report.passed);
1923        assert!(
1924            report
1925                .violations
1926                .iter()
1927                .any(|v| v.contains("stuck wrapper"))
1928        );
1929    }
1930
1931    #[test]
1932    fn deterministic_runs_are_identical() {
1933        let workers = healthy_fleet();
1934        let jobs = build_jobs(40, 700, 1);
1935        let a = simulate_storm(&workers, &jobs, &cfg());
1936        let b = simulate_storm(&workers, &jobs, &cfg());
1937        assert_eq!(a.to_jsonl().unwrap(), b.to_jsonl().unwrap());
1938        assert_eq!(a.summary, b.summary);
1939    }
1940
1941    #[test]
1942    fn summary_serde_roundtrip() {
1943        let workers = healthy_fleet();
1944        let jobs = build_jobs(10, 500, 1);
1945        let run = simulate_storm(&workers, &jobs, &cfg());
1946        let json = serde_json::to_string(&run.summary).unwrap();
1947        let back: StormSummary = serde_json::from_str(&json).unwrap();
1948        assert_eq!(run.summary, back);
1949    }
1950
1951    #[test]
1952    fn varied_jobs_mixed_policies_kinds_slots() {
1953        // Heterogeneous storm: varied runtimes, slot requests, project roots,
1954        // kinds, and proof/fail-open policies (the bead's varied-job criterion).
1955        let workers = healthy_fleet();
1956        let mut jobs = Vec::new();
1957        for i in 0..18 {
1958            let kind = match i % 3 {
1959                0 => JobKind::Build,
1960                1 => JobKind::Test,
1961                _ => JobKind::Check,
1962            };
1963            let policy = if i % 5 == 0 {
1964                JobPolicy::ForceRemote
1965            } else {
1966                JobPolicy::FailOpen
1967            };
1968            jobs.push(
1969                StormJob::build(
1970                    300 + (i as u64 % 4) * 250,
1971                    1 + (i as u32 % 3),
1972                    format!("/r{}", i % 4),
1973                )
1974                .with_kind(kind)
1975                .with_policy(policy),
1976            );
1977        }
1978        let run = simulate_storm(&workers, &jobs, &cfg());
1979        let reports = check_all_invariants(&run, &workers, 1.6, 0.30);
1980        for r in &reports {
1981            assert!(r.passed, "invariant {} failed: {:?}", r.name, r.violations);
1982        }
1983    }
1984
1985    // -----------------------------------------------------------------------
1986    // Live recorder (real-canary outcomes -> StormRun) tests
1987    // -----------------------------------------------------------------------
1988
1989    /// A healthy live run: 30 remote successes spread evenly over a 3-worker
1990    /// fleet, each with a unique remote id, upholds all five invariants — the
1991    /// SAME checkers that gate the simulated storm.
1992    #[test]
1993    fn live_healthy_run_upholds_all_invariants() {
1994        let workers = healthy_fleet();
1995        let outcomes: Vec<LiveJobOutcome> = (0..30)
1996            .map(|i| {
1997                let w = &workers[i % workers.len()].id;
1998                LiveJobOutcome::remote(format!("rchw-{i:04}"), w.clone(), (i + 1) as u64, 1, 500)
1999            })
2000            .collect();
2001        let run = build_live_storm_run(&cfg(), &workers, &outcomes);
2002        let reports = check_all_invariants(&run, &workers, 1.5, 0.25);
2003        for r in &reports {
2004            assert!(r.passed, "invariant {} failed: {:?}", r.name, r.violations);
2005        }
2006        assert_eq!(run.summary.total_jobs, 30);
2007        assert_eq!(run.summary.remote_successes, 30);
2008        assert_eq!(run.summary.local_fallbacks, 0);
2009        // Every schedulable worker carried real work.
2010        for w in &workers {
2011            assert!(
2012                run.summary.per_worker_slot_utilization[&w.id] > 0.0,
2013                "worker {} starved",
2014                w.id
2015            );
2016        }
2017    }
2018
2019    /// A live run with a bounded mix of remote successes and fail-open local
2020    /// fallbacks (under the cap) still passes — including the fallback-storm
2021    /// invariant — and every fallback carries attach/cancel guidance.
2022    #[test]
2023    fn live_mixed_run_with_bounded_fallback_passes() {
2024        let workers = healthy_fleet();
2025        let mut outcomes = Vec::new();
2026        for i in 0..24 {
2027            let w = &workers[i % workers.len()].id;
2028            outcomes.push(LiveJobOutcome::remote(
2029                format!("rchw-{i:04}"),
2030                w.clone(),
2031                (i + 1) as u64,
2032                1,
2033                400,
2034            ));
2035        }
2036        // 4 of 28 (~14%) fell back to local — within a 25% cap.
2037        for i in 24..28 {
2038            outcomes.push(LiveJobOutcome::local_fallback(
2039                format!("rchw-{i:04}"),
2040                1,
2041                400,
2042            ));
2043        }
2044        let run = build_live_storm_run(&cfg(), &workers, &outcomes);
2045        let reports = check_all_invariants(&run, &workers, 1.5, 0.25);
2046        for r in &reports {
2047            assert!(r.passed, "invariant {} failed: {:?}", r.name, r.violations);
2048        }
2049        assert_eq!(run.summary.remote_successes, 24);
2050        assert_eq!(run.summary.local_fallbacks, 4);
2051        // A deliberately strict cap flags the same run (checker is not vacuous).
2052        let strict = check_no_unbounded_local_fallback_storm(&run.events, &run.summary, 0.10);
2053        assert!(!strict.passed, "10% cap must flag a 14% fallback rate");
2054    }
2055
2056    /// The summary faithfully accounts every disposition kind a live run can
2057    /// observe, matching the simulator's `finish()` accounting.
2058    #[test]
2059    fn live_summary_accounts_every_disposition() {
2060        let workers = healthy_fleet();
2061        let outcomes = vec![
2062            LiveJobOutcome::remote("rchw-0000", "w1", 1, 1, 300),
2063            LiveJobOutcome::remote("rchw-0001", "w2", 2, 1, 300),
2064            LiveJobOutcome::local_fallback("rchw-0002", 1, 300),
2065            LiveJobOutcome::local_fallback("rchw-0003", 1, 300)
2066                .queued_for(40, Some(2))
2067                .with_disposition(LiveDisposition::QueueTimeoutFallback),
2068            LiveJobOutcome::local_fallback("rchw-0004", 1, 0)
2069                .with_disposition(LiveDisposition::ProofRefused),
2070            LiveJobOutcome::local_fallback("rchw-0005", 1, 0)
2071                .queued_for(10, Some(1))
2072                .with_disposition(LiveDisposition::Cancelled),
2073        ];
2074        let run = build_live_storm_run(&cfg(), &workers, &outcomes);
2075        assert_eq!(run.summary.total_jobs, 6);
2076        assert_eq!(run.summary.remote_successes, 2);
2077        // LocalFallback + QueueTimeoutFallback both count as local fallbacks.
2078        assert_eq!(run.summary.local_fallbacks, 2);
2079        assert_eq!(run.summary.queue_timeouts, 1);
2080        assert_eq!(run.summary.proof_refusals, 1);
2081        assert_eq!(run.summary.cancellations, 1);
2082        // Guidance + terminal invariants still hold for the mixed bag.
2083        assert!(check_attach_cancel_guidance(&run.events).passed);
2084    }
2085
2086    /// The live JSONL carries every load field on the started events and each
2087    /// line is a valid `load_storm_control` record.
2088    #[test]
2089    fn live_jsonl_carries_load_fields() {
2090        let workers = healthy_fleet();
2091        let outcomes: Vec<LiveJobOutcome> = (0..6)
2092            .map(|i| {
2093                LiveJobOutcome::remote(
2094                    format!("rchw-{i:04}"),
2095                    workers[i % workers.len()].id.clone(),
2096                    (i + 1) as u64,
2097                    1,
2098                    250,
2099                )
2100                .queued_for(5, Some(1))
2101            })
2102            .collect();
2103        let run = build_live_storm_run(&cfg(), &workers, &outcomes);
2104        let jsonl = run.to_jsonl().expect("serialize");
2105        assert!(!jsonl.is_empty());
2106        let started: Vec<&SmokeProfileEvent> = run
2107            .events
2108            .iter()
2109            .filter(|e| e.event == event::STARTED)
2110            .collect();
2111        assert_eq!(started.len(), 6);
2112        for ev in &started {
2113            assert!(ev.local_job_id.is_some());
2114            assert!(ev.remote_job_id.is_some());
2115            assert!(ev.selected_worker.is_some());
2116            assert!(ev.queue_depth.is_some());
2117            assert_eq!(ev.fallback_decision.as_deref(), Some(decision::REMOTE));
2118        }
2119        for line in jsonl.lines() {
2120            let v: serde_json::Value = serde_json::from_str(line).expect("valid json line");
2121            assert_eq!(v["scenario"], "load_storm_control");
2122        }
2123    }
2124
2125    /// A live run that (incorrectly) reports work on an ineligible worker is
2126    /// caught — the live path is held to the same eligibility invariant.
2127    #[test]
2128    fn live_detector_catches_ineligible_selection() {
2129        let workers = vec![
2130            StormWorker::healthy("good", 4, 100.0),
2131            StormWorker::healthy("disabled", 4, 100.0)
2132                .with_eligibility(WorkerEligibility::AdminDisabled),
2133        ];
2134        // A forged outcome that landed on the admin-disabled worker.
2135        let outcomes = vec![
2136            LiveJobOutcome::remote("rchw-0000", "good", 1, 1, 300),
2137            LiveJobOutcome::remote("rchw-0001", "disabled", 2, 1, 300),
2138        ];
2139        let run = build_live_storm_run(&cfg(), &workers, &outcomes);
2140        let report = check_no_ineligible_worker_selected(&run.events, &workers);
2141        assert!(!report.passed);
2142        assert!(report.violations.iter().any(|v| v.contains("disabled")));
2143    }
2144
2145    /// Two live outcomes that claim the same remote build id are caught.
2146    #[test]
2147    fn live_detector_catches_duplicate_remote_ids() {
2148        let workers = healthy_fleet();
2149        let outcomes = vec![
2150            LiveJobOutcome::remote("rchw-0000", "w1", 7, 1, 300),
2151            LiveJobOutcome::remote("rchw-0001", "w2", 7, 1, 300),
2152        ];
2153        let run = build_live_storm_run(&cfg(), &workers, &outcomes);
2154        let report = check_no_duplicate_remote_job_ids(&run.events);
2155        assert!(!report.passed);
2156    }
2157
2158    /// A live proof-refusal outcome carries the proof reason code and refuses
2159    /// rather than counting as a fallback.
2160    #[test]
2161    fn live_proof_refusal_carries_reason_code() {
2162        let workers = healthy_fleet();
2163        let outcomes = vec![
2164            LiveJobOutcome::local_fallback("rchw-0000", 1, 0)
2165                .with_disposition(LiveDisposition::ProofRefused),
2166        ];
2167        let run = build_live_storm_run(&cfg(), &workers, &outcomes);
2168        assert_eq!(run.summary.proof_refusals, 1);
2169        assert_eq!(run.summary.local_fallbacks, 0);
2170        let refused: Vec<&SmokeProfileEvent> = run
2171            .events
2172            .iter()
2173            .filter(|e| e.event == event::REFUSED)
2174            .collect();
2175        assert_eq!(refused.len(), 1);
2176        assert_eq!(
2177            refused[0].reason_code.as_deref(),
2178            Some(PROOF_REFUSAL_REASON)
2179        );
2180        // Fallback-storm invariant holds even at a zero cap (no fallbacks).
2181        assert!(check_no_unbounded_local_fallback_storm(&run.events, &run.summary, 0.0).passed);
2182    }
2183
2184    /// The live builder and the simulator emit the SAME event shape for a single
2185    /// remote success (started + passed), proving the two paths are in lockstep.
2186    #[test]
2187    fn live_builder_matches_simulator_remote_event_shape() {
2188        let workers = vec![StormWorker::healthy("w1", 4, 100.0)];
2189        // Simulator: one job placed remotely.
2190        let jobs = vec![StormJob::build(500, 1, "/p0").with_kind(JobKind::Check)];
2191        let sim = simulate_storm(&workers, &jobs, &cfg());
2192        let sim_started = sim
2193            .events
2194            .iter()
2195            .find(|e| e.event == event::STARTED)
2196            .expect("sim started");
2197        // Live: the same logical outcome.
2198        let outcomes = vec![LiveJobOutcome::remote(
2199            sim.events
2200                .iter()
2201                .find_map(|e| e.local_job_id.clone())
2202                .unwrap(),
2203            "w1",
2204            1,
2205            1,
2206            500,
2207        )];
2208        let live = build_live_storm_run(&cfg(), &workers, &outcomes);
2209        let live_started = live
2210            .events
2211            .iter()
2212            .find(|e| e.event == event::STARTED)
2213            .expect("live started");
2214        // Same scenario token, decision, status, fingerprint, selected worker.
2215        assert_eq!(sim_started.scenario, live_started.scenario);
2216        assert_eq!(sim_started.status, live_started.status);
2217        assert_eq!(
2218            sim_started.fallback_decision,
2219            live_started.fallback_decision
2220        );
2221        assert_eq!(
2222            sim_started.command_fingerprint,
2223            live_started.command_fingerprint
2224        );
2225        assert_eq!(sim_started.selected_worker, live_started.selected_worker);
2226        assert_eq!(sim_started.detail, live_started.detail);
2227    }
2228}