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:
- Memoised. Two calls to
explainwith the same target return the sameDerivedNode<Explanation>reference. A devtools UI can use object identity to cache panel state across re-mounts. - Subscribable. The handle is a derived node.
g.subscribe(lineage, render)fires whenever any node in the transitive lineage changes value — the explainer's dep set tracks every visited node, so any upstream commit refreshes the panel. - Bounded. The walker uses an explicit visited-set stack to detect cycles. A re-entered node yields a
{ via: 'cycle', cycleBackTo }marker instead of recursing. Stack overflow is not a failure mode.
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:
via | Shape | Meaning |
|---|---|---|
'input' | InputExplanation | Leaf — an input cell. deps is always readonly []. computedAt is the GraphTime of the last write. |
'derived' | DerivedExplanation | Internal node from a vanilla derived registration. deps carries one DepFrame per direct dep, each with its own recursive explanation. |
'live' | LiveExplanation | Same 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' | CycleExplanation | Defensive 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:
- Inspect
g.read(g.explain(node))to confirm the current value and lineage. - Replace the derivation's compute function via
g.derived(id, newCompute)— same id, new body. - The next commit runs the new compute through the full Phase A–H pipeline.
changedNodesin the publishedCommitrecords 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:
- Lazy depth. Subscribe to the top-level explanation; render
deps[0..k]with a "show more" affordance per depth level. The explanation is precomputed; only the rendered subtree pays UI cost. - Pinned subscriptions. A devtools panel that follows a specific node attaches one subscriber to
g.explain(node). The explainer's dep set is the transitive lineage, so the panel refreshes only when something on that lineage changes — not on every unrelated commit. - Bounded retention. Configure
commitHistoryCapincreateCauslbased on memory budget and the deepest time-travel the panel needs to support. Each retained commit holds frozenchangedNodesplus the inputs it modified; the cost is linear in N. - Zero-cost in production. Wrap the
connectDevtoolscall inif (isExtensionAvailable()). The extension is browser-injected; production deployments without it pay nothing — no allocation, no observer registration, nosubscribeCommitscall.
7. SPEC anchors
The introspection surfaces above are SPEC commitments, not implementation conveniences. The relevant sections of SPEC.md:
- §11 Liveness / inspection primitives — the contract that inspection is composable through engine primitives.
- §12.3 Capability narrowing — the rule that
BridgeGraphrealises viaPick. - §5.1 Phase F.5 retention bookkeeping — the bounded
commitLogthatsnapshotAtreads from. - §13 Deferred / out of scope — the explicit deferral of "devtools as a shippable product";
@causl/devtools-bridgestays private until either a downstream UI earns its place or §13 is amended.
Related reading
- Testing Strategies —
graph.simulate, snapshot comparison, the@causl/checkerCI gate. - Structuring Graphs — how to compose nodes so
explainoutput is useful at scale. - Performance — what
snapshotAtcosts, the retention-window memory trade-off. - API Reference — full type signatures for
Explanation,DepFrame,BridgeGraph, andDispatchEvent.