Skip to content

feat(emit): implement Behavior::Loop emission for Python and Go - #692

Merged
briansrls merged 7 commits into
mainfrom
session/wise-badger-854
Apr 24, 2026
Merged

briansrls merged 7 commits into
mainfrom
session/wise-badger-854

Conversation

@briansrls

Copy link
Copy Markdown
Contributor

Summary

  • Behavior::Loop is emitted in Python and Go by rendering the body DAG's result port, which preserves recursive self-calls natively supported by both targets
  • Documents the IR invariant at each emit site: Loop has exactly two construction sites in lower.rs, both for recursive user functions (Cardinality and Descent bounds); collection folds go through callable realizations and never reach Loop
  • Adds port() / node() substrate accessor helpers to the Python roundtrip test prelude (the std.substrate host functions used by unused_parameters.dag when walking DAG edges)
  • Removes #[ignore] from three tests that were blocked on Loop emission

Why body-port rendering is correct semantics

Behavior::Loop wraps a recursive function whose body DAG already contains the recursive self-calls. In both the Cardinality (single recursion, lower.rs ~3631) and Descent (mutual recursion cluster, lower.rs ~382) cases, loop_node.body is the root node of the function body DAG — the same DAG the compiler proved terminates. Rendering the body node's result port preserves those recursive calls. Python and Go handle recursion natively; the source/init/bound fields encode the termination proof, not operational iteration state.

Each emit site documents the IR invariant and flags what must be updated if Loop is ever extended to non-recursive collection iteration.

Test plan

  • emit_python_module_marks_ownership_as_skipped_for_gc_target — passes
  • emitted_python_lens_matches_emitted_rust_lens_on_reflected_programs — passes (Rust and Python produce identical unused-parameter sets)
  • emit_go_lens_unused_parameters_module — passes
  • Full cargo test -p v3-compiler --test integration — running

🤖 Generated with Claude Code

briansrls and others added 5 commits April 23, 2026 21:41
The emitted Python lens (unused_parameters.dag) calls the host functions
`port(dag, id)` and `node(dag, id)` from std.substrate. The Python
roundtrip test harness provides a dataclass prelude for the Dag/Behavior
types but was missing linear-search implementations for these two
accessors. Without them the roundtrip script failed with
`NameError: name 'port' is not defined` when the lens traversed the
DAG graph.

This was masked before by the Loop fail-close: the walk_steps recursion
lowered to Behavior::Loop, which previously errored before reaching the
port lookup code path. Now that Loop renders the body port (per the
Rust-baseline WIP commit), the full lens execution path runs and needs
these accessors in scope.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… regression

Reverts WIP T-Rust-Baseline and the port/node prelude addition.

The earlier change re-introduced the exact semantic bug the fail-closed
guard was written to prevent: rendering Behavior::Loop as its body's
result port silently drops iteration semantics (a Loop over a list
becomes its first iteration's expression). Making the ignored tests pass
by restoring known-wrong behavior is not a fix — it's the failure mode
the comments explicitly documented.

Correct path: model Loop emission properly for each target before
re-enabling these tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Behavior::Loop has exactly two construction sites in lower.rs, both for
recursive user functions:

  1. Cardinality bound (~line 3631): single recursive fn with
     descent-provable termination; bound.count = first param port.
  2. Descent bound (~line 382): mutual-recursion cluster.

In both cases loop_node.body is the root node of the function's body
DAG, which already contains recursive self-calls. Rendering the body
node's result port preserves those calls. Python and Go both support
recursion natively, so the emitted expression is semantically correct
without any iteration scaffolding. The source/init/bound fields encode
the termination proof, not operational iteration state.

Collection folds (fold/map/filter) route through callable realizations
(__v3_fold, .fold(), etc.) and never reach Behavior::Loop. Each emit
site documents this invariant and notes what must change if Loop is
extended to non-recursive collection iteration in the future.

The Python roundtrip prelude gains port() and node() accessor helpers,
which are the std.substrate host functions the unused_parameters lens
calls when walking DAG edges.

Unblocks:
- emit_python_module_marks_ownership_as_skipped_for_gc_target
- emitted_python_lens_matches_emitted_rust_lens_on_reflected_programs
- emit_go_lens_unused_parameters_module

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Copy link
Copy Markdown
Contributor Author

Review note:

This is materially different from #677 in the right way: I checked lower.rs, and Behavior::Loop really is only constructed at the two recursive-function sites (src/v3/compiler/src/lower.rs:382 and :3631). That makes the core semantic argument much stronger than the earlier rejected shortcut. It also matches the existing Rust emitter behavior, which already renders Loop by delegating to the body node’s result port.

One thing I’d still like before treating this as a durable closure: add a direct Python/Go receipt for a plain recursive user function program, not just the unused_parameters.dag lens path. Right now the un-ignored tests prove the reflected-lens case, but the contract being claimed is broader: recursive user functions lower through Behavior::Loop and emit correctly on Python/Go.

A small targeted roundtrip using something like recursive_function_call_six (or an even smaller source-local recursive fn fixture) would pin that claim directly and make the lane harder to regress later.

@briansrls

Copy link
Copy Markdown
Contributor Author

Review metadata

  • Provider / model: claude / claude-opus-4-7
  • Commit: 724c9d0f · Trigger: schedule
  • Thinking: 108s wall

Findings

None blocking. A couple of small observations:

  • src/v3/compiler/src/emit.rs:1255 and src/v3/compiler/src/emit/python_target.rs:1041 — the safety of "render body's result port" depends on the invariant that Behavior::Loop only wraps recursive user functions today. I verified both construction sites in lower.rs (lines 382, 3631) and they match the comment. The Rust target already does the same thing (emit/rust_target.rs:4325), so this PR is just bringing Python/Go into parity. The dissolution trigger ("if a future IR change adds Loop for non-recursive collection iteration") is documented and bounded — qualifies as tracked debt.

  • src/v3/compiler/tests/boundary/m1_4_emit_python_test.rs:339-345 — the new node accessor uses n._0.id. Fine for an in-test reflected-Dag prelude, but it's coupled to the variant tuple-field naming convention emitted for Behavior. Not a violation; just brittle if that convention shifts. Implementation-layer concern.

Verdict

APPROVE. The change reverses the over-eager fail-closed guard from 0089c00 for a justified reason: lower.rs only emits Behavior::Loop for recursive function bodies, the body DAG already carries the recursive self-calls, and Python/Go handle recursion natively — exactly mirroring the existing Rust target. Comments capture the construction-site invariant and name a concrete dissolution trigger, so this is a tracked bridge rather than silent collapse. Re-enabling the three previously-ignored lens tests is appropriate.

@briansrls

Copy link
Copy Markdown
Contributor Author

Review metadata

  • Provider / model: codex / gpt-5.4
  • Commit: 724c9d0f · Trigger: schedule
  • Thinking: 391s wall

Verdict: APPROVE. I don’t see a concrete violation of INVARIANTS.md, docs/modeling-discipline.md, CODING.md, or TESTING.md in the touched lines. The new Go/Python Behavior::Loop handling is consistent with the existing Rust emitter’s treatment of recursive loops, and the accompanying test changes are aligned with the boundary-level coverage this PR is exercising.

briansrls and others added 2 commits April 23, 2026 22:49
Pins the claim that Behavior::Loop lower-then-emit is semantically
correct for Python and Go by testing a plain recursive user function
end-to-end, not just via the unused_parameters lens reflection path.

Each receipt test:
  1. Asserts a Loop node is present in the lowered DAG (structural claim)
  2. Asserts the recursive self-call appears in the emitted source
  3. Runs the emitted program and checks the correct result (count(6) = 6)

Both tests are #[ignore]d because CI sandboxes may not carry python3/go;
they run locally with --ignored.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@briansrls
briansrls merged commit 4dc0a6d into main Apr 24, 2026
3 checks passed
briansrls added a commit that referenced this pull request Apr 24, 2026
… lists

PR #692 landed Behavior::Loop emission for both Python and Go. The sole
remaining entry in GO_EMIT_EXCLUDE and PYTHON_EMIT_EXCLUDE was
recursive_function_call_six (blocked on Loop support). Both lists are now
empty; all 9 PROGRAM_FIXTURES are covered by the omni set and by the 5×
determinism matrix for every target.

Update stale comments in determinism_test.rs to reflect the current state.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
briansrls added a commit that referenced this pull request Apr 24, 2026
* WIP: T emit go/rust bounds

* chore: apply cargo fmt

* feat(python): add mul/div/ne/lt/le/gt/ge operator realizations; lift 3 Python exclusions

python.dag was missing seven Int operator realizations that Rust and Go
already had: mul (*), div (//), ne (!=), lt (<), le (<=), gt (>), ge (>=).

This unblocked list_map_then_fold_twelve (uses *), list_filter_then_fold_seven
(uses >), and nested_list_builtins_inside_lambda_six (uses * inside a lambda)
from Python emission — all three were excluded from PYTHON_EMIT_EXCLUDE with
a MissingOperatorRealization note.

After regenerating bootstrap and lifting those exclusions, the Python
determinism matrix now covers 8/9 PROGRAM_FIXTURES (same as Go), and the
emit_omni_demo_fixtures_green closure test checks 8 fixtures instead of 5.
The sole remaining Python exclusion is recursive_function_call_six (Loop).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: apply cargo fmt

* fix(sg0/parse-manifest): register m1_5_emit_omni_demo_test.rs; refresh python.dag snapshot

Register `tests/boundary/m1_5_emit_omni_demo_test.rs` in
`EXPECTED_HAND_AUTHORED` with director-approved receipt (T-Emit lane
closure, sharp-heron-47). Refresh `parse_corpus_manifest.txt` to
reflect `python.dag`'s updated item count and hash after gaining 7
operator realizations (`mul`, `div`, `ne`, `lt`, `le`, `gt`, `ge`).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(t-emit): honest lane-closure receipt — ignore gate + hard toolchain assert

Split the single test into two:

- `emit_omni_demo_rust_roundtrip` (non-ignored): CI gate that runs
  unconditionally; proves the omni fixture set emits valid, executable Rust.

- `emit_omni_demo_fixtures_green` (#[ignore]): T-Emit lane closure receipt.
  Marked ignore because go/python3 are absent in CI. When run with
  --ignored it asserts both toolchains are reachable and fails hard if
  either is missing — a missing toolchain is an unmet receipt, not a skip.

Addresses review finding: prior implementation returned None for absent
toolchains and passed with 0/N targets checked, making it a useful local
harness but not a truthful lane-closure proof.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(t-emit): pin floor-div semantic constraint; fix tmp-dir leakage

python_int_div: add comment at definition site pinning the known
floor-vs-truncated divergence for negative dividends (-7/2 → -4 in
Python, -3 in Rust/Go). Omni fixtures don't exercise this case; the
constraint is pinned rather than fixed until a target-appropriate
truncating-div primitive is modeled.

m1_5_emit_omni_demo_test: replace bare temp-dir allocation with a RAII
`TmpDir` wrapper whose `Drop` impl calls `remove_dir_all`. Repeated
local runs with three toolchains × N fixtures no longer accumulate
`v3_emit_omni_roundtrip_*` directories indefinitely.

Regenerate bootstrap and refresh parse-corpus manifest for the
python.dag comment addition.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(python): correct OrderedRing.div to truncate toward zero via __v3_idiv

Python's `//` is floor division, which diverges from Rust/Go integer `/`
(truncate toward zero) for negative dividends (-7/2 → -4 in Python, -3
in Rust/Go). This is a semantic correctness violation for any DAG that
produces negative integer division.

Fix:
- Add `__v3_idiv(a, b)` preamble helper using `divmod`-based adjustment:
  if the remainder is non-zero and operand signs differ, adds 1 to the
  floor quotient to restore C-style truncation toward zero.
- Change `python_int_div` carrier from `"//"` to the full-expression
  template `"__v3_idiv({lhs}, {rhs})"`.
- Extend the Python emitter's binary-op rendering to detect full-expression
  carriers (those containing `{lhs}`) and render them directly instead
  of inserting into the `({lhs} {op} {rhs})` infix template.

Regenerate bootstrap and refresh parse-corpus manifest.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: apply cargo fmt

* refactor(emit): dissolve binary_op dual authority; carriers are sole expression templates

`ExpressionSyntax.binary_op` and `OperatorRealization.carrier` both
claimed authority over how binary expressions are rendered. The emitter
had to reverse-engineer carrier string content (`carrier.contains("{lhs}")`)
to decide which path to take — a string-sentinel violation of the modeling
discipline (INVARIANTS.md P2, Practice 5-6).

Fix: make `OperatorRealization.carrier` the sole authority.

- Remove `binary_op` from `ExpressionSyntax` in `std/emit_model.dag`.
- Remove `binary_op` from `python_expressions`, `go_expressions`, and
  `rust_expressions` in all three spec files.
- Convert all infix operator carriers ("+", "-", "*", "/", "==", "!=",
  "<", "<=", ">", ">=", "&&", "||", "and", "or") to full-expression
  templates: `"({lhs} + {rhs})"` etc. Python's `__v3_idiv` carrier
  gains outer parens to match: `"(__v3_idiv({lhs}, {rhs}))"`.
- All three emitters (`emit.rs`, `python_target.rs`, `rust_target.rs`):
  remove `binary_op` field from the expression-syntax binding struct,
  remove the `syntax_field_string("binary_op")` load, and replace the
  three-binding template call with a direct two-binding render on the
  carrier. The `carrier.contains("{lhs}")` probe in python_target.rs
  is also removed — it is no longer needed.

Every `OperatorRealization.carrier` is now self-describing: it is always
a full-expression template with `{lhs}` and `{rhs}` placeholders, with
no external format convention required to interpret it.

Regenerate bootstrap and refresh parse-corpus manifest.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(t-emit): lift recursive_function_call_six from Go/Python exclude lists

PR #692 landed Behavior::Loop emission for both Python and Go. The sole
remaining entry in GO_EMIT_EXCLUDE and PYTHON_EMIT_EXCLUDE was
recursive_function_call_six (blocked on Loop support). Both lists are now
empty; all 9 PROGRAM_FIXTURES are covered by the omni set and by the 5×
determinism matrix for every target.

Update stale comments in determinism_test.rs to reflect the current state.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(ci): update carrier snapshot in m1_3_prb_rust_dag_bootstrap_loads_structurally

The test was asserting `carrier == "+"` (old infix symbol form). After the
carrier unification in the previous commit, rust_int_add's carrier is now
`"({lhs} + {rhs})"`. Update the assertion to match.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(emit): fail-closed on operator carriers missing {lhs}/{rhs} placeholders

`render_named_template` is silent when a carrier lacks the expected
placeholders — a carrier like "+" would emit literally "+" instead of a
binary expression. Add a load-time check in all three emitters that rejects
any OperatorRealization whose carrier does not contain both {lhs} and {rhs},
turning a silent runtime divergence into a fail-closed load error.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(python): always-on emit assertions for new Int operator carriers

The new Python Int operators (mul, div, ne, lt, le, gt, ge) only had
determinism coverage in CI — the rendered template content was never
asserted. Add emit_python_int_operators_use_correct_expression_templates:
five always-on (non-ignored) checks that verify each operator emits the
expected Python token/call without requiring a Python toolchain.

Specifically: * emits the * operator, / emits __v3_idiv(...), != emits !=,
< emits <, > emits >.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
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