Skip to content

Derive loop scopes and bindings from the runtime, not from a table (#472) - #479

Merged
jeremymanning merged 1 commit into
mainfrom
fix/loop-runtime-parity
Aug 4, 2026
Merged

Derive loop scopes and bindings from the runtime, not from a table (#472)#479
jeremymanning merged 1 commit into
mainfrom
fix/loop-runtime-parity

Conversation

@jeremymanning

Copy link
Copy Markdown
Member

Follow-up to #473, which had the right architecture and the wrong tables.

Why the previous tables could not be trusted

Every test in #473 asked whether the validator agreed with core/loop_contracts.py. Both sides of that question came from the same file, so the suite proved the table was applied consistently and nothing about whether it was true.

tests/test_loop_runtime_parity.py closes that. For every name a contract declares, it executes a pipeline and requires the name to render; for names withheld, it requires them not to. Hermetic — filesystem tool only, no model, no network. Two mutations to the table fail it, and it caught a cheater test of my own along the way (the first version wrote the bare name into the file instead of {{ name }}, so all 31 "binding renders" assertions passed vacuously).

What running it found

foreach is not an alias of for_each. #473 declared it one, on the strength of a comment. for_each: "['A','B']" writes 0.txt and 1.txt; the same file with foreach: gives Schema validation failed: 325 errors, and in a shape that does compile the body runs once with nothing bound. compiler/control_flow_compiler.py:119 branches on for_each only. Removed from the contracts (#475).

An action_loop's body was validated outside the loop it is the body of. #473 declared action_loop and until source fields. The action_loop key holds the actions, and _build_iteration_context builds iteration state before they run, so {{ iteration }} in an action-loop body — the one place iteration state certainly exists — was rejected.

A while condition is not a for_each iterable. It is re-evaluated every iteration against the context should_continue assembles at control_flow/loops.py:428, which holds iteration and loop_state. So while: "{{ iteration < 3 }}" was being rejected. It is also narrower than the body: index and position render in a while body and fail in its condition, so this is not simply "conditions are body scope".

create_parallel_queue mixes phases inside one object. on generates the queue; the nested actions run per item. A flat tuple of source fields put the whole object in one scope. Scope is now keyed by field path (create_parallel_queue.on), matched by longest prefix so narrowing one field leaves its siblings alone.

A step declaring two loop constructs was resolved by declaration order inside loop_contracts, an order no engine agreed to. Now ambiguous_loop_construct.

The $ spelling is an observed list, not a derived one. {{ $position }} raises unexpected char '$' at run time; {{ $item }} resolves. Deriving the set as "$" + every binding accepted the first (#474).

Runtime defects found while probing — filed, not fixed here

issue defect
#475 foreach parses and silently does nothing
#476 action_loop's until: is required and never evaluateduntil: "false" with max_iterations: 4 still runs once
#477 constructs leak each other's names as stale values; LoopContext.get_debug_info() is registered wholesale as the template surface
#478 create_parallel_queue reports success while skipping a write whose template did not resolve

New evidence added to #474: the named-loop syntax {{ $outer_loop.item }} runs correctly (verified) and cannot pass validate at all, so there is no valid spelling for reaching an outer loop from a nested one.

One decision worth challenging

Where the runtime leaks a name that has no meaning for a construct — {{ iteration }} renders None inside a for_each; {{ index }} renders 0 in an iterable that has not run — this withholds it rather than blessing it. Validation is therefore deliberately stricter than the runtime in exactly the cases #477 covers.

The alternative is to accept whatever renders, which converts a bug into documented behaviour. I picked strictness because a None that silently reaches a template is the failure mode this project keeps paying for, but it is a language decision and it is reversible.

Docs

docs/loop_variables.md advertised {{ $position }} (a compile error), an as: key nothing reads, and "both formats work identically". Rewritten from the verified tables, one section per construct, with #476 and #474 marked inline where they affect what a reader would write.

Verification

  • ruff check src/orchestrator --select E9,F63,F7,F82,F821,F823,F601,F811 — clean
  • Full blocking suite — 999 passed, 13 skipped
  • Catalogue — all 52 listed examples still validate
  • Five mutations to the scope logic, each caught by exactly one test: condition given body scope; action_loop given source scope; ambiguity check removed; loops detected on any dict rather than steps; enclosing bindings subtracted instead of carried

One regression caught during the work and fixed: removing Jinja's own loop from the per-scope sets broke the data-flow validator, which consults the union to know {{ loop.index }} is not a missing task. It belongs in the union and in no contract.

Not self-merging

This touches the template language and validation behaviour, which the last review asked be gated on independent approval — and #473, the PR this corrects, was self-merged. Leaving it for review; say the word and I'll merge.

)

#473 replaced one "inside a loop" boolean with per-construct contracts. The
direction was right and the tables were wrong, because they were written from
reading the source and checked only against themselves: every test asked
whether the validator agreed with `loop_contracts`, and both sides came from
the same file. That proves consistency and says nothing about truth.

So the tables are now read off running pipelines, and
`tests/test_loop_runtime_parity.py` re-derives them the same way on every run:
a name a contract declares must render in a real execution, and a name it
withholds must fail to. The suite is hermetic -- filesystem tool only, no
model, no network.

What that turned up:

* `foreach` is not an alias of `for_each`. `for_each` writes its two files;
  `foreach` gives `Schema validation failed: 325 errors`, and in a shape that
  does compile the body runs once with nothing bound. #473 declared it
  supported on the strength of a comment. Removed (#475).

* An `action_loop`'s body was validated outside the loop it is the body of.
  The `action_loop` key holds the actions, and `_build_iteration_context`
  builds iteration state before they run.

* A `while` condition is not a `for_each` iterable. It is re-evaluated every
  iteration against `iteration` and `loop_state`, so `while: "{{ iteration <
  3 }}"` -- the ordinary way to write a bounded loop -- was being rejected.

* `create_parallel_queue` mixes phases inside one object: `on` generates the
  queue, the nested actions run per item. A flat tuple of source fields cannot
  say that, so scope is now keyed by field path.

* A step declaring two loop constructs was silently resolved by declaration
  order in this module, which no engine agreed to. It is now an error.

* The `$` spelling is an observed list, not `"$" + every binding`:
  `{{ $position }}` raises `unexpected char '$'` at run time while
  `{{ $item }}` resolves (#474).

Four runtime defects found while probing, filed rather than fixed here:
#475 (dead `foreach`), #476 (`action_loop`'s required `until` is never
evaluated, so the loop always runs once), #477 (constructs leak each other's
names as stale values -- a debug dict became the template surface), #478
(`create_parallel_queue` reports success while skipping an unresolved write).

Where the runtime leaks a name with no meaning for the construct, this
withholds it rather than blessing it, so validation is deliberately stricter
than the runtime in exactly the cases #477 covers. That is a language
decision, recorded in the module docstring.

`docs/loop_variables.md` documented `{{ $position }}` (a compile error), an
`as:` key nothing reads, and "both formats work identically". Rewritten from
the verified tables, with the two known defects marked inline.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jeremymanning
jeremymanning merged commit 6e1da42 into main Aug 4, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant