oriera-core 0.3.0

OriEra L2 core: frame scheduler and determinism frame
Documentation
//! L2 Core — real-time frame scheduler and determinism frame.
//!
//! The scheduler is generic over the world type `W`: L2 stays below the
//! object model (L3) and must not know what a `World` is. The application
//! shell instantiates `Scheduler<oriera_ecs::World>` and owns the world —
//! systems receive `&mut W` each frame. No shared global state crosses
//! layer boundaries (book T3 §3.8.1).

use oriera_common::{FrameIndex, Timestamp};
use std::collections::VecDeque;

/// Target frame time in nanoseconds (60fps ≈ 16.6ms).
pub const FRAME_BUDGET_NS: u64 = 16_666_666;

/// How many per-system timings and frame samples are kept for the
/// rolling averages the scheduler reports.
const HISTORY: usize = 60;

/// Context passed to every system each frame.
pub struct FrameCtx {
    pub frame: FrameIndex,
    pub dt: f32,
    pub now: Timestamp,
}

/// A named system that runs each frame against the world `W`.
pub trait System<W> {
    fn name(&self) -> &str;
    fn run(&mut self, ctx: &FrameCtx, world: &mut W);
}

/// The frame scheduler: runs registered systems in order, tracks frame time.
pub struct Scheduler<W> {
    systems: Vec<Box<dyn System<W>>>,
    frame: FrameIndex,
    last: Timestamp,
    frame_times: VecDeque<f32>,
    /// Frames in a row that exceeded the budget by >20% (reset on any
    /// in-budget frame).
    consecutive_overruns: u32,
    /// How many times the sustained-overrun warning has fired. Read by
    /// tests and the observability cross-cut (book T15).
    pub budget_warnings: u64,
    /// Frame budget this scheduler is graded against. Defaults to
    /// [`FRAME_BUDGET_NS`]; platform shells pass the L1 profile's
    /// budget (`frame_budget_us * 1000`) via [`Scheduler::with_budget_ns`]
    /// so AR glasses at 90fps are not graded on the 60fps curve.
    budget_ns: u64,
    /// Per-system wall time of the most recent frame, in ms, in run
    /// order. The answer to "which system ate this frame" — for
    /// AI-native apps that is usually cognition or inference. Names
    /// are captured once at `add_system`; ticks only rewrite the ms.
    system_ms: Vec<(String, f32)>,
}

/// Sustained overruns that trigger one warning, and the overrun ratio.
const OVERRUN_STREAK: u32 = 60;
const OVERRUN_RATIO: f32 = 1.2;

impl<W> Scheduler<W> {
    pub fn new() -> Self {
        Self::with_budget_ns(FRAME_BUDGET_NS)
    }

    /// Build a scheduler graded against a specific frame budget (ns).
    pub fn with_budget_ns(budget_ns: u64) -> Self {
        Self {
            systems: Vec::new(),
            frame: FrameIndex(0),
            last: Timestamp::now(),
            frame_times: VecDeque::with_capacity(HISTORY + 1),
            consecutive_overruns: 0,
            budget_warnings: 0,
            budget_ns: budget_ns.max(1),
            system_ms: Vec::new(),
        }
    }

    pub fn add_system(&mut self, s: Box<dyn System<W>>) {
        self.system_ms.push((s.name().to_string(), 0.0));
        self.systems.push(s);
    }

    /// The configured frame budget in ms.
    pub fn budget_ms(&self) -> f32 {
        self.budget_ns as f32 / 1_000_000.0
    }

    /// Per-system wall time of the last frame, in run order:
    /// `[(name, ms), ...]`. Updated by every [`Scheduler::tick`].
    pub fn system_timings(&self) -> &[(String, f32)] {
        &self.system_ms
    }

    /// Run one frame. Returns the measured frame time in ms.
    pub fn tick(&mut self, world: &mut W) -> f32 {
        let start = Timestamp::now();
        let dt = start.elapsed(self.last);
        self.last = start;

        let ctx = FrameCtx {
            frame: self.frame,
            dt,
            now: start,
        };

        for (s, timing) in self.systems.iter_mut().zip(self.system_ms.iter_mut()) {
            let t0 = Timestamp::now();
            s.run(&ctx, world);
            timing.1 = Timestamp::now().elapsed(t0) * 1000.0;
        }

        let frame_ms = Timestamp::now().elapsed(start) * 1000.0;
        self.frame.0 += 1;
        self.frame_times.push_back(frame_ms);
        if self.frame_times.len() > HISTORY {
            self.frame_times.pop_front();
        }
        self.record_budget(frame_ms);
        frame_ms
    }

    /// Book T5 `ReportBudget`: a frame >20% over budget extends the
    /// streak; 60 in a row emits one `tracing::warn!` with the mean
    /// frame time, then the streak restarts. Pure function of the
    /// measured frame time so tests can drive it deterministically.
    fn record_budget(&mut self, frame_ms: f32) {
        let budget_ms = self.budget_ms();
        if frame_ms > budget_ms * OVERRUN_RATIO {
            self.consecutive_overruns += 1;
            if self.consecutive_overruns >= OVERRUN_STREAK {
                let worst = self
                    .system_ms
                    .iter()
                    .max_by(|a, b| a.1.partial_cmp(&b.1).unwrap())
                    .map(|(n, _)| n.as_str())
                    .unwrap_or("");
                tracing::warn!(
                    frame = self.frame.0,
                    avg_frame_ms = self.avg_frame_ms(),
                    budget_ms,
                    worst_system = worst,
                    "L2 scheduler over budget for {OVERRUN_STREAK} consecutive frames"
                );
                self.budget_warnings += 1;
                self.consecutive_overruns = 0;
            }
        } else {
            self.consecutive_overruns = 0;
        }
    }

    /// Mean frame time over the last 60 frames, in ms.
    pub fn avg_frame_ms(&self) -> f32 {
        if self.frame_times.is_empty() {
            return 0.0;
        }
        self.frame_times.iter().sum::<f32>() / self.frame_times.len() as f32
    }
}

impl<W> Default for Scheduler<W> {
    fn default() -> Self {
        Self::new()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    struct IncSystem;

    impl System<u32> for IncSystem {
        fn name(&self) -> &str {
            "inc"
        }
        fn run(&mut self, _ctx: &FrameCtx, world: &mut u32) {
            *world += 1;
        }
    }

    #[test]
    fn scheduler_passes_world_to_systems() {
        let mut sched: Scheduler<u32> = Scheduler::new();
        sched.add_system(Box::new(IncSystem));
        let mut world = 0u32;
        sched.tick(&mut world);
        sched.tick(&mut world);
        assert_eq!(world, 2);
    }

    #[test]
    fn custom_budget_changes_the_grading_curve() {
        // AR-glasses budget (11.1ms): 14ms frames are overruns here but
        // not against the 60fps default.
        let mut sched: Scheduler<()> = Scheduler::with_budget_ns(11_111_000);
        let over_ms = sched.budget_ms() * 1.5;
        for _ in 0..60 {
            sched.record_budget(over_ms);
        }
        assert_eq!(sched.budget_warnings, 1);

        let mut desktop: Scheduler<()> = Scheduler::new();
        for _ in 0..120 {
            desktop.record_budget(over_ms);
        }
        assert_eq!(desktop.budget_warnings, 0);
        assert!((desktop.budget_ms() - 16.6666).abs() < 0.01);
    }

    #[test]
    fn tick_records_per_system_timings_in_run_order() {
        let mut sched: Scheduler<u32> = Scheduler::new();
        sched.add_system(Box::new(IncSystem));
        sched.add_system(Box::new(IncSystem));
        let mut world = 0u32;
        sched.tick(&mut world);
        let t = sched.system_timings();
        assert_eq!(t.len(), 2);
        assert_eq!(t[0].0, "inc");
        assert!(t.iter().all(|(_, ms)| *ms >= 0.0));
    }

    #[test]
    fn sustained_overruns_fire_one_warning_per_streak() {
        let mut sched: Scheduler<()> = Scheduler::new();
        let over_ms = (FRAME_BUDGET_NS as f32 / 1_000_000.0) * 1.5;
        for _ in 0..60 {
            sched.record_budget(over_ms);
        }
        assert_eq!(sched.budget_warnings, 1);

        // Next 60 overruns → a second warning (streak restarted).
        for _ in 0..60 {
            sched.record_budget(over_ms);
        }
        assert_eq!(sched.budget_warnings, 2);
    }

    #[test]
    fn single_in_budget_frame_resets_the_streak() {
        let mut sched: Scheduler<()> = Scheduler::new();
        let over_ms = (FRAME_BUDGET_NS as f32 / 1_000_000.0) * 1.5;
        for _ in 0..59 {
            sched.record_budget(over_ms);
        }
        sched.record_budget(1.0); // in budget: streak resets
        for _ in 0..59 {
            sched.record_budget(over_ms);
        }
        assert_eq!(sched.budget_warnings, 0);
    }

    #[test]
    fn exactly_at_ratio_threshold_is_not_an_overrun() {
        let mut sched: Scheduler<()> = Scheduler::new();
        let at_threshold = (FRAME_BUDGET_NS as f32 / 1_000_000.0) * 1.2;
        for _ in 0..120 {
            sched.record_budget(at_threshold);
        }
        assert_eq!(sched.budget_warnings, 0);
        assert_eq!(sched.consecutive_overruns, 0);
    }
}