Skip to main content

degenbot_workers/
slot.rs

1//! Worker-slot states + the T1–T9 legal-transition table (design doc
2//! §3.2–§3.3).
3//!
4//! A worker **slot** is a persistent host resource (a thread/booted task);
5//! its `role` is the scheduling unit. The FSM is a pure, total function:
6//! `transition(from, t, ctx) -> Result<SlotState, RejectedTransition>`. Any
7//! move not covered by a T-row is a loud, typed rejection — never a silent
8//! re-wrap (§3.3's illegal list).
9
10use crate::role::{CordonClass, WorkerRole};
11
12/// Pin key = the LPT bin id (job→bin affinity; §3.4).
13pub type PinKey = u64;
14/// Unit id per role queue.
15pub type UnitId = u64;
16
17/// Exactly one merge pin exists; its key is fixed (T4's pipe-drain handoff
18/// is verified by the host, the table only sees the key).
19pub const MERGE_PIN_KEY: PinKey = 0;
20
21/// State of one worker slot (design doc §3.2). `Cordoned` is a PROCESS-level
22/// posture, deliberately NOT a slot state — it gates transitions (§6).
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
24pub enum SlotState {
25    /// Parked; no lease; zero CPU.
26    Idle,
27    /// Dispatch granted a unit of work (or a pin claim; `key` is the pin
28    /// key for pinnable roles).
29    Leased {
30        role: WorkerRole,
31        key: Option<PinKey>,
32    },
33    /// Executing the unit; `key` survives from the lease's pin claim.
34    Running {
35        role: WorkerRole,
36        key: Option<PinKey>,
37    },
38    /// Steady lease held ACROSS cycles (Solver per bin, Merge exactly one);
39    /// warm arenas intact.
40    Pinned { role: WorkerRole, key: PinKey },
41    /// Finishing its in-flight unit under shed/cordon; takes nothing new.
42    Draining { role: WorkerRole },
43}
44
45impl SlotState {
46    /// The role this slot is currently leased for, if any.
47    #[must_use]
48    pub const fn role(self) -> Option<WorkerRole> {
49        match self {
50            Self::Idle => None,
51            Self::Leased { role, .. }
52            | Self::Running { role, .. }
53            | Self::Pinned { role, .. }
54            | Self::Draining { role } => Some(role),
55        }
56    }
57
58    /// Whether this state holds an in-flight unit of work (T7's shed class).
59    #[must_use]
60    pub const fn holds_in_flight(self) -> bool {
61        matches!(
62            self,
63            Self::Leased { .. } | Self::Running { .. } | Self::Pinned { .. }
64        )
65    }
66}
67
68/// A transition attempt (design doc §3.3 rows T1..T9).
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub enum Transition {
71    /// T1: dispatch granted a unit of work (or a pin claim; pinnable roles
72    /// must claim their key here).
73    Lease {
74        role: WorkerRole,
75        key: Option<PinKey>,
76    },
77    /// T2/T6: the worker dequeues the unit / claims the pin (T2) or takes
78    /// the next cycle's unit under an existing pin (T6 — same key by
79    /// construction: the pin IS the key, host-dispatched).
80    Start { unit: UnitId },
81    /// T3/T4: unit complete; a pinnable role converts to a warm pin.
82    CompleteToPinned,
83    /// T5: unit complete for pooled roles; back to the idle set.
84    CompleteToIdle,
85    /// T7: cordon onset on a deferrable role, or fleet resize mid-cycle —
86    /// the in-flight unit always completes.
87    BeginDraining,
88    /// T8: in-flight unit done; nothing taken.
89    DrainComplete,
90    /// T9: explicit rebalance (quota change / config override) — epoch
91    /// boundary only, never mid-cycle.
92    ReleasePin,
93}
94
95/// Guards the table consults; the host and the posture FSM supply them.
96#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
97pub struct TransitionContext {
98    /// True at an epoch boundary (quota re-detection / config override);
99    /// T9 is illegal anywhere else.
100    pub at_epoch_boundary: bool,
101    /// True when the posture admits lease intake for the role (cordon
102    /// blocks deferrable roles entirely; sim-pool roles are only
103    /// intake-FLOORED, so they keep passing here).
104    pub posture_admits_role: bool,
105}
106
107/// A rejected transition: loud by construction, typed for the conformance
108/// stub to assert exactly WHICH row was violated.
109#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
110#[error("rejected transition {from:?} --{transition:?}--> ({reason})")]
111pub struct RejectedTransition {
112    /// The state the move was attempted from.
113    pub from: SlotState,
114    /// The attempted transition.
115    pub transition: Transition,
116    /// Which part of the table rejected it.
117    pub reason: RejectionReason,
118}
119
120/// Why a transition left the legal table (design doc §3.3).
121#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
122pub enum RejectionReason {
123    /// T1 grant edge denied by the posture (cordon holds deferrable lease
124    /// intake; already-granted units always complete — T2/T6 are unguarded).
125    #[error("posture blocks new lease intake for this role")]
126    PostureBlocksIntake,
127    /// T9 outside an epoch boundary: mid-cycle pin mutation.
128    #[error("pin release requires an epoch boundary (T9)")]
129    MidCyclePin,
130    /// T3: a completed Solver walk carries no pin key.
131    #[error("Solver completion carries no pin key (T3)")]
132    MissingPinKey,
133    /// T1: a pinnable role's lease claim carried no pin key.
134    #[error("pinnable-role lease claim carries no pin key (T1)")]
135    ClaimMissingKey,
136    /// T1: a Merge claim does not carry the single merge pin key.
137    #[error("Merge pin claims must carry MERGE_PIN_KEY (T1/T4: exactly one merge pin)")]
138    MergeKeyMismatch,
139    /// The from/transition pair is off the table's end (§3.3 illegal list:
140    /// `Idle → Running`, mid-unit `Running → Idle` for pinned roles,
141    /// re-keying a pin without T9, `Draining → Leased`, ...).
142    #[error("no legal row (T1-T9) covers this from/transition pair")]
143    NoLegalRow,
144}
145
146/// The T1–T9 table: `from ─transition→ to`, or a loud typed rejection.
147///
148/// # Errors
149/// A [`RejectedTransition`] whenever `(from, t)` is off the legal table or
150/// a guard denies the row.
151#[must_use = "a rejected transition is a conformance event, not a suggestion"]
152pub fn transition(
153    from: SlotState,
154    t: Transition,
155    ctx: TransitionContext,
156) -> Result<SlotState, RejectedTransition> {
157    let reject = |reason: RejectionReason| {
158        Err::<SlotState, RejectedTransition>(RejectedTransition {
159            from,
160            transition: t,
161            reason,
162        })
163    };
164    // Posture guard on the T1 INTAKE edge: §3.3 — leasing a cordon-
165    // deferrable role under cordon is illegal (sim-pool roles only floor
166    // their intake, so they pass). The START edges (T2/T6) are
167    // deliberately unguarded: intake admitted before the cordon always
168    // completes (the §3.3 invariant T7 encodes) — it just takes nothing
169    // new while the fleet is cordoned.
170    let posture_guards_role = |role: WorkerRole| {
171        (role.cordon_class() == CordonClass::Deferrable && !ctx.posture_admits_role)
172            .then_some(RejectionReason::PostureBlocksIntake)
173    };
174
175    match (from, t) {
176        // T1: Idle → Leased(r) — dispatchable ∧ capacity ∧ posture admits.
177        (SlotState::Idle, Transition::Lease { role, key }) => {
178            if let Some(reason) = posture_guards_role(role) {
179                return reject(reason);
180            }
181            if role.is_pinnable() && key.is_none() {
182                return reject(RejectionReason::ClaimMissingKey);
183            }
184            if role == WorkerRole::Merge && key != Some(MERGE_PIN_KEY) {
185                return reject(RejectionReason::MergeKeyMismatch);
186            }
187            Ok(SlotState::Leased { role, key })
188        }
189        // T2: Leased(r) → Running(r, unit); the claim key carries through.
190        // No posture re-check: the posture gates INTAKE (T1) and sheds
191        // mid-cycle (T7). A grant admitted before a cordon ALWAYS starts
192        // and completes — rejecting Start here would strand the Leased
193        // slot (no exit but the shed) and trip the loud stranded-pipe
194        // abort (prod flap-window regression, unit 1316).
195        (SlotState::Leased { role, key }, Transition::Start { .. }) => {
196            Ok(SlotState::Running { role, key })
197        }
198        // T6 (same Start constructor): Pinned(r, k) → Running(r, k) — the
199        // pin IS the key, the host only dispatches that key here. No
200        // posture re-check (cordon never sheds a pin; the continuation
201        // runs — §6: cited pins are cycle-critical work).
202        (SlotState::Pinned { role, key }, Transition::Start { .. }) => Ok(SlotState::Running {
203            role,
204            key: Some(key),
205        }),
206        // T3/T4: unit complete; pinnable roles convert to a warm pin.
207        (SlotState::Running { role, key }, Transition::CompleteToPinned) => {
208            match role {
209                WorkerRole::Solver => match key {
210                    Some(k) => Ok(SlotState::Pinned { role, key: k }),
211                    None => reject(RejectionReason::MissingPinKey),
212                },
213                // T4: exactly one merge pin, at THE merge key. The pipe-drain
214                // handoff verification is host bookkeeping (§3.3 T4).
215                WorkerRole::Merge => Ok(SlotState::Pinned {
216                    role,
217                    key: MERGE_PIN_KEY,
218                }),
219                _ => reject(RejectionReason::NoLegalRow),
220            }
221        }
222        // T5 + T8 (one arm — identical bodies, distinct semantics):
223        // T5: pooled roles complete back to the idle set; T8: a drained
224        // in-flight unit finishes — nothing taken, slot returns to Idle.
225        (
226            SlotState::Running {
227                role: WorkerRole::SimDriver | WorkerRole::Resolve | WorkerRole::PoolStateUpdater,
228                ..
229            },
230            Transition::CompleteToIdle,
231        )
232        | (SlotState::Draining { .. }, Transition::DrainComplete) => Ok(SlotState::Idle),
233        // T7: cordon onset / resize mid-cycle — the unit always completes,
234        // so draining takes nothing new.
235        (
236            SlotState::Running { role, .. } | SlotState::Leased { role, .. },
237            Transition::BeginDraining,
238        ) => Ok(SlotState::Draining { role }),
239        // T9: explicit rebalance — epoch boundary only, never mid-cycle.
240        (SlotState::Pinned { .. }, Transition::ReleasePin) => {
241            if ctx.at_epoch_boundary {
242                Ok(SlotState::Idle)
243            } else {
244                reject(RejectionReason::MidCyclePin)
245            }
246        }
247        // Everything else is off the legal table.
248        _ => reject(RejectionReason::NoLegalRow),
249    }
250}
251
252#[cfg(test)]
253#[expect(clippy::expect_used)]
254mod tests {
255    use super::*;
256    use crate::role::ALL_ROLES;
257
258    const ADMITS: TransitionContext = TransitionContext {
259        at_epoch_boundary: false,
260        posture_admits_role: true,
261    };
262
263    const BLOCKS: TransitionContext = TransitionContext {
264        at_epoch_boundary: false,
265        posture_admits_role: false,
266    };
267
268    const fn admits(epochs: bool) -> TransitionContext {
269        TransitionContext {
270            at_epoch_boundary: epochs,
271            posture_admits_role: true,
272        }
273    }
274
275    #[test]
276    fn t1_idle_leases_a_pooled_role() {
277        for role in [
278            WorkerRole::SimDriver,
279            WorkerRole::Resolve,
280            WorkerRole::PoolStateUpdater,
281        ] {
282            let to = transition(
283                SlotState::Idle,
284                Transition::Lease { role, key: None },
285                ADMITS,
286            )
287            .expect("T1 pooled lease");
288            assert_eq!(
289                to,
290                SlotState::Leased { role, key: None },
291                "T1 must lease pooled roles"
292            );
293        }
294    }
295
296    #[test]
297    fn t1_pin_claims_carry_keys() {
298        let solver = transition(
299            SlotState::Idle,
300            Transition::Lease {
301                role: WorkerRole::Solver,
302                key: Some(2),
303            },
304            ADMITS,
305        )
306        .expect("T1 solver pin claim");
307        assert_eq!(
308            solver,
309            SlotState::Leased {
310                role: WorkerRole::Solver,
311                key: Some(2)
312            }
313        );
314        let merge = transition(
315            SlotState::Idle,
316            Transition::Lease {
317                role: WorkerRole::Merge,
318                key: Some(MERGE_PIN_KEY),
319            },
320            ADMITS,
321        )
322        .expect("T1 merge pin claim");
323        assert_eq!(
324            merge,
325            SlotState::Leased {
326                role: WorkerRole::Merge,
327                key: Some(MERGE_PIN_KEY)
328            }
329        );
330    }
331
332    #[test]
333    fn t1_rejects_keyless_pinnable_claims_and_bad_merge_keys() {
334        let missing = transition(
335            SlotState::Idle,
336            Transition::Lease {
337                role: WorkerRole::Solver,
338                key: None,
339            },
340            ADMITS,
341        )
342        .expect_err("pin claim must carry its key");
343        assert_eq!(missing.reason, RejectionReason::ClaimMissingKey);
344
345        let wrong = transition(
346            SlotState::Idle,
347            Transition::Lease {
348                role: WorkerRole::Merge,
349                key: Some(9),
350            },
351            ADMITS,
352        )
353        .expect_err("merge claims must carry MERGE_PIN_KEY");
354        assert_eq!(wrong.reason, RejectionReason::MergeKeyMismatch);
355    }
356
357    #[test]
358    fn t1_is_blocked_by_a_cordoned_posture_for_deferrable_roles() {
359        for role in [
360            WorkerRole::PoolStateUpdater,
361            WorkerRole::Registrar,
362            WorkerRole::Verifier,
363        ] {
364            let rejected = transition(
365                SlotState::Idle,
366                Transition::Lease { role, key: None },
367                BLOCKS,
368            )
369            .expect_err("cordon blocks deferrable intake");
370            assert_eq!(rejected.reason, RejectionReason::PostureBlocksIntake);
371        }
372        // Never / SimPool classes still lease (sim is intake-floored, not
373        // blocked; the floor is host bookkeeping, not an FSM row).
374        for (role, key) in [
375            (WorkerRole::SimDriver, None),
376            (WorkerRole::Resolve, None),
377            (WorkerRole::Solver, Some(1)),
378            (WorkerRole::Merge, Some(MERGE_PIN_KEY)),
379            (WorkerRole::Submitter, None),
380        ] {
381            transition(SlotState::Idle, Transition::Lease { role, key }, BLOCKS)
382                .map(|_| ())
383                .expect("role leases through cordon");
384        }
385    }
386
387    #[test]
388    fn t2_leased_starts_running_preserving_the_claim_key() {
389        let running = transition(
390            SlotState::Leased {
391                role: WorkerRole::SimDriver,
392                key: None,
393            },
394            Transition::Start { unit: 7 },
395            ADMITS,
396        )
397        .expect("T2");
398        assert_eq!(
399            running,
400            SlotState::Running {
401                role: WorkerRole::SimDriver,
402                key: None
403            }
404        );
405
406        let pinned_walk = transition(
407            SlotState::Leased {
408                role: WorkerRole::Solver,
409                key: Some(3),
410            },
411            Transition::Start { unit: 11 },
412            ADMITS,
413        )
414        .expect("T2 pin-claim start");
415        assert_eq!(
416            pinned_walk,
417            SlotState::Running {
418                role: WorkerRole::Solver,
419                key: Some(3)
420            },
421            "T2 must preserve the claimed pin key into Running"
422        );
423    }
424
425    #[test]
426    fn t2_starts_a_granted_unit_even_under_cordon() {
427        // The posture gates INTAKE (T1), never the start of an already-
428        // granted unit: a cordon publishing between the T1 lease and the
429        // T2 start must not strand the grant (its only other exit is the
430        // T7 shed). Flap-window regression (prod: `grant start (T2)`
431        // PostureBlocksIntake → stranded intake receipt pipe → abort).
432        let running = transition(
433            SlotState::Leased {
434                role: WorkerRole::Registrar,
435                key: None,
436            },
437            Transition::Start { unit: 1 },
438            BLOCKS,
439        )
440        .expect("a granted unit always starts — cordon sheds, never strands");
441        assert_eq!(
442            running,
443            SlotState::Running {
444                role: WorkerRole::Registrar,
445                key: None
446            }
447        );
448    }
449
450    #[test]
451    fn t3_solver_walk_pins_to_its_bin_key() {
452        let pinned = transition(
453            SlotState::Running {
454                role: WorkerRole::Solver,
455                key: Some(5),
456            },
457            Transition::CompleteToPinned,
458            ADMITS,
459        )
460        .expect("T3");
461        assert_eq!(
462            pinned,
463            SlotState::Pinned {
464                role: WorkerRole::Solver,
465                key: 5
466            }
467        );
468    }
469
470    #[test]
471    fn t3_rejects_a_keyless_solver_completion() {
472        let rejected = transition(
473            SlotState::Running {
474                role: WorkerRole::Solver,
475                key: None,
476            },
477            Transition::CompleteToPinned,
478            ADMITS,
479        )
480        .expect_err("T3 requires a pin key");
481        assert_eq!(rejected.reason, RejectionReason::MissingPinKey);
482    }
483
484    #[test]
485    fn t4_merge_pins_to_the_single_merge_key() {
486        let pinned = transition(
487            SlotState::Running {
488                role: WorkerRole::Merge,
489                key: Some(MERGE_PIN_KEY),
490            },
491            Transition::CompleteToPinned,
492            ADMITS,
493        )
494        .expect("T4");
495        assert_eq!(
496            pinned,
497            SlotState::Pinned {
498                role: WorkerRole::Merge,
499                key: MERGE_PIN_KEY
500            }
501        );
502    }
503
504    #[test]
505    fn t4_rejects_re_pinning_another_role() {
506        // Only the pinnable roles convert Running → Pinned; a complete on
507        // any other role must go to Idle (T5), never to a pin.
508        for role in [
509            WorkerRole::SimDriver,
510            WorkerRole::Resolve,
511            WorkerRole::Registrar,
512        ] {
513            let rejected = transition(
514                SlotState::Running { role, key: None },
515                Transition::CompleteToPinned,
516                ADMITS,
517            )
518            .expect_err("only pinnable roles reach Pinned");
519            assert_eq!(rejected.reason, RejectionReason::NoLegalRow, "{role:?}");
520        }
521    }
522
523    #[test]
524    fn t5_pooled_roles_return_to_idle() {
525        for role in [
526            WorkerRole::SimDriver,
527            WorkerRole::Resolve,
528            WorkerRole::PoolStateUpdater,
529        ] {
530            let idle = transition(
531                SlotState::Running { role, key: None },
532                Transition::CompleteToIdle,
533                ADMITS,
534            )
535            .expect("T5");
536            assert_eq!(
537                idle,
538                SlotState::Idle,
539                "{role:?} must return to the pooled set"
540            );
541        }
542    }
543
544    #[test]
545    fn t5_rejects_running_to_idle_for_pinned_roles() {
546        // §3.3 illegal: `Running → Idle` mid-unit for the pinned roles —
547        // Solver must complete to Pinned (T3), Merge to its pin (T4).
548        for (role, key) in [
549            (WorkerRole::Solver, Some(1)),
550            (WorkerRole::Merge, Some(MERGE_PIN_KEY)),
551        ] {
552            let rejected = transition(
553                SlotState::Running { role, key },
554                Transition::CompleteToIdle,
555                ADMITS,
556            )
557            .expect_err("pinned roles never drop straight to Idle");
558            assert_eq!(rejected.reason, RejectionReason::NoLegalRow);
559        }
560    }
561
562    #[test]
563    fn t6_pinned_slots_take_the_next_cycle_unit_same_key() {
564        let running = transition(
565            SlotState::Pinned {
566                role: WorkerRole::Solver,
567                key: 5,
568            },
569            Transition::Start { unit: 42 },
570            ADMITS,
571        )
572        .expect("T6");
573        assert_eq!(
574            running,
575            SlotState::Running {
576                role: WorkerRole::Solver,
577                key: Some(5)
578            },
579            "T6 keeps the pin key: the pin IS the key"
580        );
581    }
582
583    #[test]
584    fn t6_is_blocked_by_cordon_for_deferrable_pins() {
585        // Declared-deferrable pins (future roles) must not re-enter Running
586        // under cordon; the v1 pins are cordon-invariant classes.
587        let fake_deferrable_pin_ctx = BLOCKS;
588        let rejected = transition(
589            SlotState::Pinned {
590                role: WorkerRole::Solver,
591                key: 1,
592            },
593            Transition::Start { unit: 2 },
594            fake_deferrable_pin_ctx,
595        );
596        // v1 pin classes are Never: T6 admits them even under cordon.
597        assert!(
598            rejected.is_ok(),
599            "Solver pin continuation is cordon-invariant"
600        );
601    }
602
603    #[test]
604    fn t7_running_units_drain_under_cordon() {
605        for role in [
606            WorkerRole::SimDriver,
607            WorkerRole::Solver,
608            WorkerRole::Merge,
609            WorkerRole::PoolStateUpdater,
610        ] {
611            let draining = transition(
612                SlotState::Running { role, key: None },
613                Transition::BeginDraining,
614                ADMITS,
615            )
616            .expect("T7");
617            assert_eq!(draining, SlotState::Draining { role }, "{role:?}");
618        }
619    }
620
621    #[test]
622    fn t8_draining_completes_to_idle() {
623        let idle = transition(
624            SlotState::Draining {
625                role: WorkerRole::SimDriver,
626            },
627            Transition::DrainComplete,
628            ADMITS,
629        )
630        .expect("T8");
631        assert_eq!(idle, SlotState::Idle);
632    }
633
634    #[test]
635    fn t8_draining_rejects_new_work() {
636        // §3.3 illegal: Draining takes nothing new — no Start, no Lease.
637        for t in [
638            Transition::Start { unit: 1 },
639            Transition::Lease {
640                role: WorkerRole::SimDriver,
641                key: None,
642            },
643        ] {
644            let rejected = transition(
645                SlotState::Draining {
646                    role: WorkerRole::SimDriver,
647                },
648                t,
649                ADMITS,
650            )
651            .expect_err("Draining takes nothing new");
652            assert_eq!(rejected.reason, RejectionReason::NoLegalRow);
653        }
654    }
655
656    #[test]
657    fn t9_pin_release_is_epoch_boundary_only() {
658        let ok = transition(
659            SlotState::Pinned {
660                role: WorkerRole::Solver,
661                key: 5,
662            },
663            Transition::ReleasePin,
664            admits(true),
665        )
666        .expect("T9 at epoch boundary");
667        assert_eq!(ok, SlotState::Idle);
668
669        let rejected = transition(
670            SlotState::Pinned {
671                role: WorkerRole::Solver,
672                key: 5,
673            },
674            Transition::ReleasePin,
675            admits(false),
676        )
677        .expect_err("T9 mid-cycle is illegal");
678        assert_eq!(rejected.reason, RejectionReason::MidCyclePin);
679    }
680
681    #[test]
682    fn t9_never_re_keys_in_place() {
683        // §3.3 illegal: Pinned(Solver, k) → Pinned(Solver, k') — re-keying
684        // is T9 then T1. There is no CompleteToPinned from Pinned, and any
685        // other move from Pinned must go through Start (T6) or ReleasePin.
686        let rejected = transition(
687            SlotState::Pinned {
688                role: WorkerRole::Solver,
689                key: 1,
690            },
691            Transition::CompleteToPinned,
692            ADMITS,
693        )
694        .expect_err("re-keying in place is illegal");
695        assert_eq!(rejected.reason, RejectionReason::NoLegalRow);
696    }
697
698    #[test]
699    fn illegal_idle_to_running_is_rejected() {
700        // §3.3: lease required — there is no direct Idle → Running.
701        for t in [
702            Transition::Start { unit: 1 },
703            Transition::CompleteToPinned,
704            Transition::CompleteToIdle,
705            Transition::BeginDraining,
706            Transition::DrainComplete,
707            Transition::ReleasePin,
708        ] {
709            let rejected =
710                transition(SlotState::Idle, t, admits(true)).expect_err("Idle only moves via T1");
711            assert_eq!(rejected.reason, RejectionReason::NoLegalRow, "{t:?}");
712        }
713    }
714
715    #[test]
716    fn illegal_non_t1_moves_from_idle_and_pinned_and_draining() {
717        for from in [
718            SlotState::Idle,
719            SlotState::Draining {
720                role: WorkerRole::Resolve,
721            },
722        ] {
723            for t in [
724                Transition::CompleteToPinned,
725                Transition::CompleteToIdle,
726                Transition::ReleasePin,
727            ] {
728                let rejected = transition(from, t, admits(true))
729                    .expect_err("coverage: no row allows {t:?} from {from:?}");
730                assert_eq!(rejected.reason, RejectionReason::NoLegalRow);
731            }
732        }
733        for t in [
734            Transition::CompleteToIdle,
735            Transition::BeginDraining,
736            Transition::DrainComplete,
737        ] {
738            let rejected = transition(
739                SlotState::Pinned {
740                    role: WorkerRole::Merge,
741                    key: MERGE_PIN_KEY,
742                },
743                t,
744                admits(true),
745            )
746            .expect_err("pinned merges only Start (T6) or ReleasePin (T9)");
747            assert_eq!(rejected.reason, RejectionReason::NoLegalRow, "{t:?}");
748        }
749    }
750
751    #[test]
752    fn role_and_in_flight_projections_agree() {
753        assert_eq!(SlotState::Idle.role(), None);
754        for role in ALL_ROLES {
755            for state in [
756                SlotState::Leased { role, key: None },
757                SlotState::Running { role, key: None },
758                SlotState::Pinned { role, key: 0 },
759                SlotState::Draining { role },
760            ] {
761                assert_eq!(state.role(), Some(role));
762            }
763        }
764        assert!(SlotState::Running {
765            role: WorkerRole::Solver,
766            key: None
767        }
768        .holds_in_flight());
769        assert!(!SlotState::Idle.holds_in_flight());
770        assert!(!SlotState::Draining {
771            role: WorkerRole::Solver
772        }
773        .holds_in_flight());
774    }
775}