Skip to content

fix: 200-locals shapes and long list literals (#84, #88; builds on #78) - #91

Merged
pyrex41 merged 13 commits into
mainfrom
capcov/fix-84-88-locals-reader
Oct 19, 2026
Merged

pyrex41 merged 13 commits into
mainfrom
capcov/fix-84-88-locals-reader

Conversation

@pyrex41

@pyrex41 pyrex41 commented Oct 18, 2026

Copy link
Copy Markdown
Owner

Fixes #84
Fixes #88

This branch contains #78. It is #78's branch (cursor/fix-consume-arity-662d, 2f3ee3c), merged with current main (43.1, e3dec8b) in 0bdbfc4 without conflicts, plus two commits. #78's let-splitting and wide-mode code is what fixes #84, so this builds on it instead of competing with it. You can merge #78 first and then this PR, or merge this PR alone, which supersedes #78.

#84: 200-locals limit (5e7b5d6, tests only)

Once #78 is merged with 43.1 main, it already loads both shapes from the issue:

repro main e3dec8b this branch
nest-16/20/30.shen (datatype, nested conclusion) function at line 1 has more than 200 local variables loaded
same, SHEN_PROLOG_ENGINE=legacy same error loaded
list-200/250.shen (define returning a list literal) same error ok 200 / ok 250
capcov cc-classify (classify.shen) same error in shen:cc-classify#type loads (as on shen-go)

Larger cases also work: 60- and 100-deep patterns, and 1000/1500-element literals. 5e7b5d6 adds the issue's shapes to test/wide_compile_spec.lua (+12 checks): 16/30/60-deep patterns, typechecked, and 200/250/1000-element defines.

#88: reader on long list literals (d95688e)

shen.<s-exprs> recursed once per token without a tail call. Past about 1550 elements it overflowed LuaJIT's fixed stack, and read-file's trap-error reported the overflow as reader error near here:. <strcontents>/<alphanums>/<whitespaces> recursed once per character in the same way, so (read-from-string "[1 … 3000]") failed on its 14 KB string literal.

The fix follows the iterative comment scanners that already sit next to it in prims.lua:

  • shen.<s-exprs> keeps the KL's ordered choice and still calls the kernel's own single-token rules through F. Open brackets are kept on an explicit stack.
    • A missing closer falls back to the remaining alternatives at the bracket, as the KL does.
    • Each sequence is folded right to left, so cons-form/add-sexpr run, and raise, in the KL's order.
  • shen.cons-form builds the spine front to back.
  • <strcontents>/<alphanums> become one table.concat, and <whitespaces> becomes a loop.
  • read-file rethrows a Lua stack overflow instead of reporting it as a reader error.
repro main this branch shen-go 1f587de
rd-1600, rd-2000 reader error near here: ok 1600, ok 2000 same
rfs-3000 (read-from-string, 14 KB string) reader error near here: ok 1 ok 1
rd-4000 reader error near here: [string "reader"]:26: stack overflow ok 4000

Remaining limit. rd-4000 now fails as an honest stack overflow after the reader. To the rest of the pipeline a list literal is a cons expression nested once per element, and the kernel's recursive tree walkers overflow at about 3400 elements. A frame histogram at 4000 shows shen.find-types (from shen.process-sexprs). With that made iterative, the next overflow is 3445 × shen.map-h + 3443 × shen.special-case (shen.process-applications), and then macroexpansion and compilation would follow. The native find-types moved the limit only from 3200 to 3400, so I left it out. Lifting this properly means making those walkers iterative, which should be a separate change.

Evidence that the native reader equals the kernel's. The new test/reader_native_spec.lua compiles the kernel's own KL rules (<s-exprs>, <s-exprs1/2>, cons-form, <whitespaces>, <atom>, <str>, <sym>, <strcontents>, <alphanums>) under spec. aliases. It requires the same value, or the same error message, from the natives on 145 inputs:

  • every .shen file under tests/ and examples/ (86 files);
  • 59 edge cases: unclosed [/(, [a | b c], $, :=, {}, nested and line comments, c#NN;, empty input, stray closers, and UTF-8.

It also reads 1600–3000-element literals, a 14 KB string, 3000-deep nesting and a 20000-character symbol. As a mutation check, removing the := alternative and the [a | b c] error makes 10 checks fail.

Tests

make test and make certify were run with GIT_DIR/GIT_WORK_TREE/GIT_INDEX_FILE unset, a private TMPDIR and a private XDG_CACHE_HOME.

make test make certify (43.1 suite)
main e3dec8b TOTAL: 1210 pass, 0 fail across 23 specs passed ... 154 / failed ... 0
#78 + main (0bdbfc4) TOTAL: 1511 pass, 0 fail across 25 specs passed ... 154 / failed ... 0
head d95688e TOTAL: 1678 pass, 0 fail across 26 specs passed ... 154 / failed ... 0

Cross-port check (capcov workloads)

Payloads were extracted with capcov's bench.py payload filter and compared with shen-go 1f587de (kernel 42.2).

🤖 Generated with Claude Code

cursoragent and others added 11 commits October 9, 2026 09:00
A datatype rule with six or more premises over another user type
compiled a shen.consume helper whose continuation captured 17 or more
locals (two per premise variable, plus the assumption and engine
temps). mkhandle refused past 16 captures, so the helper was never
registered in NativePred and the caller died with
"shen.consumeN is undefined". Five premises captured 15 and fit
NEWCONT16. Wider handles now use the existing NEWCONTV path.

Co-authored-by: pyrex41 <pyrex41@users.noreply.github.com>
An inline freeze-let thawed inside a value-position control form is
expanded inside the hoisted KC function with the freeze's own env, but
the KC took only the control form's free variables as parameters. A
freeze variable the form did not mention itself resolved to a global:
(let G (freeze Z) (let Y (if (= X 0) (thaw G) 5) Y)) returned nil for
X = 0. kc_params now adds the free variables of every inline record the
form thaws (transitively). Kernel codegen is unchanged.

The KC hoisting is factored into hoist_kc for reuse.

Co-authored-by: pyrex41 <pyrex41@users.noreply.github.com>
ctail emitted one Lua local per let, so a single function with a long
let chain failed to load ("function at line 1 has more than 200 local
variables"). The kernel's pattern-matching KL for a sequent rule spends
about six lets per premise, so a datatype rule with ~30 premises over a
user type failed to load at all.

Each Lua function now tracks the locals it has declared (params, lets,
chain-hoist steps, snapshot captures, fN refs). Past 120, the rest of the
let chain continues in a fresh KC function via a proper tail call
(return KC[i](live vars)). The split is refused while a goto-kind
failure continuation is active (its label must stay in this function)
or when the continuation itself would need 120+ parameters. Kernel
codegen is unchanged; no kernel function reaches the budget.

Co-authored-by: pyrex41 <pyrex41@users.noreply.github.com>
Two limits in the native Prolog translator left NativePred[shen.consumeN]
unset for wide sequent rules, so typechecks walking the rule died with
"shen.consumeN is undefined":

* every let chained a new env table via __index, and from 16 premises
  the consume helper nested past LuaJIT's 100-table chain limit ("loop
  in gettable"). extend_env collapses the chain every 32 levels into
  one table with the same root lookup.
* the generated Lua then exceeded 200 locals in one function. When
  loadstring reports that, the predicate is translated again with let
  splitting: past 120 locals the rest of a let chain is lifted into a
  static (the existing let-bound freeze closure conversion) and called.
  Predicates that already loaded keep byte-identical code, so the
  split costs nothing on existing hot paths.

Lifted statics and continuations now keep the vec/lock bindings in their
env, so a misuse refuses as it would in the enclosing scope instead of
compiling the name as a symbol. mkhandle and lift_cont share
newcont_ctor (NEWCONT0..16, NEWCONTV past that).

datatype_consume_spec covers 5..37 premises: every consume helper and
the rule translate natively, the last element is unpacked, and misses
still miss. engine_spec pins NEWCONTV's capture layout and undo.

Co-authored-by: pyrex41 <pyrex41@users.noreply.github.com>
A defun whose ordinary Lua does not load (200 locals, 60 upvalues, 250
registers) is recompiled once with captures passed as tables: hoisted
bodies and freezes past 24 captures take one table, lambdas closing over
more than 40 names become LAMW closures, let chains split at 80 locals
whatever the live set, goto failure continuations stay freeze/thaw,
flattened call chains reuse one local, and very wide calls pass UNPACK.
Curried lambda chains deeper than 32 compile to CURRY in every mode.
Code that loads today is unchanged.

Co-authored-by: pyrex41 <pyrex41@users.noreply.github.com>
When the split translation still passes a Lua limit, translate once more
with wide statics (free vars as one table), CAPREF reads in wide
continuations, NEWCONTT handles, CONSL cons chains and lifted functions
in one LT table. Predicates that translate today are unchanged.

Co-authored-by: pyrex41 <pyrex41@users.noreply.github.com>
Co-authored-by: pyrex41 <pyrex41@users.noreply.github.com>
Merge origin/main into the wide-premise branch. compiler.lua and
prims.lua combined cleanly: the string->n fusion and the Unicode
primitives sit next to the wide-mode retry. The test runner lists both
the new UTF-8 spec and the wide-compile spec.

Co-authored-by: pyrex41 <pyrex41@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PR #78's let splitting and wide-mode recompilation already load both
shapes from #84 once merged with 43.1 main: a datatype rule whose
conclusion pattern nests 16+ deep (one local per sub-term in
<name>#type) and a define returning a 200+-element list literal (one
flattened call-chain local per element). Add them to wide_compile_spec
(16/30/60-deep patterns, typechecked; 200/250/1000-element lists) so
they stay fixed.

Fixes #84

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
shen.<s-exprs> (reader.kl) recursed once per token without a tail call,
so a list literal past ~1550 elements overflowed LuaJIT's fixed ~65500-slot
stack, and read-file's trap-error turned the overflow into a bare
"reader error near here:". <strcontents>, <alphanums> and <whitespaces>
recursed once per character the same way, so (read-from-string "[1 ...
3000]") failed on its 14 KB string literal.

prims.lua now installs iterative versions, next to the native comment
scanners that fixed the same failure for comments:
- shen.<s-exprs> keeps the KL's ordered choice and calls the kernel's
  single-token rules through F; brackets are an explicit stack. A missing
  closer falls back to the remaining alternatives at the bracket, as in
  the KL, and each sequence is folded right to left so cons-form and
  add-sexpr run (and raise) in the KL's order.
- shen.cons-form builds the cons spine front to back.
- <strcontents>/<alphanums> join their pieces with one table.concat;
  <whitespaces> loops.
read-file rethrows a Lua stack overflow instead of reporting it as a
reader error.

test/reader_native_spec.lua compiles the kernel's KL rules under spec.
aliases and requires identical values or identical errors on 145 inputs
(every .shen file under tests/ and examples/ plus edge cases), and reads
1600-3000-element lists, a 14 KB string and 3000-deep nesting.

Lists past ~3400 elements still overflow, now in the tree walkers after
the reader (shen.find-types, shen.process-applications), which see the
literal as a cons expression nested once per element.

Fixes #88

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@pyrex41
pyrex41 merged commit 3d0ed3a into main Oct 19, 2026
2 checks passed
pyrex41 added a commit that referenced this pull request Oct 19, 2026
Resolves compiler.lua against #91: hoist_kc takes the counting mode
(inherit for value-position control forms, own for a split let chain),
try_split_let returns ret(hoist_kc(..., true)), and the CURRY, LAMW and
wide freeze paths compile with CNT = nil and cexpr_tail; counted_body
uses new_fnl(true, 1). CHANGELOG keeps both sides.

Co-Authored-By: Claude Opus 5.5 <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

2 participants