Slow/Fast Fusion
Real pipelines rarely run at one rate. A camera produces frames at 60 Hz while
an IMU streams at hundreds of hertz; a control loop ticks faster than the
perception stage feeding it. The slow_fast_fusion example distills this
multi-rate pattern to its essentials: a fast source drives every
activation of a fusion operator, while a slow source contributes its most
recent sample — but only while that sample is still fresh enough to trust.
The runnable source lives at public/examples/slow_fast_fusion/ in the
SDK tree.
The entire policy lives in one temporal contract built from three value
objects: OnEach (activate per fast sample), LatestWithin (admit the newest
slow sample within an age bound), and Skip (suppress the activation when no
slow sample qualifies). compute() never checks timestamps or arrival order —
the runtime has already made those decisions before it runs. If you have not
read temporal contracts
yet, this page is the shortest end-to-end demonstration of why they exist.
- How a fast input drives activation with
OnEachwhile a slow input rides along as context - How
LatestWithinage-gates the slow input andSkipsuppresses activations with no fresh slow sample - How whole-sample metadata (capture time, clock identity) is forwarded through a fusion operator
- How the example composes, compiles, and runs the graph with the ordinary application root — no wrapper class
- What the program prints and how to interpret it
The pipeline
Four operators, three connections, two rates:
slowandfastare two instances of the samePeriodicSourceoperator, configured with different periods (20 ms and 2 ms) and different value scales so you can tell their contributions apart in the output.fuse(FuseLatest) activates once per fast sample, adds the newest fresh slow value to it, and emits the result.sink(BoundedSink) records each fused value and requests immediate whole-run success after the sixth sample.
The example is deliberately host-only and numeric — no tensors, no devices — so every line of it is about temporal behavior.
The sources: one operator, two rates
PeriodicSource is a zero-input operator whose contract requests periodic
activation. Because it has no inputs, a contract is mandatory: the default
OnAnyInput trigger is invalid for an inputless operator.
Each activation emits one monotonically increasing value, stamped with genuine capture metadata taken from the activation time:
Two details are worth noticing. First, the sample carries a SampleMetadata
record with both a capture timestamp and the clock identity it was read
from — a timestamp without its clock domain is meaningless in a multi-clock
system (see time and clocks).
Second, setup() declares .max_emits_per_compute(1U) on the output, so the
compiler knows the exact per-activation emission bound and can size queues
accordingly.
The graph instantiates this one class twice with different constructor arguments — the slow source scales values by 100, the fast source by 1:
The fusion contract: fast drives, slow is age-gated
FuseLatest is where the multi-rate policy lives. Its whole temporal behavior
is these four lines:
Read it as three independent decisions:
OnEach{fast}— the fast port is the anchor. Every accepted fast sample produces exactly one activation candidate. The slow port never causes an activation by itself; it only supplies context.LatestWithin{.max_age = 100ms}— for each activation, the runtime selects the newest causally available slow sample whose age (relative to the activation) is at most 100 ms. With a 20 ms slow period this bound is generous — it exists so the startup transient is visible rather than to filter steady-state samples.Skip{}— if no slow sample satisfies the age bound, this activation candidate is suppressed entirely.compute()does not run with a hole in its inputs; the fast sample is simply not fused.
The practical effect: at startup, the first few fast samples arrive before the first slow sample exists, so those activations are skipped. Once the slow source has published, every fast sample fuses with the most recent slow value, which is reused across roughly ten consecutive fast activations until the next slow sample supersedes it.
Skip suppresses the activation, not just the input. If your operator
should still run in a degraded mode when the slow input is stale — common
in control loops that must keep emitting — use SafeDefault{} or
ReuseLast{.max_age = ...} instead. The full policy menu is on the
temporal contracts
page.
Metadata forwarding through compute()
Because the contract has already selected both inputs, compute() is a
straight-line function — receive, combine, emit:
The last two lines are the metadata-forwarding idiom. The operator mutates the
fast sample’s payload in place, then emits the whole sample — data plus
its SampleMetadata. The fused sample therefore inherits the fast sample’s
capture timestamp, source clock identity, and the rest of its metadata record,
so downstream consumers can still ask “when was this measurement actually
taken?” via metadata.capture_time(). This follows the general rule for
derived data: the anchor input’s capture identity is usually the honest one to
propagate, because the anchor is what the activation is about.
Note also the failure style: receive() returns an
expected, and errors propagate as values with make_unexpected — no
exceptions on the hot path (see
errors as values).
Composing and running
The application root is the ordinary three-phase sequence — no wrapper class,
just a Graph, free compile(), and run_async():
The connections carry explicit queue_depth bounds — the fast edge gets the
deeper queue (16) because it carries an order of magnitude more traffic. The
graph registers a RealtimeClock as its default, which both OnClock
triggers and the LatestWithin age comparison read from. compile() either
returns a plan that is ok() or a rejected plan whose json() explains why;
the run itself is started asynchronously, and the sink calls
request_completion() after its sixth sample. The accepted request closes
new callback admission for the whole session and reports
kRequestedCompleted. It is immediate whole-run success, not EOS: it does not
guarantee source closure, downstream drain, or delivery of queued samples.
These mechanics are
covered in composing a graph
and running and stopping.
The main routine also accepts an artifact-only mode (--emit-artifacts) that
compiles and publishes the plan’s deployment artifacts without ever starting
operator lifecycle — the same composition path serves both live runs and cold
plan publication.
Running it and reading the output
From the SDK examples build, the binary takes no arguments for a live run:
A successful run ends with a line like:
What to look for:
trigger_records— the number of temporal trigger records in the compiled plan (one per operator: twoOnClock, twoOnEach). This is the contract vocabulary made visible as plan data: yourcontract()overrides were lowered into inspectable records, not interpreted ad hoc at run time.selection_records— the plan’s input-selection records, covering the fusion operator’s age-gated slow input alongside its anchor. Queried viaplan.temporal_triggers()andplan.input_selections().fused_samples— the count of fused values the sink observed before it requested completion at sample 6. Each one is proof that a fast activation found a fresh-enough slow sample.last_value— the most recent fused value. Because the slow source scales by 100 and the fast source by 1, you can decompose any fused value by eye: the hundreds digits are the slow contribution, the remainder is the fast sample it fused with. Watching consecutive values, you will see the slow contribution hold steady across ~10 fast samples and then step —LatestWithinreusing the newest admitted slow sample until a newer one arrives.
The program exits nonzero unless the run reports kRequestedCompleted with no
diagnostics — a compact illustration of explicit success and the fail-closed
run posture.
Recap
- One fast input drives activation (
OnEach{fast}); the slow input is pure context, selected by freshness (LatestWithin{.max_age = 100ms}). Skip{}suppresses activations that have no fresh slow sample — visible at startup before the first slow sample exists.compute()contains zero timing logic; the contract made every temporal decision before it ran.- Emitting the moved anchor sample forwards its whole
SampleMetadata— capture time and clock identity survive fusion. - The sink requests immediate whole-run success after sample 6; that boundary deliberately provides no EOS or downstream-drain guarantee.
- The compiled plan exposes the lowered contract as data
(
temporal_triggers(),input_selections()), which the program prints.
Next: external clients connects standalone processes to a running pipeline from outside the graph.