gram.en is an explainable grammatical analysis engine that takes a different approach from statistical or neural models for determining grammaticality. While statistical or neural models far exceed hand-written grammar rules in this engine and similar ones in terms of coverage, engines with hand-written rules earn their keep as verification tools and generators of labeled error data, as every verdict that gram.en produces also carries exactly the violated constraint, offending span, and derivable repairs all in the same computation.
It treats natural language like a compiler front end:
text → tokenizer → morphology → lexicon → parser (+ unification)
→ error detection → report
$ npm run analyze "the dog bark"
the dog bark
^^^^
Verdict: ungrammatical
Violation: subject-verb agreement (number/person)
Rule: S -> NP VP
Fix: "the dog barks" | "the dog barked" | "the dogs bark"Every fix shown was generated by the engine and re-parsed to verify it is actually grammatical. The same machinery, pointed at Swedish grammar data, diagnoses Swedish:
$ npm run analyze -- --lang sv "ett hund skäller"
ett hund skäller
^^^
Verdict: ungrammatical
Violation: en/ett gender agreement (en-words vs ett-words)
Rule: NP -> Art N
Fix: "en hund skäller"A failed parse is only reported as an error when the engine can localize it to a named constraint, otherwise it abstains.
$ npm run analyze "the dog glorps"
Verdict: not analyzed -- unknown word(s): glorps
$ npm run analyze -- --lang sv "den stora hund"
Verdict: no analysis (out of coverage; not necessarily ungrammatical)Add --trace to any analysis to watch the morphology and Earley chart work on stderr.
There is also a browser demo (index.html): violations are underlined as you type, and clicking an underline shows
the diagnosis with one-click fixes. npm run build && npm run serve, then open http://localhost:8080.
Requires Node ≥ 22.6 (the engine runs through native TypeScript type-stripping; no build step for local use).
npm run analyze "the dog barks" # analyze a sentence
npm test # unit tests + per-language regression corpora
npm run eval # held-out probe set (accuracy metrics)
npm run bench # performance benchmarks
npm run build # bundle the browser engine to dist/engine.js
npm run serve # serve the browser demo on :8080Programmatic use:
import { load_grammar } from "./src/languages.ts";
import { analyze } from "./src/analyze.ts";
const g = load_grammar("en"); // load once, reuse across sentences
const result = analyze(g, "the dog bark");
// result.verdict -> "grammatical" | "ungrammatical" | "no-analysis" | "unknown-word"
// result.violations -> [{ message, rule, span, char_span, fixes }]| Stage | Responsibility |
|---|---|
| Tokenizer | Splits raw text into tokens, including contractions (don't → do + n't). These are declared per grammar (language). |
| Morphology | A trie-backed finite-state transducer maps surface words to (lemma, category, features) analyzes, with morphophonemic rewrite rules (liked → like + ed, tried → try + ed). |
| Lexicon | Attaches categories and feature structures to closed-class words, clitics, auxiliaries, and analyzed stems. |
| Parser | An Earley chart parser over a context-free backbone; ambiguity is kept alive until structure resolves it. |
| Unification | Feature equations on rules enforce agreement, case, and verb-form constraints. |
| Diagnostics | A failed unification, a relaxed constraint, or a matched mal-rule becomes the report, complete with a span and verified fixes. |
The hand-written corpus (npm test) and probe set (npm run eval) are authored: they test the engine against
expectations I wrote myself as a speaker of those languages. For languages I don't speak natively, the engine is also
audited against Universal Dependencies.
This stays a dev-time check rather than a dependency: the treebanks are read by a small hand-written
CoNLL-U parser, kept out of the repo, and never shipped with the engine.
# fetch the treebanks once (test splits; gitignored)
mkdir -p tools/ud && base=https://raw.githubusercontent.com/UniversalDependencies
curl -sf "$base/UD_English-EWT/master/en_ewt-ud-test.conllu" -o tools/ud/en_ewt-ud-test.conllu
curl -sf "$base/UD_Swedish-Talbanken/master/sv_talbanken-ud-test.conllu" -o tools/ud/sv_talbanken-ud-test.conllu
curl -sf "$base/UD_Russian-GSD/master/ru_gsd-ud-test.conllu" -o tools/ud/ru_gsd-ud-test.conllu
npm run ud -- --lang ru # report: coverage + any disagreement with goldOn the tokens inside each fragment's coverage, all three agree with the gold annotations 100% (about 2150 English,
785 Swedish, and 84 Russian), and npm test checks this whenever the treebanks are present.
Grammars live in .gram files, a small declarative DSL for the properties of the target natural language:
%feature num : sg | pl
%feature pers : 1 | 2 | 3
dog : N <num>=sg <pers>=3 <lemma>=dog
%rule y:i => _ e d # tried -> try + ed
S -> NP VP
<NP agr> = <VP agr> ! "subject-verb agreement" fix: agree(V)
The ! annotation makes a constraint diagnosable: if strict parsing fails but relaxing that equation yields a parse,
the equation becomes the explanation and the fix: strategy generates repair candidates.
Feature declarations act as a load-time type discipline: <num>=sgular is rejected when the grammar loads, not silently never-unified.
| Form | Meaning |
|---|---|
word : CAT <feat>=val |
Lexicon entry |
A -> B C + <B f> = <C f> |
Phrase rule with feature equation |
! "message" fix: ... |
Diagnostic attached to a constraint |
%mal A -> ... |
Targeted error pattern (mal-rule) |
%feature, %class, %rule, %lex |
Feature domains, symbol classes, rewrite rules, paradigms |
%start, %tokenizer, %clitic |
Language policy, declared per grammar |
%include, %import |
Modular grammar files, TSV lexicons |
Each language is a folder under languages/ with its own manifest, regression corpus, and lexicon.
The Swedish fragment (languages/swedish/sv.gram) exercises what English doesn't: two genders with adjective agreement,
plus suffixal and double definiteness (hund → hunden, den stora hunden).
It also skips what Swedish lacks (present-tense subject-verb agreement), all without engine changes.
The Russian fragment adds case morphology (nominative and accusative), animacy, and Cyrillic, also without engine changes. This modularity
between languages without changing engine infrastructure was a main concern when writing this engine.
On a held-out probe set of 206 sentences (npm run eval):
- 96.9% precision of the
ungrammaticalverdict (63 of 65 flags are real errors) - 100% recall on in-scope errors, every flag naming the intended constraint
- 66.7% abstention on out-of-scope errors: the engine says "out of coverage" rather than guessing
- 100% of out-of-vocabulary sentences routed to
unknown-word
Analysis time is independent of lexicon size: with a 10k-word imported lexicon, grammatical sentences analyze in ~0.3 ms
and the diagnostic path (which generates and re-parses repair candidates) stays in low single-digit milliseconds (npm run bench).
src/ engine: tokenizer, FST morphology, Earley parser, unification, diagnostics
languages/ grammar data: english/, swedish/, russian/
test/ unit tests, regression corpora, held-out probe set (eval.en.txt)
scripts/ bench, eval, ud-check, corpus, flatten, serve
notes/ the accompanying paper (theory + design decisions)
index.html browser demo (loads dist/engine.js)
The accompanying paper covers the formal background. You are not required to have any previous knowledge outside of basic computer science to understand its contents. I've tried my best to make it pedagogical for readers without prior knowledge while remaining detailed as a formal paper, and it covers everything related to the design and implementation of the engine, including formal theory, design choices, and performance metrics. Keep in mind it is not finished and is written by me as somebody who does not have a degree in linguistics or formal language theory.
Source: notes/notes.tex · Typeset PDF: notes/notes.pdf