compile-dataflow

Compile a dataflow subgraph into a plain function.

import {compileDataflow} from '@tomlarkworthy/compile-dataflow'

A reactive cell is not reusable outside the runtime that schedules it. Dataflow templating answers that by instantiating the graph: cloneDataflow copies the variables, so each clone updates live. That is what you want for a widget.

When you want a value, the runtime is overhead. compileDataflow walks the same subgraph and emits straight-line JavaScript, so fn({x: 1}) returns {y: …} with no reactive variables involved and fn.source is text you can paste anywhere. It is notebook-distiller applied to a subgraph, and driven from the runtime rather than from source.

cloneDataflow compileDataflow
result a disposer; values arrive reactively a function returning {output: value}
updates after the call yes no — call it again
streams (generators, viewof values, mutable) yes refused, by name
serialisable no yes — fn.source
edit

The artifact

polygonPath.source below is the whole of the compiled polygon — that text, pasted anywhere, is the polygon.

Closure-free is guaranteed. new Function sees only global scope, so a definition that referenced an enclosing local throws ReferenceError rather than quietly binding to it. Pure is only claimed: nothing detects a definition that writes to document, mutates an object it was handed, or reads Date.now(). Emitted code is strict, so an accidental global assignment throws; that is where enforcement stops.

edit
polygonPath = ƒ(…)
edit
function polygonPath($args, $cap) {
  "use strict";
  const $need = ($o, $k, $what) => {
    if ($o == null || !($k in $o)) throw new TypeError("missing " + $what + " " + JSON.stringify($k));
    return $o[$k];
  };
  const $check = ($n, $v) => {
    if ($v != null && typeof $v.then === "function")
      throw new TypeError($n + " returned a Promise; this function is synchronous by construction — fn.asAsync awaits it");
    if ($v != null && typeof $v.next === "function" && typeof $v.return === "function")
      throw new TypeError($n + " returned a generator, which is a stream; one call gives one value");
    return $v;
  };
  // definitions, inlined verbatim from the module
  const $d0 = () => 5; // sides
  const $d1 = () => 40; // radius
  const $d2 = (n, r) =>
        Array.from({ length: n }, (_, i) => {
          const a = (i / n) * 2 * Math.PI - Math.PI / 2;
          return [+(r * Math.cos(a)).toFixed(2), +(r * Math.sin(a)).toFixed(2)];
        }); // points
  const $d3 = (pts) => "M" + pts.map((p) => p.join(",")).join("L") + "Z"; // path
  // dataflow, in topological order
  const $v0_sides = $check("sides", $d0.call(undefined));
  const $v1_radius = $check("radius", $d1.call(undefined));
  const $v2_points = $check("points", $d2.call(undefined, $v0_sides, $v1_radius));
  const $v3_path = $check("path", $d3.call(undefined, $v2_points));
  return {"path": $v3_path};
}
edit
polygonPathValue = Object {called: Object, captures: Array(0), params: Array(0)}
edit

Usage

fn = compileDataflow(variables, {
  outputs: ["chart"],   // defaults to the SINKS of `variables` — whatever nothing else reads
  inputs:  ["radius"],  // boundary variables that become $args
  live:    true,        // also an async generator, recompiling when a cell in it is edited
  views:   "refuse",    // "snapshot": read `viewof x`.value once instead of refusing `x`
  parse,                // acorn's parse; enables the undeclared-identifier scan (off without it)
  globals: ["document"],// names to treat as resolvable — browser APIs the compiler cannot see
  strictGlobals: false, // true: an unresolved identifier is a compile-time throw, not a warning
  name:    "compiled"   // the emitted function's name
})

fn($args, $cap)         // -> {outputKey: value}. No runtime is touched.
fn.source               // the same body as a named declaration; publishable text
await fn.run($args)     // convenience: reads $cap out of the live runtime, then calls fn
await fn.asAsync(…)     // on a synchronous fn: the same subgraph emitted async

fn.captures / fn.captureNames are the values the emitted code actually reads, so they match the $cap it demands; fn.params, fn.outputs, fn.snapshots, fn.isAsync, fn.awaits, fn.maybeAwaits, fn.unresolved and fn.diagnostics report the rest. A missing entry in either object throws by name — missing capture "Plot" — rather than arriving as undefined three cells later.

run() cannot fetch the sentinels for you, and fn.sentinels lists the ones it will need: reading invalidation out of a live runtime hangs, because the runtime awaits the cell's value and an invalidation promise that never fires never settles. Measured on 2026-08-10 against vendor/observable-runtime:

await main.value("invalidation")   still pending after 200ms, and the process does not exit
await main.value("visibility")     RuntimeError: visibility is not defined

So pass them: fn.run(args, {invalidation, visibility}), or fn(args, {...caps, invalidation}).

edit

What it refuses

One rule: a cell whose value is a stream cannot be compiled, because one call produces one value and there is no honest answer to which of a stream's values that should be. Every row below is an instance of it. Refusals name the cell and are all collected before throwing, so one call reports the whole problem.

refused why
generator and async-generator definitions read off definition.constructor.name
the value half of viewof x it yields a generator of the view's values — see views: "snapshot"
the value half of mutable x, and the mutable x accessor a box exists to be written to, and writes are only observable through the generator half
@variable, imports, implicit and duplicate variables not streams — there is simply no value to pass
Notebook Kit per-cell display / view they write into the original cell's DOM slot
native or bound definitions Function.prototype.toString gives [native code], so there is nothing to inline
anonymous captures no name, so they cannot become a parameter

An async definition is not on that axis — one value, arriving later. It compiles to await and the emitted function becomes async; there is no option to set. invalidation and visibility are not on it either: they are ordinary capture parameters, so the caller owns the lifetime of whatever the function builds.

A synchronous function that returns a promise is invisible to all of that, so the emitted code carries a $check on every assignment and fails at call time instead:

a returned a Promise; this function is synchronous by construction — fn.asAsync awaits it

Compiling every named cell of modules/@tomlarkworthy/*.js on its own — 65 modules, 2351 candidate cells, tools/compile-dataflow/survey.ts, 2026-08-10 — 70% compile under views: "refuse" and 96% under views: "snapshot". Everything still refused there is a stream.

edit

Making a view out of a compiled widget

A compiled function holds no reactive state, so each call builds an independent widget — which is what a view needs. The widget owns the value; viewof supplies the reactivity:

buildSlider = compileDataflow(sliderCells, {live: false})  // fn() -> {slider: <input type=range>}
viewof compiledSlider = buildSlider().slider               // the whole wiring
compiledSlider                                             // 50, and updates as you drag

Hand viewof the widget, not a generator. viewof w = EXPR already desugars to viewof w = EXPR plus w = Generators.input(viewof w), so applying Generators.input yourself applies it twice (tools/compile-dataflow/viewof-probe.mjs, 2026-08-11):

EXPR = the widget               values=["start","typed"]
EXPR = Generators.input(widget) values=[]  FAILED: input.addEventListener is not a function

The limit sits on the widget, not the compiler: it must carry .value and dispatch an event — stdlib picks the event from input.type and reads valueAsNumber for range and number. A compiled cell returning a plain object with a .value property is not a view; nothing dispatches.

edit
buildSlider = ƒ(…)
edit
edit
compiledSliderValue = Object {value: 50, isAsync: false, statelessPerCall: true}
edit

What it buys

tools/compile-dataflow/bench.mjs, 2026-08-10, bun 1.3.11, darwin arm64. Chains of width strands depth long joined at a sink, arithmetic only, so the figure is scheduling and call overhead rather than the cells' own work. The runtime arm redefines the source cell and awaits the sink; the compiled arm calls fn(). Median of 500 calls after 500 warm-up calls — warming with 50 left the 502-cell shape still tiering up and read 9x slow.

The gap narrows as the subgraph grows, 40x at 12 cells down to 17x at 502, because the runtime's fixed per-call cost is amortised over more work. It is not the same operation — the runtime schedules the whole graph and can update incrementally — so this sizes the gap rather than racing it.

fn.asAsync, the same subgraph emitted async, costs 1.04-1.20x the synchronous arm at every shape: its awaits are conditional (if ($thenable(v)) v = await v) and a test that fails does not suspend the frame.

edit
cdBench = Array(4) [Object, Object, Object, Object]
edit
010203040↑ compiled call vs runtime (×)1252102502cells in the subgraph →40× (1x10)38× (1x50)22× (5x20)17× (20x25)
edit
compileDataflow = ƒ(…)
edit
edit
edit
edit
cdRuntimes = WeakMap {}
edit
cdFixture = ƒ(build)
edit
cdDispose = ƒ(mod)
edit
cdView = ƒ(value)
edit
cdCodes = ƒ(fn)
edit
cdSettle = ƒ()
edit
cdTicks = async ƒ(…)
edit
cdWatch = ƒ()
edit
test_cd_sync = "a subgraph with nothing async compiles to a synchronous function"
edit
test_cd_async_awaits = "an async cell is compiled to await, not refused"
edit
test_cd_refuses_streams = "a generator is refused by name: one call cannot stand for a stream"
edit
test_cd_closure_free = "new Function gives the emitted code no scope to close over"
edit
test_cd_sentinels_are_captures = "invalidation is a parameter: the caller decides when what it built dies"
edit
test_cd_parameter = "a parameter replaces its variable and downstream recompiles"
edit
test_cd_output_rename = "outputs may be renamed with an object"
edit
test_cd_anonymous_cell = "anonymous cells compile, addressed by Variable"
edit
test_cd_frontier_params = `frontier "params" recompiles only what varies with an argument`
edit
test_cd_frontier_all = `frontier "all" recompiles every compilable ancestor`
edit
test_cd_this_is_undefined = "`this` is undefined, so nothing accumulates across calls"
edit
test_cd_cycle = "a cycle inside the subgraph is a compile-time error"
edit
test_cd_nb2_multi_output = "2.0 multi-output cells are just an exports object plus projections"
edit
test_cd_body_is_a_snapshot = "body definitions are frozen at compile time; recompile to follow edits"
edit
test_cd_live_is_generatorish = "the handle is generatorish, so the Observable runtime iterates it"
edit
test_cd_live_false_is_bare = "live:false returns the bare compiled function, no generator protocol"
edit
test_cd_live_first_yield = "the first .next() yields the compiled function immediately"
edit
test_cd_live_redefine_yields = "a redefine in the body yields a freshly compiled function"
edit
test_cd_live_no_spurious_yield = "a notification with nothing changed does not yield"
edit
test_cd_live_polls_without_watch = "with no watch it polls the subgraph and picks the change up"
edit
test_cd_live_return_closes = ".return() closes the stream and unsubscribes the watch"
edit
test_cd_live_handle_tracks_latest = "calling the handle runs the newest compilation"
edit
test_cd_live_drives_downstream = "as a cell value the handle drives downstream, with no runtime edge to the subgraph"
edit