the-sett/elm-superstep — a generic compute-graph language and pure,
deterministic runtime for explicit, persistent, event-driven programs. The
name comes from the Pregel-style supersteps at the heart of the execution
model.
An Elm program is already an implicit compute graph: messages select transitions, pure functions update state, commands request external work, completed effects produce further messages. This runtime makes that graph explicit — topology as data, with generic execution semantics for event delivery, state reduction, effect scheduling, parallel branches, barriers and joins, timers, persistence, replay, and suspension/resumption.
events → node reducers → updates + effects + control → committed state → further events
The decisive design test: the runtime is equally credible with no model
call anywhere in the program. LLM orchestration is one optional library on
top (Superstep.Llm), not the root of the abstraction:
general compute graph
├── document-processing pipeline (Example.Pipeline — the flagship)
├── reactive server / watchdog (Example.Monitor — long-lived)
├── compiler pipeline, ETL, business workflow, …
└── LLM application (Example.Research + Superstep.Llm)
The project began as a faithful Elm implementation of LangGraph; that heritage survives as the battle-tested mechanisms below, with the abstraction's centre of gravity moved to the general model.
The repository is an Elm package (elm.json at the root, library code
under src/) plus an examples/ application (Example.* modules and
the test suites that use them as fixtures — packages cannot contain port
modules, and Example.OpenAi is one). The toolchain is pinned locally via
npm (elm + elm-test).
npm install # installs elm + elm-test (+ xhr2, puppeteer-core)
./check.sh # build the package (with docs) + the examples
./test.sh # run both suites (~200 tests)The engine's join/lineage invariants were pinned by a multi-agent adversarial
review of the original implementation; every pin survived the port to the
general engine (tests/EngineJoinTests.elm, tests/EngineSpawnTests.elm),
alongside new pins for the general model (tests/EngineLifecycleTests.elm:
routing, publish, timers, Listening, dispositions, queue determinism).
Example.Pipeline (graph-general.md §14):
flowchart TD
n_receive["receive"]
n_parse["parse"]
n_validate["validate"]
n_index["index"]
n_store["store"]
END(((END)))
START(((START))) --> n_receive
n_receive --> n_parse
n_parse --> n_validate
n_parse --> n_index
n_store -.->|end| END
n_validate ==>|join all| n_store
n_index ==>|join all| n_store
Superstep.empty (GraphVersion 1)
|> Superstep.withChannels channels
|> Superstep.addNode Receive receiveNode
|> Superstep.addNode Parse parseNode
|> Superstep.addNode Validate validateNode
|> Superstep.addNode Index indexNode
|> Superstep.addNode Store storeNode
|> Superstep.startAt Receive
|> Superstep.connect Receive Parse
|> Superstep.fork Parse [ Validate, Index ]
|> Superstep.joinAll [ Validate, Index ] Store
|> Superstep.endAt StoreParse and Store request opaque effects an interpreter performs;
Validate and Index run in the same superstep against the same committed
snapshot; the join gathers both. No AI concept appears in the graph model.
Example.Monitor is what the old workflow framing could not express: a
heartbeat watchdog whose nodes remain active indefinitely. The execution
rests at Listening, woken by routed events and first-class timers:
|> Superstep.startAt Watchdog
|> Superstep.startAt Alerts
|> Superstep.route "heartbeat" isHeartbeat Watchdog
|> Superstep.route "missed-deadline" isMissedDeadline AlertsThe watchdog re-arms a deadline timer on every heartbeat (RequestTimer
with the same TimerId replaces the pending deadline; the superseded
host-side timer comes back stale); a missed deadline publishes an internal
event through the routes and requests a notify-ops effect. There are no
control edges at all — progression is event-driven, not completion-driven.
| Layer | Modules | Role |
|---|---|---|
| graph-core | Superstep |
Definition DSL (node/connect/fork/route/branch/join*/start*/end*), compiler, validator |
Superstep.Node |
Nodes as event reducers: RuntimeEvent → NodeReaction (updates, effects, control, disposition) |
|
Superstep.Edge |
Edge taxonomy: control, event-routing, terminals; joins with fire/failure policies | |
Superstep.Channel |
State channels + combining strategies (named to avoid the node-reducer ambiguity) | |
Superstep.Id |
Deterministic ids: supersteps, lineage branches, effect/timer/interrupt keys | |
Superstep.Visualise |
Mermaid & DOT export, routes included | |
| graph-runtime | Superstep.Runtime |
The pure interpreter: FIFO event queue, recipient selection, snapshot commit, joins, spawns, interrupts, retries, timers, Listening |
Superstep.Trace |
Human-readable trace projection | |
| workflow layer | Superstep.Task |
The old one-shot task model rebuilt as a library (run/onResult/onResume, approval, after) |
| graph-persistence | Superstep.Persistence |
Full JSON round-trip of an Execution (app state + scheduler + queue + timers), input-log codecs, replay, storage protocol |
| graph-host-elm | Superstep.Driver |
Cmd-based host loop: dispatch, correlation, backoff timing, timer realisation, notify for routed events |
| graph-llm | Superstep.Llm |
LLM node builders (chatNode, structuredOutputNode) compiling to ordinary nodes |
Superstep.Llm.Api + Types/Wire |
The sealed ChatModel interface (existential encoding via Superstep.Existential) |
|
Superstep.Llm.* codecs |
Five wire formats: OpenAI-compat, Anthropic, Gemini, Bedrock Converse, Cohere, watsonx | |
Superstep.Llm.Providers/Registry/Mock |
All 27 langchain registry keys; init secrets "provider:model" |
Dependency direction is strictly upward: the core never imports the LLM layer. The graph does not know why a node exists (§19) — parsing text, querying a database, invoking a model or waiting for a person are all just reducers emitting opaque effect values.
- Nodes are event reducers. An instance receives
Activated, routedExternalevents, correlatedEffectCompletedresults,TimerElapsed, orResumed— and returns updates + effects + control actions (Activate/Spawn/Publish/RequestTimer/Deactivate/EndBranch) + a disposition (RemainActive/BecomeInactive/CompleteActivation/SuspendActivation/FailActivation). One-shot tasks and long-lived listeners are equals. - Snapshot isolation. All recipients of one delivery — and all activations of one superstep — read the same committed state; their writes are grouped by channel, combined, and committed atomically before any routing.
- Determinism. The event queue is FIFO; recipients and activations are
evaluated in canonical order (node key, then activation id); ids are
derived, never random. The same ordered inputs reconstruct the same
execution — which is what makes
Superstep.Persistence.replayfrom a recorded input log exact (pinned by test). - Joins by lineage. Barriers key on (join identity, fan-out group), with
per-slot instance tracking, all fire policies (
All/Any/AtLeast) and failure policies (FailFast,ContinueWithSuccesses,WaitForAllThenFail,RouteFailuresTo). Only activation completion credits a barrier — aRemainActivenode processing events never trips a join; an unsatisfiable barrier fails loudly, never silently. - Lifecycle.
WaitingForEffects= blocked on requested external work;Listening= alive and quiescent (long-lived instances/timers awaiting events); execution completes only when active set, queue, awaited effects, timers, interrupts and barriers are all empty. - Retries stay core. Transient failures exist for any external work: a
failed activation with a retryable
FailureClassreschedules the same instance with attempt+1;Superstep.Drivertimes the declaredBackoff. - Effects are opaque values; results re-enter as correlated events with deterministic idempotency keys, dispatched through a durable outbox.
Everything from the provider work survives above the graph boundary:
ChatModel — an existentially-encoded interface (each provider's config
hidden in its own representation type) with call, its pure golden-testable
half spec, identity (metadata, never dispatch), capabilities, and
withModel — implemented by all 27 provider keys of langchain's
init_chat_model registry across five wire-format codecs, resolved by
Registry.init secrets "provider:model" with langchain's name-inference
heuristics. Superstep.Llm.chatNode/structuredOutputNode compile to ordinary
graph nodes via injected effect/event embeddings.
Example.Research (the original §20 workflow) is now built entirely on
Superstep.Task + engine retries; Example.Host runs it in the browser through
a sealed ChatModel selected from a dropdown — the same workflow through
seven wire protocols against mock-llm-server.py's emulation endpoints:
./host.sh # build + serve http://localhost:8080, mock LLM on :9000
FAIL_FIRST=1 ./host.sh # every effect's FIRST delivery 500s; the engine
# classifies, retries and still completes
node e2e-host.js # headless-chromium matrix: 7 providers to publication
EXPECT_RETRIES=1 node e2e-host.js # + asserts a retry per effect, per providerAnd the smallest real-API example, a Platform.worker asking OpenAI one
question through the sealed interface:
cp example.env .env # put your real OPENAI_API_KEY in .env
node ask-openai.js "What is a Pregel superstep? One sentence."String-keyed collections. Node ids are opaque custom types; every runtime collection is keyed by aStringderived via a caller-supplied stable node-key function.- The queue holds typed events, which forces the
eventparameter intoExecution nodeId state event effect— a deliberate deviation from the design sketch's three-parameter signature (recorded in the plan), and the reason persistence codecs include an event codec. - Suspension/failure are dispositions, not control actions — one
activation has exactly one lifecycle outcome per event (deviation from the
design sketch's mixed
Control, recorded). - Routing delivers to active instances only; activation comes from start rules, control edges, joins and spawns. Activating routes are a named follow-up.
- Timers are first-class:
RequestTimer→ScheduledTimer→ host sleeps →TimerFired token→TimerElapsedat the owner. Re-arming replaces; superseded tokens come back stale. Superstep.Taskis the proof of layering: the old engine's node model (onStart/onEvent/onResume, approval interrupts,goto/send) reconstructed in ~150 lines over the reducer model.
Implemented: the general engine (queue, routing, dispositions, timers, long-lived executions) with all prior mechanisms ported (supersteps, channel commit, joins with policies, dynamic spawn, interrupts, retries, validation, trace, visualisation), JSON persistence with restore/fork and event-log replay, the Cmd host, the workflow layer, and the full LLM provider fleet.
Deferred (named follow-ups in plans/general-compute-graph.md): data-
dependency edges (whenAvailable), activating routes, exact replay from
recorded effect results, engine-side backoff-as-timers, streaming, real
storage adapters, distributed hosts, graph migration.