Inspecting Live Graphs

A causl graph is inspectable in its own vocabulary. Instead of bolting a parallel mirror state onto the side of the engine, every introspection question — why does this node have this value? which inputs contributed? when did each contribution land? what depends on this transitively? — is answered by an engine primitive that is itself a derived node, subscribable, composable, and replayable. Devtools become ordinary consumers of the graph, not a separate system that has to be kept in sync.

This page covers the four production-grade introspection surfaces: graph.explain(node) for lineage, the 'live' explanation variant for edit-and-replay derivations, graph.snapshotAt(t) for time travel, and @causl/devtools-bridge for the Redux DevTools Extension wire protocol. All four are designed to be safe on a 10,000-node production graph: they read, they don't rebuild.

1. graph.explain(node) — lineage as a derived node

The simplest question is why does this node have this value? graph.explain answers it by handing back a DerivedNode<Explanation> — a regular subscribable node whose value is the structured lineage of the target.

import { createCausl } from '@causl/core' const g = createCausl() const a = g.input('a', 1) const b = g.input('b', 2) const sum = g.derived('sum', (read) => read(a) + read(b)) const lineage = g.explain(sum) console.log(g.read(lineage)) // { // via: 'derived', // node: 'sum', // value: 3, // computedAt: 1, // deps: [ // { node: 'a', contributedAt: 1, explanation: { via: 'input', value: 1, computedAt: 1, deps: [] } }, // { node: 'b', contributedAt: 1, explanation: { via: 'input', value: 2, computedAt: 1, deps: [] } }, // ], // }

Three properties make the handle useful in production:

2. The Explanation discriminated union

An Explanation is one of four shapes, tagged by the via discriminator. A devtools UI can switch on it directly with no typeof guards:

viaShapeMeaning
'input'InputExplanationLeaf — an input cell. deps is always readonly []. computedAt is the GraphTime of the last write.
'derived'DerivedExplanationInternal node from a vanilla derived registration. deps carries one DepFrame per direct dep, each with its own recursive explanation.
'live'LiveExplanationSame shape as 'derived', but the engine commits to the edit-live affordance: the derivation can be replaced while it is running and the change propagates without a graph rebuild. See §3.
'cycle'CycleExplanationDefensive frame emitted when the walker re-enters a node already on the traversal stack. Carries cycleBackTo so the UI can render the loop without recursing.

Every value-bearing variant carries node, value, computedAt, and deps. The recursive deps[i].explanation is what lets a panel render a tree of the entire upstream lineage on demand — the engine produces the structure; the UI decides how deep to render.

3. Live materialisation — edit a derivation while it runs

A REPL connected to a running graph can mutate, replace, and replay derivations without restarting the host process. This is the same property that lets a devtools panel offer "edit this compute function" as an inline affordance: the engine treats a re-registered derivation as a new revision of the same identity, not as a separate node, and Phase D propagates the change exactly like any other commit. The via: 'live' tag in an explanation is the signal a UI uses to render the edit-live affordance for that specific node.

The practical workflow:

  1. Inspect g.read(g.explain(node)) to confirm the current value and lineage.
  2. Replace the derivation's compute function via g.derived(id, newCompute) — same id, new body.
  3. The next commit runs the new compute through the full Phase A–H pipeline. changedNodes in the published Commit records the propagation; subscribers fire exactly once per affected node, in topological order.

No graph rebuild. No subscription leakage. The historical record in commitLog retains the prior compute's outputs at their original GraphTime — time travel against those commits still produces the values they originally produced.

4. Time travel — graph.snapshotAt(t)

The denotational rule the engine enforces is precise: derived(t) = f(b₁(t), ..., bₙ(t)). A derived value at time t is a pure function of its inputs at the same t. The only way GraphTime advances is one new commit per graph.commit. That contract is what makes snapshotAt(t) a safe operation in production: it projects the historical state by reading the bounded retention window, never by replaying commits forward.

const past = g.snapshotAt(t - 3) // past.inputs is a frozen map of every input's value at GraphTime (t - 3) // past.schemaHash anchors the snapshot to the schema version that produced it console.log(past.inputs.get('a'))

The retention window is bounded by commitHistoryCap. SPEC §5.1 Phase F.5 makes the trade-off explicit: the engine keeps the most recent N commits in commitLog, where N is configurable per createCausl. Reads against a GraphTime older than the retained window throw RetentionExpiredError — a typed failure, not silent stale data.

5. @causl/devtools-bridge — Redux DevTools wire protocol

For teams that already use the Redux DevTools Extension, @causl/devtools-bridge exposes a graph through the same monitor protocol. Each causl Commit forwards to the panel as a Redux action of the form { type: intent, payload: { changedNodes } } paired with a graph.snapshot() for state inspection. The bridge is zero-cost when the extension is absent — it short-circuits before allocating any subscription, mock, or observer.

import { connectDevtools, isExtensionAvailable } from '@causl/devtools-bridge' if (isExtensionAvailable()) { const disconnect = connectDevtools(g, { name: 'my-app' }) // …later, on unmount or HMR teardown disconnect() }

The reverse path — the panel sending JUMP_TO_STATE, JUMP_TO_ACTION, IMPORT_STATE, ROLLBACK, and seven other monitor messages — is implemented as a frozen handler table, one closure per message. Each handler is individually tested. Time-travel handlers project state via graph.snapshotAt(t) rather than mutating the live graph: a panel-driven mutation would forge a fractional or out-of-order GraphTime, which the engine refuses to admit. The host's live subscribers stay anchored at the present commit; the panel receives a re-init carrying the historical projection.

Capability narrowing — the bridge cannot mutate

The bridge accepts a BridgeGraph, a structural narrowing of Graph that exposes exactly four methods: subscribeCommits, snapshot, snapshotAt, and now. A real Graph is still assignable, so the call site keeps working — but the discipline is enforced at compile time inside the implementation. A future bridge author cannot accidentally call g.commit(...), g.input(...), g.hydrate(...), or g.derived(...); those methods are not on the type. The principle of least authority is enforced by the language, not by a code-review comment.

export type BridgeGraph = Pick<Graph, 'subscribeCommits' | 'snapshot' | 'snapshotAt' | 'now'>

6. Production patterns for a 10,000-node graph

At ten-thousand-node scale the question is not whether explain can produce the structure — the walker is bounded and the handle is memoised — but how much of the structure a panel should render at once. A few patterns the team uses internally:

7. SPEC anchors

The introspection surfaces above are SPEC commitments, not implementation conveniences. The relevant sections of SPEC.md:

Related reading