Skip to content

Repository files navigation

elm-superstep

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.

Layout, build & test

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).

The flagship: a document pipeline (no LLM anywhere)

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
Loading
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 Store

Parse 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.

A long-lived, server-shaped execution

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 Alerts

The 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.

Module map

Layer Modules Role
graph-core Superstep Definition DSL (node/connect/fork/route/branch/join*/start*/end*), compiler, validator
Superstep.Node Nodes as event reducers: RuntimeEventNodeReaction (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.

Execution semantics

  • Nodes are event reducers. An instance receives Activated, routed External events, correlated EffectCompleted results, TimerElapsed, or Resumed — 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.replay from 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 — a RemainActive node 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 FailureClass reschedules the same instance with attempt+1; Superstep.Driver times the declared Backoff.
  • Effects are opaque values; results re-enter as correlated events with deterministic idempotency keys, dispatched through a durable outbox.

The LLM layer (one branch of the design space)

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 provider

And 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."

Design decisions worth knowing

  • String-keyed collections. Node ids are opaque custom types; every runtime collection is keyed by a String derived via a caller-supplied stable node-key function.
  • The queue holds typed events, which forces the event parameter into Execution 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: RequestTimerScheduledTimer → host sleeps → TimerFired tokenTimerElapsed at the owner. Re-arming replaces; superseded tokens come back stale.
  • Superstep.Task is 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.

Scope

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.

About

Pregel-style superstep graph processing engine.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages