Skip to content

fix: evaluate list literals / deep call chains left-to-right - #49

Merged
pyrex41 merged 2 commits into
mainfrom
fix/flatten-chain-eval-order
Jul 29, 2026
Merged

fix: evaluate list literals / deep call chains left-to-right#49
pyrex41 merged 2 commits into
mainfrom
fix/flatten-chain-eval-order

Conversation

@pyrex41

@pyrex41 pyrex41 commented Jul 29, 2026

Copy link
Copy Markdown
Owner

The divergence

shen-lua evaluated the elements of a list literal right to left once the
literal reached 16 elements. shen-go, shen-cl and shen-rust all evaluate
left to right, so any side-effecting expression inside a list literal was
port-dependent. It broke a urdr test suite whose own output became
port-dependent (urdr d426237).

Same source, all four ports ((define demo X -> [(output "~A " 0) ... (output "~A " 19)])):

port output
shen-go 0 1 2 3 ... 19
shen-cl 0 1 2 3 ... 19
shen-rust 0 1 2 3 ... 19
shen-lua (before) 19 18 17 ... 0
shen-lua (after) 0 1 2 3 ... 19

Cause

compiler.lua's try_flatten_call_chain lowers a >=16-deep right-spine call
chain into a sequence of local bindings so the chain does not hit Lua's
~200-level expression-nesting parser limit. It built the chain inside out
and compiled each frame's non-last arguments at the call site it emitted for
that frame — so those arguments ran in reverse source order. A list literal's
KL form is exactly such a chain of cons calls, which is why the divergence
showed up there, and why the threshold was exactly 16.

Nothing else reorders: the MKLIST path (value position, >=16 elements) and
the MKTREE path (>=60 cons nodes) both already emit left to right, and
try_let_float already guards on arg_pure. Below 16 frames the lowering
does not fire at all, which is why short lists always looked correct.

Fix

Non-last arguments are evaluated up front, outermost frame first, before the
innermost value; the call sites then only reference the results.

Two properties preserved deliberately:

  • Only observable arguments are hoisted. arg_pure operands (atoms,
    unbound symbols, bound Lua locals) have no effects and cannot be disturbed
    by a later one, so they stay inline. The Prolog compiler's
    shen.gc(A, shen.gc(A, ...)) CPS spines — the chains this lowering exists
    for — are entirely of that kind, and their generated Lua is byte-for-byte
    unchanged
    (verified by diffing codegen before/after).
  • Hoisted values go into one scratch table, not into locals. Lua allows
    200 locals per function and the chain already spends one per frame, so
    hoisting into locals would halve the depth this lowering can handle. The
    table is emitted only when something actually needs hoisting.

Tests

New test/eval_order_spec.lua (40 checks), registered in scripts/run-tests.lua.
It pins left-to-right order for list literals in tail / argument / value
position at 3, 15, 16, 20 and 70 elements; deep user call chains with effectful
non-last arguments; and the collateral constructs that must not have
changed — application arguments, nested applications, let, do, arithmetic,
@p, @v, @s, if branch selection, freeze/thaw deferral. It also
pins that the flattening optimisation still fires (a 150-deep spine still
compiles to loadable Lua, and the pure chain still allocates no scratch table),
so a future "fix" that just disabled the lowering would fail here.

The first commit adds the spec failing; the second makes it pass.

TOTAL: 540 pass, 0 fail across 15 specs     (main: 500 pass, 0 fail across 14)
ALL PORT SPECS GREEN

make certify: passed ... 134 / failed ... 0 / pass rate ... 100%   (unchanged from main)

All twelve collateral constructs were also run through all four ports directly
and produce identical traces.

🤖 Generated with Claude Code

Reuben Brooks and others added 2 commits July 29, 2026 14:08
shen-lua evaluates the elements of a list literal of 16 or more elements
right-to-left, where shen-go, shen-cl and shen-rust all evaluate
left-to-right. Reproduction, all four ports, same source:

  (define demo X -> [(output "~A " 0) ... (output "~A " 19)])
  (demo 0)

  shen-go    0 1 2 3 ... 19
  shen-cl    0 1 2 3 ... 19
  shen-rust  0 1 2 3 ... 19
  shen-lua   19 18 17 ... 0     <-- diverges

The threshold is exactly the 16-frame trigger of compiler.lua's
try_flatten_call_chain, which lowers a deep right-spine call chain into
`local` bindings (to dodge Lua's ~200-level expression-nesting parser
limit) but emits them innermost-first.

This commit only adds the failing spec; the fix follows.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
try_flatten_call_chain lowers a >=16-deep right-spine call chain into a
sequence of `local` bindings so the chain does not hit Lua's ~200-level
expression-nesting parser limit. It built the chain inside out and, at
each call site, compiled that frame's non-last arguments right there --
so those arguments ran in reverse source order. For a list literal, whose
KL form is exactly such a chain of `cons` calls, that meant

  [(output "a") (output "b") (output "c") ... ]   (16+ elements)

printed c b a. shen-go, shen-cl and shen-rust all print a b c.

Non-last arguments are now evaluated up front, outermost frame first,
before the innermost value, and the call sites reference the results.

Only arguments that can be observed are hoisted: `arg_pure` operands
(atoms, unbound symbols, bound Lua locals) have no effects and cannot be
disturbed by a later one, so they stay inline. The Prolog compiler's
shen.gc(A, shen.gc(A, ...)) CPS spines -- the chains this lowering exists
for -- are entirely of that kind, and their generated Lua is byte-for-byte
unchanged (verified by diffing codegen before/after).

Hoisted values go into a single scratch table, not into `local`s: Lua
allows 200 locals per function and the chain already spends one per frame,
so hoisting into locals would halve the depth this lowering can handle.
The table is emitted only when something actually needs hoisting.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@pyrex41
pyrex41 merged commit 2a0cd60 into main Jul 29, 2026
2 checks passed
@pyrex41
pyrex41 deleted the fix/flatten-chain-eval-order branch July 29, 2026 19:20
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