Derived Nodes — Deep Dive
A derived node is a value the engine recomputes for you whenever any of its inputs
change. This page works through how derived() does that in causl — the
dynamic dependency tracking inside your compute function, the Kahn topological
recompute that runs in Phase D of every commit, the glitch-freedom
guarantee it inherits from the four-line denotational equation in
SPEC §3,
and the equality cutoff that lets long propagation chains short-circuit early.
If you have not yet read the
core concepts page,
start there. This page assumes you know what input(),
derived(), and graph.commit() do at the API level
and goes after the mechanics underneath.
1. The four-line equation, restated
Every derived value in causl is a pure function of its inputs at the
same logical time. Concretely, if d is a derived built
from upstream behaviors b₁ … bₙ:
d(t) = f(b₁(t), b₂(t), …, bₙ(t))
The variable that does the work is t: the same t on the
left-hand side and every right-hand-side term. There is no intermediate moment in
which b₁ is "already updated" but b₂ "hasn't been notified
yet." Between commits the graph is read-only; inside a commit the engine recomputes
the affected sub-graph to a fixpoint before any subscriber observes anything.
Glitch-freedom in causl is not a heuristic, a debounce, or a settle-time — it falls
out of the SPEC §3 equation mechanically.
2. Writing a derived
A derived's compute function receives the graph and returns a value. It calls
g.read(node) for every input it depends on; the reads are how the
engine learns the dependency set. There is no manual dependency list, no
useMemo-style array of identities to keep in sync.
import { createCausl, input, derived } from '@causl/core';
const graph = createCausl();
const price = input(graph, 100);
const quantity = input(graph, 3);
const taxRate = input(graph, 0.08);
const subtotal = derived(graph, g => g.read(price) * g.read(quantity));
const total = derived(graph, g => g.read(subtotal) * (1 + g.read(taxRate)));
console.log(graph.read(total)); // 324
graph.commit(() => {
graph.set(quantity, 5);
});
console.log(graph.read(total)); // 540
Two things happen at registration time. First, the engine evaluates the compute
eagerly — derived values are never lazy in causl; the value is real and
readable from the call site of derived(...). Second, every
g.read call inside the compute is intercepted by a tracker that
records the dependency set. The four edges (price → subtotal,
quantity → subtotal, subtotal → total,
taxRate → total) are the graph the engine will walk.
Detailed explanation — iterative, but still eager. Under SPEC §5.1 Amendment 3 the registration-time evaluator walks the dep graph with an explicit-stack driver rather than recursive function calls. Every derived is still computed at registration time and every §3 / §5.1 invariant holds byte-identically — but the depth ceiling on linear-chain registration lifts past 12,000 nodes on Node 22+. An earlier attempt at a lazy default broke 28 invariants and was reverted.
3. Dynamic dependencies
The dependency set of a derived is not fixed at registration. Every recompute
re-runs the compute function from scratch, so a branch that reads
quantity on one tick and g.read(quantity) never again
on the next is a real, supported pattern:
const mode = input<'tax' | 'discount'>(graph, 'tax');
const discount = input(graph, 0.1);
const final = derived(graph, g => {
const base = g.read(subtotal);
if (g.read(mode) === 'tax') {
return base * (1 + g.read(taxRate));
}
return base * (1 - g.read(discount));
});
While mode === 'tax', the dep set of final is
{ subtotal, mode, taxRate }. Flip mode to
'discount' in a commit and the engine re-records the dep set to
{ subtotal, mode, discount }. A subsequent commit that writes
only taxRate will not dirty final at all — the engine
knows final no longer reads it, even though it did last commit.
This is what the SPEC means when it says causl tracks dynamic
dependencies rather than the static set you would get from a constructor
argument list.
3.1 Conditional dep growth and cycle detection
Because the dep set is recorded after each recompute, a conditional branch that
first reaches into a new upstream on commit N+1 may close a cycle the engine
could not have seen on commit N. Causl detects this at the first commit that
closes the cycle, not at registration time, by augmenting Phase D's Kahn pass with
a post-recompute back-edge probe and throwing a structured
CycleError. The legacy strictCycles option is preserved
for backward compatibility but is a no-op.
4. Recursive derivations
A derived can read another derived. There is no API distinction — the read tracker treats every node uniformly. Chains, fans, and diamonds are all just edges in a DAG the engine will sort:
const a = input(graph, 1);
const b = derived(graph, g => g.read(a) + 1);
const c = derived(graph, g => g.read(a) * 2);
const d = derived(graph, g => g.read(b) + g.read(c)); // diamond
graph.commit(() => graph.set(a, 10));
console.log(graph.read(d)); // 31, computed from b=11, c=20
In a less disciplined reactive library the diamond at d is the
canonical glitch source: b updates and notifies d;
d recomputes with the new b and the old
c; then c updates and d recomputes again.
Subscribers see two notifications and one arithmetically nonsensical
intermediate value. Causl does not have this state because between
Phase B and Phase E the graph is private to the engine. d
is dirtied once, ordered after b and c in the Kahn
topo, and recomputed exactly once against b(t) and c(t)
with the same t.
5. Phase D — the Kahn topological recompute
Phase D is where the headline recompute happens. The pipeline reaches it after
Phase A (preconditions), Phase B (input writes published, gated by
Object.is), Phase C (clock advance), and Phase C.5
(lastWriteTime stamp). Its job is to recompute every derived whose
value might have changed, in an order that visits each derived after all
of its dependencies.
The algorithm is textbook Kahn:
- Seed a worklist with the affected sub-graph — every derived that transitively depends on an input the commit just dirtied in Phase B.
- Compute in-degrees over the affected sub-graph only. The unchanged remainder of the DAG is not touched.
- Pop a node with in-degree zero, recompute its value by re-running its compute function under the read tracker, record its new dep set.
- If the new value is not
Object.is-equal to the previous value, mark every dependent dirty and decrement their in-degrees; if it is equal, do nothing — this is the equality cutoff (§6 below). - After each recompute, forward-walk the just-recorded deps along the live dep
graph; if the recomputed entry is reachable, throw
CycleError. - Loop until the worklist is empty. A non-empty worklist with no in-degree-zero node also indicates a cycle.
Every recompute is pure: same input snapshot at the same GraphTime,
same output value, full stop. That is SPEC §3 Theorem 1 (determinism) and the
property the engine's replay-determinism contract is anchored to. The
cost-of-commit headline is that a commit producing N derived
recomputations runs in O(N), not O(graph size)
— the affected sub-graph is the only thing Phase D walks.
6. The equality cutoff
The equality cutoff is the trick that makes large derived graphs cheap in
practice. After a recompute, the engine compares the new value to the previous
value with Object.is. If they are equal, downstream dependents are
not dirtied. Phase D stops walking that branch; Phase G never notifies
anyone tied to that node.
const x = input(graph, 5);
const isPositive = derived(graph, g => g.read(x) > 0);
const sign = derived(graph, g => g.read(isPositive) ? '+' : '-');
let signNotifications = 0;
graph.subscribe(sign, () => signNotifications++);
graph.commit(() => graph.set(x, 7)); // isPositive: true -> true, cutoff fires
graph.commit(() => graph.set(x, 12)); // same, cutoff fires
graph.commit(() => graph.set(x, -1)); // isPositive: true -> false, propagates
console.log(signNotifications); // 1
isPositive is recomputed on every commit that writes x
— it is on the dirty path. But its value changes only when x crosses
zero, so sign is recomputed only then, and the subscriber fires only
then. This is the foundation of the equality-cutoff × 10000 benchmark
cell and the reason large fan-out graphs of pure projections stay tractable.
Detailed explanation — Object.is, not deep equality. The cutoff isObject.is:NaNequalsNaN,+0does not equal-0, and objects compare by reference. A derived that returns a fresh array each call —g => g.read(items).filter(p)— will never trip the cutoff. Cache the result with a stable identity (memoised projection, immutable structure-shared value, reused TypedArray) if you want the cutoff to do work for downstream nodes. Best practices for stable identity covers the patterns.
7. Glitch-freedom in one paragraph
A "glitch" in reactive programming is an observed inconsistent intermediate state
— usually a downstream node that briefly reflects a mix of old and new upstream
values during propagation. Causl's glitch-freedom is structural: the user never
observes Phase D in motion, only Phase E's frozen result. The diamond from §4
cannot glitch because d is computed exactly once per commit, against
the topologically-ordered b and c values at the same
t. SPEC §3 Theorem 2 names the property; SPEC §5 is the
implementation that satisfies it.
8. Performance trade-offs
What you get:
- O(affected sub-graph) recompute — a commit recomputes only the transitive dependents of the inputs it dirtied, not the full graph.
- Equality cutoff stops propagation at any node whose value survives the commit unchanged.
- Dynamic deps mean branches that read different upstreams on different ticks adapt automatically; you never over-subscribe.
- One subscriber notification per actual value change, not per dirty mark.
What you pay:
- Every recompute re-runs the whole compute function. There is no incremental "only the part that changed" mode. Use intermediate derived nodes to materialise expensive subcomputations and let the equality cutoff guard them.
- Read tracking has per-call cost. A compute that calls
g.reada million times per recompute pays a Map probe each time. Hoist reads out of inner loops. - Identity, not structural equality. Computes that return fresh objects defeat the cutoff. The fix is at the call site, not in the engine.
- Replay-determinism premium. Causl pays a ~3.5× factor over a
non-transactional reactive library on cutoff-bearing cells to ship
commitLogrebuild,changedNodesset construction,GraphTimemonotonicity, andreadAtretention. SPEC §17.5 documents the band; see where causl is and isn't the right tool if you do not need those four surfaces.
9. Reference identity is not a contract
One last hazard: graph.read(node) is not contractually required to
return the same JavaScript reference across calls. Reference identity is an
implementation detail of the current TypeScript engine; the day a Rust /
wasmgc backend lands per SPEC §17.6, read() for an
object-valued node will return a fresh deserialised reference each call.
Adopters who memoise on reference identity will silently re-render every commit
after migrating. The contractual surface is value identity via
Object.is at the same GraphTime. For memoisation keys
that survive a backend migration, key on commit.time or the per-node
version counter that EngineTelemetry exposes. SPEC §15.1 is the
canonical reference;
the FAQ entry walks through the
migration pitfalls.
10. What to read next
- The commit lifecycle — every phase from A through H with the rollback story for each.
- Subscribers and notifications — how Phase G dispatches per-node observers and how the equality cutoff propagates there.
- Commit-metadata deriveds — the
Phase F.5 seam for derivations that read
graph.commitLogand need to see the commit they were produced by. - Best practices for derived design — when to split a compute, when to memoise, and when to drop down to an input.
- SPEC §5 — the normative reference for Phase D, the eight named phases, and the atomicity contract derived nodes inherit.