Info
flow has one load-bearing invariant: interpret == compile. A workflow run by
xdog-flow run and the same workflow compiled to a standalone Python module must
behave identically — same nodes, same order, same outputs, same loop counts.
For a long time that invariant was maintained the expensive way. The interpreter had a scheduler. The compiler had a different scheduler. A parity test suite ran every feature through both and compared. It worked, in the sense that it caught drift. It also meant every new graph feature had to be implemented twice, in two different idioms, and the second implementation was always the harder one.
This post is about deleting the second scheduler.
Before: the interpreter resolved the schedule at run time
The interpreter's model was a readiness loop over a pending set. Three functions carried it:
def _is_ready(node_id: str) -> bool:
"""True if all non-loop predecessors of node_id have completed."""
for edge in in_edges.get(node_id, []):
if edge.loop_max is not None:
continue # skip loop back-edges
if edge.src == IN_NODE_ID:
continue # the $in source is always available
if edge.src not in completed:
return False
return True
def _successors(node_id: str) -> list[str]:
"""Successors reachable from node_id given current outputs."""
result = []
for edge in edges_from.get(node_id, []):
if edge.loop_max is not None:
continue # loop edges handled separately
if edge.dst == OUT_NODE_ID:
continue # the $output sink is collected, never scheduled
if edge.when is None or evaluate(edge.when, _source_ports(edge.src)):
result.append(edge.dst)
return result
def _activate_loops(just_finished: list[str], pending: set[str]) -> None:
"""Re-activate a back-edge destination when its condition still holds."""
Run the ready set concurrently, collect what finished, compute successors, check loop back-edges, repeat. Everything was decided from live state, which is why the interpreter handled awkward graphs correctly without special cases: it never needed to know the shape of the graph in advance.
Note the three separate exclusions for loop edges — _is_ready skips them,
_successors skips them, _activate_loops exists solely for them. Loops were a
side channel bolted onto a forward-only scheduler. That detail matters later.
Before: the compiler resolved the schedule at compile time
The generated module had no scheduler. It had control flow — the schedule, already resolved, expressed as Python statements. And there were two different ways of resolving it, chosen by graph shape:
def _render_main_body(wf: WorkflowDef, safe_ids: dict[str, str]) -> str:
"""Body of ``async def main()`` — conditional-aware when forward guards exist."""
if _has_forward_conditional(wf):
return _render_main_body_conditional(wf, safe_ids)
return _render_main_body_waves(wf, safe_ids, use_capped=wf.max_concurrency > 0)
The wave path. With no forward conditionals, the compiler simulated the
readiness loop at build time — computing each wave of concurrently-runnable nodes
and emitting an asyncio.gather per wave:
while pending:
ready = [n for n in pending if all(p in completed for p in fwd_preds.get(n, []))]
...
calls = [_invoke_expr(n, wf, safe_ids) for n in ready]
lines.append(f"{ind()}await asyncio.gather({', '.join(calls)})")
The conditional path. With any forward conditional, waves don't work — you
cannot know at compile time which branch runs. So a second strategy: Kahn
topological sort, emit every node in order, and guard each one with membership in
a _ran set built at run time.
# Topological order over forward edges (Kahn), stable in declaration order.
...
lines.append(f"{ind()}_ran: set[str] = set()")
for node_id in order:
...
lines.append(f"{ind()}_ran.add('{_ESC(node_id)}')")
Loops, in both paths, became Python for loops:
var = f"_loop_i_{loop_depth}"
lines.append(f"{ind()}for {var} in range(_loop_start({key!r}), {lmax}):")
loop_stack.append((key, var))
with a loop_stack to pair each entry with its exit, per-depth variable names to
avoid shadowing under nesting, and for/else to detect a strict while loop
that exhausted its range without converging.
The divergence nobody would have predicted
Read the dispatch again. The wave path emits asyncio.gather. The conditional
path emits one statement per node, in topological order.
The conditional path had no parallelism at all.
So adding a single conditional edge — anywhere in the graph, on a branch unrelated to the parallel section — flipped the entire generated module from concurrent to sequential. The interpreter kept running those branches in parallel, because its readiness loop neither knew nor cared that a conditional existed elsewhere.
The parity suite did not catch this. It compared outputs, and the outputs were identical — sequential execution of a correct schedule produces the same values as parallel execution of the same schedule. Only the wall-clock differed, and no test asserted on that.
This is the real argument against two implementations of one idea. It isn't that they drift into producing wrong answers; a good test suite catches that. It's that they drift along the axes you did not think to assert on.
After: one kernel, inlined verbatim
The fix was to make the scheduler a pure, data-driven state machine with no I/O, no async, and no knowledge of what a node does — then have both engines run that exact code.
flow/frontier.py is 422 lines and exposes a small surface:
| Function | Role |
|---|---|
build_frontier_spec(wf) |
compile the workflow into literal-safe static metadata |
new_frontier_state(spec, completed) |
transient run state, seeded from the entry frontier |
take_ready(spec, state) |
lease the ready activations, in node declaration order |
complete_batch(spec, state, done) |
commit completions, traverse edges, fire loop groups |
replay_completed(...) / restore_loop_activations(...) |
rebuild transient state on resume |
render_frontier_runtime() |
return the kernel's own source, for inlining |
That last one is the trick:
def render_frontier_runtime() -> str:
"""Return the exact pure transition kernel for standalone generated modules."""
aliases = (
"FrontierSpec = dict[str, object]\n"
"FrontierState = dict[str, object]\n"
"Activation = tuple[str, int, tuple[str, ...]]\n"
"Completion = tuple[str, int, dict[str, bool]]\n"
)
return aliases + "\n\n".join(inspect.getsource(fn) for fn in _INLINE_FUNCTIONS)
inspect.getsource. The generated module does not import flow — it stays
standalone, which is the whole point of a portable bundle — but the scheduler text
it carries is character-for-character the scheduler the interpreter just ran.
Parity is no longer a property the test suite verifies. It is a property of how the
file is built.
The generated main() collapsed accordingly:
# before — the schedule, baked into control flow
async def main() -> None:
provider = ai.provider("copilot")
await node_research(provider)
# loop: review -> write (max 2 iterations)
for _loop_i_0 in range(_loop_start('e3'), 2):
await asyncio.gather(node_write(provider), node_check(provider))
_loop_tick('e3', _loop_i_0)
...
# after — the schedule, as data
_FRONTIER_SPEC = {
"nodes": ("research", "write", "review"),
"entries": ("research",),
"edges": {...},
"loop_groups": {"write": (...)},
}
async def main() -> None:
await _run_generated_frontier()
The generated module is now static metadata + node functions + the shared
kernel. Its structure no longer varies with graph shape: no strategy dispatch, no
_ran sets, no loop_stack, no per-depth loop variables, no for/else.
What the migration bought
Deleting a scheduler is not usually where features come from, but this one paid out, because several things that were hard to express in emitted control flow are ordinary in a state machine.
Loops stopped being a side channel. In the old model a back-edge was skipped by
_is_ready, skipped by _successors, and handled by a third function. In the
frontier model an activation carries a generation, and a back-edge simply
advances the destination's generation. That single change made three things fall
out that had previously been out of reach:
- Multi-edge loop AND joins. Several bounded back-edges sharing one destination now form a conditional join: every member source must complete in the current generation and every member condition must hold before the destination runs again — exactly once, not once per edge.
- Heterogeneous bounds. Each member edge keeps its own
maxand its own strict-whilebehavior, so a join can mix a plain bounded edge with a strict one. - Conditional input filtering. A destination waits for every ordinary predecessor, but only condition-enabled edges contribute mapped input values. Previously "which edges supply data" and "which edges gate readiness" were the same question; now they are two.
Checkpointing got a coherent boundary. This came in the follow-up commit, and it was only possible after the migration. Node-local checkpoint writes were replaced by one write per settled frontier batch, behind a shared interceptor that both engines use. The durability contract became statable in one sentence — at-least-once per ready batch — with the checkpoint schema unchanged. A crash mid-batch re-runs the batch; a batch that settled is durable.
codegen shrank. 1275 lines to 1098 in the migration, and 1063 today — while gaining features. The 353 deleted lines were almost entirely the second scheduler: two body renderers, the Kahn sort, the loop bookkeeping, the gate expressions.
What parity tests still do
They did not go away, and they should not. Inlining the kernel guarantees both
engines make the same scheduling decisions; it guarantees nothing about the
layers on either side of it — how the interpreter builds a node's inputs versus how
the generated _inputs_<node> function does, how each stores outputs, how each
coerces types.
The honest description is that the parity suite's job got smaller and much better defined. It used to be asking "do two schedulers agree?", which is an open-ended question with an infinite surface. Now it asks "do two adapters around one scheduler agree?" — and one deliberate exception is documented in the code, where the memo-key digest is expressed twice because the generated module must not import flow's runtime.
That is the general shape of the lesson. Sharing an implementation is worth more
than testing two implementations against each other, and the reason is not that
tests are weak. It is that the divergence you eventually hit will be on an axis
your assertions never covered — a sequential gather that produced perfectly
correct output, just not concurrently.