132. Records of the Walks of Recovery Skips

  • Status: Implemented
  • Date: 2026-10-31

Problem and Mechanism

The skip expression of a #recover runs at every position where its base fails. When a repetition retries a failing recovery at each position, every skip walks forward over the input that the skips before it walked. With

main = (x / "a")* $$
x = (y #recover(skip=[a]+)) "c"
y = "b"

and the input a n times, y fails at every position, [a]+ walks to the end of the input, "c" fails, and the recovery is abandoned: the parse takes time quadratic in n on every backend. Nested recoveries, where each skip starts one position before the one before it, and walks that alternate between two regions of the input do the same.

An unbounded repetition in a skip expression is now a skip-run site when the rest of its walk from a position depends only on that position and the input (the conditions are listed in Skip Runs). The walk from a position ends where the walk from any later position it passes ends, so the parser keeps a slot per position, per site and lookahead state, that is fresh, visited or recorded:

  • A walk starts plain and marks the position of each iteration it moves past. It pays for nothing else, so a parse whose skips walk over each position once costs what it did, and a walk that fails at once, as the skips of an error-free parse do at the end of a list, touches no slot.
  • At its first iteration at a position that an earlier walk visited, the walk starts recording. When it ends, at a failed or empty iteration or at a recorded position, it stores the rest of the walk for every position it visited while recording, from the last back to the first: the end, the farthest failure with its expectations, and the input range the rest examined.
  • A recording walk that has matched its minimum looks up each position before iterating there. At a recorded position it takes the end from the record, records the expectations again and extends the examined range, as a memo hit does.

Records are stored only when a walk ends, so a parse that a limit or a cancellation stops in the middle of a walk leaves none behind. A position is evaluated at most once while its slot is fresh and once more before it is recorded, plus the minimum number of iterations and one more per walk, whatever the order of the walks: the proof shows that a reused record equals the rest of the walk and gives the bound. A slot that is not fresh is marked by a bit over the span of the positions walked, and only recorded slots hold a record, kept in a map by position.

The compiler marks the sites (SKIPREPEAT in bytecode, SkipBegin, SkipVisit, SkipHit and SkipEnd in Flow IR), and the engine, the generated Go runtime and the TypeScript runtime keep the same slots and charge the same budget units.

Applicability

Execution profile Applied / Partial / Not applied / Not applicable Reason
Closure, bytecode, iterative bytecode Applied SkipBegin, SkipVisit, SkipHit and SkipEnd of the engine core
Generated Go, ordinary and budget-controlled Applied The same operations in the runtime, emitted from Flow IR
Generated typed Go Applied Shares the runtime operations
Generated TypeScript Applied The same operations in the TypeScript runtime
Documents Applied Slots last for one parse; results examine what the walks behind them examined
Streams Applied The slots before a commit are dropped
Missing-token insertion Not applied A parse that inserts tokens keeps no slots

A repetition in a rule that a skip expression calls is not a site: the rule can be called from outside skips, where its walks are not abandoned in the same way. Such a grammar stays quadratic, which the classification shows with its own row.

Reproduction

Base 90c3e01f, Go 1.27.1, Apple M3 Max, benchlock, each run started with a load below 8. Test binaries of the bench package, one per revision, ran interleaved for 11 rounds with -benchmem over Parse and Recognize (JSON, left-recursive calculator, Minilang, Recovery), Incremental and Stream; benchstat compared them. Tests: TestSkipRunsMatchParsesWithoutRecords, TestSkipRunsNegativeControls, TestSkipRunsBudgetAbort, TestSkipRunsDocument, TestSkipRunsStream, TestSkipRunsWideMinimum, TestSkipRunsAbortStoresNothing, TestSkipRunsAbortControl, TestSkipRunsNeverRecordControl, TestSkipRunsLinearMemory, TestSkipRunsGeneratedMatchEngine, TestSkipRunsGeneratedStream, TestSkipRunsGeneratedDocuments, TestSkipRunsGeneratedLinear, TestSkipRunsTSMatchEngine, TestSkipRunsTSLinear and the E8 rows of TestControls.

Results

Steps of the engine (Steps of the complexity suite, the same on the three backends up to a few percent):

Grammar, input n = 250 500 1000 Before, n = 1000
Repeated skips (above), a n times 11,416 22,912 45,952 1,036,288
Nested skips, ( n times then a 10,864 21,616 44,128 1,039,360
Alternating regions, a n times, b, a n times 30,688 61,504 125,056 4,108,288

The nested grammar is main = (s "!" / [a(]*) $$; s = ("(" s ")") #recover(skip=[a(]+) and the alternating one is main = (p / "a" / "b")* $$; p = x "b" x "z"; x = "c" #recover(skip=[a]+). Generated Go and TypeScript spend budget units in the same proportion at n = 200, 400 and 800 (slopes at most 1.2), and walks that never record give slopes of at least 1.7. The memory a parse allocates grows linearly in n up to 16,000 on the nested grammar.

Speed and allocation (medians of 11 interleaved samples against the base; changes benchstat reports as significant, p < 0.05):

Benchmark Time Bytes allocated Allocations
Parse, Recovery, closure and bytecode ~ +0.3% +12 (+0.6%, +0.7%)
Parse, Recovery, iterative +2.8% ~ +12 (+0.7%)
Parse, Recovery, generated +4.2% +0.5% +12 (+1.0%)
Recognize, Recovery, all four backends ~ +27 to +30 KiB (+11% to +19%) +12 except closure (+4.6% to +7.7%)
Recognize, JSON, iterative +2.9% ~ ~
Recognize, JSON and calculator, closure and bytecode ~ +0.3 to +0.4 KiB (+1.7% to +4.5%) 0 or +1
Recognize, Minilang, closure and bytecode ~ -2.6%, +3.5% ~
Incremental, document, engine backends ~ +0.4% ~
Every other case, including Stream ~ ~ ~
Geometric mean +0.7% +1.6% +0.7%

The Recovery input is the only benchmark input with errors to recover from, and its skips walk each position once: the added allocations are the slot bits and the map of the sites that walk, and the time grows by at most 4.2%, on the generated parser. The other changes are below 4.5% and within the spread of the samples; the byte counts of Stream differ by less than 0.01%.

Budget totals of the budget-controlled form grow on the corpus grammars whose skips have sites: an iteration of a site is a loop that charges a step per element, where the scan of a character class charged only for the characters it read, and a recording walk charges its lookups and records. The results of every case are unchanged.

Correctness and Tradeoffs

A record is reused only by a recording walk, and only once the walk has matched its minimum, so a reuse never changes whether a walk succeeds. Every record stored is the rest of a walk, by induction on the order of storing, so whether a walk is plain or recording decides only which positions get records, never the result.

Recording from the first walk would make every skip pay for keeping expectations and storing records, including the skips of inputs with one error. Marking first defers the cost to positions that a second walk reaches. Storing records when their iteration ends instead of when the walk ends would leave records that hold only one iteration; the negative control that does so changes the results of the nested grammar on every backend. The control that never records turns the E8 grammars quadratic on every backend.

Slots kept densely, from the least to the greatest position a site visited, hold the input between walks that are far apart. Minilang tries its skip at the end of every block, where it fails at once; with dense slots marking those positions, a parse of the error-free benchmark input allocated 36 MiB instead of 10 MiB, parses and recognitions took 10% to 36% longer, and a recognition allocated 26 MiB instead of 79 KiB. Marking only the positions a walk moves past, and keeping the slots in a map, removes both costs; a map lookup costs more than an index on the long walks of the families above, which stay linear.

Decision and Limits

Adopted on every backend. Not covered: repetitions in rules that skip expressions call, which stay quadratic in the families above.