Skip to main content

oriera_core/
lib.rs

1//! L2 Core — real-time frame scheduler and determinism frame.
2//!
3//! The scheduler is generic over the world type `W`: L2 stays below the
4//! object model (L3) and must not know what a `World` is. The application
5//! shell instantiates `Scheduler<oriera_ecs::World>` and owns the world —
6//! systems receive `&mut W` each frame. No shared global state crosses
7//! layer boundaries (book T3 §3.8.1).
8
9use oriera_common::{FrameIndex, Timestamp};
10use std::collections::VecDeque;
11
12/// Target frame time in nanoseconds (60fps ≈ 16.6ms).
13pub const FRAME_BUDGET_NS: u64 = 16_666_666;
14
15/// How many per-system timings and frame samples are kept for the
16/// rolling averages the scheduler reports.
17const HISTORY: usize = 60;
18
19/// Context passed to every system each frame.
20pub struct FrameCtx {
21    pub frame: FrameIndex,
22    pub dt: f32,
23    pub now: Timestamp,
24}
25
26/// A named system that runs each frame against the world `W`.
27pub trait System<W> {
28    fn name(&self) -> &str;
29    fn run(&mut self, ctx: &FrameCtx, world: &mut W);
30}
31
32/// The frame scheduler: runs registered systems in order, tracks frame time.
33pub struct Scheduler<W> {
34    systems: Vec<Box<dyn System<W>>>,
35    frame: FrameIndex,
36    last: Timestamp,
37    frame_times: VecDeque<f32>,
38    /// Frames in a row that exceeded the budget by >20% (reset on any
39    /// in-budget frame).
40    consecutive_overruns: u32,
41    /// How many times the sustained-overrun warning has fired. Read by
42    /// tests and the observability cross-cut (book T15).
43    pub budget_warnings: u64,
44    /// Frame budget this scheduler is graded against. Defaults to
45    /// [`FRAME_BUDGET_NS`]; platform shells pass the L1 profile's
46    /// budget (`frame_budget_us * 1000`) via [`Scheduler::with_budget_ns`]
47    /// so AR glasses at 90fps are not graded on the 60fps curve.
48    budget_ns: u64,
49    /// Per-system wall time of the most recent frame, in ms, in run
50    /// order. The answer to "which system ate this frame" — for
51    /// AI-native apps that is usually cognition or inference. Names
52    /// are captured once at `add_system`; ticks only rewrite the ms.
53    system_ms: Vec<(String, f32)>,
54}
55
56/// Sustained overruns that trigger one warning, and the overrun ratio.
57const OVERRUN_STREAK: u32 = 60;
58const OVERRUN_RATIO: f32 = 1.2;
59
60impl<W> Scheduler<W> {
61    pub fn new() -> Self {
62        Self::with_budget_ns(FRAME_BUDGET_NS)
63    }
64
65    /// Build a scheduler graded against a specific frame budget (ns).
66    pub fn with_budget_ns(budget_ns: u64) -> Self {
67        Self {
68            systems: Vec::new(),
69            frame: FrameIndex(0),
70            last: Timestamp::now(),
71            frame_times: VecDeque::with_capacity(HISTORY + 1),
72            consecutive_overruns: 0,
73            budget_warnings: 0,
74            budget_ns: budget_ns.max(1),
75            system_ms: Vec::new(),
76        }
77    }
78
79    pub fn add_system(&mut self, s: Box<dyn System<W>>) {
80        self.system_ms.push((s.name().to_string(), 0.0));
81        self.systems.push(s);
82    }
83
84    /// The configured frame budget in ms.
85    pub fn budget_ms(&self) -> f32 {
86        self.budget_ns as f32 / 1_000_000.0
87    }
88
89    /// Per-system wall time of the last frame, in run order:
90    /// `[(name, ms), ...]`. Updated by every [`Scheduler::tick`].
91    pub fn system_timings(&self) -> &[(String, f32)] {
92        &self.system_ms
93    }
94
95    /// Run one frame. Returns the measured frame time in ms.
96    pub fn tick(&mut self, world: &mut W) -> f32 {
97        let start = Timestamp::now();
98        let dt = start.elapsed(self.last);
99        self.last = start;
100
101        let ctx = FrameCtx {
102            frame: self.frame,
103            dt,
104            now: start,
105        };
106
107        for (s, timing) in self.systems.iter_mut().zip(self.system_ms.iter_mut()) {
108            let t0 = Timestamp::now();
109            s.run(&ctx, world);
110            timing.1 = Timestamp::now().elapsed(t0) * 1000.0;
111        }
112
113        let frame_ms = Timestamp::now().elapsed(start) * 1000.0;
114        self.frame.0 += 1;
115        self.frame_times.push_back(frame_ms);
116        if self.frame_times.len() > HISTORY {
117            self.frame_times.pop_front();
118        }
119        self.record_budget(frame_ms);
120        frame_ms
121    }
122
123    /// Book T5 `ReportBudget`: a frame >20% over budget extends the
124    /// streak; 60 in a row emits one `tracing::warn!` with the mean
125    /// frame time, then the streak restarts. Pure function of the
126    /// measured frame time so tests can drive it deterministically.
127    fn record_budget(&mut self, frame_ms: f32) {
128        let budget_ms = self.budget_ms();
129        if frame_ms > budget_ms * OVERRUN_RATIO {
130            self.consecutive_overruns += 1;
131            if self.consecutive_overruns >= OVERRUN_STREAK {
132                let worst = self
133                    .system_ms
134                    .iter()
135                    .max_by(|a, b| a.1.partial_cmp(&b.1).unwrap())
136                    .map(|(n, _)| n.as_str())
137                    .unwrap_or("");
138                tracing::warn!(
139                    frame = self.frame.0,
140                    avg_frame_ms = self.avg_frame_ms(),
141                    budget_ms,
142                    worst_system = worst,
143                    "L2 scheduler over budget for {OVERRUN_STREAK} consecutive frames"
144                );
145                self.budget_warnings += 1;
146                self.consecutive_overruns = 0;
147            }
148        } else {
149            self.consecutive_overruns = 0;
150        }
151    }
152
153    /// Mean frame time over the last 60 frames, in ms.
154    pub fn avg_frame_ms(&self) -> f32 {
155        if self.frame_times.is_empty() {
156            return 0.0;
157        }
158        self.frame_times.iter().sum::<f32>() / self.frame_times.len() as f32
159    }
160}
161
162impl<W> Default for Scheduler<W> {
163    fn default() -> Self {
164        Self::new()
165    }
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171
172    struct IncSystem;
173
174    impl System<u32> for IncSystem {
175        fn name(&self) -> &str {
176            "inc"
177        }
178        fn run(&mut self, _ctx: &FrameCtx, world: &mut u32) {
179            *world += 1;
180        }
181    }
182
183    #[test]
184    fn scheduler_passes_world_to_systems() {
185        let mut sched: Scheduler<u32> = Scheduler::new();
186        sched.add_system(Box::new(IncSystem));
187        let mut world = 0u32;
188        sched.tick(&mut world);
189        sched.tick(&mut world);
190        assert_eq!(world, 2);
191    }
192
193    #[test]
194    fn custom_budget_changes_the_grading_curve() {
195        // AR-glasses budget (11.1ms): 14ms frames are overruns here but
196        // not against the 60fps default.
197        let mut sched: Scheduler<()> = Scheduler::with_budget_ns(11_111_000);
198        let over_ms = sched.budget_ms() * 1.5;
199        for _ in 0..60 {
200            sched.record_budget(over_ms);
201        }
202        assert_eq!(sched.budget_warnings, 1);
203
204        let mut desktop: Scheduler<()> = Scheduler::new();
205        for _ in 0..120 {
206            desktop.record_budget(over_ms);
207        }
208        assert_eq!(desktop.budget_warnings, 0);
209        assert!((desktop.budget_ms() - 16.6666).abs() < 0.01);
210    }
211
212    #[test]
213    fn tick_records_per_system_timings_in_run_order() {
214        let mut sched: Scheduler<u32> = Scheduler::new();
215        sched.add_system(Box::new(IncSystem));
216        sched.add_system(Box::new(IncSystem));
217        let mut world = 0u32;
218        sched.tick(&mut world);
219        let t = sched.system_timings();
220        assert_eq!(t.len(), 2);
221        assert_eq!(t[0].0, "inc");
222        assert!(t.iter().all(|(_, ms)| *ms >= 0.0));
223    }
224
225    #[test]
226    fn sustained_overruns_fire_one_warning_per_streak() {
227        let mut sched: Scheduler<()> = Scheduler::new();
228        let over_ms = (FRAME_BUDGET_NS as f32 / 1_000_000.0) * 1.5;
229        for _ in 0..60 {
230            sched.record_budget(over_ms);
231        }
232        assert_eq!(sched.budget_warnings, 1);
233
234        // Next 60 overruns → a second warning (streak restarted).
235        for _ in 0..60 {
236            sched.record_budget(over_ms);
237        }
238        assert_eq!(sched.budget_warnings, 2);
239    }
240
241    #[test]
242    fn single_in_budget_frame_resets_the_streak() {
243        let mut sched: Scheduler<()> = Scheduler::new();
244        let over_ms = (FRAME_BUDGET_NS as f32 / 1_000_000.0) * 1.5;
245        for _ in 0..59 {
246            sched.record_budget(over_ms);
247        }
248        sched.record_budget(1.0); // in budget: streak resets
249        for _ in 0..59 {
250            sched.record_budget(over_ms);
251        }
252        assert_eq!(sched.budget_warnings, 0);
253    }
254
255    #[test]
256    fn exactly_at_ratio_threshold_is_not_an_overrun() {
257        let mut sched: Scheduler<()> = Scheduler::new();
258        let at_threshold = (FRAME_BUDGET_NS as f32 / 1_000_000.0) * 1.2;
259        for _ in 0..120 {
260            sched.record_budget(at_threshold);
261        }
262        assert_eq!(sched.budget_warnings, 0);
263        assert_eq!(sched.consecutive_overruns, 0);
264    }
265}