065. Exec IR: One Parser VM Specified for Every Target
- Status: Proposed
- Author: @ornew
- Date: 2026-10-31
Summary
The algorithmic core of a ZeroParse parse — memoization, left-recursion growth, expected-set merging, recovery bookkeeping,
the variable environment, budget charges, stream commitment, Document reuse and the Operators Rule loop — is written
once per runtime: in the engine (internal/engine/core, shared by the closure backend and both bytecode VMs), twice in
the generated Go runtime (the node Parser in runtime/runtime.go and the typed TParser in runtime/typed.go), and
in the TypeScript runtime (internal/gen/tsgen/tsrt/runtime.ts). A fix to one of these mechanisms is a fix to up to four
copies, and the copies already differ in algorithm where they should only differ in host.
This design specifies the parser VM once. Exec IR (internal/ir/exec) is that specification: it declares the
operations a parser VM provides — memo lookup and store, seeding a left-recursion growth, entering and leaving a
lookahead, merging expected sets, recovery bookkeeping, binding variables, charging the budget, committing a stream,
validating a reused Document result — each with a behavior contract over an abstract parser state, and it composes
them into the parse: the call protocol, the growth loop, the Operators Rule loop, and the grammar code lowered from
Flow IR (record 062). An operation states what happens, not how; each
target backend implements every operation in its own language and optimizes it freely for that language, and a
conformance suite per operation, which every implementation must pass, is what makes that freedom safe.
Shared across targets: the contract of each operation, the composition of operations, the passes on Exec IR, and the conformance suite. Chosen per target: the representation of the parser state, the implementation of each operation, and how the composition is printed.
flowchart LR
g["Grammar"] --> core["Core IR"] --> flow["Flow IR"] --> low["Lowering"]
comp["Compositions<br/>(Go builders)"] --> exec
low --> exec["Exec IR"]
spec["Operation contracts"] --> exec
exec --> passes["Passes"] --> exec
exec --> gob["Go backend"]
exec --> tsb["TypeScript backend"]
gob --> goout["Generated Go parser<br/>and runtime"]
gob --> eng["internal/engine/core<br/>(last stage)"]
tsb --> tsout["Generated TypeScript parser<br/>and runtime"]
spec --> conf["Conformance suite"]
conf -. checks .-> gob
conf -. checks .-> tsb
Flow IR is lowered into Exec IR from the start, so each language has one backend that prints grammar code and the runtime alike. Compositions are written with Go builder functions. Migration starts with a pilot of memoization and left-recursion growth on generated Go (node and typed) and TypeScript, and every stage, the pilot included, applies the same performance gates to grammar code as to the runtime.
Motivation
The evidence below was measured at commit f52b6bd9. The repository history starts on 2026-10-07, so the window
covers 2026-10-07 to 2026-10-30: 1,263 commits.
Four Copies of One Core
The engine's closure backend and both VMs already share one core: they drive CallBegin, CallEnd, GrowBegin,
GrowStep and GrowEnd in internal/engine/core/runtime.go. The generated runtimes do not use it. The generated Go
runtime has one copy for node results and one for typed results, and the TypeScript runtime a fourth.
Appendix A lists every mechanism with its files, functions and sizes. In approximate lines:
| Mechanism | Engine | Go node | Go typed | TypeScript |
|---|---|---|---|---|
| Memoization, deferred memoization and the call protocol | 520 | 580 | 300 | 330 |
| Left-recursion growth | 80 | 30 | 25 | 30 |
| Expected-set merging | 85 | 215 | 30 | 155 |
| Operators Rule loop | 340 | 260 | 255 | 310 |
| Variable environment | 90 | 145 | 40 | 85 |
| Budget charges | 1,800 | 1,410 (shared) | — | 500 |
| Stream commitment | 350 | 180 | — | none |
| Document reuse | 540 | 200 | 250 | none |
On top of the copies per runtime, each runtime keeps hand-written variants per mode: the generated Go runtime has 72
functions suffixed Ordinary, Controlled, Spend or Budget, and the TypeScript runtime 28 …Spend functions,
that repeat a body with or without budget charges.
Changes Paid Several Times
Of the 1,263 commits, 79 touched the non-test sources of two or more of the four copies (the engine runtime files
under their former and current paths, runtime/ or its former internal/engine/genrt/, and the TypeScript runtime
under its former and current paths; the copy in gen/_runtime is mechanical and not counted). Classified by hand:
| Kind | Commits | Examples |
|---|---|---|
| Core mechanism semantics or cost | 22 | the left-recursion guard keyed by rule and position; reuse of results with recovered errors only in the same context; adaptive memo counter overflow; sparse seen pages; one work budget; memo growth charged by demand; Pratt nesting and committed prefixes; bounded binding histories |
| Term evaluation and the value model | 35 | named node types, tuple literals, variable binding checks, concat fusion |
| Host-specific (input, output, API) | 9 | case-insensitive literals, JSON ranges, start rule selection |
| Renames, moves and cleanup | 12 | moving the generated runtime into a package; budget naming |
| Initial import | 1 |
Of the 22 core commits, 8 touched all four copies, 7 touched three, and 7 touched two. The core commits by mechanism (a commit may count under two): budget 8, memoization 7, Operators Rule 3, expected sets 2, environment 2, left recursion 1, recovery 1, stream 1, rule completion 1, depth 1. 47 of the 79 commits edited both the node and the typed Go copy.
The copies also drift where no test observes it. In a Document parse the engine records how many reads of a growing
seed a result made (MemoSeedUses, ActiveSeed) and discharges a leader's own reads when it stops growing, while the
generated Go runtime marks every leader result and every result that read a growing entry provisional
(docProvisional). The generated rule is the more conservative of the two, so the two can reuse different results
after the same edit.
The Mechanisms Are Host-Neutral
Most of the core is integer and pointer work over the parser's own structures: position-indexed slot arrays, chained memo entries, bit pages, counters, persistent environment lists and a stack of expectation identifiers. It touches the host only to read input, to compare and build values, to report events and to check cancellation. That boundary already exists in each copy; it is not written down.
Goals
- Each core mechanism has one specification and one composition, shared by every runtime that needs it: generated Go (node and typed), generated TypeScript and, in the last stage, the engine.
- Each target implements the operations in the form that is fastest for its language, and a change of representation in one target needs no change in any other.
- Every operation implementation passes the same conformance suite, so that a target cannot drift from the contract without a failing test.
- Every parse returns byte-identical results to today on every target: values, JSON output, errors, expected sets,
recovered errors, positions in both units, budget counts (
budget.golden), and trace and profile event streams. Document statistics change only where a stage unifies an algorithm and states the change. - No stage changes parse time, allocations, generated size or generation time beyond measurement noise, for grammar code and runtime alike, measured per target as in Performance Acceptance.
- Printed code is readable, formatted, commented, deterministic and checked in, so that it can be debugged, profiled and reviewed as hand-written code is.
- A new target language needs one backend — its operation library, its printer and its host code — not a new core.
Non-Goals
- Changing Core IR, Flow IR or their passes. Flow IR gains a lowering into Exec IR; its passes still run before it.
- Changing the grammar language, the public API, the bytecode instruction set or the specification of any mechanism.
- Adding Document or stream parsing to TypeScript. A shared composition makes it cheaper; whether to add it is a separate product decision.
- A textual Exec IR syntax, an editor ecosystem or a serialized or public Exec IR. Builders come first; a text form is an extension that this design leaves room for.
- A second bytecode VM in another language (The Engine and a Second VM).
Design
Three Levels
| Core IR | Flow IR | Exec IR | |
|---|---|---|---|
| Question it answers | What does this rule mean? | How does a parser run this rule? | Which operations of the parser VM run, in what order, and what does each do? |
| Written by | The Core IR builder, from the grammar | Lowering, from Core IR | Lowering, from Flow IR; compositions written with Go builders |
| Content | Parsing expressions with effects | Registers, slots, blocks, parse primitives | Operations with contracts over an abstract parser state, and their composition |
| Optimized by | Grammar rewrites | Save/restore elision, slot allocation, charge placement | Feature specialization, operation elimination, charge coalescing, inlining |
| Turned into code by | — | — | One backend per target language |
The names say what each level holds. Core IR is the core language of a grammar: what remains of its meaning once the
surface syntax is gone. Flow IR is the control flow of a parse. Exec IR is the executable form: the operations of a
parser VM and the program that drives them. Its package is internal/ir/exec, beside internal/ir/core and
internal/ir/flow.
Record 062 keeps runtime algorithms out of Core IR and Flow IR: Flow IR
calls them as runtime services (CallRule, Isolate, MergeExpected, StreamStart) and rejected a third level
without passes of its own. Exec IR keeps that boundary for Flow IR and gives those services a home with passes of
their own: a Flow IR primitive lowers into a call of an Exec IR composition or operation, and the composition is
where the service's algorithm is written once.
The Parser VM
A parser VM is the abstract machine that runs a parse. It is not the bytecode VM: the bytecode VMs, the closure backend and every generated parser are hosts of the same parser VM. Exec IR specifies it in three parts.
- Abstract state. Registers — the position, the examined bounds, the silent depth, the cut flag, the nesting depth, the counters of provisional reads — and state components: the memo table, the seen set of deferred memoization, the expectation record, the recovered-error list, the variable environment, the capture frames, the budget, the statistics, the stream window and the Document generation. The state is abstract: a contract talks about it, a target chooses how to represent it.
- Operations. Each operation is a transition of the abstract state with a declared signature, a behavior
contract, a precondition (
Requires) that its callers guarantee, the state components it reads and writes, its cost contract, the features it belongs to and the laws that passes may use. Behavior outside the precondition is unspecified, and conformance never exercises it: two implementations may differ on states that no composition reaches. Handles that operations return (a memo entry, an expectation mark, a recovery mark, a frame) are opaque; a memo entry exposes its specified attributes (outcome, end, examined bounds, silent, recovered errors, growing, provisional) through accessor operations. - Composition. Exec IR functions that sequence operations under structured control: blocks,
if,loopwith labeledbreakandcontinue,return, labeled block exits, calls to functions, operations, rule bodies and host primitives. There is nogotoand no exception; a fatal condition is an operation that does not return. Locals are scalars (bool,int, sizedi32,u32,u64with defined wraparound) and handles. Compositions have no records, arrays or maps of their own: data structures live inside operations, where targets choose them.
The host is what touches a value, an input encoding, the user or the platform: input decoding, value and node construction, value equality, error text, readers, edits and snapshots, cancellation, tracing sinks and the public API. Host primitives are declared in Exec IR with typed signatures and implemented once per target; values and nodes are opaque to the parser VM.
The Operation Boundary
The boundary decides whether the design pays off. With operations too coarse, an operation hides a policy — whether
a memo result may be reused, when growth stops — and every target re-implements that policy: four copies again,
behind a shared name. With operations too fine, an operation is a step of a data structure — set bit k of page
p — and Exec IR becomes Go-like code that fixes one representation and leaves nothing for a target to optimize.
An operation is placed by four rules.
- No parse decision inside. An operation never chooses between outcomes of the parse: reusing or recomputing, growing or stopping, which error or expectation survives. Those choices read operation results and entry attributes in a composition, which is written once.
- Complete contract. Everything an operation does that can be observed — its results, the abstract state afterwards, the statistics it counts, and its cost — follows from its contract, so that a test can check it without knowing the implementation.
- Representation stops the split. An operation is not divided further once the parts would only make sense for one data structure. Its implementation is where a target picks slot arrays or hash maps, slabs or the garbage collector, the word size of a bit page, a membership filter.
- No re-entry. An operation does not run grammar code. Where a mechanism must run a rule body in the middle — a memoized call, a growth step — it is a composition, or a begin and an end operation around the body.
The measured history supports these rules. Each of the 22 core commits of Changes Paid Several Times is placed below by the part of this design it would change (classified by hand from the commits' subjects and the mechanisms they touched; the placement is approximate):
| Change lands in | Commits | Examples | Cost under this design |
|---|---|---|---|
| A composition | 11 | the left-recursion guard keyed by rule and position; reuse of results with recovered errors only in the same context; Pratt nesting, committed prefixes and empty applications; default budgets and the input limit at parse entry; splicing hidden rules into their caller; the iterative VM's plain call path | One edit, printed for every target |
| A pass | 1 | splitting the budget-controlled bodies off the helpers | Done by feature specialization; no hand edit |
| An operation contract | 6 | one work budget; memo growth charged by demand; bounded binding histories; adaptive memo counter overflow; charges of a CodePoints preparation | One contract and conformance edit, and one implementation edit per target, each checked by the suite |
| One target's representation | 3 | sparse seen pages; a membership filter before exact expectation scans; a shared memo key where no variable is defined | Optional per target; no shared change |
| Several parts | 1 | cancellable parsing and work limits | Contract, composition and implementations |
Under this boundary 12 of the 22 changes become a single edit or none, and 3 need no cross-target work at all. The 6 contract changes still touch every target, but each is a small, specified change guarded by the conformance suite instead of a re-derivation of an algorithm.
Operations
The operations, grouped by the state they own. Each entry gives the contract, what is shared, what a target chooses and how conformance is tested. Costs follow Cost Contracts.
Input (peek, matchLiteral, matchClass, matchAny, advance, text). Reads at the position in the
parse's position unit, advance by characters, and, in a Document, extend the examined bound to every element read.
Shared: the meaning of positions in both units and of the examined bound. Target: decoding, buffers, fast paths
for ASCII or decoded code points, literal matching by machine word. Conformance: inputs at UTF-8 and UTF-16
boundaries, invalid sequences, both units, the examined bound after each read, and the input charge.
Memo table (memo.lookup, memo.lookupGrowing, memo.store, memo.seed, memo.reseed, memo.finishSeed,
memo.prune). lookup returns the most recent entry stored for a key (rule, position, binding level, variable
values, missing-token mode), or none; lookupGrowing returns the growing entry of a rule at a position whatever the
variable values; store adds an entry that later lookups of its key return; the seed operations install, replace and
finish the growing entry of a left-recursion leader; prune drops entries below a position. The precondition of
lookup is that a rule with a growing entry at a position and level is looked up there only with the key it was
seeded with; compositions call lookupGrowing first for a leader. The generated Go memo table, whose lookup also
matches a growing entry under other key values, and the engine's, whose lookup is exact, then agree. Shared: keys, the
specified entry attributes, which entry a lookup returns, the storage charge. Target: slot arrays or maps, chaining,
slab allocation and free lists, entry layout and packing. Conformance: random sequences of stores, seeds, lookups and
prunes compared with a reference model; storage charges that do not depend on storage left by an earlier parse, as
Cancellation and Budget Limits requires.
Deferred memoization (seen.first). Records a call of a memoized rule at a position and reports whether it is
the first, under the adaptive policy: per-rule counters and the switch to eager memoization. The policy is part of the
contract, because it decides how many bodies run, which Evaluated reports. Shared: the policy and its thresholds.
Target: dense or sparse pages, word size, counter width. Conformance: call sequences over many rules and positions,
counter saturation, pages created on demand.
Expectations (expect, exp.isolate, exp.unisolate, exp.keep, exp.merge, exp.rename, silent.enter,
silent.exit). The expectation record is an insertion-ordered set of expectation identifiers at the farthest
position; expect at a position beyond it starts a new set, below it does nothing, and inside a silent scope does
nothing. isolate starts a separate record and unisolate ends it, returning its farthest position and set; keep
makes a set that outlives the record; merge adds a set at a position. Shared: the set algebra and its order, which
error messages expose. Target: membership filters, stack layout, sharing of kept sets. Conformance: random
sequences against the reference model, compared by farthest position and exact order.
Recovery (rec.mark, rec.rollback, rec.since, rec.append, rec.push; with #missing in the engine,
missing.mark, missing.reset, missing.replay). An ordered list of recovered errors with marks; rollback removes
everything after a mark, since returns it for a memo entry. Shared: order and rollback. Target: list growth and
sharing. Conformance: interleaved pushes, marks and rollbacks.
Variable environment (env.bind, env.lookup, env.save, env.restore, env.key). A persistent scope:
bindings made after a save are gone after restore; histories are bounded as specified; env.key returns the
values of a rule's variables for its memo key, and two keys are equal when the host's value equality says so.
Shared: scoping, bounds, key equality. Target: list or array, shared keys for the initial environment, history
compaction. Conformance: nested scopes, shadowing, long histories, keys of equal and unequal values.
Capture frames (frame.open, frame.set, frame.mark, frame.reset, frame.close). Slots of a rule body and
a trail of writes that a backtracking point resets. Shared: what a reset restores. Target: pooling and scratch
reuse. Conformance: nested frames, resets across repetitions.
Budget (charge). Adds units to the one work budget, checks the limit and, at intervals the target chooses,
cancellation through a host primitive; it does not return when the parse is interrupted. Charges are never rolled
back. Shared: when a charge interrupts. Target: counter representation and the cancellation interval.
Conformance: interruption exactly at the limit, with and without a cancellation signal.
Nesting, statistics and observation (depth.enter, depth.exit, stats.count, trace.event). The nesting
limit is fatal when exceeded; statistics are counters; trace events go to a host sink. Shared: when each counts or
reports. Target: the sink. Conformance: the limit at its boundary; event sequences of small compositions.
Stream window (stream.commit, stream.discard, stream.demand). commit makes everything before a position
final: its input, memo entries and scratch are released, and later reads before it are fatal; demand reports the
position the parse needs, which the default budget of a stream follows (Streaming).
Shared: what a commit releases and finalizes. Target: buffers and pruning strategy. Conformance: lookups and
reads below the committed position, scratch after a commit.
Document reuse (doc.validate, doc.move, doc.resume, doc.splice). validate advances a memo entry over
the edits since its generation and reports whether it is still valid: no edit touched its examined range; move
shifts a reused result to its new position through a host primitive on nodes; resume reuses a prefix of a repetition;
splice shifts the memo table at an edit. Whether a valid entry is reused, and whether a result is provisional,
stays in the composition. Shared: validity and the positions after a move. Target: side tables, lazy shifting.
Conformance: edit sequences before, inside and after examined ranges, compared with a parse from scratch.
The Operators Rule loop has no operation of its own beyond a longest-operator match on input
(operators.longest): the loop, binding powers, prefix commitment and the application order are composition, which
is where all three of its core changes landed. Term evaluation and the value model join in a second wave as host
primitives (Decisions).
Compositions
The compositions written once in Exec IR are the algorithms that the copies re-implemented:
- the call protocol: deferred memoization, memo lookup and the reuse conditions (silent results, recovered errors, growing entries, the missing-token mode), store, restore on failure, nesting and statistics;
- left-recursion growth: seed, the growth loop, its stop condition and the guard keyed by rule and position;
- rule completion: the rule's value, captures and hidden rules spliced into their caller;
- Document calls: validation, reuse and provisional tracking of results that read a growing seed;
- the Operators Rule loop;
- stream delivery and commitment points;
- parse entry: start rule, default budgets and the input limit.
Grammar code is composition too: a rule body lowered from Flow IR calls the same operations. A composition that must suspend while a rule body runs, as the iterative VM needs, is also available as a begin, step and end split over a small state record; a backend that recurses fuses them back into one function.
Cost Contracts
A charge sits either in a composition or inside an operation. A charge in a composition — one per call, per growth step, per loop iteration, per Operators Rule application — is shared and exact on every target. A charge inside an operation pays for work whose size depends on the operation's data: scanning an expectation record, growing the memo, inspecting input. The specification already lets budget counts depend on the backend and the generated artifact as long as they are deterministic (Cancellation and Budget Limits), and the targets already differ here: generated Go charges an expectation scan only after its membership filter matches, TypeScript charges every element it scans. An operation's cost contract is therefore a deterministic count per target within a declared bound (Decisions).
A bound is a constant plus a sum of terms, each a positive coefficient times an abstract quantity: a measure of the state before the call that the reference model of the component defines and computes, such as the key values of the entries at the call's position, or the positions a position-indexed memo copies when it doubles. A bound is the worst case over the representations a target may choose: deferred memoization is bounded both by the regions a sparse record allocates and by the size of a dense record of calls in position order.
Charges inside an operation spend the same budget that budget.charge and budget.used see. The conformance suite
measures what an implementation charges in each call, checks it against the bound and adds it to the model's budget;
an implementation may end the parse with a limit interruption inside an operation only when charges within the
bound could exceed the limit.
Authoring
Compositions, operation declarations and conformance cases are written with Go builder functions in
internal/ir/exec. A builder returns Exec IR that the verifier checks: types, definite assignment, that every exit
names an enclosing block, that operations are called with their signatures and features, that each state component is
written only by the operations that declare it, and that a specialization leaves no reference to a folded feature.
The IR has a text dump for tests, golden files and error messages, and an interpreter that runs a function against a
machine of operations, which is how Go runs conformance cases until a backend prints them. A textual syntax, parsed
by a ZeroParse grammar, can be added later without changing the IR.
Passes
Exec IR passes know operations by their declarations, not by name:
- Feature specialization. Features (
budget,document,observe,missing,stream,typed) are constants; a function specialized for a set of them folds the constants and removes dead code. The hand-written…Ordinary,…Controlledand…Spendvariants and the node and typed copies become specializations of one composition. - Operation elimination. An operation whose effect is unobserved is removed, using the declared reads and writes
and the declared laws: a save and restore of a component that nothing between them writes; an
isolateandunisolatepair around code that records no expectation; examined-bound recording outside a Document; trace events outside an observable build. - Charge coalescing. Charges in straight-line code merge, under the invariants of record 062: each loop iteration
charges, input is charged before it is inspected, and no charge moves across a commit. Budget counts stay as
budget.goldenpins them unless a stage states otherwise. - Inlining of compositions into callers, within a budget that the backend sets per target.
- Cold-path marking of error paths, growth and Document bookkeeping, which a backend may outline.
- Protocol fusion: begin, body and end fused where the target recurses.
Flow IR keeps its own passes; they run before lowering.
Target Backends
A backend turns Exec IR into code for one language. It has three parts:
- an operation library: the implementation of every operation and of the parser state, written in the target language and tuned for it;
- a printer for compositions and grammar code, which decides per call whether an operation is expanded inline or called;
- a conformance runner that executes the conformance suite against the library.
The Go backend serves the generated runtime and, in the last stage, the engine; its operation library has one source, printed with the value type of each. The tuning that the hand-written runtimes encode today becomes the backends' choice.
Go.
- State prints as structs with fields in the order the library declares, so that hot fields share a cache line and
packed
i32fields stay adjacent, as inmemoEntry; pooled entries print as a slab arena with a free list. - Hot functions stay within the inlining budget (cost 80) by outlining cold paths; a test reads the inlining cost of
each printed hot function from
go build -gcflags=-m=2, so that a change that pushes one over the budget fails a check instead of a benchmark. - Split protocol state stays on the caller's stack, passed by pointer only to non-escaping parameters.
- The node and typed specializations print as two monomorphic copies, not one generic function: a type parameter
instantiated with
*Nodeandanywould share one GC shape and pay dictionary calls.
TypeScript and JavaScript.
- State prints as classes whose constructors initialize every field, in declared order, with a value of its type
(
nullfor references,0for numbers), so that every instance has one hidden class and access stays monomorphic; references usenull, neverundefined. i32andu32stay small integers;u64bit pages print as pairs of 32-bit words; integer arrays print as typed arrays and reference arrays are allocated at their final length, so that they never become holey.- Loops print as
for (;;)with labeledbreak; no iterator protocol or closure appears in a hot path.
Printed code is checked in: the Go runtime in runtime/ (and its copy in gen/_runtime), the TypeScript runtime in
internal/gen/tsgen/tsrt/, and the engine's in internal/engine/core. A test regenerates them and fails on any
difference, as the runtime copy test does today. Names come from the compositions and operations, with a suffix only
for a specialization; each printed function starts with a comment naming its source and specialization, so that a
stack trace or a profile leads back to it; comments carry through; output is formatted by go/format and by the
TypeScript printer's formatter.
Testing
- Conformance suite. Every operation has conformance cases: small Exec IR functions, written with builders, that call operations and state the expected results, abstract state, statistics and charges. Each backend prints them into a test program in its language and runs it, so that a case runs the same way on every target; until a backend exists, the interpreter runs them in Go. A case runs in lock-step on the reference model and on the implementation, and fails at the first call where they differ in results, in which handle a result is, in whether the parse ends, or in charges beyond the bound. Randomized sequences draw arguments from small domains, so that keys collide, and keep to every precondition by consulting the model while they are drawn. A conformance case is added with every contract change and every bug found in an implementation.
- Bindings of existing runtimes. Before a backend prints an operation library, an existing runtime is checked
through a test binding that calls its functions. Where an operation is inline code of a composition, the binding
does what that code does: in the generated Go runtime,
memo.seedandmemo.reseedallocate an entry and store it withmemoPutasgrowdoes, andmemo.finishSeedclears the entry's growing flag. - Mutation controls. Each operation library has deliberately broken variants — an off-by-one prune, a lookup that ignores the binding level, a counter that wraps — that the suite must reject.
- Differential tests. Within a stage the old and new paths run side by side behind a private switch, as record 062
does for its stages. The existing differential tests between the engine, generated Go (node and typed), Documents,
streams and TypeScript run with the switch on and off, on the corpora of record 062: repository grammars with
golden files, specification test data, grammar fixture suites,
sample/inputs and mutated inputs. Results must be byte-identical as listed in Goals. The switch is removed when the default flips. - Negative controls for compositions. Each migrated composition has broken variants that the differential tests must detect, for example: reusing a silent result outside a lookahead; looking up a growing entry by variable values instead of by rule and position; storing a result that read a growing seed as complete in a Document; omitting the expectations of a reused entry.
- The engine as oracle. Until the engine adopts the shared core it is an independent implementation, so a bug in a composition shows as a difference between the engine and generated parsers. After it adopts the core, golden files and fixture expectations, which no backend derives, are the independent checks.
- Budget determinism.
budget.golden, the budget determinism tests and the ordinary-versus-controlled parity tests of each target pass unchanged unless a stage states why a count changes. - Printer tests. For each Exec IR construct, small functions printed for each target and executed: integer wraparound, labeled exits, specialization, protocol fusion.
Performance Acceptance
The baseline of each stage is the commit before it, on the same machine. The gates apply to grammar code and to the runtime alike, because every stage prints grammar code through the Exec IR backend.
- Go.
benchBenchmarkParse,BenchmarkRecognize,BenchmarkIncremental,BenchmarkIncrementalLongandBenchmarkStream, for node and typed parsers, in both position units, with and without budget controls; at least ten samples, base and head interleaved, compared withbenchstat. - TypeScript. The Node benchmarks (
bench/ts-parse.mjs,bench/ts-typed.mjs), ten interleaved samples. - Engine (final stage). The full-suite benchmarks of the closure backend and both VMs, in both families of
record 062, and
Compiletime. - Size and build. Generated source bytes per repository parser, Go runtime source bytes and the binary size of each
generated parser package; TypeScript runtime bytes and gzipped bytes; generation time of
zp genfor each repository parser.
A stage is accepted when no benchmark shows a statistically significant slowdown in sec/op (benchstat, p < 0.05),
allocs/op are equal, B/op and sizes are within noise, and budget counts are unchanged or explained. With hundreds
of benchmark rows some cross p < 0.05 by chance, so a flagged row is accepted as a regression only when a second
interleaved run reproduces it. A trade-off that is faster on one workload and slower on another is not accepted by
default; it can become an option of its own.
The Engine and a Second VM
The closure backend and both VMs already share internal/engine/core, so the engine is one copy, not three. It
adopts the shared core last: the Go backend prints the compositions into internal/engine/core with the missing
feature and the split protocols that the iterative VM needs, over the Go operation library with the engine's value
type. Until then it is the independent oracle of the differential tests.
The bytecode instruction set is specified independently of Go (Bytecode). A VM in another language needs a dispatch loop, load-time validation and the parser VM; with this design the parser VM comes from that language's backend. Whether a second bytecode VM is worth having is measured against the generated parser of the same language first, and is not part of this design.
Migration Order
Each stage is accepted by the gates of Testing and Performance Acceptance before the next begins, and a release can be cut after any completed stage. Mechanisms not yet migrated are reached through transitional operations: an existing hand-written runtime function declared as an operation with its current behavior and no conformance cases, removed by the stage that migrates its mechanism. They let grammar code move to Exec IR at once while mechanisms move one at a time.
- Foundation. Exec IR, builders, the verifier, the listing and the interpreter; the declarations of the pilot's operations, the reference models of their components and the conformance harness, with the generated Go runtime as the first implementation checked through a test binding. No output change.
- Pilot (Pilot): the passes, the Go and TypeScript backends with their printer tests, and the transitional operations that the pilot's compositions call; grammar code in the ordinary mode lowered from Flow IR into Exec IR, and memoization, deferred memoization, the call protocol and left-recursion growth as specified operations and compositions. Passes, printers and transitional operations serve printed compositions: before compositions exist, passes have nothing to transform, a printer test has no backend to run, and no composition calls a mechanism that has not migrated.
- Every mode of grammar code. Budget controls, observable builds, Document and stream variants print through the Exec IR backend over transitional operations; the default flips and the Flow IR printers are removed.
- Expected sets, recovery bookkeeping and the variable environment.
- Budget charges and mode specialization. The remaining
…Ordinary,…Controlledand…Spendvariants become specializations, and the cost contracts are written down. - Capture frames and the Operators Rule loop.
- Stream commitment (generated Go).
- Document reuse (generated Go). Provisional tracking becomes the engine's algorithm (Decisions).
- Engine adoption. The compositions and the Go operation library replace the hand-written mechanisms of
internal/engine/core; the closure backend and both VMs run on them. - Term evaluation and the value model, with value operations as host primitives.
Pilot
Scope. Generated Go (node Parser and typed TParser) and generated TypeScript, in the ordinary mode without
budget controls, Documents, streams or observation:
- grammar code lowered from Flow IR into Exec IR and printed by the Go and TypeScript backends;
- the memo table, deferred memoization and their operations, with conformance suites and reference models;
- the call protocol of a memoized rule outside Documents and left-recursion growth as compositions.
Expected sets, recovery lists, environments and frames are transitional operations.
Work.
- Write the passes, the backends and their printer tests, and the transitional operations the compositions call.
- Build the Go and TypeScript operation libraries for them from the current hand-written code, which passes the suite before anything else changes.
- Write the compositions with builders, lower Flow IR into Exec IR, and print both with the switch selecting the new or old path.
- Run the differential tests and benchmarks with the switch on and off.
- Re-apply one recorded composition change, the left-recursion guard keyed by rule and position, as a single edit, and show that it reaches all three targets and that a negative control without it fails on each.
- Change one target's representation alone — the seen pages of the TypeScript library — and show that the suite and the differential tests pass with no change elsewhere.
Acceptance criteria.
| Criterion | Measure | Bar |
|---|---|---|
| Identical results | Differential tests, parsers/test.sh, fixture suites, switch on vs off |
Byte-identical on every corpus and target |
| Identical costs | budget.golden and the budget determinism tests |
Unchanged |
| Conformance | The pilot operations' suites on Go node, Go typed and TypeScript | All pass; every mutation control fails |
| Detection | Four composition negative controls | Each fails at least one differential test on every target |
| Go speed | BenchmarkParse, BenchmarkRecognize, BenchmarkStream on every bench grammar including calclr (left recursion), node and typed, both units; n ≥ 10 interleaved |
No reproduced sec/op slowdown (p < 0.05); allocs/op equal |
| TypeScript speed | ts-parse and ts-typed on every bench grammar, n ≥ 10 interleaved |
No reproduced slowdown |
| Size | Generated source and binary size, Go runtime source, TypeScript runtime bytes and gzip, generation time | Within noise; any growth is reported |
| Inlining | Inlining cost of each printed hot function, grammar code included | Every function inlined today is still inlined |
| Readability | Printed functions next to the hand-written ones | Same structure and names; differences limited to specialization suffixes and comments |
| Single source | Hand-written call protocol and growth code removed from the three copies | Reported |
| Independent optimization | Work step 6 | No change outside the TypeScript library |
If a criterion fails and cannot be met by backend tuning, the pilot stops and its findings decide between this design and the alternatives.
Decisions
These are settled.
-
Three levels: Core IR, the core language of a grammar; Flow IR, the control flow of a parse; Exec IR, the parser VM, in
internal/ir/exec. -
Exec IR specifies operations, not code. Each operation has a behavior contract; each target implements and optimizes it for its language. Shared: the contracts, the compositions, the passes on Exec IR and the conformance suite, which every implementation must pass.
-
Go builders first. Compositions, operations and conformance cases are built with Go builder functions; a textual syntax and its tooling are a later extension.
-
Flow IR is lowered into Exec IR from the start, so each language has one backend for grammar code and runtime. Because that puts grammar code at risk in every stage, every stage applies the same performance gates to grammar code.
-
The engine adopts the shared core in the last stage; golden files and fixtures carry the independent checks after that.
-
Where copies differ in algorithm, the engine's algorithm wins, after measuring the incremental benchmarks on both, with the change of
Statsstated in the stage's record. For provisional seed tracking in Documents, the engine reuses more results than the generated runtime; the specification makes reuse visible only through the statistics (Reuse), so results do not change. -
Printed code is checked in, with a drift test, as
gen/_runtimeis today. -
Term evaluation and the value model are a second wave, with value operations as host primitives. They account for 35 of the 79 multi-copy commits.
-
Cost contracts are deterministic per target, within a declared bound. Each operation's contract states an upper bound on its charges in abstract terms (for
expect, at most one unit per element of the current record) and requires determinism; the exact count belongs to the target and is pinned by that target's golden counts. The budget bounds work, and a bound is what a user relies on; an exact cross-language count would forbid the representation choices this design exists to allow, and the specification already lets counts depend on the backend. Charges in compositions stay exact and shared. -
Each state component has a reference model: a small, deliberately naive Go implementation of its contract, against which randomized operation sequences are compared on every target. It finds what handwritten cases miss (counter overflow, page boundaries) and is never printed or optimized, so it does not become another copy to tune.
-
The Flow IR printers are removed after stage 3, once every mode of grammar code prints through Exec IR. Each language has one backend early; transitional operations live until their mechanism migrates.
-
A slowdown counts only when a second interleaved run reproduces it. With hundreds of benchmark rows some cross p < 0.05 by chance; a reproduced slowdown fails the stage.
Alternatives Considered
A restricted subset of Go as the source of the core. Write the core in Go, type-check it with go/types, reject
what is outside a subset, and translate the rest to every target. The source would run as Go for its own tests and
work with existing tools. It is rejected. Go fixes the procedure — the data structures, their layout, the order of
steps — so every other target receives Go's representation, and optimizing per language means teaching the
translator to recognize and rewrite Go idioms: a compiler from Go to many languages. And Go code cannot say what it
means for a parse: a loop over a memo chain does not state which entry a lookup must return, so neither a translator
nor a reviewer can tell an optimization from a change of behavior. Operation contracts state exactly that.
An imperative Exec IR of records, arrays and loops, printed per target. The core written once as target-neutral imperative code, with printers tuned per target. It shares one fixed procedure instead of a specification: a target can only tune how a given data structure is printed, not choose another one, and the parse meaning is spread over code instead of stated per operation. Conformance can only be tested end to end.
Hand-written runtimes with a shared conformance suite. Keep four copies and add a suite. It catches observable divergence but not divergence in policy, cost or reuse, and every composition change — 11 of the 22 measured core changes — is still paid on every copy. This design keeps the suite and removes that cost by sharing compositions.
One core compiled to WebAssembly. The core calls the host on every rule body and stores host values, so each memo lookup and call would cross the WebAssembly boundary, and values in the JavaScript heap need handles. Go's WebAssembly output adds megabytes to every parser, and the Go targets keep their copies.
Extending Flow IR with data structures instead of a third level. Flow IR's invariants — every loop iteration consumes input, exits or charges; every Document read is recorded — do not hold for runtime loops such as a memo chain walk, so they would have to be scoped by function kind, and Flow IR's passes would have to skip or understand the runtime's state. Exec IR keeps Flow IR's invariants and gives the runtime's mechanisms passes of their own.
Generics to merge only the node and typed Go copies. It removes one copy of four, pays dictionary calls where the instantiations share a GC shape, and does not help TypeScript or the engine.
Risks
- Grammar code regresses. Every generated rule body goes through a new backend from the start. The per-stage gates on grammar code, the inlining check and the size report are the guards, and the old printers stay behind the switch until stage 3.
- The boundary erodes. A policy can slip into an operation because it is faster there, and reappear as per-target copies. The rule is checked in review: an operation whose contract mentions a reuse, growth or error choice is too coarse.
- Contracts are incomplete. An implementation can pass the suite and still differ in a way no case observes. The reference models, mutation controls and differential tests together cover it; each implementation bug adds a case.
- Transitional operations linger. They are listed per stage and the stage that migrates a mechanism removes its transitional operations.
- A shared bug reaches every target. One composition means one bug everywhere. The engine as oracle until the last stage, golden files, fixtures and negative controls are the guards.
- Budget counts. Charge coalescing and new operation implementations could change counts; counts stay pinned per target unless a stage states why they change.
Appendix A: Inventory
Measured at f52b6bd9. Line counts are approximate, from function boundaries in non-test sources, and include
comments.
Implementations
| Implementation | Files | Non-test lines |
|---|---|---|
| Engine core, shared by closures and both VMs | internal/engine/core/*.go |
7,142 |
| Engine backends (dispatch only) | internal/engine/closure/*.go, internal/engine/vm/vm.go, ivm.go |
1,218 + 2,015 |
| Engine Document API | internal/engine/document/*.go |
1,784 |
| Generated Go runtime | runtime/*.go (copied to gen/_runtime) |
11,475 |
| TypeScript runtime | internal/gen/tsgen/tsrt/*.ts |
4,444 |
Mechanisms
| Mechanism | Kind | Engine | Go node (runtime.go, document.go, stream.go) |
Go typed (typed.go, typed_document.go) |
TypeScript (runtime.ts, budget.ts) |
|---|---|---|---|---|---|
| Memo table | Core | MemoTable Get, growing, Put, Alloc, Retire, Reset, Prune, Splice (memo.go, ~410) |
memoTable get, put, alloc (~120) |
memoGet, memoPut, …Controlled (~110) |
memoGet, memoPut, newEntry (~90) |
| Deferred memoization | Core | firstCall, MarkSeen, MarkSparseSeen, seen pages (memo.go, runtime.go FirstCall, ~130) |
FirstCall, markSeen, markSparseSeen, seenPageArena (~160) |
shares the node code | firstCall, markSeen, markSparseSeen (~95) |
| Call protocol | Core | CallBegin, CallEnd, InvokePlain, InvokeBegin, InvokeEnd, Memoizes (runtime.go, ~250) |
Call, callBody, callMemo, callUnmemoized, invoke, InvokePlain (~300) |
Call, callBody, CallMemo, invoke, InvokePlain (~190) |
call, callBody, invoke, invokePlain (~200) |
| Left-recursion growth | Core | GrowBegin, GrowStep, GrowEnd, memoGrowing (~80) |
grow (~25) |
grow (~25) |
grow (~30) |
| Expected sets | Core | Expect, Isolate, Unisolate, Keep, MergeExpected (runtime.go, ~85) |
Expect, ExpectBudget, ExpectOrdinary, Isolate, Unisolate, Keep, MergeExpected, RenamedExpected (~215) |
tracked, ErrorAttr, DiagnosticAttr (~30) |
expect, keep, mergeExpected, isolate, unisolate, renameExpected, mergeKept (~155) |
| Recovery bookkeeping | Core | recovered lists in CallEnd, GrowStep, applyRecovered; #missing in missing_token.go (engine only, ~190) |
RecoverAttr, Restore, applyErrors (~50) |
RecoverAttr, applyErrors (~35) |
recovery in callBody, apply (~20) |
| Variable environment | Core | Env.Lookup, Bind, EnvValues, memoEnv, SeedVars (~90) |
bind, envValues, memoEnv, SetVars (~145) |
Bind, Lookup (~40) |
envValues, bind, bindValue (~85) |
| Capture frames and trail | Core | Mark, Reset, SetCapture, Finish, AttachCaptures (~90) |
NewFrame, frameScratch, Mark, Reset, SetCapture…, Finish (~135) |
NewFrame, FreeFrame, DropTrail, Mark, Reset, Finish (~185) |
newFrame, reset, setCapture, finish (~120) |
| Operators Rule loop | Core | operators_rule.go (~340) |
longest, operatorsRuleExpr, operatorsRuleNud, operatorsRuleBuild (~260) |
same names (~255) | same names (~310) |
| Budget charges | Core (charges), host (cancellation) | budget_*.go (~1,800) |
budget.go (~1,410, shared by node and typed) and 72 mode variants |
shares budget.go |
budget.ts (~500) and 28 …Spend variants |
| Stream commitment | Core (commit, prune), host (readers) | Commit, Discard (input.go), stream_*.go (~350) |
streamCommit, streamDiscard, pruneStreamMemo, EmitStream (~180 of 758) |
— | none |
| Document reuse | Core (reuse), host (edits, snapshots) | resume.go, Splice, moveResult, MoveTree (~540); Document API (1,784, host) |
callDocument, documentTouch, editReuse (~200 of 423) |
DocumentCall, invalidate (~250) |
none |
| Input access | Host | input.go, input_pieces.go |
Peek…, MatchLiteral…, stream… readers |
MatchLiteral… |
peek, decode…, matchLiteral… |
| Values, nodes, terms | Host (later candidate) | eval.go, value_runtime.go |
ActCtx, rt… built-ins |
TActCtx, trt… built-ins |
Actx, rt… built-ins |
| Public API, I/O, tracing | Host | internal/engine/program*.go, trace.go |
RunParse…, WithLimits…, JSON, trace.go, profile.go |
TParse…, TPub… |
run, resolveOptions, trace.ts, profile.ts |
Duplication Cost
Method: for each commit from 2026-10-07 to 2026-10-30 (the whole history after the initial import), the non-test files it touched were mapped to the four copies (engine runtime, Go node runtime, Go typed runtime, TypeScript runtime) under their former and current paths; commits touching two or more copies were classified by hand from their subjects and diffs.
| Measure | Commits |
|---|---|
| All commits in the window | 1,263 |
| Touching two or more copies | 79 |
| … of which core mechanism semantics or cost | 22 |
| … of which term evaluation and value model | 35 |
| … of which host-specific | 9 |
| … of which renames, moves, cleanup, import | 13 |
| Core commits touching all four copies | 8 |
| Core commits touching three copies | 7 |
| Core commits touching two copies | 7 |
| Touching both the node and the typed Go copy | 47 |