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:
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 oftotalthat depends on two different versions ofprice. 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:
-
Seed the affected set. Phase B already wrote the new value of
priceinto its input cell. The engine walks the reverse-dependency graph frompriceand collects every derived that transitively depends on it. For our diamond, that set is{ withTax, withDiscount, total }. -
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.
withTaxandwithDiscounthave onlypriceupstream (which is already settled in Phase B), so they go first.totalhas bothwithTaxandwithDiscountupstream, so it goes after both have run. -
Recompute in order. Run each derivation's compute body against the
current engine state.
withTaxseesprice = 200and produces216.withDiscountsees the sameprice = 200and produces180.totalruns last, sees the just-recomputedwithTax = 216andwithDiscount = 180, and produces198. -
Equality-cutoff per node. If a derivation's new value is
Object.is-equal to its previous value, the engine removes it from thechangedNodesset 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:
-
A derivation reads its upstreams via the callable getter
g(...). Whatever it reads becomes its tracked dep set for this commit. The set can change commit-over-commit — conditional reads, branchy compute bodies, dynamic indexing — and the engine re-indexes the affected sub-graph on the fly. -
Within a single commit, every derivation runs after every upstream it reads. Kahn
guarantees a topological order exists iff the graph is acyclic. If a cycle closes
mid-recompute (a forward reference dropped into a closure, say), Phase D detects it via
the post-recompute back-edge probe and throws a structured
CycleErrornaming the cycle path. The commit rolls back and engine state is byte-identical to the pre-commit moment.
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.