1use crate::role::{CordonClass, WorkerRole};
11
12pub type PinKey = u64;
14pub type UnitId = u64;
16
17pub const MERGE_PIN_KEY: PinKey = 0;
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
24pub enum SlotState {
25 Idle,
27 Leased {
30 role: WorkerRole,
31 key: Option<PinKey>,
32 },
33 Running {
35 role: WorkerRole,
36 key: Option<PinKey>,
37 },
38 Pinned { role: WorkerRole, key: PinKey },
41 Draining { role: WorkerRole },
43}
44
45impl SlotState {
46 #[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 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub enum Transition {
71 Lease {
74 role: WorkerRole,
75 key: Option<PinKey>,
76 },
77 Start { unit: UnitId },
81 CompleteToPinned,
83 CompleteToIdle,
85 BeginDraining,
88 DrainComplete,
90 ReleasePin,
93}
94
95#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
97pub struct TransitionContext {
98 pub at_epoch_boundary: bool,
101 pub posture_admits_role: bool,
105}
106
107#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
110#[error("rejected transition {from:?} --{transition:?}--> ({reason})")]
111pub struct RejectedTransition {
112 pub from: SlotState,
114 pub transition: Transition,
116 pub reason: RejectionReason,
118}
119
120#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
122pub enum RejectionReason {
123 #[error("posture blocks new lease intake for this role")]
126 PostureBlocksIntake,
127 #[error("pin release requires an epoch boundary (T9)")]
129 MidCyclePin,
130 #[error("Solver completion carries no pin key (T3)")]
132 MissingPinKey,
133 #[error("pinnable-role lease claim carries no pin key (T1)")]
135 ClaimMissingKey,
136 #[error("Merge pin claims must carry MERGE_PIN_KEY (T1/T4: exactly one merge pin)")]
138 MergeKeyMismatch,
139 #[error("no legal row (T1-T9) covers this from/transition pair")]
143 NoLegalRow,
144}
145
146#[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 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 (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 (SlotState::Leased { role, key }, Transition::Start { .. }) => {
196 Ok(SlotState::Running { role, key })
197 }
198 (SlotState::Pinned { role, key }, Transition::Start { .. }) => Ok(SlotState::Running {
203 role,
204 key: Some(key),
205 }),
206 (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 WorkerRole::Merge => Ok(SlotState::Pinned {
216 role,
217 key: MERGE_PIN_KEY,
218 }),
219 _ => reject(RejectionReason::NoLegalRow),
220 }
221 }
222 (
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 (
236 SlotState::Running { role, .. } | SlotState::Leased { role, .. },
237 Transition::BeginDraining,
238 ) => Ok(SlotState::Draining { role }),
239 (SlotState::Pinned { .. }, Transition::ReleasePin) => {
241 if ctx.at_epoch_boundary {
242 Ok(SlotState::Idle)
243 } else {
244 reject(RejectionReason::MidCyclePin)
245 }
246 }
247 _ => 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 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 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 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 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 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 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 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 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 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}