Pure-MoonBit spell checker compatible with the Hunspell .aff/.dic dictionary format.
For reviewers. This repository is the submission — acceptance reads the git snapshot, so everything needed is in-tree:
Conformance (official Hunspell corpus) .good 841/848 = 99.2% · .wrong 611/613 = 99.7% Differential test vs hunspell 1.7.3 235,976 real words: 199 disagreements = 0.084% Tests 182 (wasm / wasm-gc / js) · 186 (native) Direct lookup vs hunspell parity (0.34–0.38 vs 0.36–0.38 µs/word) wasm artifact 142.2 KiB, with no C++ runtime ACCEPTANCE.md is the requirement-by-requirement self-check against all five acceptance yardsticks — charter stage three (9 items), charter chapter seven (10 items), the website's 6 standards, the organisers' 4 review dimensions, and this project's own proposal. It also names which proposal commitments were exceeded and which single commitment turned out to be untrue and was fixed. Every figure above comes from a script in this repository: bash check-all.sh re-runs all of them.
This file is README.mbt.md — MoonBit type-checks .mbt.md files, so every block below marked mbt check is compiled by moon check (an unmarked block is not). README.md is a symlink to this file so that GitHub renders it.
Status: 0.9.6. The .aff/.dic parsers, the affix engine, the spell() judgement engine, the suggestion engine, the public API and the check / suggest CLI subcommands are implemented and tested on all four backends. See Not implemented yet for what suggestion generation still leaves out (ngram/MAXNGRAMSUGS candidates in particular).
| Metric | Passing | Total | Pass rate |
|---|---|---|---|
| .good (must be accepted) | 841 | 848 | 99.2% |
| .wrong (must be rejected) | 611 | 613 | 99.7% |
| .sug (expected best suggestion produced) | 141 | 173 | 81.5% |
Measurement note: the harness emits exactly one verdict per non-empty input line and counts those verdicts, so a corpus line that itself contains a space is now scored correctly. Earlier runs paired the two streams positionally with paste -d' ', which silently misaligned every line containing a space — morph.good has 16 of them (drink eat, …) — and scored them as failures whatever the engine said. conformance/run.sh counts verdicts and also prints the historical positional totals (825/848, 611/613) next to the counted ones so the two stay comparable; the 16-verdict difference between 825 and 841 is the size of that measurement artefact, not a behaviour change. Tab-terminated lines such as utf8_bom.good's are unaffected either way, because awk splits on tabs as well as spaces.
| on /usr/share/dict/words (235,976 words, en_US) | count | share |
|---|---|---|
| we accept, hunspell rejects (false accepts) | 198 | 0.084% |
| we reject, hunspell accepts (false rejects) | 1 | 0.000% |
| total disagreement | 199 | 0.084% |
bash bench/run.sh| step | wasm debug (moon run) | wasm release (moon run --release) |
|---|---|---|
| process start | 0.0190 s | 0.0210 s |
| start + load (49,568 entries) | 0.1390 s | 0.1120 s |
| load only | 0.1200 s | 0.0910 s |
| 49,568-word run (all hits) | 0.2020 s | 0.1980 s |
| checking only | 0.0630 s | 0.0860 s |
| per-word check | 1.27 µs | 1.73 µs |
| throughput, end-to-end | 245,386 words/s | 250,343 words/s |
| throughput, checking only | 786,794 words/s | 576,372 words/s |
| workload | ours native | hunspell | ratio |
|---|---|---|---|
| process start | 0.0020 s | 0.0030 s | 0.67× |
| dictionary load only | 0.0300 s | 0.0070 s | 4.29× |
| 49,568 all-hit words: total | 0.0510 s | 0.0280 s | 1.82× |
| 49,568 all-hit words: checking only | 0.0190 s | 0.0180 s | 1.06× |
| 49,568 all-hit words: per word | 0.38 µs | 0.36 µs | 1.06× |
| 49,568 all-miss words: total | 0.5080 s | 0.0840 s | 6.05× |
| 49,568 all-miss words: checking only | 0.4760 s | 0.0740 s | 6.43× |
| 49,568 all-miss words: per word | 9.60 µs | 1.49 µs | 6.44× |
| 235,976 mixed words (/usr/share/dict/words, 18.4% hits) | 1.9100 s | 0.2870 s | 6.66× |
| 235,976 mixed words: throughput | 123,548 words/s | 822,216 words/s |
Run-to-run range, so no single ratio is over-read. Repeating bash bench/run.sh on this machine moves the direct-hit row between 0.89× and 1.06× (ours 0.34–0.38 µs/word, hunspell 0.36–0.38) and the all-miss row between 6.30× and 6.44× (ours 9.38–9.60 µs/word, hunspell 1.49). That spread is why this section claims parity on hits and "≈6× slower" on misses instead of quoting one run's ratio as if it were a property of the code. The table above is one such run.
| entries | .dic KiB | load | hit | miss | hit end-to-end | miss end-to-end |
|---|---|---|---|---|---|---|
| 1,000 | 17.6 | 1.00 ms | 0.24 µs/word | 0.81 µs/word | 3,703,704 w/s | 1,190,476 w/s |
| 10,000 | 181.6 | 7.00 ms | 0.26 µs/word | 0.85 µs/word | 2,857,143 w/s | 1,063,830 w/s |
| 50,000 | 940.4 | 35.00 ms | 0.30 µs/word | 0.88 µs/word | 1,492,537 w/s | 800,000 w/s |
| 200,000 | 3,817.6 | 144.00 ms | 0.28 µs/word | 0.94 µs/word | 574,713 w/s | 416,667 w/s |
| target | bytes |
|---|---|
| wasm (cmd/main) | 145,655 (142.2 KiB) |
| wasm-gc | 113,333 |
| js | 469,140 |
| native | 761,128 |
bash examples/doccheck/run.sh # scans this repository| run | words checked | misspelled tokens | distinct words |
|---|---|---|---|
| first run, no allowlist | 20,287 | 616 | 118 |
| with examples/doccheck/allowlist.txt | 20,287 | 0 | 0 |
moon add Careylq/spellimport {
"Careylq/spell",
}import {
"Careylq/spell",
} for "test"///|
test {
let dictionary = @spell.load("SET UTF-8\nSFX S Y 1\nSFX S 0 s .", "1\ncat/S")
assert_true(@spell.check(dictionary, "cat"))
assert_true(@spell.check(dictionary, "cats"))
assert_false(@spell.check(dictionary, "dog"))
assert_eq(@spell.rule_count(dictionary), 1)
}///|
test {
let dictionary = @spell.load("SET UTF-8", "1\ncat") catch {
error => abort("could not load the dictionary: \{error.to_string()}")
}
assert_true(@spell.check(dictionary, "cat"))
}///|
test {
let dictionary = @spell.load(
"SET UTF-8\nTRY abcdefghijklmnopqrstuvwxyz\nREP 1\nREP ph f", "3\nform\nhello\nphantom",
)
assert_true(@spell.check(dictionary, "form"))
assert_eq(@spell.suggest(dictionary, "phorm", 5), ["form"])
// a correct word needs no correction
assert_eq(@spell.suggest(dictionary, "hello", 5), [])
}moon run cmd/main -- check --aff en_US.aff --dic en_US.dic --words -moon run cmd/main -- suggest --aff en_US.aff --dic en_US.dic --words -moon run examples/basicbash examples/doccheck/run.shsrc/aff/ .aff lexer + parser -> typed AST
src/dic/ .dic parser + dictionary storage
src/affix/ affix rule engine (condition matching, strip/add)
src/spell/ spell() judgement: indexed dictionary, reverse lookup, case rules
src/suggest/ suggest(): REP, edit distance 1/2, case, splitting, ranking
src/api/ public facade: load, check, suggest, metadata
cmd/main/ CLI (the `check` and `suggest` subcommands)
examples/ runnable examples (`basic`, `doccheck`)
conformance/ conformance harnesses (`run.sh` for .good/.wrong, `suggest.sh` for .sug)moon check # type / lint check
moon build # build
moon test # run tests
moon fmt # format
moon info # regenerate .mbti interfacesbash check-all.sh # reports land in .final-check/test {
let dictionary = @api.load("SET UTF-8", "1\nhello")
assert_true(@api.check(dictionary, "hello"))
assert_false(@api.check(dictionary, "hella"))
}test {
let dictionary = @api.load(
"SET UTF-8\nTRY abcdefghijklmnopqrstuvwxyz\nREP 1\nREP ph f", "2\nform\nphantom",
)
assert_eq(@api.suggest(dictionary, "phorm", 5), ["form"])
}Install
Download zipPure-MoonBit spell checker compatible with the Hunspell .aff/.dic dictionary format.