LeanParserGenerator generates Pager LR(1) parsers from external .lig grammar
files. Its dependent specification backend follows Menhir's a-posteriori validation model: parser
construction is untrusted, generated certificates are checked in Lean, and a
small generic runtime carries the proofs.
The verified core proves soundness, validated Err-freedom, operational completeness, and semantic unambiguity. Generated refinement witnesses now relate every paired native executable engine to that dependent specification at matching logarithmic fuel.
.lig grammar
│
▼
untrusted frontend → IR → Pager construction → code generation
│
┌────────────────────────┴───────────────────────┐
▼ ▼
dependent specification module parser module
Grammar + Automaton generated value type V
safety/completeness certificates Runtime.Exec tables
│
▼
trusted validators + interpreter + theorems
The generic runtime proves:
| Guarantee | Assumption | Public theorem |
|---|---|---|
| Parsed results correspond to parse trees with the returned semantics | none | Runtime.parseCorrect |
Internal .Err is unreachable on arbitrary inputs |
Safe |
Runtime.parse_err_free, parse_ne_err |
A supplied parse tree is followed exactly; timeout obeys the tree-size fuel bound; .Fail and .Err are impossible on that trace |
Complete |
Runtime.parseComplete |
| Two parse trees for one word have equal semantics | Complete |
Runtime.unambiguity |
| Parser execution simulates the dependent interpreter at matching fuel | generated ExecRefinement, Safe |
Runtime.Refinement.run_refines, generated parse_eq_dependent |
Safe and Complete are derived from executable Boolean validators:
theorem safetyValidation :
safeValidator (A := automatonInst) = true := by native_decide
theorem safety : Safe :=
safe_of_safeValidator safetyValidation
theorem completenessValidation :
completeValidator (A := automatonInst) = true := by native_decide
theorem completeness : Complete :=
complete_of_completeValidator completenessValidationnative_decide discharges generated certificate and finite refinement-agreement
checks. It trusts compiled Boolean evaluation; the parametric runtime theorems
themselves use no sorry, custom axioms, or Classical.choice.
The validators establish that the emitted automaton satisfies the runtime
properties. They do not prove that the untrusted frontend preserved the author's
intended grammar; semantic correctness is relative to the emitted Grammar.
Trusted verified core (LeanParserGenerator/Runtime, excluding Exec.lean):
- dependent grammar, parse-tree, automaton, and stream interfaces;
- interpreter;
- safety and completeness validators plus their linking theorems;
- soundness, operational safety, completeness, and unambiguity proofs.
Untrusted implementation:
.ligfrontend and normalized IR;- nullable/FIRST analysis and Pager construction;
- certificate annotation and code emitters;
- lexer DFA construction and table emission (the emitted tables are validated by
native_decideand the scanner is proved sound, §9.7–9.8 — parallel to the parser: construction is untrusted, the checked tables are not; thelexrefrontend and the bytes-to-Charlayer stay unverified); Runtime/Exec.leanand the executable backend.
A malformed automaton certificate or refinement witness fails generated
validation. Runtime/Exec.lean remains an implementation rather than part of the
specification; Runtime.Refinement.run_refines proves its generated instances
simulate the trusted dependent interpreter.
LeanParserGenerator/
├─ Runtime/
│ ├─ Alphabet.lean
│ ├─ Grammar.lean
│ ├─ Automaton.lean
│ ├─ Buffer.lean
│ ├─ Interpreter.lean
│ ├─ Correct.lean
│ ├─ Safety.lean
│ ├─ Complete.lean
│ ├─ Validator/Safe.lean
│ ├─ Validator/Complete.lean
│ ├─ Exec.lean # native non-dependent engine
│ ├─ Span.lean # source spans on tokens/values, line:col rendering
│ ├─ Diagnostic.lean # Menhir-style syntax-error diagnostics (unverified)
│ └─ Refinement.lean # generic native/dependent simulation
├─ IR/Grammar.lean
├─ Construct/
│ ├─ Analysis.lean # nullable and FIRST
│ ├─ LR0.lean # item cores and augmentation
│ ├─ LR1.lean # canonical LR(1) operations and Pager
│ ├─ Tables.lean # Pager tables
│ └─ Annotate.lean # validator certificate
├─ Frontend/File.lean # .lig parser
├─ Emit/Codegen.lean # parser backend
├─ Emit/CodegenVerified.lean # dependent specification backend
├─ Emit/CodegenLexer.lean # lexer backend
└─ Lexer/
├─ Regex.lean # regex, CharClass, nullable/deriv, longestMatch
├─ Engine.lean # Tokenizer, Compiled tables, tokenize, tokenizeStr
├─ DFA.lean # untrusted DFA construction, scan/scanStr, FlatDFA + flatScan
├─ Spec.lean # bestMatch, MatchStep, Tokenization spec objects
├─ Validator.lean # LexCert certificate and Boolean validate
├─ Tokenize.lean # tokenize_congr unambiguity + tokenize_sound
├─ StringScan.lean # byte-index scanStr/tokenizeStr = List Char spec (bridge)
├─ FlatScan.lean # flat state×class scan = DFA.scan given agreements
├─ FlatValidator.lean # a-posteriori flatValidate → the three agreements
└─ FlatTokenize.lean # tokenizeFlatStr = tokenize (the emitted runtime)
The %lexer { … } surface syntax is parsed by Frontend/File.lean (the
scanRegex* functions), the same frontend that reads the rest of the .lig.
Main.lean is the leanparsergenerator CLI. test/ contains grammars,
hand-written token/AST modules, committed generated modules, and regression tests.
The computable grammar signature is separate from semantic types:
class GrammarSig where
Terminal : Type
NonTerminal : Type
Production : Type
prodLhs : Production → NonTerminal
prodRhsRev : Production → List (Symbol Terminal NonTerminal)
class Grammar extends GrammarSig where
semT : Symbol Terminal NonTerminal → Type
prodAction : (p : Production) →
arrowsRight (semT (.NT (prodLhs p))) ((prodRhsRev p).map semT)
Token : Type
tokenTerm : Token → Terminal
tokenSem : (tok : Token) → semT (.T (tokenTerm tok))Validators depend only on GrammarSig, so generated validation expressions are
native-computable. The interpreter and semantic theorems depend on Grammar.
Productions store the RHS in reverse stack-pop order.
Automaton supplies action/goto tables and the annotations checked by the two
validators:
lastSymb;pastSymbandpastStatesstack-shape summaries;- LR(1) items and lookaheads;
- nullable and FIRST tables;
- initial-state start nonterminal.
Shift and goto values carry equalities tying target states to incoming symbols. This makes malformed stack transitions explicit in the type of the table.
The dependent stack is:
List (Σ state : NonInitState, semT (lastSymb state))TokenStream abstracts sequential head and tail. Proofs use the transparent
Nat → Token instance; other stream implementations can use the same runtime.
parseFix uses logarithmic fuel: logN permits up to 2^logN steps.
The dependent stack's element type depends on a runtime state through
semT : Symbol → Type. Lean therefore cannot compile the generic verified
interpreter after type erasure. Generated dependent specification parsers run by kernel
reduction (decide), while their Boolean certificates use native_decide.
Construct/LR1.lean implements Menhir's two-phase Pager design:
- Build closed LR(1) candidates and transitions.
- Group states by LR(0) core.
- Drop candidates subsumed by an existing family member.
- Repeatedly fuse states satisfying Pager's token-wise weak compatibility criterion.
- Traverse again, redirect each transition candidate to a subsuming family member, and number only reachable states.
Compatibility is not transitive, so the growing fused candidate is checked again
against the remaining family. The grammar has no distinguished error token;
its generated $ is an ordinary terminal, so Menhir's special error/stream-end
compatibility predicates are not needed.
Completed items reduce only on their LR(1) lookaheads. The exact final
Collection.lr1Items array is consumed both by table construction and certificate
annotation, preventing the executable tables and validator items from drifting.
nullable and firstSets are computed once in pagerFrom and passed into
lr1Closure/lr1Goto/lr1Start. LR(1) closure is called once per (state, symbol)
during both the discovery and numbering traversals — thousands of times on a
large grammar — so recomputing the two fixpoints inside lr1Closure (as an earlier
version did) made construction quadratic in the analysis cost. Threading the cached
tables through cut c99 construction from ~25 s to ~3 s with no change to the result.
numberPager keeps a Std.HashMap (List AnnItem) Nat from each discovered state to
its number, so the dedup lookup for a transition target is O(1). The previous
states.findIdx? (· == target) scanned every discovered state comparing full item
lists, which is O(states² × itemsize). The map mirrors states exactly — a state is
pushed and inserted together — so state numbering, and therefore the emitted
automaton, is unchanged.
Permanent tests cover:
- an LR(1)-not-LALR grammar requiring split same-core states;
- a genuinely ambiguous grammar that must still report a conflict;
- the no-incoming-edge invariant for state 0;
- generated safety/completeness validation and parser behavior.
One CLI invocation parses and constructs the grammar once, then renders both
modules from a shared PreparedGrammar:
leanparsergenerator Foo.lig FooVerified.lean FooParser.leanModule names default to the two output stems. Explicit
--backend verified|executable remains available for a single output.
The dependent specification output contains:
- finite index types for terminals, nonterminals, productions, and states;
- a computable
GrammarSigandAutomaton; - the dependent semantic
Grammar; - validator certificates and derived
Safe/Completeproperties; - a kernel-reducible parser entry point.
The executable output contains:
- a generated sum type
Vcovering every semantic value type; abbrev VP := Runtime.Span × V, the engine value: each stack cell carries the semantic value together with its source span;- token decoding
decode : Runtime.Positioned Tok → Nat × VP, attaching each token's span; - semantic actions over
List VP:sem/redvbind, for a symbolx, both the valuexand its spanx_span, and fold the production's span asRuntime.Span.merge (first symbol) (last symbol)(Menhir's$startpos/$endpos; a single symbol folds to its own span, an empty RHS to the dummy span). An action that never mentionsx_spanpays nothing — generatedsem/redvsetlinter.unusedVariables falseso an ignored span binder is not a warning; - flat Pager action/goto arrays;
run : List (Runtime.Positioned Tok) → Runtime.Exec.Result VPandparse : List (Runtime.Positioned Tok) → Option StartTy, which drops the span;- Menhir-style diagnostics wrappers
terminalName/diagnose/diagnoseSpanned/errorMessage(§ error messages), wherediagnoseSpannedrecovers the offending token's span fromVPforline:colrendering.
Both modules are generated from identical augmented grammar, state graph, and
parse tables. The paired specification module additionally emits a symbol-indexed
encoding into VP — refinementEncodeValue wraps each dependent value as
(Runtime.Span.dummy, .ctor value) — native-checked table/decoder obligations,
per-production semantic-action proofs, parserRunRefines, and
parse_eq_dependent. The dependent side has no spans: the refinement feeds
Span.dummy-tagged values, and because Span.merge dummy dummy reduces to
dummy, the per-production refinementSemanticAgrees still closes by rfl, so
the span machinery does not disturb the verified refinement.
The generic simulation in Runtime/Refinement.lean relates stacks, shifts,
reductions, finite EOF-padded inputs, matching logarithmic fuel, and final parser
results. The sum representation uses no unsafeCast.
These constraints are not obvious from the emitted APIs and must be preserved:
- Augmentation is
S' → S $. Consuming$makes the accepted start language prefix-free and gives completeness the required start behavior. The augmented action ignores$and returns theSvalue. - State 0 is only
Init. Pager statek + 1maps toNinit ⟨k⟩; state 0 has no incoming edge. The disjoint types are required by safety validation. - Dependent tables use reducible
match-on-Fin. Shift/goto equalities close byrflonly whenlastSymbreduces by iota; array lookup is too opaque. - Production contents use one dependent match.
prodContentsreturns the(lhs, rhsRev)pair and action together throughbox. Separate large matches cause quadratic type reduction on Cedar-sized grammars. - Large finite matches have a catch-all. Lean does not always recognize
consecutive
Finliteral arms as exhaustive at large cardinalities. grammarInstextends the emittedsigInst. Their signatures must be definitionally equal so validators and interpreter use the same automaton.- Executable tables are flat arrays. Thousands of generated match arms are expensive to elaborate; row-major arrays keep generated modules tractable.
A grammar is read at build time:
%header {
import calc.CalcAst
open Calc
}
%tokentype Tok
%token PLUS TIMES LPAR RPAR
%token <Int> NUM
%token EOF
%eof EOF
%start expr
%%
expr :: Int
| e=expr PLUS t=term { e + t }
| t=term { t }
term :: Int
| t=term TIMES f=factor { t * f }
| f=factor { f }
factor :: Int
| LPAR e=expr RPAR { e }
| n=NUM { n }
%headeris copied verbatim into the generated module.%tokentypenames the user's token inductive.%tokendeclaration order fixes terminal indices;%token <T>carries a semantic value of typeT.%eofnames the token mapped to generated$.%startnames the entry nonterminal.%option <name>sets a code-generation option (§below). The three accepted names arecompress_tables(compact table literals, §10.%option compress_tables),roundtrip_lexer(also emit the lexer roundtrip module, §16.4), and the value-carryingmodule_prefix <path>(the dotted prefix the generated modules live under, e.g.%option module_prefix calcso the parser importscalc.CalcLexer). Options affect only how the modules are emitted and which ones, never the grammar the proofs are about; an unknown name is rejected.name=symbolbinds an RHS value for the Lean semantic action.- Rule and token constructors must agree with the declarations.
- An optional
%lexer { … }block defines a tokenizer, so one CLI invocation generates the parser and the lexer together (LALRPOP-style). It carrieslet name = <lexre>definitions and| <lexre> [as x] => { <action> } | skiprules (withenter/↓,leave/↱mode transitions and an optionaleof => { … }), where<lexre>is the regex sub-syntax parsed byFrontend/File.leanintoLexer.Regex("str",'c',_,ε,[ … ]/[^ … ],*,+,?, concatenation,|, grouping, andlet-name references). Actions are captured verbatim like production actions. With only a.ligargument, the CLI infers every output from the path and its%options:leanparsergenerator Foo.ligwritesFooVerified.lean,FooParser.lean,FooLexer.lean(when a%lexerblock is present), andFooLexerRoundtrip.lean(when%option roundtrip_lexeris also set) next to the input. Output paths and module names can also be given explicitly (leanparsergenerator Foo.lig FooVerified.lean FooParser.lean FooLexer.lean, or--lexer Foo.lig FooLexer.lean [Module]). Either way it builds the DFA and validator certificate offline and writes an inspectableFooLexer.lean(§9).
The frontend preserves source spans so future .lig diagnostics can map errors
from generated Lean back to grammar locations.
A right-hand-side symbol may be a Menhir-style parameterized-rule application, which
Frontend/Macros.lean expands (before Pager construction) into a fresh concrete
nonterminal:
top :: Prog
| items=separated_list(COMMA, item) opt=option(trailer) { Prog.mk items opt }
The built-ins are list(X), nonempty_list(X), option(X),
separated_list(sep, X), and separated_nonempty_list(sep, X). Each distinct
application becomes one auto-named nonterminal (separated_list_COMMA_item) with the
usual alternatives and a result type derived from X's declared type
(List (…) / Option (…)); sep is a terminal that carries no value. Repeated
identical applications share one generated nonterminal.
These are pure surface sugar: the expansion runs before irOfSurface, so the IR,
Pager tables, and proofs see only ordinary rules — the trust boundary is unchanged.
Expansion does not reduce the automaton's state count (a macro grammar and its
hand-written equivalent produce identical states); it only removes the boilerplate of
hand-writing recursive list/separator nonterminals, which matters for
list-heavy grammars.
The lexer produces the token type declared by %tokentype, with
regular-expression definitions, maximal-munch matching, mode stacks, skip, and
an optional eof => TOKEN action. It is generated by the offline CLI, parallel to
the parser:
A %lexer { … } block in the .lig file (§8) is read by leanparsergenerator,
which builds the DFA and the validator certificate at generation time
(Lexer.DFA.build?WithVecs, ordinary compiled code, like Pager construction) and
writes an inspectable FooLexer.lean module: the tokenizer with its DFA tables
and certificate as literal data (a Lexer.Compiled Tok), the native_decide
validation theorem FooLexer.validated⟨m⟩, the maximal-munch soundness corollary
FooLexer.scanSound⟨m⟩, the flat state × class tables (FooLexer.cflat) with
their native_decide agreement theorem FooLexer.flatValidated⟨m⟩, and a
FooLexer.tokenize entry point that runs the fast flat scanner and returns
List (Runtime.Positioned Tok) — each token wrapped with its source Span
(Lexer.tokenizeFlatStrPos, §9.7 step 1, §9.8) (Emit/CodegenLexer.lean). The
bare List Tok path stays available as FooLexer.tokenizeBare
(Lexer.tokenizeFlatStr), used by the roundtrip theorems and the byte path;
tokenizeFlatStrPos_val proves the positioned tokens' values are exactly the bare
output, so tokenize_sound transfers unchanged. This is the
analogue of the parser's generated
modules — untrusted construction plus emission, with the emitted native_decide
the checked boundary.
The DFA construction, scanner, validator, and soundness proofs live in Lexer/
and are shared by every generated lexer; only the tables and certificate differ
per grammar. An earlier in-Lean DSL that built the same tables at Lean
elaboration time has been removed: there is one path, the CLI, and generated
lexers are written source files, not spliced definitions. Compiled.ofTokenizer
(Lexer/Engine.lean) still builds the tables at run time, but only as the
cross-check reference used by test/cedar/CedarBench.lean, not as a generation
path.
A verified bytes-to-tokens layer is later work and can remain independent of the parser refinement.
The lexer began (Lexer/Regex.lean, Lexer/Engine.lean, and an in-Lean DSL macro
since removed) as a
live Brzozowski-derivative interpreter. Tokenizer holds, per mode, an ordered
list of Rules, each a Regex tree and an action. tokenizeAux scanned a
List Char; at every position bestMatch ran Regex.longestMatch for every
rule independently, and longestMatch took a derivative per character. There was
no offline construction and no table: the automaton was the regex term, walked at
scan time. This subsection records why that was replaced; §9.6 records the
measurements, and the interpreter tokenizer no longer exists.
This is slow, and the slowness is algorithmic, not a constant factor. On Cedar policies the interpreter lexer measured about 800x slower than the Rust lexer, while the generated parser was only a small constant factor slower than its Rust counterpart (the current fused lex+parse is ~1.65x, §9.6.1). The causes of the interpreter's slowness, in decreasing order of impact:
longestMatchscans the whole remaining input. Itsgorecurses to the end ofcseven after the regex has derived tonever, so matching a short token near the start of anN-character input still walksNcharacters. Withkrules this isO(N² · k)over a file. This is the dominant term.- No shared automaton across rules.
bestMatchre-derived the input against allkrules at every position; maximal munch was recomputed per rule from scratch. - Derivatives are not simplified.
derivbuildscat/alt/starnodes without collapsingnever/epsilon, so the regex term grows as the scan proceeds andnullable/derivget slower per step. List Charwith per-tokentake/drop/ofList. The input is a linked list; each token doesO(len)slicing and rebuilds aString.
Rust's lexer is a linear DFA scan, O(N) with a small constant. That gap
explains the 800x.
The lexer adopted the generator + flat-table + generic-runtime half of the parser architecture (§1, §6), which is what makes the parser fast:
- An untrusted construction step compiles each mode's rule set to a DFA. Brzozowski
derivatives move from scan time to build time: derivative classes give the DFA
states, so the existing
Regexengine is reused, not discarded. - An emitter writes a
LexerTablesvalue: a flat transitionArrayover a compressed alphabet (Char → class id), per-state accept information recording which rule accepts (ties broken by rule order, preserving current semantics), and the mode-stack action for each accepting rule. - A generic
Runtime.Lexer.runscans aStringby index against the table, tracking the last accepting state for maximal munch, allocating nothing per token until it slices the lexeme. This is the lexer analog ofRuntime.Exec.run.
The .lig %lexer block stays as the surface syntax; it feeds construction
instead of building a Tokenizer of live regexes. The table was at one point
built at Lean elaboration time inside an in-Lean lexer macro; that path was
later replaced by the generated source file route the parser backends use
(Emit/CodegenLexer.lean, §9), so the DFA and certificate are emitted as literal
data in FooLexer.lean.
The parser uses untrusted construction plus a validated certificate because LR(1) construction is hard to prove correct directly, so checking a witness is easier than verifying the constructor. The lexer takes the same route, for the same reason and with one structural difference.
The reason: the hard part of Lexer/DFA.lean is not the derivative theory, it is
the alphabet partitioning — classIntervals, boundaries, and the atom grouping
in transitionsOf. That code sorts, merges and complements interval lists and
groups atoms by successor vector. Proving it correct means proving all of that
correct. Checking its output does not: the construction already computes the only
certificate needed, namely each state's derivative vector, so annotating the
emitted tables with it is nearly free, and the conditions relating the vectors to
the edges are cheap to check.
The structural difference from the parser: the parser's validators enumerate the
alphabets, because terminals, states and productions are Finite. The lexer
cannot do that — its alphabet is the 1.1M Unicode scalar values. Every validation
condition therefore has to be interval arithmetic over CharClass (containment,
disjointness, coverage) rather than quantification over characters. §9.7 gives the
conditions in that form.
This is also why CharClass.opaque has to go before any of it (§9.7). An opaque
Char → Bool has no interval structure to check, which is the same reason it
defeats DecidableEq and makes the opaqueWindow assumption in classIntervals
unprovable.
§9.7 has since been carried out (steps 0, 2–5; §9.8): the emitted lexer tables are
validated by native_decide and the scanner's maximal-munch soundness is proved,
so the lexer's DFA scan is on the verified path. The token→character frontend
(the lexre → Regex translation) and the bytes-to-Char layer remain unverified
— see the §2 trust boundary and §9.7's "out of scope".
The target properties, in the order §9.7 builds them:
- Language agreement. The DFA state reached after consuming a prefix accepts
exactly when some rule's regex matches that prefix, i.e. the tables agree with
Regex.matchesFull. - Maximal munch. Each step consumes the longest prefix accepted by any rule,
ties broken by rule order:
DFA.scanagrees withRegex.longestMatchminimized over rule order.matchesFullandlongestMatchare kept inLexer/Regex.leanfor exactly this purpose — they are the derivative-based statement of what the tables are supposed to compute, not an execution path. - Tokenization soundness and completeness.
Lexer.tokenizereturns a token list precisely when a maximal-munch tokenization derivation exists, and returns that derivation's tokens (§9.7 step 2). - Unambiguity, which unlike the parser's is a lemma rather than a separate
obligation: maximal munch is deterministic, so the derivation is unique, and
uniqueness gives unambiguity without a
Complete-style hypothesis. - Round-trip (token fixed point). Lex a source to tokens
T₁, renderT₁back to text, lex that text toT₂, and requireT₂ = T₁. This is a fixed point on the second lex, not a text-identity round trip, and it is not unconditional. Some actions are lossy by design, and the Cedar lexer (test/cedar/CedarLexer.lean) shows every case:- keyword folding:
keywordOrIdentifiermaps"if"to.IF, dropping the surface text; - value normalization:
NUMBER value.toNat!discards leading zeros and the original digit string; - text stripping:
stripStringQuotesdrops the surrounding quotes; - discarded tokens: whitespace and
//commentsskip, so they are gone after the first lex and cannot round-trip. The property therefore needs a defined token→text rendering and holds only up to re-lexing equality, and only for tokens whose rendering re-lexes to the same token. A correct rendering emits canonical surface text for each token constructor (for example, the keyword spelling for.IF, a canonical decimal for.NUMBER n, a re-quoted-and-escaped form for.STRINGLIT), and skipped tokens are simply absent fromT₁. Stating and proving this cleanly is a reason to keep token payloads carrying enough information to reprint, or to accept the documented lossy cases explicitly.
- keyword folding:
The work was done in this order and is complete (§9.6 for timings, §9.7/§9.8 for
proofs): (1) baseline with test/cedar/CedarBench.lean; (2) cheap derivative fixes
keeping the interpreter shape (stop longestMatch at never, never/epsilon
smart constructors); (3) build the DFA construction/scan and emit the tables as data
in FooLexer.lean, removing the interpreter tokenizer; (4) verify per §9.7 (clear
the blockers, define the Tokenization derivation, validator conditions, generic
theorem, per-lexer native_decide), then the String.Pos runtime and the flat
state × class scanner, each carried across a-posteriori without re-proving
soundness. The round-trip rendering (§9.4) is independent and not yet done.
Corpus: cedar-spec/cedar-benchmarking/corpus driven by test/cedar/CedarBench.lean.
Native-compiled; per-item
averages for before/after comparison, not absolute figures. Every step below keeps
the token output identical (checksums unchanged).
The lexer went from a live derivative interpreter to a validated flat-table scanner in four stages, each removing a §9.1 cause:
| Stage | Cedar lex-only | What changed |
|---|---|---|
| Interpreter | ~92,000 us (1 KB file) | whole-input scan + unbounded derivative growth (§9.1 causes 1, 3) |
| Derivative fixes | 755 us/policy | stop longestMatch at never; never/epsilon smart constructors |
DFA (Compiled.ofTokenizer) |
~210 us/policy | array-indexed DFA.scan shared across rules (§9.1 cause 2, at scan time) |
String.Pos runtime |
143 us/policy | walk the String directly, String.extract lexemes, no List Char (§9.1 cause 4) |
Flat state × class table |
66.8 us/policy | one classOf + one flat table read per char, replacing stepChar's edge-list search |
The tables are emitted as literal data in FooLexer.lean (Emit/CodegenLexer.lean),
not built at run time; a .olean grows by ~876 KB (in line with the parser tables).
The final flat scanner is the current runtime. After the String.Pos change a
profiler attributed ~87% of lex-only time to the DFA walk (DState.stepChar:
Array.findSome? over the state's edges, each a List-range CharClass.mem);
Lexer/DFA.lean's FlatDFA replaces that with a classOf : Char → classId (binary
search over sorted class boundaries + a 128-entry ASCII fast path) and a flat
state × classId → target array, scanned over String.Pos by DFA.flatScanStr
(Lexer/FlatTokenize.lean's tokenizeFlatStr is the emitted FooLexer.tokenize).
Soundness is a-posteriori (§9.7 step 1, §9.8): a Boolean flatValidate
(Lexer/FlatValidator.lean) checks the flat tables step/accept identically to the
DFA, discharged per lexer by native_decide, and tokenizeFlatStr_eq_tokenize
proves the emitted tokenize equals the spec tokenize (axioms only
propext/Classical.choice/Quot.sound), so tokenize_sound transfers unchanged.
Measured effect of the flat scanner vs the List Char and String.Pos runtimes
(2000 iterations; lex-only checksums identical throughout, so tokens are unchanged):
| Corpus | List Char |
String.Pos |
flat | speedup vs List Char |
|---|---|---|---|---|
Cedar generated/ (22) |
185.3 us/policy | 143 | 66.8 | ~2.2x |
A profiler decomposition of the flat path on Cedar: flat scan ~45 us/policy (~72%,
~29 ns/char), String.extract lexeme ~7 us, per-token semantic actions ~11 us
(user code the generator cannot speed up — keyword folding, String.toNat!,
token-list building), list building ~0. This is the ceiling for the
scan-representation lever: ~2.2x, sound, tokens identical, not an order of magnitude
because per-token user actions dominate the remainder.
Abandoned along the way, each for the stated reason:
- The
LexRefinementplan (prove soundness on the fast flat object directly) was dropped once soundness was already proved againstDFA.scan, leaving the a-posteriori validator as the simpler bridge. - A linear
classOf(scan 59 classes per char) regressed on Cedar, soclassOfis a binary search with an ASCII fast path. - Building the flat boundaries from complement-resolved
classIntervalsendpoints failedflatValidateon the calculator's negated classes, so boundaries are the raw edge-class endpoints (whereCharClass.memactually changes). - A fused
state × 128direct-ASCII transition table (one read per ASCII char) measured identical toclassOf+table, so it was not adopted. - The interim
test/LexerEmitAgreementTest.lean(compare emitted tables against a run-time rebuild) was removed once the emitted tables are validated directly (§9.7 step 5). - Against Rust
PolicySet::from_strthe end-to-end lex+parse is ~1.65x on the current flat+redv runtime (§9.6.1), down from ~2.4–2.9x on the pre-flat runtime. Cedar's Rust parser fuses lexing and parsing (logosDFA + LALRPOP) in one call, so there is no Rust lex-only figure to compare against; only the fused unit is comparable.
Cedar cannot separate lexing from parsing, so the comparison is against the fused
unit on both sides. Rust: the cedar-benchmarking policy_parse target
(src/executor.rs, bench_policy_parsing) times src.parse::<PolicySet>() — file
read once, 10 warmups, then timed trials with black_box. Lean: CedarBench.lean's
[lex+parse] (CedarLexer.tokenize then CedarParser.parse). Both ran on the same
28-file corpus (cedar-spec/cedar-benchmarking/corpus: 22 generated/, 2
clauses/, tinytodo, 3 oopsla/), native/release, 4000–6000 iterations, all 28
policies accepted on both.
I could not build the cedar-benchmarking binary directly against this checkout:
cedar-policy-generators has drifted from cedar core 4.12.0 (5 × E0599, e.g.
ExprKind::ExtHasAttr/Expr::extended_has_attr removed from core). That crate is
only used for authorization request generation, not policy_parse, so I built a
standalone harness linking only cedar-policy 4.12.0 that reproduces
bench_policy_parsing, recording per-trial time in nanoseconds (the shipped tool
truncates to whole microseconds, which would bias the fast policies; nanosecond
timing is if anything slightly more favorable to Rust).
Per-policy mean over the 28-policy corpus:
| Engine | per-policy mean | notes |
|---|---|---|
Rust policy_parse (lex+parse) |
~117 us | 3 runs: 117.2, 116.3, 116.9 |
Lean [lex+parse], warm |
~195 us | 5 warm runs: 190.7–201.1 |
| Lean lex-only | ~72 us | matches §9.6 |
| Lean parse-only | ~120 us | CedarBench [parse only], full 28-policy corpus |
So Lean lex+parse is ~1.65x slower than Rust (≈195 vs ≈117 us). The first
Lean iteration paid a ~273 us warm-up cost that the steady state does not; the
warm figure is the fair one. The split is ~72 us lex + ~120 us parse (192 ≈ the
fused 195, so little slack between phases). Two boundaries keep this from being a
pure like-for-like constant factor: the bytes-to-Char decode is excluded on the
Lean side (§2), and Rust builds its validated PolicySet AST while Lean builds
CedarAst, so the parse-side allocation differs.
Cedar's Rust parser fuses lexing and parsing into one driver (§9.6.1). I measured
whether a fused Lean lexer+parser recovers any of the ~1.65x gap, with a throwaway
harness (since removed) reusing the emitted flat lexer and parser tables so only the
handoff differed. Three fused drivers (log-fuel recursion + List stack; imperative
while + List stack; imperative while + Array stack) all agreed with the split
path on the 28-file corpus and all ran slower (0.95x, 0.88x, 0.47x). Removing the
intermediate List Tok recovers nothing here: the list is cheap (§9.6 put list
building at ~0), and each fused loop re-boxes a lookahead per parser step. The Array
stack was 2x slower because through Id.run do/let mut the array reference is not
provably unique, so push/shrink copy on nearly every step; a genuinely in-place
stack needs linearity (ST with a unique ref), not pursued once fusion showed no
benefit. Fusion is not made the primary spec anyway — if ever wanted it can be a
library-level equivalence runFused = runRedv (tokenize |>.map decode), keeping the
List Tok theorems. The one regime not covered is a multi-GB single input file, where the
intermediate List Tok is one file-sized allocation; that is a
memory-residency question a 49 KB corpus cannot surface, and fusion there should be
re-measured before committing.
The ~72 us Lean lex figure and the ~1.65x fused gap both exclude the
bytes-to-Char decode layer (§2, §9.6.1), which is a separate pass over the input
that Rust's logos does not pay — logos scans UTF-8 bytes directly. The flat
runtime (§9.6, §9.8) scans a String by String.Pos, i.e. over already-decoded
Chars, and the alphabet partition (Lexer/DFA.lean) is over Unicode scalar values.
A byte-level scanner moves the DFA alphabet to UInt8, scans the ByteArray
directly, and folds the decode into the same pass — removing the separate decode and
shrinking the per-char cost (a 256-wide byte table, no String.Pos get/next UTF-8
stepping). This is a structural lever on lex throughput orthogonal to the parser
refinement: the parser theorem still begins at tokens.
The scanner is implemented as a byte-expansion of the already-verified FlatDFA,
not a fresh automaton against Regex semantics, so the soundness chain
(FlatDFA = DFA.scan = MatchStep) is inherited rather than rebuilt. The runtime and
construction are done, the byte↔flat scan simulation is proved up to a Boolean
agreement, and the scanner is emitted into every lexer as a native_decide-validated
fast path; the one remaining piece is deriving that Boolean agreement for all Char,
so tokenizeByte can become the proved default. The details follow.
Lexer/ByteScan.lean defines ByteDFA (a flat state × 256 byte-transition table
plus a boundary/intermediate state split — boundary states are the char-DFA states
and the only accepting ones; intermediate states thread the UTF-8 continuation bytes
of a multi-byte code point) and ByteDFA.byteOfFlat, which byte-expands a FlatDFA
per state per class via Utf8Ranges.compile (the standard code-point-interval →
UTF-8 byte-range-sequence construction, so the Unicode catch-all class compiles to 9
sequences, not a million code points). On Cedar's flat DFA this produces 71 byte
states (57 boundary + 14 intermediate), and the byte tokenizer produces byte-for-byte
identical tokens to CedarLexer.tokenize on sample policies. Construction is
untrusted, validated a-posteriori.
Lexer/ByteValidator.lean proves the byte walk simulates the flat char walk:
byteScanListGo_consume shows that from a boundary state, consuming one char's UTF-8
bytes lands on FlatDFA.step for that char while passing only through non-accepting
intermediate states (no mid-code-point accept), and byteScanListGo_eq_flatScanCharGo
lifts this by induction over the input to show the byte scan and the flat char scan
record the same rule. These are proved with no sorry and only the standard axiom
budget, up to byteScan_rule_eq_flatScanArr: given the agreements, the byte scan over
s.toUTF8 records the same rule as the flat scan over s.toList (which
flatScanArr_eq_scan ties to DFA.scan). The remaining piece is a Boolean validator
deriving the abstract byte agreements (ByteStepAgree/ByteNoMidAccept, quantified
over all Char) that these theorems assume. The obstacle is real and is the "real
work" flagged above: a flat class holds code points with different UTF-8 byte
sequences, so a single class-representative check does not cover the class the way it
does for the char-level FlatDFA, and the byte run is not constant across a class at
the byte level. Full code-point enumeration is not a way out — validating
runList = f.step at every scalar value for every state measures ~250 s of native
evaluation on Cedar (57 states × ~1.1M code points), far past what native_decide can
discharge. The provable route is to certify the byte automaton recognizes each class's
UTF-8 byte ranges (Utf8Ranges.compile correctness). The ASCII fragment of this is
proved (runList_utf8EncodeChar_ascii: a single-byte code point's run is one validated
byte step); the multi-byte fragment (byte-range coverage of the non-ASCII classes — for
Cedar the single catch-all class) is what remains. The runtime String.toUTF8
input is bridged to the encodeChars list model by toUTF8_size_eq/toUTF8_getElem_eq
(via String.utf8Encode_toList), the one place String/ByteArray internals appear.
Lexer/ByteTokenize.lean is the runtime byte tokenizer (CompiledByte = a
CompiledFlat plus per-mode ByteDFA; tokenizeByte scans input.toUTF8 by byte
index and slices lexemes with ByteArray.extract/String.fromUTF8!, reusing the
needsText skip). Measured lex-only, byte tokenizer vs the flat String.Pos
tokenizer, native/release (the [byte lex] phase of cedar-bench), tokens
byte-for-byte identical (Cedar 28/28):
| corpus | flat lex | byte lex | speedup |
|---|---|---|---|
| Cedar (28 files) | ~68 us/file | ~58 us/file | ~1.17x |
So folding the UTF-8 decode into the scan removes ~15% of lex time on these ASCII
corpora, where the String.Pos cursor's per-char UTF-8 stepping is pure overhead. The
win would be larger on multi-byte input (the byte scanner reads each byte once; the
String.Pos path decodes a code point per step). This is the throughput half of the
goal; the verified half (the byte validator deriving the agreements) is the remaining
work noted above.
The emitter (Emit/CodegenLexer.lean) now generates the byte scanner into every
lexer: per mode a ByteDFA (FooLexer.cbyte = CompiledByte.ofFlat cflat), a
FooLexer.byteValidated{m} : byteValidate … = true := by native_decide check, and a
FooLexer.tokenizeByte entry point. The proven FooLexer.tokenize stays on the flat
String.Pos scanner, so all existing guarantees are unchanged; the byte path is an
additional, per-lexer native_decide-validated fast path (the same a-posteriori
discharge the flat tables use). The generated lexers (calculator, Cedar,
CedarSchema, CedarEntities) regenerate, compile, and discharge byteValidated{m}, and their
Verified proofs (Cedar checked directly) still check. Making tokenizeByte the
proved default awaits agreements_of_byteValidate (the derivation noted above); until
then tokenize remains the proved path and tokenizeByte the validated fast path.
Fusion (§9.6.2) found no gain; the byte scanner (§9.6.3) is the one structural lever
that did, and is implemented. Two smaller lexer-side candidates were tried against the
measured hot spots (lex ~72 us: 45 us flat scan, 7 us String.extract, 11 us user
actions). Parser-side candidates are recorded separately in §13.
-
Skip the lexeme copy for fixed-text tokens — done, retained.
String.extract(~7 us, ~9% of lex) copied the matched bytes for every token, but most tokens (PLUS,LBRACE, punctuation, and — where matched by literal rules — keywords) discard the text: the action ignores itsStringargument and emits a constant. The emitter now marks each rule text-dependent iff its action binds the lexeme and emits (r.binder.isSome && r.emit.isSome), recorded asCompiledFlat.textDep. The runtime (tokenizeFlatStrAux) passes""for text-independent rules and onlyString.extracts for the rest. Soundness:tokenizeFlatStr_eq_tokenizegained a hypothesishtext(a text-independent rule's action is constant in its argument, so""and the real lexeme give the sameAct); the proof cases onneedsText, thefalsecase rewriting byhtext. Measured: Cedar lex-only −5.6% (73.7→69.6 us); tokens identical,CedarVerifiedunchanged. -
Reduce
Nat/String.Posarithmetic in the scan loop — non-optimizing. At ~29 ns/char the flat scan is the largest lex cost.classOfalready routes code points< 128through an O(1) 128-entry ASCII table and only reachesbsearchforn ≥ 128. The Cedar corpus is 100% ASCII (0 bytes ≥ 128), so the fast path is hit on every character andbsearchis never entered — there is nothing to change. The remaining per-char cost is UTF-8 decode + machine-int arithmetic, which only the byte scanner (§9.6.3, deferred) removes.
This is the plan the verification follows; §9.8 records what is implemented (steps
0–5 are all done — step 1, the flat table, is the emitted runtime). It mirrors the
parser's architecture (§1, §6, §11)
rather than only borrowing its tactics: an untrusted construction, a Boolean
validator over an emitted certificate, one generic theorem turning validation into
the properties, and per-lexer native_decide to discharge the Boolean. The
differences from the parser are that the alphabet cannot be enumerated (§9.3), the
fuel bound is exact rather than logarithmic, and unambiguity is a lemma rather than
an obligation.
Step 0 — clear three blockers. None of the rest is worth starting first.
- Remove
CharClass.opaque. It makes language agreement unprovable (classIntervalsscans[0, opaqueWindow)and assumes the predicate is false above, which cannot be shown for an arbitraryChar → Bool), it merges DFA states wrongly (CharClass.beqcompares opaque classes by name only, so two different predicates sharing a name are treated as equal), and it blocksDecidableEq CharClassand hence the validator. It is not needed: Lean'sChar.isWhitespaceresolves to exactly[(9,10),(13,13),(32,32)], sotest/cedar/CedarLexer.leanandtest/cedarschema/CedarSchemaLexer.leancan use an explicit class. Note the separate finding that this exposed: Lean'sChar.isWhitespaceis false for U+0085, U+00A0, U+2028 and U+3000, where Rust'schar::is_whitespaceis true, so the Cedar fixtures may not matchgrammar.lalrpopon Unicode whitespace. That is a fixture-fidelity question to settle before differential testing against the Rust parser, independent of this plan. - Make
DFA.buildsignal failure. Its loop iswhile !worklist.isEmpty && steps > 0; on fuel exhaustion the unprocessed states keepdefault, which has no edges and no accept, so the lexer silently rejects valid input. It should returnOption DFA/Except. The proof needs "the worklist emptied" as a hypothesis regardless. - Decide the surrogate story. The atom partition covers
[0, maxCodePoint]including[0xD800, 0xDFFF], which contains no validChar;repOfdodges the gap andChar.ofNatmaps invalid values to U+FFFD. The theorem must quantify over valid scalar values only.
Step 1 — the flat-table optimization (throughput; now implemented and sound).
§9.6's String.Pos runtime still spent ~87% of lex-only time in the DFA walk
(DState.stepChar: Array.findSome? over the state's edges, each a List-range
CharClass.mem). A flat representation — a classOf : Char → classId map plus a
flat state × classId → target array — replaces that per-character search with two
array reads. The original plan was to do this before proving soundness, "in
refinement shape", with a LexRefinement tying the fast table to the
derivative-level DFA so the soundness theorem would be about the fast object. That
premise did not hold in the interim (soundness was proved directly against
DFA.scan, §9.8), so this became a pure throughput change carried across by an
a-posteriori validator — the parser's route, not a rewrite of the soundness proof.
Implemented in Lexer/DFA.lean, Lexer/FlatScan.lean, Lexer/FlatValidator.lean,
and Lexer/FlatTokenize.lean:
structure FlatDFAholdsbounds(sorted class boundaries, the raw endpoints of every edge class of every state),nClasses,nStates, a 128-entryasciifast-path class table, a flattrans : Array Nat(state*nClasses + classId ↦ target+1, 0 = dead), andaccept : Array Int(rule, or −1).classOfis a binary search (bsearch) overboundswith the ASCII table for code points < 128.DFA.flatOfDFAbuilds it from the derivative-vector DFA (untrusted).DFA.flatScanStrwalks aStringbyString.PoswithFlatDFA.step/acceptOf(the same shape asscanStr);tokenizeFlatStr(Lexer/FlatTokenize.lean) is the emittedFooLexer.tokenize, slicing lexemes withString.extract.- Soundness is a-posteriori.
flatValidate f dfa : Bool(Lexer/FlatValidator.lean) checks, per state and per flat class, that the flat step and accept agree with the DFA at the class representative, thatCharClass.memis constant on each flat class (intervalConst, no edge-class endpoint strictly inside the class — the interval form of the constancystepChar_congrneeds), that transition targets are in range, and the bounds/ascii shape.flatScanArr_eq_scan_of_valid/flatScanStr_eq_scanStrprove that whenflatValidate = truethe flat scanner equalsDFA.scan/scanStr, andtokenizeFlatStr_eq_tokenizelifts that to the whole tokenizer for a single validated mode with stack-preserving actions. The key lemma isbsearch_spec(the binary search brackets each code point in its class interval). All carry only thepropext/Classical.choice/Quot.soundbudget; the per-lexerflatValidate = … := by native_decideadds onenative_decideaxiom, as the derivative-vectorvalidatealready does. SoDFA.scan/tokenizeAuxDFAremain the specification, and the fast flat scanner is a verified-equal running object — exactly theLexRefinementrole, discharged by a validator instead of a hand-proved constructor. Measured ~2.2x lex-only on Cedar (§9.6), tokens identical.
The related List Char removal (§9.1 cause 4) is also done: before the flat scanner,
the runtime already walked the String by String.Pos (DFA.scanStr/tokenizeStr,
Lexer/StringScan.lean), a sound ~1.3x; the flat scanner builds on that
String.Pos cursor.
Step 2 — define the specification object. The lexer has no analogue of
ParseTree yet, and every guarantee has to be stated against one. Because
tokenization is a linear scan with a mode stack, the derivation is a sequence, not
a tree: a Tokenization witness that a token list is a valid maximal-munch
tokenization of an input, each step recording the mode, the winning rule index, the
consumed length, and carrying
- the rule's regex matches the consumed prefix (
matchesFull), - no rule matches a longer prefix,
- no earlier rule matches the same length (tie-break by rule order),
- the length is non-zero,
- the action's emit and mode-stack effect.
Its "size" is the input length, so unlike
ptSizeit needs no fuel bound. The spec-levelbestMatchoverlongestMatchbelongs here too — it was removed fromEngine.leanas an execution path and should come back as a specification-only definition.
Step 3 — the validator conditions. Emit, per mode and per state, the state's
derivative vector vec : Array Regex; the construction already computes it, so
this is an annotation, not new work. The conditions, all Boolean and all interval
arithmetic where the alphabet is involved:
| Condition | |
|---|---|
V0 |
state 0's vec is the mode's rule regexes, in rule order |
V1 |
each state's accept is vec.findIdx? nullable |
V2 |
for each edge (cls, tgt) of state s: states[tgt].vec = states[s].vec.map (deriv c) for c a representative of cls |
V3 |
for each edge class cls of state s, each component r of states[s].vec, and each k ∈ classesOf r: cls ⊆ k or cls ∩ k = ∅ |
V4 |
a state's edge classes are pairwise disjoint, and on the complement of their union every component's derivative is never (dead) |
V5 |
the construction completed — no state left as the default placeholder |
V3 is what replaces quantification over characters: it makes "every component
derives identically across cls" true, so checking one representative in V2 is
sound. The supporting lemma is that if c₁ and c₂ agree on membership in every
class in classesOf r, then deriv c₁ r = deriv c₂ r, by induction on Regex.
V4 is needed because transitionsOf drops dead successors, so "no edge" has to
mean "dead", not "unspecified".
Step 4 — the generic theorem. Prove once, for any tables satisfying V0–V5,
that DFA.scan returns the maximal-munch winner as defined in step 2, and then that
the tokenization loop returns a token list exactly when a Tokenization derivation
exists, with the same tokens. Uniqueness of the derivation follows from determinism
of maximal munch and gives unambiguity. This is the analogue of
safe_of_safeValidator composed with parseCorrect/parseComplete, and like them
it is proved in the library, not per lexer.
Step 5 — validate the emitted tables, not a rebuild. Run the validator on the
tables that actually run, discharged per lexer by native_decide, exactly as
CalcVerified.lean:457 does for safeValidator. This subsumes
test/LexerEmitAgreementTest.lean: once the emitted tables are validated directly,
comparing them against Compiled.ofTokenizer is redundant, and
Compiled.ofTokenizer becomes purely untrusted scaffolding for the emitter. The
lexer command then emits the tables, the certificate, the validation theorem, and
the corollaries, the way the parser backend emits parser_sound and friends.
Out of scope, and stated as such. The lexre → Regex frontend stays
unverified, the same boundary as .lig for the parser: the theorem is about the
emitted regexes, not the author's intent. Semantic actions are arbitrary Lean
functions and are not constrained. Step 1 moved the scan to a String.Pos cursor
over Unicode code points (not UTF-8 byte positions), and classOf's correctness
against the code point is discharged by flatValidate (§9.8), so the indexing is
inside the verified boundary; the bytes-to-Char decode that produces those code
points remains unverified, as for the String.Pos runtime generally.
Steps 0 and 2–5 are done and build; step 1 (the flat-table throughput
optimization) is now also done and is the emitted runtime (see the Step 1 bullet
below). The verified tokenizer is
validated end-to-end for the generated lexers (calculator, Cedar, CedarSchema,
and CedarEntities — their generated modules emit validated⟨m⟩/scanSound⟨m⟩ and
flatValidated⟨m⟩, discharged by native_decide), with no sorry and the same
axiom budget as the parser (propext, Classical.choice, Quot.sound, and one
native_decide axiom per emitted Boolean). The scanner's maximal-munch soundness is stated against the
specification object MatchStep (scan_matchStep and its converse
scan_of_matchStep), and holds for DFA.scan — the scanner the emitted lexers
actually run — not only against the derivative-level fusedScan; see Step 4
below. The interval-refined V2/V4 (§9.3) make Cedar's
Unicode-wide edge and leading classes tractable — 293 representatives, not 3.3M
code points — so no code-point budget gate is needed.
-
Step 0.
CharClassis now the ranges-only structure⟨neg, rs⟩withDecidableEq(Lexer/Regex.lean); the opaque predicate andopaqueWindoware gone,Regex.asciiWhitespacereplacesChar.isWhitespacein the Cedar fixtures.DFA.build?returnsOption DFA,nonewhen the worklist did not empty (Lexer/DFA.lean).DFA.repOfdocuments that the theorem quantifies over valid scalar values, so the surrogate gap is never a match position. -
Step 2.
Lexer/Spec.leandefinesbestMatch(spec-only maximal munch over a rule array vialongestMatch), the relationalMatchStep, and theTokenizationsequence witness. -
Step 3.
Lexer/Validator.leandefines the certificateLexCert(per-state derivative vectors, produced byDFA.build?WithVecs) and the Booleanvalidate.V2is interval-refined (§9.3): for each edge it tests one representative per boundary cell — a maximal sub-interval on whichderivis constant across the source vector, delimited by the raw endpoints of every class in every component (cellReps,splitCells,vecEndpoints), sound viaagreeOn_of_noEndpointBetweenderiv_congrand covered bysplitCells_covers. For Cedar this is 293 representatives instead of 3.3M code points, sonative_decideis tractable.V4(dead-complement coverage) is likewise interval-based and negation-aware (coveredByEdges/coversInterval, sound viamemRanges_of_coversInterval), handling Cedar's[^"\]/[^\n]rules. Cell refinement replaced an earlier point-enumerationV2(exact but intractable for Unicode-wide edges) and the plan'sV3edge-class-in-or-out condition (unsound becausetransitionsOfmerges atoms across regex-class boundaries).
-
Step 4. The generic theorem
scan_soundproves that for validated tablesDFA.scan dfa cs = fusedScan rules cs 0 none(the derivative-defined maximal munch), from the certificate step lemmavalidate_step;scan_deterministicis the unambiguity lemma andtokenize_congr(Lexer/Tokenize.lean) lifts it to the whole tokenizer. Soundness is stated against the specification objectMatchStep(Lexer/Spec.lean), bridged fromfusedScan's derivative bookkeeping by the pure-regex factmatchesFull r cs = nullable (derivs cs r):scan_matchStep(validatedDFA.scan … = some (rule, len)with0 < len⇒MatchStep), its conversescan_of_matchStep, andmatchStep_unique(the winner is unique). These lift to the tokenizer astokenizeAuxDFA_sound/tokenize_sound: for a single-mode lexer whose start mode is validated and whose actions keep the mode stack put, a successfultokenizeimplies the input has a maximal-munchTokenizationderivation. All carry only thepropext/Classical.choice/Quot.soundbudget. Two items are deliberately deferred: the spec-onlybestMatchis not proved equal tofusedScan(not needed —MatchStepis the spec object, and they differ only on the empty-match case, which is not a token), and the multi-mode lift (threading the mode stack throughTokenization) is the remaining generalization. -
Step 5. The CLI lexer backend (
Emit/CodegenLexer.lean) emits, per mode,⟨name⟩.rules⟨m⟩,⟨name⟩.dfa⟨m⟩,⟨name⟩.cert⟨m⟩as data,⟨name⟩.validated⟨m⟩ : Lexer.validate … = true := by native_decide, and⟨name⟩.scanSound⟨m⟩. BecauseV2/V4are interval-based (§9.3), there is no code-point budget gate: the calculator, Cedar, and CedarSchema lexers all emit and discharge the validation and soundness theorems, with the same axiom budget as the parser (propext,Classical.choice,Quot.sound, and one per-lexernative_decideaxiom). This subsumes the intent oftest/LexerEmitAgreementTest.leanfor the lexers that validate directly. -
Step 1 (flat-table optimization) is done and is the emitted runtime. The derivative-vector
DFA.scan/tokenizeAuxDFAremain the specification; the fastFlatDFAscanner is a verified-equal running object.Lexer/FlatValidator.lean'sflatValidate f dfa : Bool(discharged per lexer bynative_decideasFooLexer.flatValidated⟨m⟩) implies the flat tables step and accept identically to the DFA (StepAgree/AcceptAgree/StatesInRange);flatScanStr_eq_scanStr(Lexer/FlatScan.lean) andtokenizeFlatStr_eq_tokenize(Lexer/FlatTokenize.lean) prove the emittedFooLexer.tokenize := Lexer.tokenizeFlatStr FooLexer.cflatequals the spectokenize, sotokenize_soundtransfers unchanged. Axioms are the same budget (propext/Classical.choice/Quot.sound+ one per-lexernative_decideaxiom). The generated lexers (calc, Cedar, CedarSchema, CedarEntities) emitcflat,flatValidated⟨m⟩, and the flattokenize, and compile. Measured ~2.2x lex-only on Cedar (145.7 → 66.8 us/policy), tokens identical (§9.6). TheList Charremoval (Lexer/StringScan.lean,tokenizeStr_eq_tokenize) is theString.Poscursor the flat scanner builds on. -
Byte scanner (§9.6.3). A
UInt8/ByteArrayscanner (Lexer/ByteScan.lean,Lexer/ByteTokenize.lean) is emitted into every lexer asFooLexer.tokenizeByteand validated per lexer byFooLexer.byteValidated⟨m⟩(native_decide, same axiom budget asflatValidated⟨m⟩). It is ~1.17x faster than the flat scanner on Cedar (tokens identical). The byte↔flat scan simulation is proved up to that Boolean (byteScan_rule_eq_flatScanArr,Lexer/ByteValidator.lean); the provedtokenizestays on the flat scanner andtokenizeByteis the validated fast path. Making it the proved default awaitsagreements_of_byteValidate(§9.6.3).
lake build # library, CLI, verified smoke regressions (no heavy fixtures)
lake build parsers # regenerate both backends for every non-heavy fixture
lake build heavyParsers # regenerate the heavy fixtures (c99)
lake build Generated # compile executable calc/IMP/Cedar/... modules
lake build VerifiedSmoke # compile verified calc smoke and agreement tests (default)
lake build VerifiedMid # compile + cache the non-heavy *Verified proof modules
lake build VerifiedHeavy # compile + cache the heavy *Verified module (c99)
lake build ParserTests # calc/imp end-to-end, span, error-message, and x_span testsLake 5.0.0 has no -j flag; bound build parallelism with the LAKE_NUM_JOBS
environment variable (e.g. LAKE_NUM_JOBS=4 lake build VerifiedMid). This matters
for the heavy fixtures: build VerifiedHeavy alone (see below).
The paired *Verified proof modules are split across two non-default
lean_libs so plain lake build never pays their cost: VerifiedMid roots the
ones that fit comfortably in memory (calc, imp, cedar, cedarschema, cedarentities),
and VerifiedHeavy roots the heavy one (c99), reached only through
the separate heavyParsers regeneration target. Building either caches each
module's native_decide certificates to .olean, so an unchanged rebuild is a
Lake trace no-op (~0.9 s for Cedar) instead of the full re-check that
lake env lean test/<g>/<G>Verified.lean pays every time (~20 s for Cedar).
Neither is a default target — plain lake build (which builds VerifiedSmoke
only) must not pay the Cedar/c99 proof cost. Measured cold vs cached: Cedar
~20 s → 0.9 s.
c99 is split out for memory, not just time. c99.C99Verified checks
cold in ~30 s on 32 cores (~18 min before the changes in the next subsection). Its peak memory has not been re-measured. Build the heavy fixture
one worker at a time on a host with enough RAM (or swap):
LAKE_NUM_JOBS=1 lake build VerifiedHeavy, or one module at a time.
The end-to-end functional tests are rooted in the ParserTests lean_lib
(lake build ParserTests): calc/imp acceptance, test/calc/PositionTest.lean
(span folding), test/calc/ErrorMessageTest.lean (diagnostics and line:col), and
test/spanuse/ (an action reading x_span, and a sibling rule ignoring spans that
compiles without a linter warning).
Generated modules are committed for inspection. Lake targets regenerate them
when their .lig input or the generator executable is newer. See
test/README.md.
The default regression set exercises both validators, operational theorem instantiation, Pager construction, generated parsing, calculator/IMP/Cedar end-to-end behavior, rejection of a corrupted executable table, and expected elaboration failure for a mismatched semantic dispatcher.
Caching (VerifiedMid/VerifiedHeavy, above) removes the repeat cost. The cold cost
of CedarVerified fell from 386 s wall (503 s CPU, measured with the profiler on, which
inflates it; the earlier figure was ~230 s) to about 18 s wall (38 s CPU) on a 32-core
machine. C99 fell from the earlier ~18 min figure (not re-measured) to about 32 s. The
costs below come from lake lean <file> -- -Dtrace.profiler=true, largest first:
refinementSemanticAgrees(285 s of 386 s). Each production'srflreducesrefinementEncodeValueat concrete symbols. That function was amatchover all 105 symbols whose arms mentionsemT', itself a 105-armmatch. Lean'swhnfof a matcher is linear in its arms, and applying the matcher whnf'dsemT'once per arm, so one reduction cost 0.3 s (quadratic in the number of symbols). NowsemT'and the encoder are projections of one tablesemTbl : List (Σ T : Type, T → VP)indexed bysymIdx, andList.getDat a literal index reduces in time linear in the index with a small constant (285 s → 11 s). The proof is also one theorem per production, so the per-production jobs run in parallel (11 s → under 1 s). When no executable module is paired, the encoder component isfun _ => ().items'(57 s elaboration). About 50k⟨n, of_decide_eq_true rfl⟩literals. Items are now one packedNatliteral per state (count, thenprod | dot | lookahead bitmaskfields) decoded once intoitemTable.completeValidator(17 s interpretednative_decideon Cedar, 82 s on C99).nonTerminalClosedItemCheckrecomputed FIRST of the suffix and rescanned the target item list for every (item, lookahead, terminal). The checks inRuntime/Validator/Complete.leanare now per item:futureLookaheadsgathers the lookaheads certified for a (state, production, suffix) once, and a lookahead set is tested against it withlookaheadsSubset. Items with no lookaheads still pass vacuously, as before. The linking theorems are re-proved.actionTable'(9 s to compile) andgotoTable'plus the code arrays (9 s to elaborate). Rows are packedNatliterals with generic decoders (packedField,decodeLA), which also rebuild theShift/goto equality proofs.
Emitting Fin bound proofs as of_decide_eq_true rfl instead of by decide is
kept. The executable parser modules are byte-identical after these changes; I did not
rerun the throughput benchmark.
The dependent interpreter remains the specification; the parser engine is linked to it rather than replacing its proofs.
Runtime.Refinement.ExecRefinement records each generated grammar's semantic encoding,
state/terminal/nonterminal/production numbering, decoder agreement, table
agreement, and semantic-action agreement. Structural obligations are finite
Boolean checks discharged with native_decide; semantic obligations are
per-production proofs that reduce by rfl.
The generic proof:
- relates dependent heterogeneous values and stacks to executable
Vcells; - proves dependent
popagrees with executablepopNand source-order action arguments; - simulates shifts, reductions, rejection, goto, and augmented-start acceptance;
- lifts the simulation through the two structurally identical logarithmic-fuel
parseFixfunctions; - relates complete
parse/runresults throughRuntime.Refinement.run_refines.
Executable finite lists and dependent NatStreams both pad exhausted input with
the generated EOF token. Every paired specification module exports
parserRunRefines and parse_eq_dependent; calculator, IMP, and
Cedar compile these generated proofs. The existing parseCorrect,
parseComplete, parse_err_free, and unambiguity theorems remain unchanged and
transfer through this equality.
The generated semT', prodContents', tokenTerm', and tokenSem' definitions
are authoritative but their Fin matches are difficult to audit. Each dependent
specification module therefore also emits a readable verifiedGrammarView of
exactly the augmented grammar surrounding those definitions, rather than merely
pretty-printing the untrusted .lig parse.
Runtime.VerifiedGrammarView records:
- every terminal and nonterminal, including augmented
$andS', with indices and declared semantic type names; - the augmented start nonterminal, original user start, and EOF token's mapping
to
$; - every production in source order with a readable left-hand side and RHS, including the source binder attached to each symbol;
- the compact source action expression and stable
semanticActionNdeclaration actually reused byprodContents'.
The generated verifiedGrammarViewStructuralCheck compares candidate metadata
with the emitted GrammarSig: terminal/nonterminal bounds, start symbol,
production indices/LHS/RHS, augmentation, EOF, and token-terminal bounds all
agree. verifiedGrammarView_agrees discharges that checker with native_decide,
checks that the view contains exactly the declared token-constructor names, and
proves by exhaustive token-constructor cases that every entry selected through
generated tokenName' agrees with tokenTerm'. A corrupted calculator view with
a missing production is a negative regression for the structural checker.
Users can inspect the value directly in generated source, call
verifiedGrammarView.renderBNF or .renderMarkdown, or render from the same
augmented generation data on demand. BNF keeps the source binders and action
expression compact while naming the generated Lean declaration as a comment:
expr ::= e:expr PLUS t:term { e + t } -- semanticAction0
leanparsergenerator --print-grammar bnf grammar.lig
leanparsergenerator --print-grammar markdown grammar.ligNormal generation does not print the view. Semantic type, binder, and source
action strings are presentation metadata because Lean cannot generally reflect a
Type or function back to trustworthy source text. The actual Lean definitions
remain authoritative: semantic actions have stable generated declarations, and
those declarations—not the strings—are what prodContents' uses.
Paired specification modules export direct parser_sound, parser_err_free /
parser_ne_err, parser_complete, and parser_unambiguous corollaries, so
callers do not assemble refinement or validator witnesses. Existing-repository
and automatic Lake generation workflows are documented in
INTEGRATION.md. Later independent work includes a verified
lexer and .lig language-server diagnostics.
The generated ExecTable V (§6) already stores the action, goto, default-reduce,
prodLhs, prodRhsLen, and semantic tables as flat Array literals — the LR table
lookup is one array read. But the six ExecTable fields are fun closures over
those arrays (action := fun st t => actionData.getD (st * numT + t) .error, and so
on), so each Exec.step performs several indirect closure calls. I profiled the
parser hot path of a generated parser to find where the time goes:
| Component | share |
|---|---|
decode (toks.map decode) |
~8% |
LR engine (steps, table reads, popN, stack) |
~77% |
semantic actions + V allocation (user code) |
~15% |
Two measured facts ruled out the obvious levers. Switching the LR stack and input
cursor from List to Array did not help (within noise of the List baseline): List.getD 0/drop 1 are O(1) and the Array version pays for
shrink and bounds-checked indexing, so the lexer's List Char story has no parser
analogue. A flat while-loop engine with an explicit step counter — no Sum
allocation per step, no logarithmic-fuel double recursion — also measured the same as
the shipped parseFix, so the Sum/parseFix structure is not the
cost either. The one lever that moved the number was removing the ExecTable
closure indirection: reading the raw arrays inline dropped parse-only time by about 12%.
Runtime/Exec.lean therefore adds a closure-free engine and proves it equal to the
closure engine, exactly the lexer's a-posteriori pattern (§9.8 step 1) — the
ExecTable Exec.run stays the specification the refinement theorem is stated
against, and the inline engine is a verified-equal running object:
structure FlatTable Vholds the raw arrays plus the two row widths (actWidth/gotoWidth), no closures.FlatTable.toExecTablewraps it in exactly thegetDclosures the emitter used to write, so it is definitionally the oldtable.reduceInline/stepInline/parseFixInline/runInlineread the arrays inline.FlatTable.topStatereadsstartStatedirectly; an earlier version routed throughtopState f.toExecTable, which reconstructs the six-closure record on every step and maderunInline2x slower — the fix is the reason to keep a separatetopState.runInline_eq_run : runInline f input n = run f.toExecTable input n(viastepInline_eq,reduceInline_eq,parseFixInline_eq) carries onlypropext/Quot.sound, nonative_decide.
The emitter (Emit/Codegen.lean) now emits def flat : FlatTable V := {…arrays…},
def table : ExecTable V := flat.toExecTable, and run := Exec.runInline flat ….
Because table is definitionally the same closures as before, every generated
native_decide agreement check (refinementLookaheadCheck_ok, etc.) and the
refinementSemanticAgrees rfl proofs are unchanged. The verified emitter
(Emit/CodegenVerified.lean) bridges parserRunRefines with one line —
simp only [run, table, runInline_eq_run] rewrites the emitted run to
Exec.run table — so parse_eq_dependent and the parser_sound/parser_complete/
parser_err_free/parser_unambiguous corollaries transfer with the same axiom
budget (propext, Quot.sound, and the per-grammar native_decide safety and
refinement axioms; no new axioms, no sorry).
Measured on Cedar: a direct A/B on the same tables (Exec.run CedarParser.table vs Exec.runInline CedarParser.flat, cedar-spec/cedar-benchmarking/corpus,
0 output mismatches): the generated/ corpus (22 policies) 146.9 → 130.0 µs/policy
(~11.5%), the full 28-policy corpus 195.1 → 168.9 (~13.4%), the oopsla real-world
set 79.3 → 69.8 (~12%). So the gain is ~11–13% across the corpora.
The remaining ~62 µs engine time was popN's per-reduce args-list allocation, the
sem : List V → V dispatch (which forces that list), and per-token V payload
construction — the last is user semantic-action code the generator cannot speed up,
the parser analogue of the lexer's irreducible per-token actions (§9.6). The
generated grammars (calc, c99, cedar, cedarschema, cedarentities, imp) emit flat.
Abandoned, for the stated reason:
- An
ArrayLR stack andArrayinput cursor measured no faster thanList(theList Charlexer lever does not transfer), so the stack and input stayList. - Rewriting the grammars' right-recursive list rules to left recursion (the classic
Bison advice) was measured and rejected: it was ~3% slower on the corpus and only
~2% faster on a synthetic 2000-element list. The "left recursion saves stack" rule
concerns fixed-size parser stacks that overflow; this runtime's stack is a heap
List, so deep right-recursion costs proportional memory, not time, and the reverse-once wrapper offsets it.
The second step attacks that per-reduce allocation directly. reduceInline
still does, per reduce, popN (which allocates a List V of the operands) and
then sem p args (which pattern-matches that list). Both go away if the emitted
code reads the operands off the stack in one match. I profiled the ceiling on
parse-only time: a no-op sem that reads the stack by RHS length without building
the list runs well below a no-op sem that still builds the popN list, so the
List V allocation and the sem list-match dispatch are a measurable share.
Runtime/Exec.lean adds redv : Nat → Stack V → Option (V × Stack V) as a
FlatTable field and a runRedv engine that uses it, following the same
a-posteriori pattern as the inline engine — runInline stays the intermediate
spec, runRedv is a verified-equal running object:
- The emitter (
Emit/Codegen.lean,emitRedv) generatesredvkeyed on the production index first (a flatNatmatch), then on the stack shape. For productionpwith RHS lengthn, the arm matches the topnstack cells in reverse RHS order (last symbol on top), binds and unwraps the operands, and returnssome (semantic-value, remaining-stack)— noList Vis built and no list is matched. A non-matching shape (and any other production index) falls to(popN (lenData.getD p 0) stk).map (fun r => (sem p r.1, r.2)), thepopN+semcomposite, computed from the top-level arrays (not throughflat, which would be a definitional cycle). Runtime.Exec.popNsem f p stknames that composite;reduceInlineis definitionallyreduceRedvwithf.redvreplaced bypopNsem f. So the emitted obligation isredvMatches : flat.redv p stk = popNsem flat p stk, proved byfun_cases redv p <;> simp only [redv] <;> (first | rfl | (split <;> rfl)). Keyingredvonpfirst is what makes this cheap:fun_casessplits on the production index only (a flatNatmatch), not the deeply nested stack shapes. Casing the merged nested match instead took ~2m36s on a 72-production grammar; the flat-on-pform is ~8 s. Because the fallback ispopN+sem,redvMatchesholds for matched shapes and fallbacks alike, so no arm is left over.- Given
redvMatches, the library provesreduceRedv_eq,stepRedv_eq,parseFixRedv_eq,runRedv_eq_runInline, andrunRedv_eq_rungenerically. The emitter'srunnow callsExec.runRedv flat, andCodegenVerified.leanbridgesparserRunRefineswithrunRedv_eq_run flat redvMatches. All of this carries onlypropext/Quot.sound— no new axioms beyond the per-grammarnative_decidealready present, and nosorry. Verified end-to-end (fullparser_sound/parser_err_free/parser_complete/parser_unambiguous/parse_eq_dependent, same axiom budget) for calc, imp, cedarschema, cedar, and c99.
Measured: across the
inline→redv steps, Cedar parse-only (full 28-policy corpus) 160.0 → ~151.5 µs/policy
(~5.5%). The A/B win is smaller than the no-op ceiling because the real per-
production V construction (user AST building) dominates and cannot be removed, and
because redv's Option (V × Stack V) result still boxes one tuple per reduce; a
variant that fuses the goto into redv to drop that box measured ~55 µs but its
per-production equality proof was fragile, so it was not adopted.
The generated grammars emit flat, redv, redvMatches, and run runRedv.
Abandoned along the way:
- A fused reduce that returns
Result V ⊕ Stack Vdirectly (goto inlined per production, no intermediateOption) was ~11% faster than theOption-returningredvin a fair loop, but the per-armreduceRedv = reduceInlineproof had to reconstruct thegoto/topState/array reads defeq-identically toreduceInlineand was brittle; the cleanredvMatches(equateredvtopopNsem, not the whole reduce) was kept instead.
Beyond the inline and redv engines above, three parser-side candidates target the
remaining hot spots (parse ~120 us on Cedar: AST construction + sem dispatch). None
is measured yet; recorded with expected effect and proof impact.
-
Unchecked table reads. The state/goto/action tables are read with
Array.getD, which bounds-checks every access. The validator already establishes the indices are in range (theV3/goto range condition), so anArray.uget/getElem-with-proof read would drop the per-step branch. Proof impact:getD→ugetchangesFlatTable.toExecTableand forces an in-range proof threaded throughstep/parseFix; the range fact exists but is not free to wire in.Array.get!is not the win — it still bounds-checks (panics instead of defaulting). -
Specialize
Vwith unboxed payloads.Vis a sum with one constructor per distinct semantic type (44 for Cedar); each reduce boxes aV. Carrying unboxedUInt32payloads where the AST does cuts per-reduce boxing. Touches the generatedVand everysemarm, re-checked by the existing refinementnative_decide(parse_eq_dependent) — no new theorem shape, but a large codegen change whose win depends on how many reduces carry currently-boxed payloads. -
Default-reduction chain collapse.
stepRedvconsultsdefRedDatathenactionDataeach step; a state with a default reduction still re-entersparseFixRedvthrough theResult ⊕ (Stack × input)sum. Collapsing a chain of default reductions into one loop iteration removes per-reduce fuel-recursion overhead. Proof impact: it changesparseFixRedv, sorunRedv_eq_runmust be re-proved for the collapsed loop (a bounded-unrolling lemma:ndefault reduces =nsingle steps). Only helps grammars with long default-reduce chains — worth profiling the Cedar default-reduce fraction first.
A fourth idea — an Array/ST parser stack — is not pursued: §9.6.2 measured the
naive array stack at 2x slower (non-unique reference, copy per push/shrink), and a
genuinely linear ST stack needs the mutable engine proved equal to pure runRedv,
more than the current thin FlatTable equivalences. The redv ceiling data show AST
construction dominates the remainder, so this is likely low payoff.
The generated executable parser (FooParser.lean) is itself expensive to compile
for large grammars, independent of the proofs. A parser file can be a few
thousand lines yet take many minutes and produce a large .olean. The cause is
actionData: a single flat Array Runtime.Exec.Act literal of numStates × numTerminals entries. Lean's compiler scales poorly generating C/IR for an array
literal that large; gotoData has the same problem.
Most of that literal is repetition: the large majority of action cells are .error
and of goto cells are none. The %option compress_tables directive (set in the .lig
before %%) exploits this. It does not change the tables — actionData/gotoData are
the same dense arrays with the same values either way. It changes only how they are
written in source: instead of one full literal with a cell per entry, it writes just
the non-default cells as (index, value) pairs and reconstructs the full array with
buildDense:
def buildDense {α} (n : Nat) (dflt : α) (cells : Array (Nat × α)) : Array α := Id.run do
let mut a := Array.replicate n dflt
for (i, v) in cells do a := a.set! i v
return a
def actionCells : Array (Nat × Runtime.Exec.Act) := #[(2, .shift 4), (5, .shift 5), …]
def actionData : Array Runtime.Exec.Act := buildDense (numStates * numTerminals) .error actionCellsgotoData is written the same way over the none default. Only the ~10 % (action) /
~1 % (goto) non-default cells appear in the literal, so the literal the compiler must
process shrinks ~10–100x. The full array is rebuilt once at module load (buildDense),
so actionData/gotoData still have the same value and per-parse reads are still O(1)
getD array indexing — there is no runtime penalty.
The proofs are unchanged, with no added native_decide cost. This is the point of
compressing the literal rather than storing a sparse table: actionData is still a
dense Array Act value — buildDense (numStates * numTerminals) .error actionCells evaluates (by the native
compiler, which is what native_decide uses) to exactly the array the full literal would
have written. The refinement connects to the executable table only through
table.action s t = f.actionData.getD (st*w+t) .error; since actionData has the
identical value, refinementLookaheadCheck and the other native_decide agreement checks
see identical values and stay true, and the runtime Runtime/Exec.lean FlatTable is
untouched. A genuinely sparse table — a lookup with an .error default evaluated per
native_decide entry — would instead trade the executable-side win for a verified-side
cost (a binary search run ~57 000 times inside native_decide); compressing only the
source literal avoids that tension.
One interaction to know: buildDense is a normal recursive def, so code that forces
the elaborator/kernel to reduce the array (rather than the native compiler) can hit
maximum recursion depth. The generated specification modules do not — their checks are
native_decide, which uses compiled evaluation — but a hand-written test that elaborates
the table's type can (the calc RefinementTest negative test does, which is why calc, a
tiny grammar with no literal-size problem, is left uncompressed). A grammar that opts in
and has such a test should raise set_option maxRecDepth in that test. The option is
meant for large grammars whose table literal is the compile bottleneck; small grammars
gain nothing from it.
Measured (v4.34.0, this machine), full literal vs %option compress_tables, executable
backend:
| grammar | full compile | compressed compile | full .olean |
compressed .olean |
|---|---|---|---|---|
| Cedar | 67 s | 39 s | 54.1 MB | 49.7 MB |
Cedar's .olean shrinks modestly because it is dominated by the V sum type and
sem dispatch, not the tables; the compile-time win (≈1.7x) comes from the smaller
table literal. Grammars whose .olean is dominated by the tables benefit more. Runtime parse throughput is unchanged (Cedar parse-only 23.5 µs
full vs 22.8 µs compressed over the same corpus, within noise). The option is off by
default so existing grammars are byte-for-byte unaffected until they opt in.
Soundness was checked after enabling the option: CalcVerified and CedarVerified
both compile, and #print axioms *.parser_sound reports the unchanged set
(propext, Quot.sound, and the five safety/refinement native_decide certs; no new
axioms, no Classical.choice). CedarVerified took 147 s with compress_tables versus
~147–149 s without — no verified-side regression, because actionData/gotoData keep the
identical value and the compressed form exists only in the emitted source literal, not in
what native_decide evaluates. Note this does not
change the *Verified compile cost: that is the per-entry native_decide over
the dependent actionTable', which is independent of how the executable actionData
literal is written.
We will need a good user experience for the tool. The test/ folder shows how it
works today, but that only works because the library is local. A published version
of LPG (LeanParserGenerator) has to provide, for a downstream Lake package:
- a generator tool that emits the parser and lexer implementations from a
.ligfile, fetched as a build-time executable dependency, and - a runtime library the generated parser and lexer link against directly.
The design must support automatic regeneration and build. Two mechanisms already in this repository are the template for it:
-
Regenerate-on-stale. The
parsersLake target (see the lakefile) fetches the generator executable, compares mtimes of each.lig, the generator, and the two committed outputs, and regenerates only stale pairs before the library compiles. A downstream package copies this target, pointing it at its own grammars. Committing the generated modules keeps them reviewable and makes a plainlake buildenough on a clean checkout; a from-scratch checkout regenerates once before the first build. -
Cache the proofs; do not re-check them. The dependent-specification modules (
*Verified) carry the expensivenative_decidecertificates — ~20 s for Cedar, minutes and multiple GB for the largest grammars. Re-runninglake env leanon them every time is the wrong workflow. Instead they are rooted in a non-defaultlean_lib(VerifiedMid/VerifiedHeavyhere) so Lake caches each certificate to.oleanand an unchanged rebuild is a trace no-op (Cedar ~20 s → 0.9 s; §10). A downstream package should likewise put its generated*Verifiedmodules in a dedicated library so CI checks the proofs once per change, not once per invocation, and so the heavy grammars (Cedar/C99-scale) stay off the default build path while remaining onelake buildaway. This split — executable parser on the default path, proof modules in an opt-in cached library — is the intended integration shape.
A published version therefore ships (a) the generator as a Lake exe dependency,
(b) the runtime as a Lake lib dependency, and (c) a documented pair of Lake targets
(regenerate-on-stale + a cached verified library) that a consumer instantiates for its
own grammars. docs/INTEGRATION.md carries the concrete setup.
The .lig frontend is split in two. The structure of a .lig file — the sequence of
%-directives, the rules, and the alternative/element shape of each rule — is parsed by
a parser LPG generates from a .lig grammar of its own. The embedded-Lean leaves —
%header, %lexerheader, %lexer, the { … } action bodies, and the rest-of-line
:: Type — are read by a hand-written lexer. The leaves are brace-balanced Lean, not a
regular language, so a DFA cannot tokenize them; everything else is context-free and
LR(1), so it does not need to be hand-written.
The pieces:
LeanParserGenerator/Frontend/lig.ligis the grammar for the.ligstructure, 59 lines. It uses the hand-written-lexer regime (no%lexer): its%headerimports the token type and theSurfaceconstructors, and its actions buildFrontend.Directives andFrontend.Rules.LeanParserGenerator/Frontend/LigLexer.leanis the hand-written lexer. It turns.ligtext into aList LigTok, capturing each embedded-Lean leaf verbatim into one string-carrying token with the same balanced-brace and rest-of-line code the old scanner used. It also holdsassembleSurface, which folds the parsed directives and rules into aSurface, applying the same defaults and validation asparseLig.LeanParserGenerator/Frontend/Generated/{LigParser,LigVerified}.leanare generated by LPG fromlig.ligand committed.LeanParserGenerator/Frontend/LigParse.leanexposesparseLigGen: tokenize withLigLexer, parse with the generatedLigParser.
I did this for two reasons, in this order. The first is that the hand-written scanner
(Frontend/File.lean, ~950 lines of Cursor code) is hard to maintain and is where
misparse bugs hide: directive ordering, the rule/alternative/element structure, and
symbol-sequence parsing are all encoded in control flow rather than in a grammar. A
misparse there does not crash — it produces a Surface that is not what the file says,
LPG generates a parser for a different grammar than the author meant, and the only place
that shows up is verifiedGrammarView (§12), which the author has to read. A declared
grammar plus a focused lexer moves that structure out of control flow and into 59 lines
that state it directly. The second reason is that a generated parser comes with the same
proofs every generated parser gets — LigVerified proves parser_sound,
parser_complete, and parser_unambiguous for the .lig grammar, with the usual axiom
set (propext, Classical.choice, Quot.sound, and the safety/completeness/refinement
native_decide certs; no new axioms, no sorry).
That proof is a bonus, not the reason, and I want to be exact about what it does and does
not say. It is not a soundness claim about LPG. We do not make one, and the a-posteriori
architecture already makes a frontend bug unable to produce an unsound user parser — a
frontend bug can only produce a parser for a different grammar than intended, never one
that disagrees with its own emitted grammar. What the proof buys is an internal
consistency check: LigParser matches the .lig grammar written in lig.lig, and
parser_unambiguous shows that grammar is unambiguous.
The scope is the structure only. %header, %lexerheader, %lexer, and the { … }
action bodies stay behind the LigLexer balanced-brace escape hatch, outside the
generated parser and its proof. So the part of .lig most prone to subtle bugs is now a
generated, declared-grammar parser, and the embedded free-text leaves are still
hand-written. This is not "the frontend is verified"; it is "the structural parser is
generated and the leaves are not."
parseLig, the hand-written frontend, is retained, and it is still what the generator
and the LSP call. Two reasons. The LSP diagnostics engine (Frontend/Diagnostics.lean)
reads Surface spans to place error markers, and the generated parser produces
Span.dummy because the LigTok stream carries no source positions. And a hand-written
frontend that does not depend on the generated LigParser avoids a build cycle and is
the known-good fallback for the bootstrap. parseLigGen is checked to agree with
parseLig up to spans on every .lig file in the repository — the seven fixtures,
and lig.lig itself — by test/lig/BootstrapAgreementTest.lean (lake exe lig-bootstrap-test). Agreement up to spans is what code generation needs, because
codegen never reads spans: regenerating all the fixture grammars through parseLigGen
produces byte-identical output to the committed generated files.
Once generated, LigParser and LigVerified are ordinary Lean modules over the LPG
runtime, so updating the .lig grammar is the same self-hosting loop LPG already runs
for the fixtures:
- Edit
LeanParserGenerator/Frontend/lig.lig. If the change adds a new token or a new leaf shape, also editFrontend/LigLexer.lean— theLigToktype, the hand lexer, andassembleSurface. lake build parsersregeneratesFrontend/Generated/{LigParser,LigVerified}.lean(theparserstarget listslig.ligfirst). This needs a working generator, which is built from the hand-writtenparseLig, so it does not depend on the parser being regenerated.lake exe lig-bootstrap-testconfirmsparseLigGenstill agrees withparseLig, andlake build LeanParserGenerator.Frontend.Generated.LigVerifiedre-checks the proofs.- Commit
lig.lig, the regeneratedGenerated/*.lean, and anyLigLexer.leanchange together.
This is the usual self-hosting-parser-generator situation, the one Menhir also has:
regenerating LigParser needs a working LPG, so a generation bug can brick the frontend
until you regenerate from a good LigParser. Version control handles it. The generated
modules are committed, so a clean checkout builds without bootstrapping, and the previous
commit is always a good generator to regenerate from. The rule is "always keep a valid
version of the generator," which is routine under version control. If parseLig is ever
retired in favor of parseLigGen, spans have to be threaded through LigLexer and
lig.lig first, or the LSP loses precise error locations.
The emitters build the generated module as Lean.Syntax via quotation and pretty-print it, rather
than concatenating source Strings. The point is that a malformed structural emission fails against
Lean's grammar when the generator runs, not later when the generated file is compiled or its proof
fails. This gives up byte-identical regeneration — the pretty-printer reflows to its own layout — so
it was done only once byte-equality was no longer required. The requirement is that regenerated
grammars still compile and their proofs still check.
Emit/Quote.lean holds the infrastructure: EmitM = StateT (Array String) CommandElabM,
renderCmd/renderCmds (via PrettyPrinter.ppCommand), and the leaf mechanism below. Emission runs
in CommandElabM over an Init-only environment, so the generator exe sets supportInterpreter := true (rendering runs the elaborator and pretty-printer at generator runtime). Emit/Builder.lean
still holds string combinators for the parts kept as raw text.
Two subtleties shape the design, both found by experiment:
-
Every generated name (def names, types, constructors, binders) is built with
mkIdent, never written literally inside a`(…)quotation. A literal name carries a macro scope thatppCommandrenders assem✝, which is invalid source. This applies to everything:V,Array,some/none, the.shift/.reduceconstructors, the grammar binders insempatterns. -
Verbatim embedded-Lean leaves (action bodies,
%typestrings, dependentFinliterals and types) are not parsed.runParserCategoryhas only the builtin term parsers, so it fails on ordinary notation (+,if, list literals), and the generator cannot know a user%header's notation. Each leaf is spliced as a placeholder identifier (__ligLeaf{n}__) and, after pretty-printing,substituteLeavesreplaces it with the leaf's exact source text and checks that every recorded leaf was emitted — a check the old%%REDV%%String.replacedid not have. So the structure is grammar-checkedSyntaxand the opaque user text is copied through untouched.substituteLeavesmakes one pass: it splits the rendered module once on the__ligLeafmarker and reassembles the segments, reading each placeholder's index and splicing its source. The earlier version calledString.replaceonce per leaf, each a full scan and rebuild of the whole module —O(leaves × moduleLength), which was the dominant generation cost on large grammars (c99's dependent module has ~580 leaves in a ~3.4 MB string; that pass alone took ~205 s). The single pass isO(moduleLength)and brought it under 1 s.
The split is per-grammar structure to Syntax, fixed scaffolding to raw text. The executable backend
(Emit/Codegen.lean) puts V, decode, sem, and redv on Syntax. Its flat/table record and
the redvMatches proof stay raw text. The flat table array literals (actionData/gotoData, the
compressed actionCells/gotoCells, defRedData/lhsData/lenData) are also emitted as raw text,
by emitTable: they are literals of numbers and fixed nullary/unary constructors (.shift n,
.reduce n, .error, some n, none), so Syntax adds no real check, and routing them through
ppCommand is the generator's worst performance cliff. ppCommand's parenthesizer is superlinear in
the size of a single array literal, and these literals reach tens of thousands of elements — c99's two
dense arrays alone cost ~21 s in ppCommand (the subsequent Format.pretty was ~25 ms). emitTable
instead renders the element text directly and lays it out with a greedy width-116 fill that reproduces
ppCommand's array-literal formatting exactly (#[ on a 2-space-indented line, 4-space continuation,
each line packed to ≤116 columns, closing ] after the last element), verified byte-for-byte against
the committed fixtures. This is O(total element text) and dropped c99's executable emit from ~21 s to
under 1 s.
The verified backend
(Emit/CodegenVerified.lean) puts its per-grammar semantic defs on Syntax — the named
semanticAction{n}s, prodContents' and its projections, and tokenName'/tokenTerm'/tokenSem'
— with dependent patterns, signatures, and bodies as leaves. The symbol-type table semTbl/semT' and
the packed Nat tables (actionRows, gotoRows, itemRows) with their decoders are raw text. Its
Automaton tables and the fixed refinement/validation proof scripts stay
raw text: proof-carrying dependent Lean whose content is nearly all opaque, so routing it through
Syntax would make everything a leaf and check nothing (the same reason the executable flat and
redvMatches are raw). The stopping point is where Syntax stops adding a real check.
One emitter stays string-based despite being pure data: verifiedGrammarView. An anonymous structure
instance { field := … } rendered by ppCommand daggers its field names (index✝), and the record
has nothing (only Nats and reprStr strings) that string emission gets wrong — so Syntax there is
net-negative.
This is verified end to end: all fixtures plus lig.lig regenerate through both backends with
zero ✝ daggers, lake build is green, and the verified proof modules (calc, imp,
cedarschema via the paired build, and cedar checked directly) discharge their native_decide
certificates against the regenerated output. Nothing here moves the trust boundary: emission is
untrusted, and the a-posteriori validator re-checks the output however it was produced (§2).
The library and the generated code use Lean 4.35's module system (module header,
public/meta/import all imports, @[expose] public section). Every .lean file
in the package is a module; a module file may only import other modules, so the
whole graph — the 48 library files, Main, the LSP server, the generated
parser/lexer/verified/roundtrip modules, and the hand-written test support — is ported
together. The port follows the reference recipe (module first; public import;
@[expose] public section for reasoned-about code, public section for programs) and
then tightens visibility where it is worth it.
The two rules that drove almost every decision:
-
native_decideand#guardrun at the meta phase. Any module they reduce must bemeta imported, not justpublic imported. When a module is used both in ordinary definitions and bynative_decide, it is imported twice —public import Xfor the ordinary use andmeta import Xfor the reduction. The generated lexer thereforemeta importsLexer.Validator/DFA/ByteScan/ByteTokenize/ByteValidator/FlatTokenize(itsnative_decidevalidations reduce the tables) on top of the plainpublic imports itscflat/cbytedefinitions need; the verified spec does the same forRuntime.Validator.Safe/Complete,Runtime.GrammarView, and the parser module; the roundtrip module for the lexer. -
Def-eq unfolding across modules needs the body. A
publicdefinition's body is private unless the definition is@[expose]d or the client usesimport all. Proofs that unfold a definition byrfl/simp/decidefail otherwise.
Generated-code visibility is deliberate, not uniform:
-
Parser (
Emit/Codegen.lean):public section, not exposed. Its public API is the value type,run, andparse; the LR tables,decode,sem, andredvare internal (their bodies stay in the private scope). A plainpublic import CalcParsergives a caller only the API. The paired verified spec, which unfoldsdecode/sem/tablein its refinementrflproofs andnative_decides over the flat tables, reaches those internals withimport all CalcParser(allowed because both are in this package) plus the meta import. The hand-writtenRefinementTest, which compares the executable parser to the spec, alsoimport alls the parser, because that is exactly the internal-access use caseimport allexists for. -
Lexer (
Emit/CodegenLexer.lean):public section, not exposed. Public API is the token type andtokenize/tokenizeByte; the DFA/flat/byte tables and validator certificates are internal. The lexer's ownnative_decidevalidation theorems reduce those tables through the meta imports (same-file visibility does not need@[expose]). -
Verified spec (
Emit/CodegenVerified.lean):@[expose] public section. This is the dependent specification module: itsGrammar, automaton, dependent semantic types,verifiedGrammarView, and the exported corollaries (parser_sound,parser_complete, …) are the public API that downstream proofs reference, and the validation/refinement proofs unfold them throughout. Exposing it is the right choice, not a fallback. -
Lexer roundtrip (
Emit/CodegenLexerRoundtrip.lean):@[expose] public section. The module usesimport all FooLexerbecause its private bridge lemmas unfold the generated action arrays. It publicly imports the handwrittenFooLexerRoundtripSupportrenderer and exports only the renderer predicate,render, and the universalrender_roundtriptheorem; table-specific lemmas are private.
User %header/%lexerheader text is copied into generated modules, so Emit.Builder.publicizeImports
rewrites any bare import X in it to public import X (an unqualified import is rejected inside a
module). This port does not move the trust boundary or change any axiom budget: the generated
native_decide certificates still depend only on propext, Classical.choice, Quot.sound, and
their own per-native_decide axioms, with no sorry and no new custom axioms.
We should work on providing nice error messages for when the lexer or parser fails. For the parser, let us take inspiration from how Menhir handles error messages. See ERROR_MESSAGES.
%option roundtrip_lexer emits FooLexerRoundtrip.lean. The handwritten
FooLexerRoundtripSupport.lean module supplies renderTok : Tok → String; the import
name is inferred from the lexer import. The current implementation accepts one lexer mode,
requires every action to stay in that mode, uses one space as the separator, and checks that
the winning space rule consumes one character and skips it. Unsupported lexers fail
generation rather than receiving a weaker theorem.
The generated public boundary is:
abbrev RoundtrippableTok : Tok → Prop :=
Lexer.RoundtrippableTok FooLexer.lexer FooLexerRoundtripSupport.renderTok ' '
def render (ts : List Tok) : String :=
Lexer.render FooLexerRoundtripSupport.renderTok " " ts
theorem render_roundtrip (ts : List Tok)
(h : Lexer.Forall RoundtrippableTok ts) :
FooLexer.tokenizeBare (render ts) = .ok (ts ++ [Tok.EOF])This theorem is universal over lists, not a native_decide check over a fixed list.
RoundtrippableTok t is a decidable structural predicate. It says that renderTok t is
nonempty, wins a complete start-mode scan, emits exactly t, stays in the mode, dies on
the separator, and begins with a character on which the separator scan dies. It excludes
well-typed payloads that cannot be produced again by the chosen renderer. Concrete token
facts can be discharged with native_decide; general value-token facts are handwritten
lemmas about the user's renderer and action.
The generic proof is in Lexer/Roundtrip.lean. scan_lit_sep_indep makes token scans
independent of the suffix. peel_emit emits one token and peel_skip consumes its
separator. hpeel_of_roundtrippable combines them, including the EOF suffix case.
tokenize_render_fold_pred performs a position-aware induction and tracks enough fuel for
both steps. tokenizeFlatStr_render then uses tokenizeFlatStr_eq_tokenize to transfer the
result to the flat string runtime used by tokenizeBare.
The generated module proves the flat-runtime premises with private finite proofs over the
emitted action table. It contains no per-token examples, reachability witnesses, constructor
coverage claim, fixed token list, guarantees string, or #eval.
%option roundtrip_parser <unparse> works with generated or handwritten lexers. It adds
source-order parseTreeToken*, parseTreeProductionN, and
parseTreeProductionN_sem helpers to FooVerified.lean. The helpers hide terminal indices
and the reversed dependent ParseTreeList. FooParserRoundtrip.lean defines
Certificate tokens value, computes sufficient logarithmic fuel from its tree, and proves
parse_certificate by applying the existing parser_complete theorem. The input word is
tokens ++ [EOF], matching the augmented start production. No corpus is generated.
The user supplies FooUnparse.lean with the named token function and a separate proof
module. That proof selects productions with the generated helpers and constructs a
certificate for every value in its printable domain. The generator cannot infer this mapping
from arbitrary Lean semantic actions. A restricted printable type may map to the parser result
type, as CedarPrintable.PrintablePolicies.toPolicies does.
The workflow after a grammar edit is: regenerate first, then compile the handwritten proof. Production indices or RHS shapes may have changed, so Lean reports exactly which certificate branches need updating. The current examples are:
- calc: every
Inthas a token-level certificate;PrintableIntadds the lexer obligation; - IMP:
PrintableComenforces the grammar's right-shaped sequence and covers allAExpandBExpvalues with parenthesized printers; - Cedar:
PrintablePoliciescontains permit/forbid policies with scope-variable lists and converts toPolicies; its proof composes the parser certificate with the universal lexer theorem.
%option roundtrip_format <renderTok> additionally emits format, relex, and
fullRoundtrip definitions. A handwritten full theorem supplies both a parser certificate and
a proof that every emitted token satisfies the lexer's RoundtrippableTok predicate.