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:

  1. Seed a worklist with the affected sub-graph — every derived that transitively depends on an input the commit just dirtied in Phase B.
  2. Compute in-degrees over the affected sub-graph only. The unchanged remainder of the DAG is not touched.
  3. 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.
  4. 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).
  5. After each recompute, forward-walk the just-recorded deps along the live dep graph; if the recomputed entry is reachable, throw CycleError.
  6. 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 is Object.is: NaN equals NaN, +0 does 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:

What you pay:

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