101. Skip Positional Items for Action-Free Rules
- Status: Implemented
- Date: 2026-10-18
Problem and Mechanism
The type checker types a rule body once, then typed every visible top-level
sequence item a second time to build the positional item list that a rule
action reads through $1, $2 and so on. That list was built for every
sequence rule, including rules without an action, where nothing reads it.
For a long sequence of captures the second pass repeated all expression
checking, capture merging and type construction.
The checker now builds the positional list only for rules with an action. The first pass over the whole body already reports every diagnostic of the items and records every checked field and result, and the second pass ran each item in a fresh capture scope whose captures were discarded. Rules with an action, Operators Rules and the single-item fallback for non-sequence bodies are unchanged.
Applicability
| Execution profile | Status | Reason |
|---|---|---|
| Shared type checking | Applied | Runs before compilation and generation for every backend |
| Parser runtimes and generated code | Not applicable | No matching-path change |
Reproduction
Baseline 178943c and the candidate differ only in internal/engine/check.go.
Both run on Apple M3 Max with Go 1.27.1. Three alternating
baseline/candidate rounds of two samples each give six samples per case:
go test ./internal/engine -run '^$' -bench '^BenchmarkCaptureChecking$' -benchmem -count 2
Each iteration runs the complete checkTypes on an already prepared Program.
The shapes are those of record 072:
recursive repeats "๐" x:main in main; record repeats x:item; mixed
repeats x:a x:b? with two node types; unique uses distinct labels. The
control cases check the calculator, minilang and JSON grammars. Raw results
stay local.
Results
benchstat reports p=0.002 for every row; time variation is within ยฑ2%.
| Workload | Time before โ after | B/op before โ after | Allocs/op before โ after |
|---|---|---|---|
| Control calculator | 12.34 โ 11.93 ยตs (โ3.3%) | 16.73 โ 16.23 KiB | 401 โ 383 |
| Control minilang | 193.7 โ 189.9 ยตs (โ2.0%) | 268.0 โ 263.8 KiB | 3,635 โ 3,518 |
| Control JSON | 32.11 โ 29.58 ยตs (โ7.9%) | 47.12 โ 43.84 KiB | 908 โ 800 |
| Recursive 1,024 | 65.14 โ 55.54 ms (โ14.7%) | 30.68 โ 1.67 MiB | 274.57k โ 60.85k |
| Recursive 32,768 | 2.080 โ 1.779 s (โ14.5%) | 1,086 โ 44.38 MiB | 8.529M โ 1.712M |
| Recursive 131,072 | 8.310 โ 7.109 s (โ14.5%) | 4,366 โ 177.9 MiB | 34.09M โ 6.823M |
| Record 32,768 | 18.57 โ 9.74 ms (โ47.5%) | 35.08 โ 5.04 MiB | 131,243 โ 103 |
| Mixed 32,768 | 97.80 โ 67.94 ms (โ30.5%) | 120.6 โ 50.07 MiB | 3.146M โ 2.425M |
| Unique 32,768 | 20.16 โ 11.33 ms (โ43.8%) | 57.08 โ 27.04 MiB | 132,297 โ 1,181 |
The 1,024 and 8,192 sizes of each shape change by the same proportions. The geometric mean over all sixteen cases is โ28.5% in time, โ77.4% in bytes and โ90.0% in allocations. Bytes are cumulative allocations over one checker invocation, not retained heap.
Correctness and Tradeoffs
Before the change, a differential run typed the skipped items anyway for every action-free sequence rule and compared the result with the state the first pass left. It covered 289 grammars from the repository and the test corpora, then the engine, syntax, grammar, LSP and root test suites, about 2,900 checked rules. No rule gained a diagnostic, a checked field or a checked result; grammars with type errors in items, predicates and inline actions were included as positive controls. Rules with an action still type their items, so positional indexes, recursive widening and generated types are unchanged.
Decision and Limits
Adopted as the default. Recursive inference still checks every occurrence in each inference round, which dominates the remaining large recursive cost.