Canonical Word List Redesign¶
Date: 2026-07-19 Status: Design approved, pending spec review Jira: PHON-193 (to be created) Predecessor: PHON-186 (Done) — diagnosed the root cause and deferred the rule change to this ticket
Problem¶
The is_canonical flag defines the user-facing vocabulary (Word Lists, Contrast Sets,
Lookup browse). It is currently produced by a winner-take-all dominant-POS gate in
packages/data/src/phonolex_data/pipeline/words.py (lines 270–279):
record.is_canonical = (record.pos in _CONTENT_POS and len(record.word) > 1)
where record.pos is the single most-frequent POS for the word from the FineWeb-Edu
spaCy parse. This is flawed in two directions:
-
False negatives (the documented
kingdomclass). A common content word whose corpus occurrences are dominated by proper-noun contexts gets the wrong dominant POS and is excluded.kingdom→ PROPN (via "United Kingdom", "Kingdom of God");baker→ PROPN (the surname);downtown, etc. PHON-186 enumerated 1,030 such inversions (research/2026-07-13-phon-186-morphynet-root/canonicality_inversions.parquet), 890 of them the PROPN class. A band-aid repair pass (lines 392–412) only rescues a base form when one of its inflections survived as canonical — sokingdomreturns only becausekingdomshappened to be tagged NOUN. Fragile and incomplete. -
False positives (garbage the user sees). Being tagged with a content-POS dominant label is too weak a positive signal. Foreign words, proper-noun junk tagged as common NOUN, abbreviations/initialisms/OCR fragments, and legitimately real-but-far-too-rare words all pass the gate and flood the outputs.
Additionally, canonical is not applied consistently across the app. Sound similarity and word associations run over the full ~125K vocabulary, so their result sets are flooded with the same non-canonical junk — even though there is no reason those surfaces should differ from Word Lists.
Goals¶
- Redefine
is_canonicalso it is a positive statement — "this is a real, reasonably common English content word" — that fixes both failure directions at the pipeline root. - Make canonical the vocabulary scope for every generated result set across the app, with sentence retrieval the single deliberate exception.
- Pick thresholds from data via a probe, and QA the resulting set against ground truth — no blind magic numbers.
Non-Goals¶
- Redistributing any external lexical resource. WordNet is used at build time to derive a boolean; it never ships to D1 or the client (same posture as PHON-186's MorphyNet use).
- Changing the similarity algorithm, the edges/associations graph construction, or the sentence-retrieval ranking. Only the candidate scope changes.
- Removing any word from the
wordstable. Non-canonical words stay fully present (searchable by exact lookup, usable in similarity/sentence internals); they are just not offered as browse/generate candidates.
Design¶
Two independent components. Component 1 is a data-pipeline change requiring a seed regen; Component 2 is a Worker query-time change that ships independently.
Component 1 — Two-sided canonical definition (data pipeline)¶
Replace the dominant-POS gate and delete the inversion-repair band-aid (lines
392–412 become unnecessary — the inversion class is now natively canonical). Extract the
logic into a single focused, testable module (packages/data/src/phonolex_data/pipeline/canonical.py)
that build_words() calls after norms are merged.
A word is_canonical iff both conditions hold:
(A) Content-POS presence — replaces winner-take-all with the full POS distribution the
word already carries (all_pos / all_freqs, populated by load_phonolex_frequency):
content_mass = sum(all_freqs[i] for i where all_pos[i] in {NOUN, VERB, ADJ, ADV})
total_mass = sum(all_freqs)
present = (total_mass > 0 and content_mass / total_mass >= TAU)
or (max content-POS absolute count >= FLOOR_ABS)
A word with no FineWeb-Edu frequency row (all_pos empty) has no content-POS mass, so it
is not canonical — and the realness floor below independently excludes it (no frequency →
below the floor). WordNet membership alone is never sufficient; there is no separate
no-frequency fallback (WordNet lemmas are inherently open-class, so a synset-POS fallback
would add nothing the realness gate doesn't already require).
This makes kingdom's real NOUN mass count even though PROPN "wins" the single-label
argmax, and it does so uniformly rather than via inflection-dependent rescue.
(B) Realness floor — the positive signal that excludes the four garbage classes:
real = (word in REAL_WORDS) and (frequency_signal >= FREQ_FLOOR)
REAL_WORDS is the subset of our vocabulary that is a genuine English open-class word,
precomputed at build time from WordNet. The 2026-07-19 threshold probe
(research/2026-07-19-canonical-redesign/) showed a naive word in all_lemma_names()
check fails in both directions, so REAL_WORDS is built with two corrections:
- Lemma-normalize (fixes false drops). WordNet stores only base lemmas, so a raw
surface-form check drops inflected content words (
flew,mountains,toddlers,represents) — ~80% of the probe's dropped sample was legitimate inflections. A word is real ifwordor itsnltk.corpus.wordnet.morphy(word)base form is a WordNet common lemma. (morphyreturns a base lemma only when it exists in WordNet, somountains → mountain,flew → fly.) - Exclude proper-noun instances (fixes false adds). WordNet carries named entities as
instance synsets (
england,venice,aristotleare lemmas), so plain membership leaks proper nouns — and the probe showed a frequency floor makes this worse (frequent names likebrussels,pepsiclear any floor).WORDNET_COMMON_LEMMAStherefore keeps only lemmas with at least one non-instance synset (syn.instance_hypernyms()empty), dropping pure named entities while keeping words that also have a common sense (chinathe porcelain,mercurythe metal). - Do NOT reuse the
wordnetcolumn inresearch/2026-07-13-phon-186-morphynet-root/lemma_comparison.parquet— that column is a lemmatizer output (null when unresolvable), not clean membership. frequency_signal= the on-recordfrequency/contextual_diversity(CD_pct) fields already populated on everyWordRecordfrom the in-house FineWeb-Edu corpus (no SUBTLEX re-load). Its job is only to trim the real-but-obscure tail — NOT to filter proper nouns (WordNet-instance exclusion does that). The probe foundcd_floor=0.5collapses the set and is not a usable lever at that value; floors are chosen by the re-run probe.
canonical.py stays pure: it receives the precomputed real_words set and checks simple
membership. The morphy-normalization and instance-exclusion live in the WordNet loader
(build_real_word_set(vocab, common_lemmas) + load_wordnet_common_lemmas()).
The len(word) > 1 single-character exclusion is retained (folded into the module).
Retained downstream shape: is_canonical stays a boolean column on words and a
derived flag on pairs (a pair is canonical iff both members are). No schema change; the
existing indexes and compileWordFilter default (w.is_canonical = 1) are untouched.
Dependency: add nltk + the WordNet corpus as a developer-local build dependency
only. The build is developer-local (CI consumes the LFS seed and runs no Python pipeline),
so this never enters CI or the runtime. WordNet 3.0's license permits commercial use;
build-time boolean derivation is not redistribution.
Component 2 — Canonical as the universal candidate scope (Worker)¶
The governing rule:
- Generated candidate sets are canonical-scoped — anything that produces a set of suggested words (similarity neighbors, association targets, Word Lists, Contrast Sets).
- Exact-word resolution resolves any word — endpoints that look up a specific typed
word by key (
/api/words/:word, the/api/audio/*target lookup) are not candidate sets and stay full-vocabulary. - Sentence retrieval is the single candidate-generating exception — it stays full-vocabulary for the reasons below.
Expressed as an explicit per-endpoint policy (the scope decision belongs at the endpoint — this is the root expression of the policy, not a leaf patch). Query-time filtering; no table re-emit, so this ships independently of the Component 1 seed regen and is fully reversible.
| Endpoint | Change |
|---|---|
/api/similarity/search (routes/similarity.ts) |
Restrict the neighbor candidate pool to is_canonical = 1. The route currently loads the entire word_syllables table as its search corpus with no canonical filter (line 54). Arbitrary query words still resolve their own phoneme decomposition on the fly (from words.phonemes); only the returned neighbors are canonical-scoped. |
/api/associations/* (routes/associations.ts) |
Return only canonical targets — join returned edge targets against words.is_canonical = 1. |
/api/words/:word (routes/words.ts) |
Unchanged. Direct exact-word lookup still resolves any typed word (line 34), so a user can look up a proper noun or rare word. Its similar-words / associations panels inherit canonical scoping from the two rows above. |
/api/audio/* (routes/audio.ts) |
Unchanged. The audio target word is resolved by exact key (SELECT phonemes FROM words WHERE word = ?, line 345) — a clinician may score a production against any target (proper noun, rare word). Like /api/words/:word this is exact-word resolution, not a generated candidate pool, so it must stay full-vocabulary. Do not wrap a canonical filter around the target lookup. |
/api/sentences (routes/sentences.ts) |
Unchanged — the single deliberate candidate-generating exception. For sentence retrieval, attestation and sentence naturalness outrank word canonicality; a natural attested sentence is the unit of value, not its constituent words. Non-canonical junk also self-filters here because it rarely appears in corpus sentences. |
Contrast Sets / pairs (routes/contrastive.ts) |
Unchanged — already is_canonical = 1 filtered. |
Threshold selection + validation (probe)¶
Thresholds (TAU, FLOOR_ABS, the frequency signal + FREQ_FLOOR) are not
hardcoded blind. A probe script (research/2026-07-19-canonical-redesign/) sweeps
candidate values and reports, for each configuration:
- "Should be included" recall: fraction of the 1,030-row
canonicality_inversions.parquetworklist (the knownkingdomclass) that the new rule makes canonical. Target: the PROPN class (890) should be recovered. - "Should be excluded" precision: a random sample of words the new rule adds vs. today's 47K, and a random sample of words it drops, hand-inspected for the four garbage classes (foreign / proper-noun / abbreviation / obscure). Report counts and set sizes so any coverage change is explicit (per "no silent drops").
- Set-size delta: total canonical count before/after, and the churn (added / dropped).
The probe output picks the thresholds; the chosen values and the QA sample are recorded in the research notes and referenced from the ticket before the production build runs.
Build & rollout sequence¶
- Land Component 2 (Worker query-time canonical scoping) + tests — ships independently, no seed change.
- Build the probe, pick thresholds, QA the delta.
- Implement
canonical.py, wire intobuild_words(), delete the repair pass, add thenltk/WordNet dev dependency, add Python tests. - Local pipeline regen →
export-to-d1.py→d1-seed.sql(LFS) → deploy applies it. Coordinate with any other pending seed change so it remains one regen. - Update
CLAUDE.md: the canonical-scope invariant changes from "sound similarity and corpus retrieval use the full vocabulary" → generated candidate sets are canonical-scoped; sentence retrieval is the only full-vocabulary candidate-generating surface; exact-word resolution (/api/words/:word, audio target lookup) resolves any word. Update the lexicon-scope paragraph and thecompileWordFilternote accordingly.
Testing¶
- Python (
packages/data/tests/): unit tests forcanonical.py— thekingdomcase (PROPN dominant, real NOUN mass → canonical), a proper-noun-only word (no WordNet lemma → not canonical), an abbreviation (not a WordNet lemma → not canonical), an obscure WordNet word below the frequency floor (→ not canonical), and the single-char exclusion. - Worker (
packages/web/workers/,cloudflare:test+SELF.fetch): similarity and associations responses contain onlyis_canonical = 1words;/api/words/:wordstill resolves a non-canonical word directly;/api/sentencesstill returns sentences containing non-canonical surfaces.
Risks / open questions¶
- WordNet recall gaps. WordNet lacks some legitimate content words (very new coinages,
some compounds). The realness floor is
WordNet AND frequency(per the approved "strict" option); the probe's drop-sample QA quantifies how many real words this costs. If it is material, revisit toward "WordNet OR high-frequency." - Coordination with pending seed regens. Component 1 requires a seed regen; batch it with any other pending data change to keep it a single regen (per project convention).