Skip to main content

degenbot_workers/
role.rs

1//! `WorkerRole` — the sized role enum and its policy attributes (ADR-042
2//! design doc §3.1).
3//!
4//! Mirrors `degenbot-bot`'s `stage_handlers::ALL_STAGES` idiom: a sized
5//! `const` table plus a u8 index used by the conformance stub. Adding a
6//! role variant without declaring its `ALL_ROLES` position is a compile
7//! error, and the conformance harness indexes roles by position, so any
8//! new role or re-ordering fails the harness loudly.
9
10/// Class deciding what cordon does to a role's lease intake (design doc §6).
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
12pub enum CordonClass {
13    /// Never cordoned: pinned latency-critical work (`Solver`, `Merge`,
14    /// the never-deferrable `Submitter`) and pooled resolve work.
15    Never,
16    /// I/O-dominant pooled sims: in-flight sims are never cancelled, but
17    /// new intake is floored at half the slot cap while cordoned.
18    SimPool,
19    /// Declared background roles (`PoolStateUpdater`, `Registrar`,
20    /// `Verifier`): no new leases while cordoned.
21    Deferrable,
22}
23
24/// Work a fleet worker slot can be leased for (design doc §3.1). The first
25/// four variants are v1-active; the remainder are **declared now, hosted
26/// later** — adding them later must be an entry here plus a posture/census
27/// row, not a redesign.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
29pub enum WorkerRole {
30    /// One persistent pin per LPT bin (RAYPAR T3). Pinned by bin key; warm
31    /// L1/L2 + allocator arenas survive across cycles.
32    Solver,
33    /// Pipelined inline sims behind the slot pool. Absorbs `SimSlots`'
34    /// drivers and the per-cycle `arb-sim-*` spawn (the first pooling
35    /// candidate). I/O-dominant: cordon throttles intake, never cancels.
36    SimDriver,
37    /// Path resolution (the resolve chunks; the retired rayon partition
38    /// pool's consumers now run on scoped std threads). I/O-adjacent CPU.
39    Resolve,
40    /// The detached merge sidecar: drains the result pipe so per-path sends
41    /// land in a pipe somebody drinks from. Pinned (exactly one, T4).
42    Merge,
43    /// Pool-state update application (deferrable: sheds artifact-free).
44    /// Hosted (PRG-3): the registration intake station — bounded per-role
45    /// unit pool of keyed build units, behind Solver precedence.
46    PoolStateUpdater,
47    /// Registration verify-lifecycle driver (deferrable). Declared now.
48    Registrar,
49    /// Published-edge verification reads (deferrable). Declared now.
50    Verifier,
51    /// Settlement submission delivery (never deferrable: latency-critical).
52    /// Declared now.
53    Submitter,
54}
55
56/// The sized role table — position is the conformance stub's u8 script
57/// encoding (v1-active roles first, then declared-not-active exactly as
58/// in the ADR's decision table).
59pub const ALL_ROLES: [WorkerRole; 8] = [
60    WorkerRole::Solver,
61    WorkerRole::SimDriver,
62    WorkerRole::Resolve,
63    WorkerRole::Merge,
64    WorkerRole::PoolStateUpdater,
65    WorkerRole::Registrar,
66    WorkerRole::Verifier,
67    WorkerRole::Submitter,
68];
69
70/// The v1-active prefix of [`ALL_ROLES`] (ADR-042 Q2: Solver, `SimDriver`,
71/// Resolve, Merge; PRG-3 adds `PoolStateUpdater` — the registration
72/// intake station. The rest are declared gating only).
73pub const V1_ACTIVE_ROLES: [WorkerRole; 5] = [
74    WorkerRole::Solver,
75    WorkerRole::SimDriver,
76    WorkerRole::Resolve,
77    WorkerRole::Merge,
78    WorkerRole::PoolStateUpdater,
79];
80
81impl WorkerRole {
82    /// What cordon does to this role's lease intake (design doc §6):
83    /// `Never` roles lease freely, `SimPool` intake is floored, and
84    /// `Deferrable` intake is held entirely while cordoned.
85    #[must_use]
86    pub const fn cordon_class(self) -> CordonClass {
87        match self {
88            Self::SimDriver => CordonClass::SimPool,
89            Self::PoolStateUpdater | Self::Registrar | Self::Verifier => CordonClass::Deferrable,
90            Self::Solver | Self::Resolve | Self::Merge | Self::Submitter => CordonClass::Never,
91        }
92    }
93
94    /// True for the hosted roles (ADR-042 Q2 + PRG-3); false for
95    /// declared-not-active.
96    #[must_use]
97    pub const fn v1_active(self) -> bool {
98        matches!(
99            self,
100            Self::Solver | Self::SimDriver | Self::Resolve | Self::Merge | Self::PoolStateUpdater
101        )
102    }
103
104    /// Roles whose steady lease is a pin held across cycles (T3/T4).
105    #[must_use]
106    pub const fn is_pinnable(self) -> bool {
107        matches!(self, Self::Solver | Self::Merge)
108    }
109
110    /// Worker-census `resource` id (small closed set; extends the ids
111    /// documented in `degenbot_core::worker_census`).
112    #[must_use]
113    pub const fn census_resource(self) -> &'static str {
114        match self {
115            Self::Solver => "fleet_solver_slots",
116            Self::SimDriver => "fleet_simdriver_slots",
117            Self::Resolve => "fleet_resolve_slots",
118            Self::Merge => "fleet_merge_slots",
119            Self::PoolStateUpdater => "fleet_pool_state_updater_slots",
120            Self::Registrar => "fleet_registrar_slots",
121            Self::Verifier => "fleet_verifier_slots",
122            Self::Submitter => "fleet_submitter_slots",
123        }
124    }
125
126    /// Distinct, greppable thread-name pattern (GOQWCL rule: never share a
127    /// thread-name pattern across resources).
128    #[must_use]
129    pub const fn thread_name(self) -> &'static str {
130        match self {
131            Self::Solver => "work-fleet-solver-{n}",
132            Self::SimDriver => "work-fleet-sim-{n}",
133            Self::Resolve => "work-fleet-resolve-{n}",
134            Self::Merge => "work-fleet-merge-{n}",
135            Self::PoolStateUpdater => "work-fleet-poolupd-{n}",
136            Self::Registrar => "work-fleet-registrar-{n}",
137            Self::Verifier => "work-fleet-verifier-{n}",
138            Self::Submitter => "work-fleet-submit-{n}",
139        }
140    }
141
142    /// Worker-census `kind` text.
143    #[must_use]
144    pub const fn census_kind(self) -> &'static str {
145        match self {
146            Self::Solver => "fleet worker slot: LPT-bin pin (RAYPAR T3)",
147            Self::SimDriver => "fleet worker slot: pooled sim driver",
148            Self::Resolve => "fleet worker slot: resolve chunk",
149            Self::Merge => "fleet worker slot: merge sidecar pin",
150            Self::PoolStateUpdater => "fleet worker slot: pool-state updater",
151            Self::Registrar => "fleet worker slot: registrar",
152            Self::Verifier => "fleet worker slot: verifier",
153            Self::Submitter => "fleet worker slot: submitter",
154        }
155    }
156
157    /// Worker-census `sizing` rule text (who derives the count).
158    #[must_use]
159    pub const fn census_sizing(self) -> &'static str {
160        match self {
161            Self::Solver => "pins = one seat per LPT bin (floor(Q) minus the solve headroom, the structural bin count; walk admission stays the Solver share)",
162            Self::SimDriver => "slot cap = 4 (today's SimSlots cap), duty-counted; fractional-quota remainder spendable here",
163            Self::Resolve => "fixed v1 (1 slot, 12.4 ms/cycle measured)",
164            Self::Merge => "exactly one sidecar",
165            Self::PoolStateUpdater => {
166                "slot cap = fleet.pool_state_updater_slots (default 4), duty-counted; \
167                 fractional-quota remainder spendable here; Deferrable cordon class"
168            }
169            Self::Registrar | Self::Verifier | Self::Submitter => "declared, unhosted in v1",
170        }
171    }
172
173    /// Lowercase label for metrics/gauges (small closed label set).
174    #[must_use]
175    pub const fn label(self) -> &'static str {
176        match self {
177            Self::Solver => "solver",
178            Self::SimDriver => "sim_driver",
179            Self::Resolve => "resolve",
180            Self::Merge => "merge",
181            Self::PoolStateUpdater => "pool_state_updater",
182            Self::Registrar => "registrar",
183            Self::Verifier => "verifier",
184            Self::Submitter => "submitter",
185        }
186    }
187
188    /// u8 index into [`ALL_ROLES`] (the conformance stub's script encoding).
189    #[must_use]
190    pub const fn index_in_all_roles(self) -> Option<u8> {
191        match self {
192            Self::Solver => Some(0),
193            Self::SimDriver => Some(1),
194            Self::Resolve => Some(2),
195            Self::Merge => Some(3),
196            Self::PoolStateUpdater => Some(4),
197            Self::Registrar => Some(5),
198            Self::Verifier => Some(6),
199            Self::Submitter => Some(7),
200        }
201    }
202}
203
204#[cfg(test)]
205#[expect(clippy::expect_used)]
206mod tests {
207    use super::*;
208
209    #[test]
210    fn all_roles_is_the_sized_8_role_table() {
211        assert_eq!(ALL_ROLES.len(), 8, "ADR-042 Q2: exactly 8 declared roles");
212        assert_eq!(
213            ALL_ROLES,
214            [
215                WorkerRole::Solver,
216                WorkerRole::SimDriver,
217                WorkerRole::Resolve,
218                WorkerRole::Merge,
219                WorkerRole::PoolStateUpdater,
220                WorkerRole::Registrar,
221                WorkerRole::Verifier,
222                WorkerRole::Submitter,
223            ],
224            "ALL_ROLES must list every variant exactly once, positions are the conformance script"
225        );
226    }
227
228    #[test]
229    fn u8_index_round_trips_through_the_table() {
230        for (i, role) in ALL_ROLES.iter().enumerate() {
231            let idx = usize::from(role.index_in_all_roles().expect("all roles indexed"));
232            assert_eq!(idx, i, "index_in_all_roles must match ALL_ROLES position");
233            assert_eq!(ALL_ROLES[idx], *role, "round trip");
234        }
235    }
236
237    #[test]
238    fn v1_active_is_exactly_the_hosted_prefix() {
239        // ADR-042 Q2 hosted the first four; PRG-3 hosts PoolStateUpdater
240        // (the registration intake station) as the fifth.
241        for (i, role) in ALL_ROLES.iter().enumerate() {
242            assert_eq!(
243                role.v1_active(),
244                i < V1_ACTIVE_ROLES.len(),
245                "v1-active must be exactly {V1_ACTIVE_ROLES:?}"
246            );
247        }
248        assert_eq!(V1_ACTIVE_ROLES, ALL_ROLES[..V1_ACTIVE_ROLES.len()]);
249    }
250
251    #[test]
252    fn cordon_classes_match_the_sign_off_table() {
253        // design doc §6: (b) sim-slot intake throttled; (c) declared
254        // background roles held; merge pin + ambient I/O never cordoned;
255        // Submitter never deferrable; Solver/Resolve unregulaged v1 roles.
256        let class = |r: WorkerRole| match r.cordon_class() {
257            CordonClass::Never => "never",
258            CordonClass::SimPool => "sim-pool",
259            CordonClass::Deferrable => "deferrable",
260        };
261        assert_eq!(class(WorkerRole::SimDriver), "sim-pool");
262        for r in [
263            WorkerRole::PoolStateUpdater,
264            WorkerRole::Registrar,
265            WorkerRole::Verifier,
266        ] {
267            assert_eq!(class(r), "deferrable", "declared background set: {r:?}");
268        }
269        for r in [
270            WorkerRole::Solver,
271            WorkerRole::Resolve,
272            WorkerRole::Merge,
273            WorkerRole::Submitter,
274        ] {
275            assert_eq!(class(r), "never", "cordon-invariant role: {r:?}");
276        }
277    }
278
279    #[test]
280    fn only_solver_and_merge_are_pinnable() {
281        for role in ALL_ROLES {
282            assert_eq!(
283                role.is_pinnable(),
284                matches!(role, WorkerRole::Solver | WorkerRole::Merge)
285            );
286        }
287    }
288
289    #[test]
290    fn census_and_thread_names_are_distinct_and_greppable() {
291        let mut resources = Vec::new();
292        let mut threads = Vec::new();
293        for role in ALL_ROLES {
294            resources.push(role.census_resource());
295            threads.push(role.thread_name());
296            assert!(role.thread_name().starts_with("work-fleet-"));
297            assert!(role.census_kind().starts_with("fleet worker slot"));
298            assert!(!role.census_sizing().is_empty());
299        }
300        let n = resources.len();
301        resources.sort_unstable();
302        threads.sort_unstable();
303        resources.dedup();
304        threads.dedup();
305        assert_eq!(resources.len(), n, "census resource ids must be unique");
306        assert_eq!(
307            threads.len(),
308            n,
309            "thread-name patterns must be unique (GOQWCL)"
310        );
311    }
312
313    #[test]
314    fn gauge_labels_are_snake_case() {
315        for role in ALL_ROLES {
316            let label = role.label();
317            assert!(!label.is_empty());
318            assert!(label.chars().all(|c| c.is_ascii_lowercase() || c == '_'));
319        }
320    }
321}