Skip to content

fix(prolog): run deep proofs on fresh coroutine stacks (#85) - #90

Merged
pyrex41 merged 3 commits into
mainfrom
capcov/fix-85-prolog-depth
Oct 19, 2026
Merged

pyrex41 merged 3 commits into
mainfrom
capcov/fix-85-prolog-depth

Conversation

@pyrex41

@pyrex41 pyrex41 commented Oct 18, 2026 •

Copy link
Copy Markdown
Owner

Fixes #85

Problem

The native Prolog engine runs in continuation-passing style on the Lua stack. A successful proof returns only after the whole query has succeeded, so proof depth equals Lua stack depth. LuaJIT's stack is fixed at about 65500 slots, and on main (e3dec8b) that gives these limits:

  • (shen.typecheck (mk 400) peano) fails with shen: stack overflow.
  • An 800-element [list number] fails the same way.
  • The legacy engine reaches about 1000 levels, and shen-go has no limit.

Why not the issue's suggested fix

The issue suggests tail-calling thawH from unify/bind1 and letting the choice points unwind. That doesn't hold. The translated predicates try their alternatives as if r == false then <next clause>, with no trail marks and no undo (dumped peano#type / shen.system-S), so the undo inside unify_core and bind1 is what restores bindings when a clause fails. Two other frames can't be tail calls either:

  • cut sets the lock when its continuation fails.
  • A clause sequence ends with (unlock N) after failure.

Removing these frames would change semantics.

Fix: stack segments

Every coroutine has its own Lua stack, so a deep proof now continues on a fresh one.

  • What counts. unify, bind1 and cut count their non-tail thaws. The counting is inlined, so no frame is added.
  • Translated predicates. They call the new counted E.thawN (THAWN) for every thaw that isn't their return value. tail_return turns a thaw in return position back into a plain tail-called THAW. A tail call therefore never counts, and a constant-stack loop never opens a segment.
  • Opening a segment. Every 96 counted frames, the next thaw runs on a new coroutine (thaw_segment). Errors are rethrown unchanged with error(e, 0), so Shen exception objects survive. A host's coroutine.yield from inside is passed through to the enclosing coroutine.
  • Restoring the count. query_end restores the count, because an error unwinds past the decrements. A nested query keeps counting its caller's stack.
  • Depth limit. Past 1000 nested segments, a query raises the trappable Prolog depth limit exceeded (1000 stack segments) instead of overflowing.

E.segments() and E.segment_limits() are exposed for tests and diagnostics.

Results (repros from the issue)

Run at head 8f50b50 with luajit bin/shen --hush-load <file>:

repro base e3dec8b head
nat-400 … nat-2000 (mk from the issue) shen: stack overflow peano
tc-800, tc-1000 ([list number]) shen: stack overflow [list number]
nat-5000 [string "shen:mk"]:1: stack overflow same (see below)

Cost

  • certify. make certify opens 0 segments: I counted with E.segments() around run-kernel-tests.lua, and it printed SEGMENTS 0.
  • Microbenchmarks. In-process CPU time, best of N, base and head interleaved four times on a loaded machine (the same luajit build). Everything is within noise:
base: einstein 0.00172  tc-peano100 0.00176  tc-peano300 0.01340  tc-define 0.00028
head: einstein 0.00139  tc-peano100 0.00183  tc-peano300 0.01413  tc-define 0.00026
base: einstein 0.00183  tc-peano100 0.00183  tc-peano300 0.01390  tc-define 0.00025
head: einstein 0.00147  tc-peano100 0.00182  tc-peano300 0.01408  tc-define 0.00026
base: einstein 0.00148  tc-peano100 0.00187  tc-peano300 0.01353  tc-define 0.00024
head: einstein 0.00145  tc-peano100 0.00182  tc-peano300 0.01398  tc-define 0.00026
base: einstein 0.00178  tc-peano100 0.00194  tc-peano300 0.01416  tc-define 0.00026
head: einstein 0.00173  tc-peano100 0.00098  tc-peano300 0.00652  tc-define 0.00026

Tests

test/prolog_semantics_spec.lua gets 13 new checks:

  • a 1500-deep peano typecheck and a 3000-element (list number) typecheck, both of which open segments;
  • a shallow typecheck that opens none;
  • backtracking out of a 3000-deep failing branch, which must undo (is R bottom) before (is R top);
  • a cut 3000 levels deep that still prunes;
  • an error 3000 levels deep, whose message must be identical to the shallow one;
  • the engine staying usable after that error;
  • no segment left open;
  • a host coroutine.yield from 3000 levels deep reaching the host and resuming, and a yield with no host coroutine raising without leaving a segment open (4456af0);
  • the cap, set to 2 via segment_limits, raising a trappable Prolog depth limit exceeded, with the next query still working.

On base, the spec dies at the first deep typecheck with stack overflow.

make test make certify (43.1 suite)
base e3dec8b TOTAL: 1210 pass, 0 fail across 23 specs passed ... 154 / failed ... 0
head 4456af0 TOTAL: 1223 pass, 0 fail across 23 specs passed ... 154 / failed ... 0

Both were run with GIT_DIR/GIT_WORK_TREE/GIT_INDEX_FILE unset and a private TMPDIR.

Merge note

This merges cleanly with #78 (which also edits prolog_compile.lua / prolog_engine.lua) and with #91 (#84/#88) and #89 (#86/#87), apart from a trivial CHANGELOG.md conflict. On a local merge of all three (not pushed), make test gave TOTAL: 1706 pass, 0 fail across 26 specs and certify gave passed ... 154 / failed ... 0.

🤖 Generated with Claude Code

The native engine is CPS on the Lua stack: a successful proof returns only
when the whole query has succeeded, so proof depth is Lua stack depth, and
LuaJIT's stack is a fixed ~65500 slots. A typecheck overflowed at ~375
nested peano rules and at an ~800-element [list number]; the legacy engine
reached ~1000 and shen-go has no limit.

The issue's suggested fix (tail-call thawH from unify/bind1 and rely on
choice points to unwind) does not hold: the translated predicates try
their alternatives without trail marks or undo calls, so the undo inside
unify_core and bind1 is what restores bindings on failure. The lock-on-fail
frame in cut and the unlock after a clause sequence cannot be tail calls
either.

Instead, every coroutine has its own stack, so a deep proof continues on a
fresh one (a segment):
- unify, bind1 and cut count their non-tail thaws (inline, no extra
  frame); the translated predicates call the new counted E.thawN (THAWN)
  for every thaw that is not their return value, and tail_return keeps a
  thaw in return position a plain tail-called THAW, so constant-stack
  loops never count.
- Every 96 counted frames the next thaw runs in a new coroutine; errors
  are rethrown unchanged and a host's yield is passed through.
- query_end restores the count, which an error skips past. Past 1000
  nested segments a query raises 'Prolog depth limit exceeded'.

Typechecks of 1500-deep peano terms and 3000-element lists now succeed;
deeper ones stop at the kernel's recursive shen.curry preprocessing
(~2000 nested [s N] / ~4600 elements), which is outside the engine.

Fixes #85

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
thaw_segment passed a host's coroutine.yield up through the segments,
but two cases were wrong:
- With no host coroutine to yield to, the error skipped the decrement of
  the nesting count in every suspended inner segment, so later deep
  queries had less headroom.
- unpack(res, 2) truncated the yielded values at the first nil.

Each segment now restores the count it saw on entry (by assignment, not a
decrement), query_end restores it too, and values are packed with their
count. New checks: a yield from 3000 levels deep reaches the host and
resumes, and a yield with no host coroutine raises and leaves no segment
open.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@pyrex41
pyrex41 merged commit f4e2dff into main Oct 19, 2026
2 checks passed
pyrex41 added a commit that referenced this pull request Oct 19, 2026
fix: run deep non-tail recursion on fresh coroutine stacks (#93; builds on #90)
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.

Native Prolog engine overflows the Lua stack on modest typecheck depth (~400), where the legacy engine reaches about 1000 and shen-go has no limit

1 participant