Loop Control System
The two-sided iterIn / iterOut pivots β loop-carry, stop, the final side, scopes, and nesting
A drawn graph is acyclic, but an agent must loop β observe, act, observe again. The loop control system is the one structure that expresses iteration: a paired iterIn / iterOut, the loop pivots. iterIn opens each iteration and hands the body its inputs; iterOut closes it, collects what carries forward, and decides whether to go round again. This page is the deep reference on that subsystem β the pivots, what flows between them, and how nested loops (scopes) are analysed. How the executor actually schedules the firing is Graph Executor; the JSON schema is Graph System.
1. What a loop pivot is
Plain dataflow stops when data stops flowing β fine for a pipeline, not for an agent. The engine recognises exactly one looping structure: a paired iterIn and iterOut. They are not wired to each other; they are linked by id (config.pairedWith), and the executor hands iterOut's collected values back to iterIn between iterations. Everything drawn between them β predict, env step, whatever β is the loop body, ordinary nodes with no special treatment. Each pivot is two-sided: iterIn has an init side (run-start inputs) and an output side (per-iteration values to the body); iterOut has an input side (loop-carry values + a stop signal) and a final side (values emitted once, when the loop ends).
This is the current model since ADR-dataflow-008 + ADR-dataflow-009. The earlier three-node model (a separate initialize seed node and a separate termination node) is gone: init values moved onto iterIn's init side, and the halt signal moved onto iterOut's stop input. Graphs that still carry those node types are rejected at load (Β§9).
2. The smallest loop
One iterIn, one iterOut, one carried value (obs), and a stop signal from the body. The three handle families you'll meet β init_*, iterout_*, final_* β all appear here:
Its two configs β note there is no edge between the pivots, only pairedWith:
// iterIn β declares the init side; output handles are synthesised at load { "id": "iterIn_1", "type": "iterIn", "config": { "version": 3, "pairedWith": "iterOut_1", "initPorts": [ { "name": "obs", "wire_type": "TEXT", "persist": false } ] } } // iterOut β carry ports on the input side, stop halt input, final_* on the output side { "id": "iterOut_1", "type": "iterOut", "config": { "pairedWith": "iterIn_1", "ports": [ { "name": "obs", "wire_type": "TEXT", "persist": true } ] } }
3. The four-moment mental model
Hold the whole subsystem as four moments in the life of a value:
| Moment | Where | What happens |
|---|---|---|
| Seed | iterIn init side, run-start | The entry node's values land on init_<name>; iterIn emits them to the body on iteration 0. |
| Carry | iterOut β iterIn, each boundary | iterOut collects config.ports values and the executor transfers them into iterIn's iterout_<name> slots; iterIn re-fires, opening the next iteration. |
| Stop | iterOut stop input, each boundary | If the BOOL wired into stop is truthy (or the step budget is hit), the scope terminates after this iteration. |
| Finalise | iterOut final side, once | At termination iterOut emits each carry port on final_<name> plus a constant final_stop β exactly once β feeding the after-loop verdict stage. |
Β§4 details seed and carry (the port-synthesis machinery), Β§5 stop, Β§6 finalise. The runtime timing of these β the order in which the boundary phases run β belongs to the executor (Graph Executor Β§4.3); here we describe the contract, not the schedule.
4. Port synthesis & the persist matrix
iterIn never carries a hand-written port list. Its effective config.ports is synthesised at load by _synthesize_iterin_ports from two writers, with prefixes that keep the two value sources distinct on the same iterIn:
- iterIn's own
initPortsβ handles prefixedinit_<name>, defaultpersist=false(one-shot: the slot empties after iteration 0). - the paired iterOut's
config.portsβ handles prefixediterout_<name>, defaultpersist=true(refreshed every iteration).
A direct canvas edge into iterIn that targets an undeclared handle is also accepted as an init-writer surrogate (the handle name is used verbatim). The persist flag and the writer origin give four legal per-port configurations:
| Origin | persist | Behaviour |
|---|---|---|
| init | false (default) | Seed once; the slot empties after iteration 0 β pure run-start input. |
| init | true | A run-invariant the body reads every iteration (instruction, episode config) β set this for anything the body needs past iteration 0. |
| iterout | true (default) | Standard loop-carry: refreshed by iterOut each boundary. |
| iterout | false | Loop-carry that must be re-supplied every iteration or the slot goes empty β rare. |
A name may legitimately appear on both sides (init_X for iteration 0, iterout_X for 1+) β that's the normal pattern for an observation that the entry node seeds and the loop then refreshes. It is only safe when the init entry is one-shot; Β§10 has the freeze hazard if it isn't.
5. Loop-carry & the stop signal
At each iteration boundary the executor reads iterOut's collected outputs and routes every config.ports value X into the paired iterIn's iterout_X slot, then re-queues iterIn. A persist=false slot clears after its consumer fires; a persist=true slot holds until overwritten. That transfer is the loop β there is no drawn back-edge.
Termination is decided once per iteration, at the iterOut boundary, from iterOut's own stop input (a class-level optional BOOL). If stop is truthy the scope ends after the current iteration; if it's unwired, the loop runs purely to its step_budget. stop is special only in that it does not participate in loop-carry (it isn't mirrored to the final side and a bare stop arrival doesn't make iterOut ready β readiness still needs the carry ports). There is no separate termination node and no termination condition string; the halt is just this BOOL.
6. The final side & the after-loop band
When a scope terminates, iterOut emits once on its final side: one final_<name> per carry port (mirroring the terminal iteration's values) plus a constant final_stop=True. These are real wires β verdict nodes (evaluate, graphOut) connect to them with ordinary edges β but they deliver exactly once, at the end.
The set of nodes downstream of a root iterOut's final_* edges is the after-loop band: the verdict stage that runs once, after the loop. Its defining rule (enforced by the validator, Β§9) is purity β a band node may take inputs only from the final side or from other band nodes, never from the loop body. Anything the verdict needs must ride the pivot: expose it as an iterOut carry port and read it from final_<name>. This is what makes βthe verdict reflects the terminal iterationβ a structural guarantee rather than a scheduling accident β the rationale is in ADR-dataflow-009, the runtime drain in Graph Executor Β§5.1.
7. Scopes: what a loop is, in code
A scope is one iterIn/iterOut pair together with the body it bounds. The executor and validator work in terms of a ScopeInfo (scope_analysis.py), keyed by the iterIn node id:
@dataclass class ScopeInfo: # scope_analysis.py β one (iterIn, iterOut) pair scope_id: str # the iterIn node id β canonical key for the scope iter_out_id: str # its paired iterOut parent_scope_id: str | None # None = root scope child_scope_ids: list[str] # scopes nested directly inside this one member_node_ids: set[str] # body nodes, by innermost membership graphin_node_ids: list[str] # graphIn boundary nodes serving this scope graphout_node_ids: list[str] # graphOut boundary / latch nodes step_budget: int | None # resolved from iter_in.config.step_budget
Nodes outside any pivot pair belong to the implicit graph scope (id GRAPH_SCOPE_ID = "") β entry nodes and the after-loop band live there. A graph with zero or one scopes is is_single_scope, and the executor takes a fast path that mirrors the pre-nesting behaviour exactly; multi-scope graphs go through the general machinery of Β§8.
8. Nested scopes & the forest
Loops can nest β an outer planning loop containing an inner action loop. analyze_scopes(graph) builds the full ScopeForest (and returns any topology errors) by a pure analysis of the flat graph:
- Membership β a node belongs to a scope if it is reachable forward from that iterIn and backward from its iterOut; the intersection is the body. A node in several nested bodies is assigned to the innermost one (
member_node_ids). - Nesting β scopes are arranged by pivot containment: the parent is the innermost scope whose body contains this scope's pivots, giving
parent_scope_id/child_scope_idsand theroot_scope_idsat the top. - Boundaries β graphIn/graphOut nodes are attached to the scope they serve; an inner scope's graphOut acts as a latch so the outer body can read the inner loop's result.
- Validation β cross-scope wires that bypass the graphIn/graphOut boundary, duplicate
pairedWith, or unpaired pivots are reported as errors.
The forest also writes a per-node node_to_scope map and the graph_scope_node_ids (everything outside any author scope). The executor consults all of this to run each scope's boundary independently. This nesting machinery is ADR-dataflow-007 + ADR-executor-003.
9. Validator contract (loop-specific)
validate_graph_connectivity (graph_def.py) enforces the loop rules at graph load; the full catalogue (and the non-loop rules) is in Graph System Β§4.2. The loop-specific ones:
- Removed pivots rejected β an
initializeorterminationnode fails with a migration hint (init values β iterIninitPorts; halt β iterOutstop). - Pairing integrity β every iterIn's
pairedWithpoints at an iterOut and vice-versa; no duplicate or dangling pairing. - iterIn handles β legacy
init_ports/loop_portsschemas rejected; outgoing edges may only reference synthesisedinit_*/iterout_*handles (plusstep). - iterOut handles β non-empty
config.ports; incoming edges target carry ports orstop; outgoing edges source only fromfinal_*/final_stop. - After-loop band purity β band nodes are graph-scope and take inputs only from the final side or other band nodes (Β§6).
10. Edge cases & hazards
- Dual-wire freeze. Wiring both
init_Xanditerout_Xinto the same consumer input with the init entry setpersist=truefreezes that input at its iteration-0 value (the persisted init slot keeps winning). Keep the init entry one-shot when a name is dual-sided. This was the root cause of the 2026-05-05 CMA β100% LEFT spinβ. - Run-invariants must persist. The mirror trap: anything the body reads every iteration that comes only from the init side must be
persist=true, or it vanishes after iteration 0. - Budget-only loops. Leave
stopunwired and the loop runs tostep_budgetβ intended for fixed-length rollouts. - Signals don't filter by scope. State-container
reset_onmatches a signal name, not a scope, so an inner scope'sstep_endcan clear an outer node's step-lifetime slot β see State Containers Β§6.
11. References
- ADR-dataflow-008 β two-sided iterIn (init side);
initializenode removed. - ADR-dataflow-009 β two-sided iterOut (
stopinput + final side);terminationnode removed; after-loop band. - ADR-dataflow-007 + ADR-executor-003 β multi-scope iteration & execution.
- ADR-dataflow-006 + ADR-executor-002 β the superseded three-pivot model (historical context for the 008/009 collapse).
- Graph Executor β how the pivots actually fire; Graph System β the JSON schema & validation; State Containers β loop-carried vs container state.
- Source:
scope_analysis.py(analyze_scopes,ScopeForest,ScopeInfo,GRAPH_SCOPE_ID),graph_def.py(_synthesize_iterin_ports,validate_graph_connectivity),builtin_nodes.py(IterInNode,IterOutNode),graph_executor.py(boundary, transfer, final emission),portResolution.ts(frontend synthesis mirror).