Skip to main content

nros_node/executor/
sched_context.rs

1//! Phase 110.B — `SchedContext` API + supporting types.
2//!
3//! A `SchedContext` is a first-class scheduling capability. Multiple
4//! callbacks share one SC; one OS priority slot per Executor regardless
5//! of callback count. Inspired by seL4 MCS (Mixed-Criticality
6//! Scheduling).
7//!
8//! 110.B.a (this commit) lands the type surface + `EdfReadySet`. The
9//! Executor builder methods (`create_sched_context`,
10//! `register_subscription_in`, ...) and the cbindgen / C / C++ wrappers
11//! land in 110.B.b once the const-generic `Executor<MAX_HANDLES,
12//! MAX_SC>` reshape is sorted.
13
14use core::num::NonZeroU32;
15
16/// Optional time field with a sentinel `0` for "absent".
17///
18/// Phase 110.B keeps a stable `#[repr(transparent)]` u32 layout so
19/// cbindgen emits plain `uint32_t` for C consumers — `Option<NonZeroU32>`
20/// loses its niche optimization the moment a `#[repr(C)]` struct
21/// embeds it. Rust callers see the ergonomic
22/// [`get`](OptUs::get)-returning-`Option<NonZeroU32>` getter.
23///
24/// Sentinel `0` is physically meaningful for every time field on
25/// [`SchedContext`]: 0-period would mean infinite frequency, 0-budget
26/// means unbounded, 0-deadline means no deadline.
27#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
28#[repr(transparent)]
29pub struct OptUs(u32);
30
31impl OptUs {
32    pub const NONE: Self = Self(0);
33
34    pub const fn from_us(us: u32) -> Self {
35        Self(us)
36    }
37
38    pub const fn from_nz(nz: NonZeroU32) -> Self {
39        Self(nz.get())
40    }
41
42    /// Returns the inner value or `None` when the sentinel is set.
43    pub const fn get(self) -> Option<NonZeroU32> {
44        NonZeroU32::new(self.0)
45    }
46
47    pub const fn is_some(self) -> bool {
48        self.0 != 0
49    }
50
51    pub const fn raw(self) -> u32 {
52        self.0
53    }
54}
55
56/// Scheduling class — picks the runtime queue + selection policy for
57/// the contained callbacks.
58///
59/// Phase 110.A only exercises `Fifo`; `Edf` lands with the
60/// `EdfReadySet` plumb-up in 110.B.b; `Sporadic` is post-v1 (110.E);
61/// time-triggered windows are not a class but a per-`SchedContext` gate
62/// (`tt_window_offset_us` / `tt_window_duration_us`, 110.G).
63#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
64pub enum SchedClass {
65    #[default]
66    Fifo,
67    Edf,
68    Sporadic,
69    BestEffort,
70}
71
72/// Criticality bucket for [`SchedContext`]. Phase 110.C uses this to
73/// pick which `BucketedFifoSet` / `BucketedEdfSet` slot a callback
74/// dispatches through; later phases (110.D) map it to OS priority.
75///
76/// Default `Normal` keeps existing single-bucket workloads unchanged
77/// — every default-Fifo SC sits in `Normal`, so dispatch order is
78/// bit-identical to pre-110.C when no callback opts in to `Critical`
79/// or `BestEffort`.
80///
81/// Single-thread non-preemption note: a `BestEffort` callback already
82/// running blocks `Critical` work that becomes ready mid-cycle. Hard-
83/// RT scenarios need 110.D's multi-executor preemption.
84#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Ord, PartialOrd)]
85pub enum Priority {
86    /// Highest-priority bucket. Drained first within a single
87    /// `spin_once` cycle; non-preemptive against in-flight lower-
88    /// priority callbacks (see Phase 110.D for preemption).
89    Critical = 0,
90    /// Default bucket. Most callbacks (and the auto-default Fifo SC)
91    /// live here.
92    #[default]
93    Normal = 1,
94    /// Lowest-priority bucket. Drained last; first to be skipped if a
95    /// future cycle-budget overrun forces an early return.
96    BestEffort = 2,
97}
98
99impl Priority {
100    pub const COUNT: usize = 3;
101
102    pub const fn index(self) -> usize {
103        self as usize
104    }
105}
106
107/// How an EDF deadline is interpreted relative to a callback firing.
108///
109/// - `Released`: deadline is `release_time + period`. Default for
110///   timer-triggered callbacks.
111/// - `Activated`: deadline is `activation_time + relative_deadline`.
112///   Default for event-triggered subscriptions.
113/// - `Inherited`: deadline travels in the message header — latency-
114///   aware pipelines extract it per-message at dispatch time.
115#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
116pub enum DeadlinePolicy {
117    Released,
118    #[default]
119    Activated,
120    Inherited,
121}
122
123/// RFC-0052 / phase-296 W3b.5 — what the executor DOES when a dispatched
124/// callback runs past its bound SC's `deadline_us`. Distinct from
125/// [`DeadlinePolicy`] (which says where the deadline COMES from); this is
126/// the miss REACTION, lowered from the tier table's `deadline_policy`
127/// string (`ignore | warn | skip | fault`).
128#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
129pub enum DeadlineAction {
130    /// Measure nothing, report nothing (uncontracted default).
131    #[default]
132    Ignore,
133    /// Push a `deadline-miss-runtime` violation onto the monitor drain.
134    Warn,
135    /// Warn AND skip the offending SC's remaining callbacks for the rest
136    /// of this spin cycle (damage containment: a runaway callback does
137    /// not get to also starve unrelated groups with its siblings).
138    Skip,
139    /// Warn AND invoke the executor's fault hook (panic when none is
140    /// registered — on embedded targets that is a watchdog-visible stop).
141    Fault,
142}
143
144impl DeadlineAction {
145    /// Lower the tier-table string (`[tiers.<t>].deadline_policy`).
146    /// Unknown strings map to `Ignore` — the bake already validated the
147    /// vocabulary; runtime tolerance here avoids a boot-time panic path.
148    ///
149    /// phase-462 W2 -- that string is no longer authored per tier and nothing
150    /// else. The launch contract's `on_violation` is now its SOURCE
151    /// (`nros-orchestration-ir::violation_agreement`, applied in
152    /// `plan_from_model`), and a tier table that states a different word is a
153    /// resolve-time refusal naming both. So these four variants are what the
154    /// contract's safety vocabulary reaches the target AS: the word in the
155    /// YAML file becomes something the executor does.
156    pub fn from_tier_str(s: &str) -> Self {
157        match s {
158            "warn" => Self::Warn,
159            "skip" => Self::Skip,
160            "fault" => Self::Fault,
161            _ => Self::Ignore,
162        }
163    }
164
165    /// The tier-table spelling of this action -- the inverse of
166    /// [`from_tier_str`](Self::from_tier_str).
167    ///
168    /// The bake and the runtime share no type (this crate is `no_std` and
169    /// knows nothing of the model), so the STRING is the interface between
170    /// `ViolationAction::as_tier_str` on the bake side and this. Spelling it
171    /// here rather than only on the bake side is what lets both sides pin the
172    /// same four words in a test.
173    pub const fn as_tier_str(self) -> &'static str {
174        match self {
175            Self::Ignore => "ignore",
176            Self::Warn => "warn",
177            Self::Skip => "skip",
178            Self::Fault => "fault",
179        }
180    }
181}
182
183/// Identifier for a [`SchedContext`] registered with an Executor.
184/// 110.B.b adds storage `[Option<SchedContext>; MAX_SC]`; this index
185/// addresses into that array.
186#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
187pub struct SchedContextId(pub u8);
188
189/// First-class scheduling capability — one SC per scheduling concern,
190/// shared by every callback that should run under the same budget /
191/// period / deadline / class.
192///
193/// Phase 110.B.a defines the shape; 110.B.b's builder methods on
194/// Executor consume it.
195#[derive(Debug, Clone, Copy, Default)]
196pub struct SchedContext {
197    pub class: SchedClass,
198    pub priority: Priority,
199    pub period_us: OptUs,
200    pub budget_us: OptUs,
201    pub deadline_us: OptUs,
202    pub deadline_policy: DeadlinePolicy,
203    /// Phase 110.F — opt-in OS-level priority for per-callback
204    /// dispatch. `0` (default) means "no per-callback OS priority"
205    /// — the executor's cooperative dispatch path runs every
206    /// callback bound to this SC. Non-zero values trigger the
207    /// per-priority worker-pool path (registered via
208    /// `Executor::register_os_priority_dispatcher`); each callback
209    /// then runs on a worker thread the OS scheduler has elevated
210    /// to that numeric priority.
211    ///
212    /// Numeric meaning is platform-defined (POSIX 1..99 for
213    /// SCHED_FIFO; FreeRTOS 0..configMAX_PRIORITIES-1; Zephyr
214    /// direction-flipped). Chain-priority assignment + chain
215    /// grouping happen at the orchestration layer and are out of
216    /// executor scope.
217    pub os_pri: u8,
218    /// Phase 110.G — time-triggered window offset within the
219    /// executor's major frame. `None` (sentinel `0`) = always
220    /// eligible (no TT gate); `Some(off)` + `tt_window_duration_us`
221    /// gates dispatch to the half-open interval
222    /// `[off, off + duration) mod major_frame`.
223    ///
224    /// Independent of `class` — a `Sporadic`-class SC can also be TT-
225    /// gated; both gates apply (skip dispatch when EITHER fails).
226    /// Pairs with `Executor::register_time_triggered_dispatcher`
227    /// which sets the major-frame length.
228    /// W3b.5 — reaction on a deadline miss (see [`DeadlineAction`]).
229    pub deadline_action: DeadlineAction,
230    pub tt_window_offset_us: OptUs,
231    /// Phase 110.G — time-triggered window length. See
232    /// `tt_window_offset_us`.
233    pub tt_window_duration_us: OptUs,
234}
235
236/// Phase 110.E.b — atomic sporadic-server state for ISR-driven
237/// refill. ISR / timer-thread context calls `refill_thunk` to top up
238/// the budget; spin_once reads atomically without any `&mut` access.
239///
240/// Replaces the polled-clock `SporadicState` shape on platforms with
241/// a `PlatformTimer` impl. The Executor still keeps the legacy
242/// `SporadicState` path active on `feature = "std"` so the
243/// transition is non-breaking.
244pub struct AtomicSporadicState {
245    pub budget_remaining_us: portable_atomic::AtomicU32,
246    /// Wraps every ~50 days at ms resolution; saturates per
247    /// `tick`'s monotonic-clock contract. portable-atomic provides
248    /// AtomicU32 even on RISC-V `riscv32imc` / Cortex-M0+ that lack
249    /// native 32-bit atomics.
250    pub last_refill_ms: portable_atomic::AtomicU32,
251    pub budget_capacity_us: u32,
252    pub period_us: u32,
253    /// Phase 110.E.b — cumulative count of dispatched callbacks
254    /// whose measured wall-clock runtime exceeded the SC's
255    /// `budget_us`. Bumped by the per-callback runtime closure
256    /// inside `Executor::spin_once` (std-only — the no_std fallback
257    /// continues to use the polled `SporadicState` path without
258    /// per-callback overrun accounting). Cooperative single-thread
259    /// dispatch can't preempt a runaway callback, so this counter
260    /// is the diagnostic signal — the design's oneshot-IRQ-and-
261    /// cancel pattern is structurally equivalent for non-preemptive
262    /// callbacks, and `last_overrun_us` carries the worst-case
263    /// observation for tuning. Both reset by `clear_overrun_stats`.
264    pub overrun_count: portable_atomic::AtomicU32,
265    /// WORST overrun amount seen (`measured_us - budget_us`) since the
266    /// last `clear_overrun_stats`; `0` when none has been observed.
267    ///
268    /// This used to `store` the most RECENT overrun while its doc claimed
269    /// it "carries the worst-case observation for tuning". A large overrun
270    /// followed by a small one silently replaced the number anyone would
271    /// have sized a budget from, and the only surviving evidence of the bad
272    /// dispatch was that `overrun_count` had gone up by one. It is a
273    /// `fetch_max` now, which is what the name and the purpose both say.
274    pub last_overrun_us: portable_atomic::AtomicU32,
275    /// Worst execution time observed on this SC, in microseconds, recorded
276    /// on EVERY dispatch rather than only on an overrun.
277    ///
278    /// `overrun_count` and `last_overrun_us` answer "did it exceed the
279    /// budget, and by how much". Neither can answer "how close did it come"
280    /// — a system that never overruns produces no evidence at all, and that
281    /// is exactly the system whose budget you are trying to choose. Sizing
282    /// `budget_us` from a measured maximum is the whole point of the field.
283    ///
284    /// Only meaningful where the dispatch loop actually measures: elapsed is
285    /// `0` unless a latency monitor, a deadline action, or a clock is
286    /// present (`measure_us` in `spin_once`). No clock, no number.
287    pub max_exec_us: portable_atomic::AtomicU32,
288}
289
290impl AtomicSporadicState {
291    pub const fn new(budget_us: u32, period_us: u32) -> Self {
292        Self {
293            budget_remaining_us: portable_atomic::AtomicU32::new(budget_us),
294            last_refill_ms: portable_atomic::AtomicU32::new(0),
295            budget_capacity_us: budget_us,
296            period_us,
297            overrun_count: portable_atomic::AtomicU32::new(0),
298            last_overrun_us: portable_atomic::AtomicU32::new(0),
299            max_exec_us: portable_atomic::AtomicU32::new(0),
300        }
301    }
302
303    /// Record one overrun: callback measured runtime exceeded the
304    /// SC's `budget_us`. Bumps `overrun_count` + stores the absolute
305    /// overrun amount in `last_overrun_us`. Called from the
306    /// per-callback runtime closure inside `Executor::spin_once`.
307    #[inline]
308    pub fn record_overrun(&self, overrun_us: u32) {
309        self.overrun_count
310            .fetch_add(1, portable_atomic::Ordering::Relaxed);
311        self.last_overrun_us
312            .fetch_max(overrun_us, portable_atomic::Ordering::Relaxed);
313    }
314
315    /// Record one dispatch's measured execution time. Called on EVERY
316    /// dispatch charged to this SC, overrun or not.
317    #[inline]
318    pub fn record_exec(&self, elapsed_us: u32) {
319        self.max_exec_us
320            .fetch_max(elapsed_us, portable_atomic::Ordering::Relaxed);
321    }
322
323    /// Worst execution time seen since the last `clear_overrun_stats`.
324    #[inline]
325    pub fn max_exec_us(&self) -> u32 {
326        self.max_exec_us.load(portable_atomic::Ordering::Relaxed)
327    }
328
329    /// Reset both overrun statistics. Useful when tuning the budget
330    /// across windows (monitoring code logs + clears periodically).
331    #[inline]
332    pub fn clear_overrun_stats(&self) {
333        self.overrun_count
334            .store(0, portable_atomic::Ordering::Relaxed);
335        self.last_overrun_us
336            .store(0, portable_atomic::Ordering::Relaxed);
337        self.max_exec_us
338            .store(0, portable_atomic::Ordering::Relaxed);
339    }
340
341    /// Read the budget atomically; spin_once consults this to decide
342    /// whether to skip the SC's entries this cycle.
343    pub fn has_budget(&self) -> bool {
344        self.budget_remaining_us
345            .load(portable_atomic::Ordering::Acquire)
346            > 0
347    }
348
349    /// Saturating subtract — used by spin_once after dispatching a
350    /// callback bound to this SC.
351    pub fn consume(&self, us: u32) {
352        let mut cur = self
353            .budget_remaining_us
354            .load(portable_atomic::Ordering::Acquire);
355        loop {
356            let next = cur.saturating_sub(us);
357            match self.budget_remaining_us.compare_exchange_weak(
358                cur,
359                next,
360                portable_atomic::Ordering::Release,
361                portable_atomic::Ordering::Acquire,
362            ) {
363                Ok(_) => return,
364                Err(observed) => cur = observed,
365            }
366        }
367    }
368}
369
370/// C-callable refill thunk that `PlatformTimer::create_periodic`
371/// invokes from the platform's timer context. Single atomic store —
372/// safe in any thread / ISR context.
373///
374/// # Safety
375/// `user_data` must point at a live `AtomicSporadicState`; the caller
376/// of `PlatformTimer::create_periodic` owns the lifetime contract.
377pub extern "C" fn atomic_sporadic_refill_thunk(user_data: *mut core::ffi::c_void) {
378    if user_data.is_null() {
379        return;
380    }
381    let state = unsafe { &*(user_data as *const AtomicSporadicState) };
382    state
383        .budget_remaining_us
384        .store(state.budget_capacity_us, portable_atomic::Ordering::Release);
385}
386
387/// Phase 110.E — user-space sporadic-server runtime state.
388///
389/// Tracks remaining `budget_us` for the current period and the wall-
390/// clock instant of the last refill. The executor consults this state
391/// during dispatch: when `budget_remaining_us` reaches 0 the SC is
392/// suppressed until the next period boundary, at which point a refill
393/// resets the counter.
394///
395/// Refill cadence is polled — each `spin_once` checks whether the
396/// elapsed time since the last refill exceeds `period_us` and tops
397/// the budget back up. Less precise than an ISR-driven refill (Phase
398/// 110.E's per-platform timer hook is what gets that) but correct as
399/// an upper-bound bandwidth limiter.
400#[derive(Debug, Clone, Copy)]
401pub struct SporadicState {
402    pub budget_remaining_us: u32,
403    pub budget_capacity_us: u32,
404    pub period_us: u32,
405    pub last_refill_ms: u64,
406    /// issue 0736 — consecutive spins in which this SC was skipped for want of
407    /// budget. A skip is invisible by construction: `spin_once` `continue`s
408    /// past the entry, so a starved tier looks exactly like an idle one. This
409    /// counter is what lets it say so. Reset on any dispatch.
410    pub consecutive_budget_skips: u32,
411    /// issue 0736 — skips and dispatches over a rolling window.
412    ///
413    /// The consecutive counter above catches TOTAL starvation quickly and
414    /// misses the commoner shape entirely: a budget that merely THROTTLES.
415    /// Measured on nuttx-arm/rust, a 5 ms budget per 10 ms period held the
416    /// tier to about a quarter of its declared rate — skip a few, refill,
417    /// dispatch, repeat — so no streak ever reached the consecutive threshold
418    /// and the tier was silently 4x slow. A declaration that cannot be met has
419    /// to be reported whether it starves or merely throttles.
420    pub window_skips: u32,
421    pub window_dispatches: u32,
422    /// Worst execution time observed on this SC, in microseconds, recorded
423    /// on EVERY dispatch.
424    ///
425    /// This is the polled path, and issue 0736 established it is the one
426    /// that matters: `has_budget` prefers the atomic state only when a
427    /// refill timer was registered, which no board or entry does. So a
428    /// number recorded only on the atomic side would be a number no shipped
429    /// image ever writes.
430    ///
431    /// Sizing `budget_us` needs a measured maximum, and the overrun
432    /// counters cannot supply one: they say nothing at all until the budget
433    /// is already exceeded, which is the wrong end of the question when the
434    /// budget is what you are trying to choose.
435    ///
436    /// Only meaningful where the dispatch loop measures: elapsed is `0`
437    /// unless a latency monitor, a deadline action, or a clock is present.
438    pub max_exec_us: u32,
439}
440
441impl SporadicState {
442    pub const fn new(budget_us: u32, period_us: u32) -> Self {
443        Self {
444            budget_remaining_us: budget_us,
445            budget_capacity_us: budget_us,
446            period_us,
447            last_refill_ms: 0,
448            consecutive_budget_skips: 0,
449            window_skips: 0,
450            window_dispatches: 0,
451            max_exec_us: 0,
452        }
453    }
454
455    /// Replenish the budget at period boundaries. Returns `true` if the SC has
456    /// budget remaining afterwards.
457    ///
458    /// issue 0736 — this used to be `tick(now_ms, delta_us)`, which refilled
459    /// AND then charged the whole inter-spin `delta_us` as consumption. A
460    /// budget bounds the CPU the SC's callbacks consume; the wall-clock gap
461    /// between two spins is not that, and on any target where the gap exceeds
462    /// the budget it exhausts the SC on every single spin no matter what the
463    /// callbacks did. Measured on nuttx-arm/rust: `delta_us` 10_000..80_000
464    /// against a 5_000 us budget, giving 1200 budget skips against 3 dispatches
465    /// while the sibling tier — identical but declaring no budget — dispatched
466    /// on all 450 of its spins.
467    ///
468    /// The `delta_us` charge was documented as a "worst-case attribution"
469    /// standing in for per-callback measurement. That measurement now exists
470    /// and runs on every flavour, so consumption belongs to
471    /// [`Self::consume`], called with what the callback actually cost.
472    pub fn refill(&mut self, now_ms: u64) -> bool {
473        // Refill at period boundaries — coarse but correct.
474        if now_ms.saturating_sub(self.last_refill_ms) >= self.period_us as u64 / 1000 {
475            self.budget_remaining_us = self.budget_capacity_us;
476            self.last_refill_ms = now_ms;
477        }
478        self.budget_remaining_us > 0
479    }
480
481    /// Charge measured callback runtime against the remaining budget.
482    ///
483    /// The polled-state twin of `AtomicSporadicState::consume`. Both exist
484    /// because the atomic one is only present when a caller has registered a
485    /// refill timer via `Executor::register_sporadic_timer` — which, outside
486    /// this crate's own tests, nothing does. Every shipped image runs THIS
487    /// path, so it is the one that has to be right (issue 0736).
488    pub fn consume(&mut self, us: u32) {
489        self.budget_remaining_us = self.budget_remaining_us.saturating_sub(us);
490        self.consecutive_budget_skips = 0;
491        self.window_dispatches = self.window_dispatches.saturating_add(1);
492        // Every dispatch, not only the overrunning ones. See `max_exec_us`.
493        if us > self.max_exec_us {
494            self.max_exec_us = us;
495        }
496    }
497
498    /// How many dispatch opportunities before the throttle ratio is judged.
499    /// Long enough that a brief burst of skips is not news; short enough to
500    /// close inside a short run. 1000 was the first value and it NEVER closed
501    /// on the measured case — that tier misses roughly 240 dispatches over the
502    /// whole e2e window, so a 1000-opportunity window is larger than the
503    /// evidence and the warning existed without ever being reachable, which is
504    /// the same silence it was written to break.
505    pub const BUDGET_WINDOW: u32 = 200;
506    /// Report when more than this share of the window was skipped. A quarter
507    /// is already a tier delivering at 75% of what it declared.
508    pub const BUDGET_SKIP_REPORT_PERMILLE: u32 = 250;
509
510    /// Close the window if it is full. Returns `Some((skips, total))` when the
511    /// window closed with a skip share worth reporting.
512    pub fn take_budget_window(&mut self) -> Option<(u32, u32)> {
513        let total = self.window_skips + self.window_dispatches;
514        if total < Self::BUDGET_WINDOW {
515            return None;
516        }
517        let skips = self.window_skips;
518        self.window_skips = 0;
519        self.window_dispatches = 0;
520        if skips.saturating_mul(1000) / total.max(1) >= Self::BUDGET_SKIP_REPORT_PERMILLE {
521            Some((skips, total))
522        } else {
523            None
524        }
525    }
526
527    /// Record one budget-skipped dispatch. Returns `Some(n)` when the streak
528    /// has reached a length worth reporting — at 100, then each power of ten —
529    /// so a starved SC is loud once and does not then flood the console.
530    pub fn note_budget_skip(&mut self) -> Option<u32> {
531        self.consecutive_budget_skips = self.consecutive_budget_skips.saturating_add(1);
532        self.window_skips = self.window_skips.saturating_add(1);
533        let n = self.consecutive_budget_skips;
534        // Once when the streak is clearly not noise, then sparsely: a starved
535        // SC must be loud, and must not then drown the console it is reporting
536        // on. (The measured case ran 1200 consecutive skips.)
537        if n == 100 || (n > 100 && n.is_multiple_of(1000)) {
538            Some(n)
539        } else {
540            None
541        }
542    }
543}
544
545impl SchedContext {
546    pub const fn new_fifo() -> Self {
547        Self {
548            class: SchedClass::Fifo,
549            priority: Priority::Normal,
550            period_us: OptUs::NONE,
551            budget_us: OptUs::NONE,
552            deadline_us: OptUs::NONE,
553            deadline_policy: DeadlinePolicy::Activated,
554            os_pri: 0,
555            deadline_action: DeadlineAction::Ignore,
556            tt_window_offset_us: OptUs::NONE,
557            tt_window_duration_us: OptUs::NONE,
558        }
559    }
560
561    /// RFC-0052 — the **common backend** that lowers an RTOS-agnostic tier
562    /// policy (`[tiers.<t>]` class / budget / period / deadline) to a
563    /// [`SchedContext`]. ONE implementation shared by every language: the Rust
564    /// runtime (`ExecutorNodeRuntime::apply_tier_sched_policy`)
565    /// and the C / C++ entries (`nros_{c,cpp}_create_sched_context_from_policy`)
566    /// all call this, so the mapping can never drift between codegen paths.
567    ///
568    /// - `real_time` + `budget_us` + `period_us` → [`SchedClass::Sporadic`]
569    ///   (budget + RT replenishment period on the SC);
570    /// - `best_effort` → [`SchedClass::BestEffort`];
571    /// - `time_triggered` + `period_us` → a TT window (major frame =
572    ///   `period_us`, window = `budget_us` or the whole frame). The returned
573    ///   `Some(frame_us)` is the major frame the caller must install via
574    ///   [`crate::executor::Executor::register_time_triggered_dispatcher`]
575    ///   (the SC itself stays `Fifo`-class per the 110.G TT refactor);
576    /// - `deadline_us` sets the SC deadline; `deadline_policy` its
577    ///   [`DeadlineAction`].
578    ///
579    /// Returns `None` when the tier declares no class / budget / deadline — the
580    /// caller keeps the default `Fifo` SC (byte-identical pre-policy behavior).
581    /// `os_pri` is intentionally NOT set here (it is an orthogonal per-callback
582    /// OS-priority knob the caller applies); this fn covers only the
583    /// RTOS-agnostic real-time policy.
584    pub fn from_tier_policy(
585        class: Option<&str>,
586        period_us: Option<u64>,
587        budget_us: Option<u64>,
588        deadline_us: Option<u64>,
589        deadline_policy: Option<&str>,
590    ) -> Option<(Self, Option<u32>)> {
591        let sporadic = class == Some("real_time") && budget_us.is_some() && period_us.is_some();
592        let best_effort = class == Some("best_effort");
593        let time_triggered = class == Some("time_triggered") && period_us.is_some();
594        if !sporadic && !best_effort && !time_triggered && deadline_us.is_none() {
595            return None;
596        }
597        let clamp = |v: u64| v.min(u32::MAX as u64) as u32;
598        let mut sc = Self::default();
599        let mut tt_frame = None;
600        if sporadic {
601            sc.class = SchedClass::Sporadic;
602            sc.budget_us = OptUs::from_us(clamp(budget_us.unwrap_or(0)));
603            sc.period_us = OptUs::from_us(clamp(period_us.unwrap_or(0)));
604        } else if best_effort {
605            sc.class = SchedClass::BestEffort;
606        } else if time_triggered {
607            tt_frame = Some(clamp(period_us.unwrap_or(0)));
608            let window_us = clamp(budget_us.unwrap_or(period_us.unwrap_or(0)));
609            sc.tt_window_duration_us = OptUs::from_us(window_us);
610        }
611        if let Some(d) = deadline_us {
612            sc.deadline_us = OptUs::from_us(clamp(d));
613        }
614        if let Some(action) = deadline_policy {
615            sc.deadline_action = DeadlineAction::from_tier_str(action);
616        }
617        Some((sc, tt_frame))
618    }
619}
620
621// ----------------------------------------------------------------------
622// Phase 110.G — TimeTriggered schedule-table API.
623//
624// ARINC-653-style cyclic executive: the major frame is partitioned
625// into fixed windows; each callback is bound to a window via
626// `SchedContext { tt_window_offset_us, tt_window_duration_us }`.
627// The runtime gate inside `Executor::spin_once` already enforces
628// per-window dispatch suppression (Phase 110.G runtime, landed
629// pre-session). This block adds the schedule-table types +
630// builder helpers so callers can declare a complete cyclic
631// schedule with a single API call instead of stitching
632// `create_sched_context` + `bind_handle_to_sched_context` together.
633// ----------------------------------------------------------------------
634
635/// One slot in a time-triggered schedule.
636///
637/// Window `[offset_us, offset_us + duration_us)` within the major
638/// frame. `name` is a static-lifetime label for diagnostics
639/// (logging, panic messages); the runtime never inspects it.
640#[derive(Debug, Clone, Copy)]
641pub struct TimeTriggeredWindow {
642    pub offset_us: u32,
643    pub duration_us: u32,
644    pub name: &'static str,
645}
646
647impl TimeTriggeredWindow {
648    pub const fn new(offset_us: u32, duration_us: u32, name: &'static str) -> Self {
649        Self {
650            offset_us,
651            duration_us,
652            name,
653        }
654    }
655}
656
657/// Fixed-size, no_std-friendly cyclic schedule. `N` is the
658/// declared maximum window count; `window_count` is the active
659/// length (callers can build the array up to `N` and set
660/// `window_count` to the actual size used).
661#[derive(Debug)]
662pub struct TimeTriggeredSchedule<const N: usize> {
663    pub major_frame_us: u32,
664    pub windows: [TimeTriggeredWindow; N],
665    pub window_count: usize,
666}
667
668impl<const N: usize> TimeTriggeredSchedule<N> {
669    /// Construct a schedule from an exhaustive `[TimeTriggeredWindow; N]`
670    /// array; `window_count` is set to `N`.
671    pub const fn new_full(major_frame_us: u32, windows: [TimeTriggeredWindow; N]) -> Self {
672        Self {
673            major_frame_us,
674            windows,
675            window_count: N,
676        }
677    }
678
679    /// Validate the schedule: every window must fit inside
680    /// `[0, major_frame_us)` and windows must be non-overlapping
681    /// in offset-sorted order. Sliding-window check; O(N²) is fine
682    /// because TT schedules are small (rarely > 16 slots).
683    pub fn validate(&self) -> Result<(), TimeTriggeredScheduleError> {
684        if self.major_frame_us == 0 {
685            return Err(TimeTriggeredScheduleError::ZeroMajorFrame);
686        }
687        if self.window_count > N {
688            return Err(TimeTriggeredScheduleError::WindowCountOverflow);
689        }
690        for (i, w) in self.windows[..self.window_count].iter().enumerate() {
691            if w.duration_us == 0 {
692                return Err(TimeTriggeredScheduleError::ZeroWindowDuration { window: i });
693            }
694            let end = (w.offset_us as u64) + (w.duration_us as u64);
695            if end > self.major_frame_us as u64 {
696                return Err(TimeTriggeredScheduleError::WindowExceedsMajorFrame { window: i });
697            }
698            for (j, other) in self.windows[..self.window_count].iter().enumerate() {
699                if i == j {
700                    continue;
701                }
702                let o_end = (other.offset_us as u64) + (other.duration_us as u64);
703                let overlaps = (w.offset_us as u64) < o_end && (other.offset_us as u64) < end;
704                if overlaps {
705                    return Err(TimeTriggeredScheduleError::WindowsOverlap {
706                        window_a: i,
707                        window_b: j,
708                    });
709                }
710            }
711        }
712        Ok(())
713    }
714}
715
716/// Validation errors for a [`TimeTriggeredSchedule`].
717#[derive(Debug, Clone, Copy, PartialEq, Eq)]
718pub enum TimeTriggeredScheduleError {
719    ZeroMajorFrame,
720    WindowCountOverflow,
721    ZeroWindowDuration { window: usize },
722    WindowExceedsMajorFrame { window: usize },
723    WindowsOverlap { window_a: usize, window_b: usize },
724}
725
726#[cfg(test)]
727mod tests {
728    use super::*;
729
730    #[test]
731    fn opt_us_sentinel_round_trip() {
732        assert!(!OptUs::NONE.is_some());
733        assert_eq!(OptUs::NONE.get(), None);
734        let some = OptUs::from_us(42);
735        assert!(some.is_some());
736        assert_eq!(some.get().map(|nz| nz.get()), Some(42));
737        assert_eq!(some.raw(), 42);
738    }
739
740    #[test]
741    fn opt_us_layout_is_u32() {
742        // ABI guard — `OptUs` MUST stay `#[repr(transparent)]` over
743        // `u32` so cbindgen emits a plain `uint32_t`.
744        assert_eq!(core::mem::size_of::<OptUs>(), core::mem::size_of::<u32>());
745        assert_eq!(core::mem::align_of::<OptUs>(), core::mem::align_of::<u32>());
746    }
747
748    #[test]
749    fn sched_context_default_is_fifo() {
750        let sc = SchedContext::default();
751        assert_eq!(sc.class, SchedClass::Fifo);
752        assert!(!sc.period_us.is_some());
753        assert!(!sc.budget_us.is_some());
754        assert!(!sc.deadline_us.is_some());
755        assert_eq!(sc.deadline_policy, DeadlinePolicy::Activated);
756    }
757
758    // RFC-0052 — the common-backend tier→SchedContext lowering, shared by the
759    // Rust runtime (`apply_tier_sched_policy`) and the C/C++ FFI
760    // (`nros_{c,cpp}_create_sched_context_from_policy`). One place, one test.
761
762    #[test]
763    fn from_tier_policy_real_time_lowers_to_sporadic() {
764        let (sc, tt) = SchedContext::from_tier_policy(
765            Some("real_time"),
766            Some(20_000),
767            Some(3_000),
768            Some(15_000),
769            Some("fault"),
770        )
771        .expect("real_time+budget+period is a policy");
772        assert_eq!(sc.class, SchedClass::Sporadic);
773        assert_eq!(sc.period_us.raw(), 20_000);
774        assert_eq!(sc.budget_us.raw(), 3_000);
775        assert_eq!(sc.deadline_us.raw(), 15_000);
776        assert_eq!(sc.deadline_action, DeadlineAction::Fault);
777        assert_eq!(tt, None);
778    }
779
780    #[test]
781    fn from_tier_policy_real_time_without_budget_or_period_is_not_sporadic() {
782        // `real_time` needs BOTH budget and period to be a sporadic server; a
783        // bare `real_time` with neither (and no deadline) declares no policy.
784        assert!(
785            SchedContext::from_tier_policy(Some("real_time"), None, None, None, None).is_none()
786        );
787    }
788
789    #[test]
790    fn from_tier_policy_best_effort() {
791        let (sc, tt) = SchedContext::from_tier_policy(Some("best_effort"), None, None, None, None)
792            .expect("best_effort is a policy");
793        assert_eq!(sc.class, SchedClass::BestEffort);
794        assert_eq!(tt, None);
795    }
796
797    #[test]
798    fn from_tier_policy_time_triggered_returns_major_frame() {
799        // TT stays Fifo-class (110.G) but returns the major frame to register
800        // and sets the window duration (budget → window, else whole frame).
801        let (sc, tt) = SchedContext::from_tier_policy(
802            Some("time_triggered"),
803            Some(10_000),
804            Some(4_000),
805            None,
806            None,
807        )
808        .expect("time_triggered+period is a policy");
809        assert_eq!(sc.class, SchedClass::Fifo);
810        assert_eq!(tt, Some(10_000));
811        assert_eq!(sc.tt_window_duration_us.raw(), 4_000);
812    }
813
814    #[test]
815    fn from_tier_policy_deadline_only() {
816        // A deadline with no RT class still yields a policy (Fifo + deadline).
817        let (sc, _tt) = SchedContext::from_tier_policy(None, None, None, Some(8_000), Some("warn"))
818            .expect("a deadline is a policy");
819        assert_eq!(sc.class, SchedClass::Fifo);
820        assert_eq!(sc.deadline_us.raw(), 8_000);
821        assert_eq!(sc.deadline_action, DeadlineAction::Warn);
822    }
823
824    #[test]
825    fn from_tier_policy_none_when_no_policy() {
826        // No class, no deadline → None → caller keeps the default Fifo SC.
827        assert!(SchedContext::from_tier_policy(None, Some(1_000), None, None, None).is_none());
828        assert!(SchedContext::from_tier_policy(Some("default"), None, None, None, None).is_none());
829    }
830
831    /// phase-462 W2 -- the four words are the interface between the bake and
832    /// this crate. `nros-orchestration-ir`'s `violation_agreement` pins the
833    /// same three non-default ones in `the_tier_spelling_round_trips`; if
834    /// either side renames one, the contract's `on_violation` silently lowers
835    /// to `Ignore` and the image stops reacting.
836    #[test]
837    fn the_contract_vocabulary_is_the_tier_vocabulary() {
838        for a in [
839            DeadlineAction::Ignore,
840            DeadlineAction::Warn,
841            DeadlineAction::Skip,
842            DeadlineAction::Fault,
843        ] {
844            assert_eq!(DeadlineAction::from_tier_str(a.as_tier_str()), a);
845        }
846        assert_eq!(DeadlineAction::Fault.as_tier_str(), "fault");
847        // An unreadable word is the default, not a boot panic.
848        assert_eq!(
849            DeadlineAction::from_tier_str("abort"),
850            DeadlineAction::Ignore
851        );
852    }
853}
854
855#[cfg(test)]
856mod exec_high_water_tests {
857    use super::*;
858
859    /// The number a budget gets sized from has to survive a later, smaller
860    /// dispatch. `max_exec_us` is a maximum, not a last-write.
861    #[test]
862    fn polled_state_keeps_the_worst_execution_not_the_latest() {
863        let mut st = SporadicState::new(1000, 10_000);
864        st.consume(120);
865        st.consume(900);
866        st.consume(30);
867        assert_eq!(st.max_exec_us, 900);
868    }
869
870    /// Recorded on EVERY dispatch, including ones well inside budget. A
871    /// system that never overruns still has to say how close it came.
872    #[test]
873    fn polled_state_records_without_any_overrun() {
874        let mut st = SporadicState::new(10_000, 20_000);
875        st.consume(700);
876        st.consume(450);
877        assert_eq!(
878            st.max_exec_us, 700,
879            "no overrun occurred, yet 700 is the evidence"
880        );
881    }
882
883    #[test]
884    fn atomic_state_keeps_the_worst_execution_not_the_latest() {
885        let st = AtomicSporadicState::new(1000, 10_000);
886        st.record_exec(120);
887        st.record_exec(900);
888        st.record_exec(30);
889        assert_eq!(st.max_exec_us(), 900);
890    }
891
892    /// Regression: `last_overrun_us` used to `store`, so a big overrun
893    /// followed by a small one lost the big one entirely -- while its own
894    /// doc promised "the worst-case observation for tuning".
895    #[test]
896    fn last_overrun_keeps_the_worst_not_the_most_recent() {
897        let st = AtomicSporadicState::new(1000, 10_000);
898        st.record_overrun(500);
899        st.record_overrun(20);
900        assert_eq!(
901            st.last_overrun_us.load(portable_atomic::Ordering::Relaxed),
902            500,
903            "a later small overrun must not erase the worst one"
904        );
905        assert_eq!(st.overrun_count.load(portable_atomic::Ordering::Relaxed), 2);
906    }
907
908    #[test]
909    fn clearing_stats_resets_the_high_water_too() {
910        let st = AtomicSporadicState::new(1000, 10_000);
911        st.record_exec(4242);
912        st.record_overrun(7);
913        st.clear_overrun_stats();
914        assert_eq!(st.max_exec_us(), 0);
915        assert_eq!(
916            st.last_overrun_us.load(portable_atomic::Ordering::Relaxed),
917            0
918        );
919        assert_eq!(st.overrun_count.load(portable_atomic::Ordering::Relaxed), 0);
920    }
921}