Skip to main content

lazily/
time.rs

1//! Phase 1 of the realtime + distributed primitives plan — `#lztime` temporal
2//! sources.
3//!
4//! See `lazily-spec/docs/temporal-sources.md` and the formal model
5//! `lazily-formal/LazilyFormal/Temporal.lean`. Time is modeled by a **logical
6//! clock** (a monotone `now: u64` tick) exactly like `relay_policy` — a binding
7//! drives the sources from its own runtime timer (`tokio::time`, a manual game
8//! loop) by feeding a non-decreasing `now`.
9//!
10//! Each source is a pure [`TimelineSource`] **compute core** (a side-effect-free
11//! state machine over plain integers — the C++-eligible, `BytesPayload` part)
12//! split from a thin reactive **cell** that projects the core's fire edge onto a
13//! [`Cell`](crate::Source) so dependents invalidate *only on an actual fire*
14//! (the backend-portability rule). `DeadlineCell<T>` is `PyObjectPayload` — it
15//! carries an opaque user value alongside a bytes-eligible deadline core.
16
17use std::cell::RefCell;
18
19use crate::Context;
20use crate::cell::Source;
21
22/// A pure temporal compute core driven by a monotone logical clock.
23///
24/// A runtime advances any source uniformly via [`tick`](TimelineSource::tick);
25/// [`next_fire`](TimelineSource::next_fire) lets a scheduler compute the delay to
26/// the next wake-up.
27pub trait TimelineSource {
28    /// Advance to logical time `now` (callers must not go backwards). Returns
29    /// `true` on a **fire edge** — a fire happened on this tick.
30    fn tick(&mut self, now: u64) -> bool;
31
32    /// Logical time of the next fire, or `None` when the source is exhausted.
33    fn next_fire(&self) -> Option<u64>;
34}
35
36/// A monotone logical clock a manual runtime (game loop, test) can own to drive
37/// sources. `advance` clamps backwards moves so `now` is always non-decreasing.
38#[derive(Debug, Clone, Copy, Default)]
39pub struct ManualClock {
40    now: u64,
41}
42
43impl ManualClock {
44    pub fn new() -> Self {
45        Self { now: 0 }
46    }
47    pub fn now(&self) -> u64 {
48        self.now
49    }
50    /// Advance to `now` (monotone: a smaller value is clamped to the current
51    /// time). Returns the effective `now` a source should be ticked with.
52    pub fn advance(&mut self, now: u64) -> u64 {
53        self.now = self.now.max(now);
54        self.now
55    }
56}
57
58// ---------------------------------------------------------------------------
59// Single-shot timer
60// ---------------------------------------------------------------------------
61
62/// Single-shot compute core: `None → Some(())` at the first tick with
63/// `now ≥ fire_at`; fires exactly once (idempotent thereafter).
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65pub struct TimerCore {
66    fire_at: u64,
67    fired: bool,
68}
69
70impl TimerCore {
71    pub fn new(fire_at: u64) -> Self {
72        Self {
73            fire_at,
74            fired: false,
75        }
76    }
77    pub fn fired(&self) -> bool {
78        self.fired
79    }
80}
81
82impl TimelineSource for TimerCore {
83    fn tick(&mut self, now: u64) -> bool {
84        if self.fired || now < self.fire_at {
85            return false;
86        }
87        self.fired = true;
88        true
89    }
90    fn next_fire(&self) -> Option<u64> {
91        if self.fired { None } else { Some(self.fire_at) }
92    }
93}
94
95/// Reactive single-shot timer: projects [`TimerCore`]'s fire edge onto a cell so
96/// `has_fired`/`value` dependents invalidate only on the fire (idempotent).
97pub struct TimerCell {
98    core: RefCell<TimerCore>,
99    fired: Source<bool>,
100}
101
102impl TimerCell {
103    pub fn new(ctx: &Context, fire_at: u64) -> Self {
104        Self {
105            core: RefCell::new(TimerCore::new(fire_at)),
106            fired: ctx.source(false),
107        }
108    }
109
110    /// Advance to logical time `now`; returns the fire edge. On a fire the
111    /// backing cell flips to `true` (the `PartialEq` store-guard makes a repeat
112    /// tick a no-op, so dependents invalidate exactly once).
113    pub fn tick(&self, ctx: &Context, now: u64) -> bool {
114        let edge = self.core.borrow_mut().tick(now);
115        if edge {
116            self.fired.set(ctx, true);
117        }
118        edge
119    }
120
121    /// Whether the timer has fired (reactive read).
122    pub fn has_fired(&self, ctx: &Context) -> bool {
123        self.fired.get(ctx)
124    }
125
126    /// `None` before the fire, `Some(())` after (reactive read).
127    pub fn value(&self, ctx: &Context) -> Option<()> {
128        if self.fired.get(ctx) { Some(()) } else { None }
129    }
130
131    /// The backing cell, for dependents that want to subscribe directly.
132    pub fn fired_cell(&self) -> Source<bool> {
133        self.fired
134    }
135
136    pub fn next_fire(&self) -> Option<u64> {
137        self.core.borrow().next_fire()
138    }
139}
140
141// ---------------------------------------------------------------------------
142// Periodic interval
143// ---------------------------------------------------------------------------
144
145/// Periodic compute core: fire boundaries at `period, 2·period, …`. A tick counts
146/// every boundary in `(frontier, now]`, so a jump past several boundaries counts
147/// them all.
148#[derive(Debug, Clone, Copy, PartialEq, Eq)]
149pub struct IntervalCore {
150    period: u64,
151    next: u64,
152    count: u64,
153}
154
155impl IntervalCore {
156    pub fn new(period: u64) -> Self {
157        let period = period.max(1);
158        Self {
159            period,
160            next: period,
161            count: 0,
162        }
163    }
164    pub fn count(&self) -> u64 {
165        self.count
166    }
167
168    /// Boundaries crossed on a single tick (0 when `now` is below the frontier).
169    fn fires_this_tick(&self, now: u64) -> u64 {
170        if now < self.next {
171            0
172        } else {
173            (now - self.next) / self.period + 1
174        }
175    }
176}
177
178impl TimelineSource for IntervalCore {
179    fn tick(&mut self, now: u64) -> bool {
180        let fires = self.fires_this_tick(now);
181        if fires == 0 {
182            return false;
183        }
184        self.count += fires;
185        self.next += fires * self.period;
186        true
187    }
188    fn next_fire(&self) -> Option<u64> {
189        Some(self.next)
190    }
191}
192
193/// Reactive periodic interval: projects [`IntervalCore`]'s fire count onto a cell
194/// (invalidates only when `count` changes).
195pub struct IntervalCell {
196    core: RefCell<IntervalCore>,
197    count: Source<u64>,
198}
199
200impl IntervalCell {
201    pub fn new(ctx: &Context, period: u64) -> Self {
202        Self {
203            core: RefCell::new(IntervalCore::new(period)),
204            count: ctx.source(0u64),
205        }
206    }
207
208    /// Advance to logical time `now`; returns whether a boundary fired. The count
209    /// cell mirrors the core's total fire count.
210    pub fn tick(&self, ctx: &Context, now: u64) -> bool {
211        let edge = self.core.borrow_mut().tick(now);
212        if edge {
213            let c = self.core.borrow().count();
214            self.count.set(ctx, c);
215        }
216        edge
217    }
218
219    /// Total fires so far (reactive read).
220    pub fn count(&self, ctx: &Context) -> u64 {
221        self.count.get(ctx)
222    }
223
224    pub fn count_cell(&self) -> Source<u64> {
225        self.count
226    }
227
228    pub fn next_fire(&self) -> Option<u64> {
229        self.core.borrow().next_fire()
230    }
231}
232
233// ---------------------------------------------------------------------------
234// Cron pattern
235// ---------------------------------------------------------------------------
236
237/// Count of `m ∈ 1..=n` with `m mod cycle == o` (`0 ≤ o < cycle`).
238fn count_upto(n: u64, o: u64, cycle: u64) -> u64 {
239    if o == 0 {
240        n / cycle
241    } else if o <= n {
242        (n - o) / cycle + 1
243    } else {
244        0
245    }
246}
247
248/// Pattern-periodic compute core: a tick `m ≥ 1` fires iff `m mod cycle ∈
249/// offsets`. Structurally an interval with a match set — a cron expression's
250/// shape. The match count in `(cursor, now]` is computed arithmetically, so a
251/// large `now` jump is `O(offsets)`.
252#[derive(Debug, Clone, PartialEq, Eq)]
253pub struct CronCore {
254    cycle: u64,
255    offsets: Vec<u64>,
256    cursor: u64,
257    count: u64,
258}
259
260impl CronCore {
261    /// `offsets` are reduced `mod cycle`, sorted, and deduped. `cycle` is clamped
262    /// to ≥ 1; empty offsets means the source never fires.
263    pub fn new(cycle: u64, offsets: impl IntoIterator<Item = u64>) -> Self {
264        let cycle = cycle.max(1);
265        let mut offsets: Vec<u64> = offsets.into_iter().map(|o| o % cycle).collect();
266        offsets.sort_unstable();
267        offsets.dedup();
268        Self {
269            cycle,
270            offsets,
271            cursor: 0,
272            count: 0,
273        }
274    }
275    pub fn count(&self) -> u64 {
276        self.count
277    }
278
279    fn matches_in(&self, lo: u64, hi: u64) -> u64 {
280        self.offsets
281            .iter()
282            .map(|&o| count_upto(hi, o, self.cycle) - count_upto(lo, o, self.cycle))
283            .sum()
284    }
285}
286
287impl TimelineSource for CronCore {
288    fn tick(&mut self, now: u64) -> bool {
289        if now <= self.cursor {
290            self.cursor = self.cursor.max(now);
291            return false;
292        }
293        let fires = self.matches_in(self.cursor, now);
294        self.cursor = now;
295        if fires == 0 {
296            return false;
297        }
298        self.count += fires;
299        true
300    }
301    fn next_fire(&self) -> Option<u64> {
302        if self.offsets.is_empty() {
303            return None;
304        }
305        // Smallest m > cursor with m mod cycle ∈ offsets.
306        let start = self.cursor + 1;
307        let base = start / self.cycle * self.cycle;
308        for cyc in 0..2u64 {
309            let block = base + cyc * self.cycle;
310            for &o in &self.offsets {
311                let cand = block + o;
312                if cand >= start {
313                    return Some(cand);
314                }
315            }
316        }
317        None
318    }
319}
320
321/// Reactive cron source: same reactive contract as [`IntervalCell`].
322pub struct CronCell {
323    core: RefCell<CronCore>,
324    count: Source<u64>,
325}
326
327impl CronCell {
328    pub fn new(ctx: &Context, cycle: u64, offsets: impl IntoIterator<Item = u64>) -> Self {
329        Self {
330            core: RefCell::new(CronCore::new(cycle, offsets)),
331            count: ctx.source(0u64),
332        }
333    }
334
335    pub fn tick(&self, ctx: &Context, now: u64) -> bool {
336        let edge = self.core.borrow_mut().tick(now);
337        if edge {
338            let c = self.core.borrow().count();
339            self.count.set(ctx, c);
340        }
341        edge
342    }
343
344    pub fn count(&self, ctx: &Context) -> u64 {
345        self.count.get(ctx)
346    }
347
348    pub fn count_cell(&self) -> Source<u64> {
349        self.count
350    }
351
352    pub fn next_fire(&self) -> Option<u64> {
353        self.core.borrow().next_fire()
354    }
355}
356
357// ---------------------------------------------------------------------------
358// Value + deadline
359// ---------------------------------------------------------------------------
360
361/// A value paired with a liveness state: `Live` until its deadline, then
362/// `Expired` — the value is preserved across the flip.
363#[derive(Debug, Clone, Copy, PartialEq, Eq)]
364pub enum Deadlined<T> {
365    Live(T),
366    Expired(T),
367}
368
369impl<T> Deadlined<T> {
370    pub fn is_expired(&self) -> bool {
371        matches!(self, Deadlined::Expired(_))
372    }
373    pub fn value(&self) -> &T {
374        match self {
375            Deadlined::Live(v) | Deadlined::Expired(v) => v,
376        }
377    }
378    pub fn into_value(self) -> T {
379        match self {
380            Deadlined::Live(v) | Deadlined::Expired(v) => v,
381        }
382    }
383}
384
385/// Deadline compute core (bytes-eligible): a [`TimerCore`] over the deadline. The
386/// value lives in the reactive cell (`PyObjectPayload`).
387#[derive(Debug, Clone, Copy, PartialEq, Eq)]
388pub struct DeadlineCore {
389    timer: TimerCore,
390}
391
392impl DeadlineCore {
393    pub fn new(deadline: u64) -> Self {
394        Self {
395            timer: TimerCore::new(deadline),
396        }
397    }
398    pub fn is_expired(&self) -> bool {
399        self.timer.fired()
400    }
401}
402
403impl TimelineSource for DeadlineCore {
404    fn tick(&mut self, now: u64) -> bool {
405        self.timer.tick(now)
406    }
407    fn next_fire(&self) -> Option<u64> {
408        self.timer.next_fire()
409    }
410}
411
412/// Reactive value + deadline: flips `Live(v) → Expired(v)` at the deadline,
413/// preserving the value; the `state` reader invalidates only on the expiry edge.
414pub struct DeadlineCell<T> {
415    core: RefCell<DeadlineCore>,
416    value: T,
417    expired: Source<bool>,
418}
419
420impl<T> DeadlineCell<T>
421where
422    T: Clone + 'static,
423{
424    pub fn new(ctx: &Context, value: T, deadline: u64) -> Self {
425        Self {
426            core: RefCell::new(DeadlineCore::new(deadline)),
427            value,
428            expired: ctx.source(false),
429        }
430    }
431
432    /// Advance to logical time `now`; returns the expiry edge.
433    pub fn tick(&self, ctx: &Context, now: u64) -> bool {
434        let edge = self.core.borrow_mut().tick(now);
435        if edge {
436            self.expired.set(ctx, true);
437        }
438        edge
439    }
440
441    /// The current state, cloning the preserved value (reactive read).
442    pub fn state(&self, ctx: &Context) -> Deadlined<T> {
443        if self.expired.get(ctx) {
444            Deadlined::Expired(self.value.clone())
445        } else {
446            Deadlined::Live(self.value.clone())
447        }
448    }
449
450    pub fn is_expired(&self, ctx: &Context) -> bool {
451        self.expired.get(ctx)
452    }
453
454    pub fn expired_cell(&self) -> Source<bool> {
455        self.expired
456    }
457
458    pub fn next_fire(&self) -> Option<u64> {
459        self.core.borrow().next_fire()
460    }
461}
462
463#[cfg(test)]
464mod tests {
465    use super::*;
466
467    #[test]
468    fn timer_fires_once() {
469        let mut t = TimerCore::new(3);
470        assert!(!t.tick(1));
471        assert_eq!(t.next_fire(), Some(3));
472        assert!(t.tick(3));
473        assert!(!t.tick(5)); // idempotent
474        assert_eq!(t.next_fire(), None);
475        assert!(t.fired());
476    }
477
478    #[test]
479    fn timer_cell_edge_only_invalidation() {
480        let ctx = Context::new();
481        let timer = TimerCell::new(&ctx, 3);
482        let observed = {
483            let timer_fired = timer.fired_cell();
484            ctx.computed(move |c| timer_fired.get(c))
485        };
486        assert!(!observed.get(&ctx));
487        assert!(!timer.tick(&ctx, 1));
488        assert_eq!(timer.value(&ctx), None);
489        assert!(timer.tick(&ctx, 3));
490        assert_eq!(timer.value(&ctx), Some(()));
491        assert!(timer.has_fired(&ctx));
492        // idempotent: repeat tick does not re-fire.
493        assert!(!timer.tick(&ctx, 9));
494        assert!(observed.get(&ctx));
495    }
496
497    #[test]
498    fn interval_counts_boundaries_including_jumps() {
499        let mut iv = IntervalCore::new(2);
500        assert!(!iv.tick(1));
501        assert_eq!(iv.count(), 0);
502        assert!(iv.tick(2));
503        assert_eq!(iv.count(), 1);
504        assert!(iv.tick(4));
505        assert_eq!(iv.count(), 2);
506        assert!(!iv.tick(5));
507        assert_eq!(iv.count(), 2);
508        assert!(iv.tick(8)); // crosses 6 and 8
509        assert_eq!(iv.count(), 4);
510        assert_eq!(iv.next_fire(), Some(10));
511    }
512
513    #[test]
514    fn cron_fires_on_pattern() {
515        let mut c = CronCore::new(5, [0, 3]);
516        assert!(!c.tick(2));
517        assert_eq!(c.count(), 0);
518        assert_eq!(c.next_fire(), Some(3));
519        assert!(c.tick(3));
520        assert_eq!(c.count(), 1);
521        assert_eq!(c.next_fire(), Some(5));
522        assert!(c.tick(5));
523        assert_eq!(c.count(), 2);
524        assert!(c.tick(8));
525        assert_eq!(c.count(), 3);
526        assert!(c.tick(10));
527        assert_eq!(c.count(), 4);
528        assert_eq!(c.next_fire(), Some(13));
529    }
530
531    #[test]
532    fn deadline_expires_preserving_value() {
533        let ctx = Context::new();
534        let d = DeadlineCell::new(&ctx, "payload".to_string(), 4);
535        assert!(matches!(d.state(&ctx), Deadlined::Live(_)));
536        assert!(!d.tick(&ctx, 2));
537        assert!(d.tick(&ctx, 4));
538        match d.state(&ctx) {
539            Deadlined::Expired(v) => assert_eq!(v, "payload"),
540            _ => panic!("expected Expired"),
541        }
542        assert!(!d.tick(&ctx, 9)); // idempotent
543        assert!(d.is_expired(&ctx));
544    }
545}