Part 3: Derived Nodes and Glitch-Freedom

Parts 1 and 2 introduced a single input and a single derived node — enough to see the commit / read / subscribe shape, not enough to see why anyone would prefer it over a hand-rolled callback. The interesting question — the one causl exists to answer — is what happens when one derived node depends on two upstream nodes that share a common ancestor. The shape is called a diamond, and naive event systems get it wrong in a way that is famously hard to test for: subscribers fire with one input updated and the other still stale, producing a brief inconsistent state called a glitch.

This page builds a small diamond by hand, walks through what would go wrong in a naive implementation, and then traces what actually happens during causl's Phase D Kahn topological recompute. By the end you should know exactly why an intermediate inconsistent state is unobservable from outside the commit boundary — not just that the tests pass, but why the construction makes the bad state non-existent.

1. The diamond that breaks naive event systems

Consider four nodes. price is an input cell. withTax reads price and adds 8%. withDiscount reads price and subtracts 10%. total reads both withTax and withDiscount and returns their average. The dependency graph looks like this:

price / \ withTax withDiscount \ / total

Now imagine a hand-rolled event bus where each cell emits a change event and each derived subscribes to its dependencies. When price changes from 100 to 200, the bus has to deliver two notifications to total — one because withTax updated, one because withDiscount updated. Whichever arrives first triggers a recompute of total against a half-fresh world: withTax reflects the new price but withDiscount is still stale (or the other way round). For one tick, an observer of total sees a value that corresponds to no consistent state of the inputs. The number is wrong, the diff is wrong, and the bug is almost invisible in tests because it only appears when the event order is exactly wrong.

Detailed explanation. The §9.1 race catalogue lists this as row 5, "Diamond glitches (D recomputes off mismatched B and C versions)." Its resolution sentence is one line: "the §3 semantic equation makes the bad state non-existent: D(t) = f(B(t), C(t)) is a function." That sentence is doing a lot of work — it says the engine never publishes a value of total that depends on two different versions of price. The mechanism is Phase D.

2. Wiring the diamond in causl

Build the same shape with @causl/core. There is nothing special about the diamond at registration time — you just declare each derivation's compute and let the engine track reads.

import { createCausl } from '@causl/core'; const graph = createCausl(); const price = graph.input('price', 100); const withTax = graph.derived('withTax', g => g(price) * 1.08); const withDiscount = graph.derived('withDiscount', g => g(price) * 0.90); const total = graph.derived('total', g => (g(withTax) + g(withDiscount)) / 2, ); console.log(graph.read(total)); // 99.0 ((108 + 90) / 2)

Every g(...) call inside a derivation body is tracked: the compute receives a callable getter (get), and the engine records the dynamic dependency set the first time the body runs and refreshes it on every subsequent recompute. There is no manual subscribe call between derivations. The graph structure is inferred from what each compute actually reads, not from a schema you hand the engine up front.

3. Watch what does not happen on commit

Subscribe to all four nodes and update price. A glitchy implementation would fire total's subscriber more than once, or fire it once with a number that does not equal the consistent recomputation. Neither happens here.

const log: Array<{ node: string; value: number }> = []; graph.subscribe(price, v => log.push({ node: 'price', value: v })); graph.subscribe(withTax, v => log.push({ node: 'withTax', value: v })); graph.subscribe(withDiscount, v => log.push({ node: 'withDiscount', value: v })); graph.subscribe(total, v => log.push({ node: 'total', value: v })); graph.commit('bump-price', tx => { tx.set(price, 200); }); console.log(log); // [ // { node: 'price', value: 200 }, // { node: 'withTax', value: 216 }, // { node: 'withDiscount', value: 180 }, // { node: 'total', value: 198 }, // ]

Each subscriber fires exactly once, with the post-commit value. There is no intermediate notification where total equals (216 + 90) / 2 = 153 or (108 + 180) / 2 = 144. Those values, in causl's vocabulary, do not exist — no read, no subscriber callback, no commit-log entry, no devtools snapshot will ever return them.

4. What Phase D actually does

The commit pipeline in causl is named in SPEC §5.1 as a sequence of phases A through H. The relevant one for derivations is Phase D: a Kahn topological recompute over the affected sub-graph. The steps are:

  1. Seed the affected set. Phase B already wrote the new value of price into its input cell. The engine walks the reverse-dependency graph from price and collects every derived that transitively depends on it. For our diamond, that set is { withTax, withDiscount, total }.
  2. Kahn-order the affected set. Build a sub-DAG of just the affected nodes and topologically sort it. Nodes with no remaining in-edges go first. withTax and withDiscount have only price upstream (which is already settled in Phase B), so they go first. total has both withTax and withDiscount upstream, so it goes after both have run.
  3. Recompute in order. Run each derivation's compute body against the current engine state. withTax sees price = 200 and produces 216. withDiscount sees the same price = 200 and produces 180. total runs last, sees the just-recomputed withTax = 216 and withDiscount = 180, and produces 198.
  4. Equality-cutoff per node. If a derivation's new value is Object.is-equal to its previous value, the engine removes it from the changedNodes set so its subscribers do not fire in Phase G. This is why Phase D's cost is O(N) in the number of nodes the affected sub-graph actually contains, not in the size of the entire graph.

The crucial property: nothing outside the commit body observes any intermediate state. Subscribers do not fire in Phase D — they fire in Phase G, after the entire sub-graph has settled. A concurrent graph.read from outside the commit cannot interleave with Phase D either, because commit is single-threaded and re-entry throws CommitInProgressError. The two-version total = 153 exists for a moment in the engine's working memory, but the §3 atomicity contract says that moment is not part of any observable timeline.

5. The semantic equation, and why it matters

SPEC §3 states the contract as an equation: at every settled commit t, every derived d satisfies d.value == d.compute(get → b(d.lastObservedTimes[b])) and every lastObservedTime[b] == now. In plain English: there is one global commit clock, every derived's value equals what its compute body would return against the inputs at that clock, and no input is ever seen at a different clock than any other. Theorem 2 of the SPEC names this glitch-freedom; the property test glitch-freedom.test.ts verifies it at a 1000-trial floor in CI.

Detailed explanation — why the equation, not just the tests. Tests catch regressions; they do not catch classes of bug the implementation could harbour. The semantic equation lets the engine team rule out an entire class — any commit sequence whose observable trace violates the equation is by definition a bug, full stop, no further argument needed. That is what "glitch-freedom is a theorem, not a hope" means in the project's posture.

6. Diamonds of every shape

The diamond is only the smallest non-trivial case. Phase D handles arbitrary DAGs the same way: any number of inputs, any depth of derivations, any branching factor. The two rules are structural, not topological:

The cycle case is worth knowing about. Earlier versions of causl ran an O(N) DFS at every derived() registration to catch cycles up front. SPEC §9.1 Amendment 1 retired that registration gate in favour of first-commit-time Kahn detection — same contract, far cheaper at registration. The strictCycles option on createCausl() is preserved for backward compatibility but is a no-op as of that amendment.

7. A larger worked example

A spreadsheet-flavoured graph makes the Kahn behaviour easier to feel. Imagine three input cells holding line items, two derivations holding subtotals, and one derivation holding the grand total:

import { createCausl } from '@causl/core'; const graph = createCausl(); const lineA = graph.input('lineA', 10); const lineB = graph.input('lineB', 20); const lineC = graph.input('lineC', 30); const subtotalAB = graph.derived('subtotalAB', g => g(lineA) + g(lineB)); const subtotalBC = graph.derived('subtotalBC', g => g(lineB) + g(lineC)); const grandTotal = graph.derived('grandTotal', g => g(subtotalAB) + g(subtotalBC), ); graph.subscribe(grandTotal, v => console.log('grandTotal =', v)); graph.commit('update-lines', tx => { tx.set(lineA, 100); tx.set(lineB, 200); // shared upstream of both subtotals tx.set(lineC, 300); }); // grandTotal = 800 // subtotalAB = 100 + 200 = 300 // subtotalBC = 200 + 300 = 500 // grandTotal = 300 + 500 = 800

lineB is the shared ancestor of both subtotals — the same role price played in the four-node diamond. Phase D's Kahn order guarantees both subtotals see the post-commit lineB = 200 before grandTotal runs, so grandTotal's subscriber fires exactly once with 800. No transient 700 or 900 from a half-updated subtotal ever escapes the commit.

8. What we have not covered yet

Phase D is the load-bearing phase for derivations, but the commit pipeline has seven other phases doing useful work. Part 4 of this tutorial walks through subscribers and the Phase G dispatch in detail — the equality-cutoff that prevents redundant notifications, the dynamic-dep cleanup, and the contract that graph.read() reference identity is not guaranteed (SPEC §15.1, the H1 hazard adopters most commonly hit). Part 5 covers simulate and how to preview what a commit would do without publishing it.

Two notes before moving on. First, derivations are pure functions of their tracked reads: if your compute body has side effects, the engine will faithfully execute them once per recompute, which is almost never what you want. Put side effects in subscribers, not in compute bodies. Second, the engine's per-commit cost is O(N) in the affected sub-graph size — derivations that don't read anything that changed don't recompute at all. Building wide flat graphs is often cheaper than building deep nested ones.