Skip to content

Fix tree-walker escaping function from match-in-loop tail#157

Merged
danieljohnmorris merged 2 commits into
mainfrom
fix/match-in-loop-tail
May 11, 2026
Merged

Fix tree-walker escaping function from match-in-loop tail#157
danieljohnmorris merged 2 commits into
mainfrom
fix/match-in-loop-tail

Conversation

@danieljohnmorris
Copy link
Copy Markdown
Collaborator

Summary

A ?match{} expression at the tail of an @ or wh loop body was silently early-returning the enclosing function with the match arm's value. Three personas independently reported this during assessment runs as "match arm silently exits the outer loop." VM and Cranelift were correct; only the tree-walker was wrong.

Engine-divergent silent miscompilation is the worst possible failure mode for an AI-targeted language: the program verifies, runs, returns the wrong number with no error signal, and the agent burns retries debugging logic that isn't broken. Same severity class as the slc/mset bug PR #150 fixed.

Repro

ilo 'f>n;cs=["1"];@c cs{rn=num c;?rn{^_:99;~v:42}};5' --run-tree f
Engine Before After
--run-tree 42 5
--run-vm 5 5
--run-cranelift 5 5

Root cause

Stmt::Match in eval_stmt had a special-case if is_last { return BodyResult::Return(v) } that fired whenever the match was the final statement of any body — function or loop. The function-body handler in eval_body already returns BodyResult::Value(last) for the function caller to convert into the function return, so the special-case at Stmt::Match was duplicating that logic incorrectly. Inside a loop body it made the match's value propagate as a function-level Return, bypassing whatever came after the loop.

What's in the diff

  1. interpreter: tree-walker no longer escapes function from match-in-loop tail — one-line removal of the if is_last branch in Stmt::Match's Value(v) arm. is_last was the only consumer of that parameter and eval_body was the only caller of eval_stmt, so removed the plumbing entirely rather than leaving dead args that invite the special-case to come back.

  2. tests + example: pin match-in-loop tail behaviour across engines — 6 cross-engine regression tests covering match-as-loop-tail, match-as-function-tail (sanity), side-effect arms, cnt/brk propagation, and the wh-loop analogue. examples/match-in-loop.ilo demonstrates both loop-tail and function-tail shapes with -- run: / -- out: directives so tests/examples_engines.rs exercises them across every engine.

Test plan

  • cargo test --release --features cranelift — 3419 passed, 0 failed, 74 ignored (3413 baseline + 6 new)
  • cargo fmt --all -- --check clean
  • Repro returns identical output across tree, vm, cranelift
  • Function-tail match (no surrounding loop) still returns the match's value correctly
  • Code-reviewed by subagent; should-fix and nice-to-have findings addressed (full is_last plumbing removed, wh-loop test added, example expanded with function-tail sanity case, header comment trimmed)

Follow-ups

The doc entry about mset m k variable silently storing wrong value when a 0 was previously bound was a stale-binary artefact of PR #150 — same root cause, already fixed there. Updated ilo_assessment_feedback.md to reflect this in the Addressed section.

The custom ARM64 JIT removal is in flight on a separate branch (feature/remove-custom-jit) — independent of this PR, no conflict.

…p tail

A `?match{}` expression at the tail of an `@` or `wh` loop body was
silently early-returning the enclosing function with the match arm's
value. Three personas independently reported this as "match arm
silently exits the outer loop" across the assessment runs.

Root cause: `Stmt::Match` in eval_stmt had a special-case
`if is_last { return BodyResult::Return(v) }` that fired whenever the
match was the final statement of any body, function or loop. The
function-body handler in `eval_body` already returns
`BodyResult::Value(last)` at line 1691 for the function caller to
convert into the function return, so the special-case was duplicating
that logic incorrectly. Inside a loop body it caused the match's value
to propagate as a Return through the loop and out of the function,
bypassing whatever came after.

The fix is one-line removal. With the special-case gone the match
yields `Value(v)` like any other expression statement: the loop body's
`last` accumulator captures it on each iteration, the loop returns
`Value(last)` to its caller, and function-tail match still works
because eval_body's existing tail handling does the conversion.

`is_last` was the only consumer of that parameter and the only caller
of `eval_stmt` is `eval_body`, so removed the plumbing entirely
rather than leaving dead args that invite the special-case to come
back. VM and Cranelift were always correct; this brings the
tree-walker into engine parity.
Cross-engine regression covers: match-as-tail-of-@-loop preserves the
function's actual tail; match-as-tail-of-function still returns the
match value; side-effect arms inside loops update accumulators
correctly; cnt and brk inside match arms still propagate to the loop;
match-as-tail-of-wh-loop matches @-loop behaviour. All five run
against tree, vm, and cranelift.

examples/match-in-loop.ilo demonstrates both shapes (match as loop
tail, match as function tail) with run/out directives so
examples_engines.rs exercises them per engine. Gives agents an
in-context learning sample of the now-correct pattern alongside the
harness-level coverage.
@codecov
Copy link
Copy Markdown

codecov Bot commented May 11, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ All tests successful. No failed tests found.

📢 Thoughts on this report? Let us know!

@danieljohnmorris danieljohnmorris merged commit d8f756c into main May 11, 2026
5 checks passed
@danieljohnmorris danieljohnmorris deleted the fix/match-in-loop-tail branch May 11, 2026 16:26
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