004. Stabilizing Left Recursion in the VM
- Status: Superseded
- Author: @ornew
- Date: 2025-06-30
Note (2026-10): This document records an investigation of the former bytecode VM. Since the engine rewrite, left recursion is handled as in pegen: only the leader rule is evaluated with grow-the-seed (see docs/development.md).
1. The original problem and its analysis
The initial VM implementation failed to parse grammars with simple direct left recursion such as Expr -> Expr '+' Term | Term. The test cases simple_addition and chained_addition reported failures where the parser should have succeeded.
Analysis of the debug logs showed that the left-recursion growth algorithm ("growing the seed") itself found the longest match (for example x+x) correctly. However, the VM's state transitions on returning from the left-recursive evaluation, after the growth loop had finished, were flawed, and as a result the parse as a whole failed.
2. Investigation and abandoned approaches
Several hypotheses were tested to solve the problem, but none of the fixes addressed the root cause.
2.1. Hypothesis 1: choiceStack was not reset
- Idea: The
choiceStack(the backtrack stack for alternatives) was not reset on each iteration of the left-recursion growth loop, which might be corrupting the state. A fix resetchoiceStackinOpReturnwhen returning to the top of the loop. - Problem: The tests still failed. Resetting
choiceStackpointed in the right direction but was not sufficient, which suggested that the underlying problem lay deeper in state management.
2.2. Hypothesis 2: A SuperChoice opcode
- Idea: The semantics of PEG's ordered choice (
|) might conflict with the "longest match" that left recursion requires. Based on this hypothesis, a new family of opcodes for a longest-match choice specific to left-recursive rules (OpOpenSuperChoice,OpRegisterChoice,OpCloseSuperChoice) was proposed. - Problem: Analysis of the
pegenreference implementation showed that this approach was fundamentally wrong.pegendoes not change the semantics of choice; it obtains the longest match indirectly by re-evaluating the whole rule repeatedly until the result stops growing.SuperChoiceovercomplicated the problem and missed the point.
2.3. Hypothesis 3: Corruption of a global failed flag
-
Idea: The VM's single
vm.failedflag served two different purposes, which might be the source of the corruption:- Failure for choice: a failure that triggers backtracking.
- Failure for left recursion: a failure set deliberately by the algorithm to prevent infinite recursion.
The hypothesis was that the two interfered with each other and corrupted the state. A fix was considered in which
OpCallwould handle the failure on left-recursion detection with local backtracking instead of the globalfailedflag. -
Problem: The fix considered only one case (when a choice is present) and was not general. Given the other failure patterns, it was likely to introduce new bugs.
3. The common root cause: no separate execution context
All of the failed approaches pointed to the same root cause: there was no independent execution context for managing the left-recursion growth loop.
In pegen, the while loop in the memoize_left_rec decorator provides that independent context. The loop manages its own state (the best result and the last position) and calls the parser method inside it like a subroutine. Failures inside the method do not directly corrupt the loop's state.
In our VM, the logic corresponding to this loop was implemented inside OpReturn, which shared state (the failed flag, choiceStack and so on) with the VM's main loop. State conflicts and corruption were therefore unavoidable by construction.
4. Final proposal: encapsulating the context in an OpEvalLeftRecursive opcode
To solve the problem structurally, introduce a dedicated opcode, OpEvalLeftRecursive, that manages the entire left-recursion growth loop.
4.1. Design overview
The new opcode fully encapsulates the role of pegen's memoize_left_rec decorator as a VM instruction.
-
The compiler's role: When compiling a rule that is a left-recursion leader (for example
Expr), the compiler generates code that evaluates the rule body throughOpEvalLeftRecursiveinstead of evaluating it directly.Before:
// Rule Expr: OpChoice(...) ... OpReturnAfter:
// Rule Expr: OpEvalLeftRecursive(Address_of_Expr_Body) OpReturn // Rule Expr_Body: OpChoice(...) ... OpReturn // return from the subroutine -
Behavior of
OpEvalLeftRecursivein the VM: When this opcode executes, the VM enters an independent subroutine evaluation loop that runs the following logic:- Initialization: Set up a left-recursion frame (
lrStack) and prime the memo at the current position with "failure". - Growth loop:
- Save the VM state (
pos, stack pointers and so on) before the iteration. - Call
evalRuleBodyrecursively, as a subroutine. The call ends at theOpReturnat the end ofExpr_Body. - Evaluate the result: After the subroutine returns, check
vm.failedandvm.pos. - Check for growth: If the subroutine succeeded and
vm.posadvanced beyond the previous iteration, store the best result inlrStackandmemoand continue the loop (back to step 1). - Stop growing: If the subroutine failed or
posdid not grow, exit the loop.
- Save the VM state (
- Finalization: Push the best result found by the loop onto
valueStack, update the VM state (pos,failed) to match the final result, and continue with the instruction afterOpEvalLeftRecursive.
- Initialization: Set up a left-recursion frame (
4.2. Expected benefits
- Complete separation of concerns: The complex logic of the left-recursion growth loop is fully encapsulated in a single instruction,
OpEvalLeftRecursive. - State safety: Because
evalRuleBodyis called as a subroutine, the VM's global state is no longer corrupted on each iteration of the loop, which greatly simplifies and stabilizes state management. - A clearer architecture: This structure matches the spirit of the
pegenreference implementation, which makes it reliable and easier to maintain.