yog 0.0.1

yog: a balls-oriented session manager for lernie loops (egui frontend)
Documentation
//! Debounce + sweep scheduling (DESIGN §7.2, §15 Y6): the clock-gated timing
//! decisions the frame consults each tick.
//!
//! Two rhythms, one injected clock:
//!
//! - **Debounce** — a dirty root opens a 100 ms coalescing window
//!   ([`DEBOUNCE`]); [`Schedule::due`] yields it only once the window elapses,
//!   collapsing a streaming-append storm to ≤10 rebuilds/s of a workspace
//!   (§7.2). Rebuild is always correct, so coalescing only ever delays, never
//!   drops.
//! - **Sweeps** — [`Schedule::sweep`] is the pure `should_run(now, last)`
//!   decision behind the 2 s cheap sweep and the 15 s full sweep (§7.2). The
//!   full sweep supersedes the cheap one. The *effects* (enumerate + reconcile
//!   the [`WatchSet`](crate::watch::WatchSet), re-probe liveness, mark roots
//!   dirty) live in [`AppModel`](super::AppModel) — this module is timing only,
//!   so every branch is testable with an injected clock and no sleeps.
//!
//! Time is [`crate::ui_state::Clock`], reused verbatim (§7.2: "the same
//! injection pattern") — Y6 mints no second clock trait.

use crate::ui_state::Clock;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::{Duration, Instant};

/// Coalescing window: a dirty root re-derives at most once per 100 ms (§7.2).
pub const DEBOUNCE: Duration = Duration::from_millis(100);
/// Cheap sweep cadence: enumerations + reconcile + targeted liveness (§7.2).
/// Also the frame's `request_repaint_after` poll floor (I4).
pub const CHEAP_SWEEP: Duration = Duration::from_secs(2);
/// Full sweep cadence: re-derive everything, bounding staleness to 15 s (§7.2).
pub const FULL_SWEEP: Duration = Duration::from_secs(15);

/// Which periodic sweep a tick owes (§7.2). `Full` implies (and supersedes)
/// `Cheap`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Sweep {
    None,
    Cheap,
    Full,
}

/// Debounce windows + sweep deadlines over one injected clock. The clock is a
/// trait object (`Arc<dyn Clock>`, cold-path virtual dispatch) so the schedule
/// carries no `Clock` generic; the `Arc` shares one time source with the
/// `ui.json` debounce (§7.2).
pub struct Schedule {
    clock: Arc<dyn Clock>,
    pending: HashMap<PathBuf, Instant>,
    last_cheap: Instant,
    last_full: Instant,
}

impl Schedule {
    pub fn new(clock: Arc<dyn Clock>) -> Self {
        let now = clock.now();
        Self {
            clock,
            pending: HashMap::new(),
            last_cheap: now,
            last_full: now,
        }
    }

    /// Open a coalescing window for each root. A root already pending keeps its
    /// earliest deadline, so repeated marks within a window still fire once.
    pub(crate) fn mark<I: IntoIterator<Item = PathBuf>>(&mut self, roots: I) {
        let deadline = self.clock.now() + DEBOUNCE;
        for root in roots {
            self.pending.entry(root).or_insert(deadline);
        }
    }

    /// Roots whose window has elapsed — ready to re-derive now. Removed from
    /// the pending set; a later change reopens a fresh window.
    pub fn due(&mut self) -> Vec<PathBuf> {
        let now = self.clock.now();
        let mut ready = Vec::new();
        for (root, &deadline) in &self.pending {
            if now >= deadline {
                ready.push(root.clone());
            }
        }
        for root in &ready {
            self.pending.remove(root);
        }
        ready
    }

    /// The sweep this tick owes, resetting the fired deadline(s). A full sweep
    /// also resets the cheap deadline (it did the cheap work and more).
    pub fn sweep(&mut self) -> Sweep {
        let now = self.clock.now();
        if elapsed(now, self.last_full, FULL_SWEEP) {
            self.last_full = now;
            self.last_cheap = now;
            Sweep::Full
        } else if elapsed(now, self.last_cheap, CHEAP_SWEEP) {
            self.last_cheap = now;
            Sweep::Cheap
        } else {
            Sweep::None
        }
    }
}

/// `now - last >= period`, saturating (a non-monotonic injected clock can't
/// underflow).
fn elapsed(now: Instant, last: Instant, period: Duration) -> bool {
    now.saturating_duration_since(last) >= period
}

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

    fn root(s: &str) -> PathBuf {
        PathBuf::from(s)
    }

    #[test]
    fn debounce_holds_a_root_until_the_window_elapses() {
        let clock = FakeClock::new();
        let mut sched = Schedule::new(clock.arc());
        sched.mark([root("/w")]);
        assert!(sched.due().is_empty(), "held during the window");
        clock.advance(DEBOUNCE);
        assert_eq!(sched.due(), vec![root("/w")], "released after the window");
        assert!(sched.due().is_empty(), "consumed — not re-emitted");
    }

    #[test]
    fn debounce_coalesces_repeated_marks_to_one_release() {
        let clock = FakeClock::new();
        let mut sched = Schedule::new(clock.arc());
        // A storm within the window keeps the earliest deadline.
        sched.mark([root("/w")]);
        clock.advance(Duration::from_millis(40));
        sched.mark([root("/w")]);
        clock.advance(Duration::from_millis(60)); // 100 ms since first mark
        assert_eq!(sched.due(), vec![root("/w")]);
        // A fresh mark reopens a new window.
        sched.mark([root("/w")]);
        assert!(sched.due().is_empty());
    }

    #[test]
    fn sweep_none_then_cheap_then_full() {
        let clock = FakeClock::new();
        let mut sched = Schedule::new(clock.arc());
        assert_eq!(sched.sweep(), Sweep::None);
        clock.advance(CHEAP_SWEEP);
        assert_eq!(sched.sweep(), Sweep::Cheap);
        assert_eq!(sched.sweep(), Sweep::None, "cheap deadline reset");
        clock.advance(FULL_SWEEP);
        assert_eq!(sched.sweep(), Sweep::Full);
        assert_eq!(sched.sweep(), Sweep::None, "full reset both deadlines");
        clock.advance(CHEAP_SWEEP);
        assert_eq!(sched.sweep(), Sweep::Cheap, "cheap resumes after a full");
    }
}