Skip to content

Time and Sync (Notebook)

The notebook companion to Time and Sync: a graph makes two independent decisions per Flow — a clock picks when a Flow wakes, and a sync policy on each edge picks which buffered record it reads. Below we build tiny multi-rate graphs and step them in-process so both knobs are visible. Every output block is captured from a real run — nothing here needs a backend, camera, or robot.

⬇  Download .ipynb▶  Open in Colab

Every Flow answers two separate questions. The clock (@ Rate/Trigger/Hybrid) answers when do I wake? The sync policy (sync= on each edge) answers which buffered record do I read? They compose freely, and each has a plain, inspectable repr.

from retriever.flow import Events, Hold, Hybrid, Latest, Rate, Trigger, Window

print("Clocks — decide WHEN a Flow wakes:")
print("  Rate(hz=20)                 ->", Rate(hz=20), f"(every {Rate(hz=20).interval:.3f}s)")
print("  Trigger('value')            ->", Trigger("value"))
print("  Hybrid(hz=5, trigger=[...]) ->", Hybrid(hz=5, trigger=["value"]))
print()
print("Sync policies — decide WHICH upstream record step() gets:")
print("  Latest()                    ->", Latest())
print("  Window(20, 0.35s, 'mean')   ->", Window(buffer_size=20, duration=0.35, agg="mean"))
print("  Hold(debounce=0.25)         ->", Hold(debounce=0.25))
print("  Events(10, 0.35s)           ->", Events(buffer_size=10, duration=0.35, include_timestamps=False))

Output

Clocks — decide WHEN a Flow wakes:
  Rate(hz=20)                 -> Rate(hz=20) (every 0.050s)
  Trigger('value')            -> Trigger('value')
  Hybrid(hz=5, trigger=[...]) -> Hybrid(hz=5, trigger=['value'])

Sync policies — decide WHICH upstream record step() gets:
  Latest()                    -> Latest(buffer_size=1)
  Window(20, 0.35s, 'mean')   -> Window(buffer_size=20, duration=0.35, agg='mean')
  Hold(debounce=0.25)         -> Hold(buffer_size=1, debounce=0.25)
  Events(10, 0.35s)           -> Events(buffer_size=10, duration=0.35, include_timestamps=False)

Takeaway: clock and sync are orthogonal. One says when; the other says which record. Every combination is legal, so cameras, controllers, and slow policies can coexist without a shared timestep.

A Rate Flow wakes on its own timer, every tick, whether or not new data arrived. A Trigger Flow wakes only when its named field arrives. A Hybrid Flow wakes on the timer or immediately on the event. Here a sensor fires every tick but only emits a reading on even ticks — watch which downstream Flows go quiet when nothing new arrives.

from retriever.flow import Flow, Pipeline, io

@io
class Reading:
    value: int

class Sensor(Flow[None, Reading]):
    """Wakes every tick, but only publishes a reading on even ticks."""
    def reset(self):
        self.tick = 0
        self.emitted = 0
    def step(self, _):
        self.tick += 1
        if self.tick % 2 == 1:  # emit on the 1st, 3rd, 5th tick...
            self.emitted += 1
            return Reading(value=self.emitted)
        return Reading()  # nothing published this tick

class Ticker(Flow[Reading, Reading]):       # @ Rate — wakes every tick
    def step(self, r): return Reading(value=r.value)

class Detector(Flow[Reading, Reading]):     # @ Trigger — wakes only on arrival
    def step(self, r): return Reading(value=r.value)

class RateOrEvent(Flow[Reading, Reading]):  # @ Hybrid — wakes on timer OR event
    def step(self, r): return Reading(value=r.value)

clocks = Pipeline("time.clocks")
with clocks:
    sensor   = Sensor()      @ Rate(hz=10)
    ticker   = Ticker()      @ Rate(hz=10)
    detector = Detector()    @ Trigger("value")
    hybrid   = RateOrEvent() @ Hybrid(hz=10, trigger=["value"])
    clocks.connect(sensor, ticker,   sync=Latest())
    clocks.connect(sensor, detector, sync=Latest())
    clocks.connect(sensor, hybrid,   sync=Latest())

print("tick | Sensor emits | Ticker(Rate) | Detector(Trigger) | RateOrEvent(Hybrid)")
for i in range(6):
    res = clocks.step(dt=0.1)
    emitted = res.outputs["Sensor"].value
    emit_s = f"v={emitted}" if emitted is not None else "  -"
    tick_seen = f"sees v={res.inputs['Ticker'].value}"
    det = f"FIRED v={res.inputs['Detector'].value}" if "Detector" in res.executed else "idle"
    hyb = "FIRED" if "RateOrEvent" in res.executed else "idle"
    print(f"  {i}  |    {emit_s:5}    |  {tick_seen:9} | {det:11} | {hyb}")
clocks.close_stepper()

Output

tick | Sensor emits | Ticker(Rate) | Detector(Trigger) | RateOrEvent(Hybrid)
  0  |    v=1      |  sees v=1  | FIRED v=1   | FIRED
  1  |      -      |  sees v=1  | idle        | FIRED
  2  |    v=2      |  sees v=2  | FIRED v=2   | FIRED
  3  |      -      |  sees v=2  | idle        | FIRED
  4  |    v=3      |  sees v=3  | FIRED v=3   | FIRED
  5  |      -      |  sees v=3  | idle        | FIRED

Takeaway: Detector is the only Flow that goes quiet on odd ticks — its Trigger clock has nothing to fire on, while Rate and Hybrid wake every tick. The in-process stepper wakes Rate and Hybrid once per tick; the extra thing Hybrid buys you — waking immediately on an event instead of waiting for the next timer edge — is a live-scheduling property.

Every edge keeps a timestamped buffer of upstream outputs. The sync= policy consumes that buffer at wake time and returns exactly one input. Fan the same 10 Hz ramp (emitting 1, 2, 3, …) into three consumers with three policies and compare what each step() receives on the same tick.

@io
class Signal:
    value: int

class Ramp(Flow[None, Signal]):
    def reset(self): self.n = 0
    def step(self, _):
        self.n += 1
        return Signal(value=self.n)

class Newest(Flow[Signal, Signal]):
    def step(self, s): return Signal(value=s.value)

class Averaged(Flow[Signal, Signal]):
    def step(self, s): return Signal(value=s.value)

class Recent(Flow[Signal, Signal]):
    def step(self, s): return Signal(value=s.value)

sync = Pipeline("time.sync")
with sync:
    ramp     = Ramp()     @ Rate(hz=10)
    newest   = Newest()   @ Rate(hz=10)
    averaged = Averaged() @ Rate(hz=10)
    recent   = Recent()   @ Rate(hz=10)
    sync.connect(ramp, newest,   sync=Latest())
    sync.connect(ramp, averaged, sync=Window(buffer_size=20, duration=0.35, agg="mean"))
    sync.connect(ramp, recent,   sync=Events(buffer_size=10, duration=0.35, include_timestamps=False))

print("tick | Latest() | Window(mean, 0.35s) | Events(recent 0.35s)")
for i in range(6):
    res = sync.step(dt=0.1)
    latest_v = res.inputs["Newest"].value
    mean_v = res.inputs["Averaged"].value
    recent_v = res.inputs["Recent"].value
    print(f"  {i}  |    {latest_v:>2}    |        {mean_v:>4}         | {recent_v}")
sync.close_stepper()

Output

tick | Latest() | Window(mean, 0.35s) | Events(recent 0.35s)
  0  |     1    |         1.0         | [1]
  1  |     2    |         1.5         | [1, 2]
  2  |     3    |         2.0         | [1, 2, 3]
  3  |     4    |         2.5         | [1, 2, 3, 4]
  4  |     5    |         3.5         | [2, 3, 4, 5]
  5  |     6    |         4.5         | [3, 4, 5, 6]

Takeaway: same stream, same tick, three different inputs. Latest() hands over one fresh value; Window(agg=“mean”) summarizes the last 0.35 s and slides forward; Events(…) returns the recent records themselves. Each policy is a pure function of the buffered, timestamped records — not of any global state.

Hold is a zero-order hold: it repeats the last accepted value. With debounce=, it also refuses to accept a new value until that many seconds have passed — a leading-edge rate limiter. Feed the same 10 Hz ramp through Latest() and Hold(debounce=0.25) side by side.

class Debounced(Flow[Signal, Signal]):
    def step(self, s): return Signal(value=s.value)

hold = Pipeline("time.hold")
with hold:
    src    = Ramp()      @ Rate(hz=10)
    live   = Newest()    @ Rate(hz=10)
    steady = Debounced() @ Rate(hz=10)
    hold.connect(src, live,   sync=Latest())
    hold.connect(src, steady, sync=Hold(debounce=0.25))

print("tick | Latest() | Hold(debounce=0.25)")
for i in range(6):
    res = hold.step(dt=0.1)
    print(f"  {i}  |    {res.inputs['Newest'].value:>2}    | {res.inputs['Debounced'].value:>2}")
hold.close_stepper()

Output

tick | Latest() | Hold(debounce=0.25)
  0  |     1    |  1
  1  |     2    |  1
  2  |     3    |  1
  3  |     4    |  4
  4  |     5    |  4
  5  |     6    |  4

Takeaway: Latest() passes every reading; Hold(debounce=0.25) accepts one, ignores updates for 0.25 s, then re-serves the held value — a calm, rate-limited signal with no extra state downstream. Clocks and sync are also the determinism boundary: the wall clock decides which records land in a buffer, but every sync policy is a pure function of those records, so replaying the same trace makes every step() see the same input. Next: Runtime connects clocks and sync to validation, in-process stepping, backends, and replay.


This page is generated from notebooks/src/time_and_sync.py (jupytext py:percent source). Grab the runnable notebook and run every cell yourself.