Repository navigation
Description
Summary
Add a deterministic, bounded-exhaustive verifier that catches drift between the real Datalog parser (src/query/datalog/parser.rs) and the documented grammar (tests/grammar/grammar.pest and the EBNF in the docs site's content/datalog-reference.md).
This follows the idea in #627 (check a property for every input up to a bound, run the deeper bound on a schedule), but as a plain cargo test that enumerates inputs, not Kani: model-checking pest's generated parser over symbolic strings is not practical.
It extends the grammar conformance harness from #233. That harness checks a hand-picked .edn corpus. It passes on main (v2.0.8) and v3, yet an audit on 2026-10-20 found the drift listed below on both branches, because no corpus file covers those inputs.
Property
For every input up to the bound:
- pest rejects ⇒ the parser rejects.
- pest accepts ⇒ the parser accepts, or rejects with a semantic error code (one listed in the reference's "Semantic Constraints" section).
Design
Layer 1: lexical (exact agreement)
- Every string of up to 3 characters over: printable ASCII,
\t,\n,\r, and a few non-ASCII stand-ins (é,名,٣, U+00A0). About 1M strings. - Each goes through pest's
edn_valuerule and the parser'sparse_edn; acceptance must match exactly. - Lives as a
#[cfg(test)]unit test besideparser.rs(pest is already a dev-dependency), so it reachesparse_edndirectly with no semantic layer in the way.
Layer 2: structural (token sequences)
- Token sequences placed into fixed templates:
(query [ _ ]),(transact [[ _ ]]),(transact { _ } [[ _ ]]),(retract [[ _ ]]),(rule [( _ ) _ ]), and clause holes inside:where,not,not-join,or,or-join,and,:over. - About 25 token stand-ins: brackets, command and clause heads,
:find:where:with:as-of:valid-at:any-valid-time:over:partition-by:order-by, an unknown keyword:foo,?x,foo,1,1.5,"s",<,-,$s,#uuid "…",{}. - Only bracket-balanced sequences are generated, shortest first, so the first reported failure is minimal.
- Deterministic: fixed alphabet, fixed bound, fixed order, no randomness.
Bounds and CI
- Small bound in the normal test run (target: under a minute).
- Deeper bound in a scheduled workflow, alongside
soak.yml. - The first PR measures run time and sets both bounds from the numbers. The sizes above are estimates.
EBNF: generate, don't verify
Make grammar.pest the single source of truth:
- A small renderer turns
grammar.pestinto the EBNF text. - The docs site renders the EBNF from each tag's
grammar.pest, the same way it already readsCHANGELOG.mdanddocs/ERROR_REFERENCE.mdper tag (minigraf-docs PR).
Translating a hand-written EBNF back to a PEG for comparison isn't exact (EBNF choice is unordered), so it isn't planned.
Prerequisites
- Classify parser errors as syntax vs semantic.
parser.rshas 28INT-019call sites on bothmainandv3, used for both kinds of error (for example "Rule head must be a list" vs "':order-by' is required in the ':over' clause"). Give them their own PRS codes so the classification isn't guesswork.INT-019stays registered (codes are never removed or recycled). - Known-drift list. The verifier fails on both branches today. Land it with an explicit
KNOWN_DRIFTlist of inputs that must keep disagreeing. Each fix PR removes its entries, so fixes and the list can't silently disagree.
Initial known drift (both main and v3)
Parser accepts, pest and EBNF reject
| Input | Parser behavior |
|---|---|
; line comments |
skipped (added in 19d0991, never added to the grammar) |
Non-ASCII letters, e.g. :person/名前, ?ñ |
accepted; the grammar is ASCII-only |
Unknown keyword followed by no values, e.g. [:find ?e :foo :where …] or a trailing :foo |
silently ignored |
:find with no specs, e.g. [:find :where [?e :a ?v]] |
runs, returns one empty row |
:with with no variables |
accepted |
:where with no clauses |
accepted on main; v3 rejects it only through PRS-080 |
Bare : keyword |
accepted |
Non-variable rule head args (foo ?x 1), (foo ?x :k); variable as the rule name (?x ?y) |
accepted; the grammar says variable* |
Rule call named by a variable or operator, (?r ?e), (< ?e ?v) |
accepted, query runs without error |
Function call named by a variable, [(?f ?x) ?y] |
parsed as a UDF named ?f |
Parser bug the grammar doesn't catch
A fact [e a v {map} extra…] is accepted and everything after the 4th element is silently dropped (parse_transact only checks fact_vec.get(3)). (transact [[:alice :a 1 {…} 99 :junk]]) succeeds. The EBNF says 3 elements plus an optional map; pest's fact = edn_value+ is deliberately loose. Silent data loss, so this should be fixed as a bug with its own PRS code.
EBNF vs grammar.pest
- pest allows
op_sym(<,+, …) as a value, an aggregate name ((+ ?x), which the parser accepts) and a one-argument function name; the EBNF'ssymbolexcludes operators. - pest and the parser accept any character after
\in strings ("\q"→q); the EBNF lists only\n \t \r \" \\. - The EBNF whitespace note omits
\r; neither grammar mentions comments. - The EBNF has no word boundary after
true/false/nil(pest uses!word_cont), sotrueishis ambiguous.
Word boundaries on literal keywords in grammar.pest
pest matches literal keywords such as ":find" with no end-of-word check, so [:find?e …] parses in pest as :find ?e. The tokenizer reads it as one unknown keyword :find?e followed by an empty :find. Both accept only by coincidence. Same for ":where", ":with", etc.
Suggested plan
- Split the
INT-019sites inparser.rsinto syntax / semantic PRS codes (v3 first, backport tomain). - Add the verifier with the
KNOWN_DRIFTlist (v3 first, thenmain); measure and set PR / scheduled bounds. - Add the pest → EBNF renderer; switch minigraf-docs to render the EBNF per tag.
- Fix the drift, each PR removing its
KNOWN_DRIFTentries. The 5+ element fact is the data-integrity one (v3 first, backport tomain); the other parser tightenings change accepted input, so they belong in v3.
Related
- Explore formal verification of storage encoding with Kani #627: Kani bounded verification of storage encoding (same bounded-exhaustive approach)
- [FEATURE] EBNF grammar specification and semantics documentation #233: EBNF grammar specification and the grammar conformance harness
🤖 Generated with Claude Code
Activity
docs(spec): address review — one meaning per semantic code, exact-one…
Merge pull request #644 from project-minigraf/spec/643-grammar-verifier
docs(plan): binding note wording (#643)
Merge pull request #646 from project-minigraf/plan/643-grammar-verifier
Merge pull request #648 from project-minigraf/feat/643-error-codes
Merge pull request #652 from project-minigraf/feat/643-verifier
Merge origin/v3 into feat/547-window (#643 error codes; test counts c…
test(grammar): compare docs/GRAMMAR.md across CRLF checkouts (#643)
Merge pull request #653 from project-minigraf/feat/643-grammar-doc
Merge origin/v3 into fix/649-fact-arity (#643 grammar page; test coun…
test(parser): backport the parser vs grammar verifier to v2.x (#643)
docs: test counts and INT-019 caveat for the verifier backport (#643)
Merge pull request #657 from project-minigraf/ci/643-grammar-verifier-v3
test(parser): backport the parser vs grammar verifier to v2.x (#643) (#…
adityamukho commentedon Oct 20, 2026
Done. The verifier is on both branches, and the remaining drift has its own issues.
Shipped
INT-019parse errors (feat(errors): PRS-083..PRS-107 replace user-facing INT-019 parse errors (#643) #648). Verifier (test(grammar): bounded-exhaustive parser vs grammar.pest verifier (#643 PR 3) #652). Generateddocs/GRAMMAR.mdand the docs-site Grammar page (docs(grammar): generate docs/GRAMMAR.md from grammar.pest (#643 PR 4) #653, minigraf-docs#47).INT-019counts as semantic on v2.x, as spec §8 says, andmaingets no new codes.grammar-verifier.ymlruns k = 4 nightly onmain(05:30 UTC) andv3(17:30 UTC) (test(parser): backport the parser vs grammar verifier to v2.x (#643) #655, ci: nightly grammar verifier at bound 4 on main and v3 (#643) #657). k = 3 runs in everycargo test.Drift, one issue per fix. Each fix removes its
KNOWN_DRIFTentry::or-, unterminated strings).Closing by hand. The drift fixes are tracked in #649–#651.