Constraint Model + UI Unification — Design¶
Date: 2026-08-14 Jira: PHON-217 (parent), PHON-218–223 (one per PR). Related, not blocking: PHON-216. Status: Approved in-session 2026-08-14; blocks prod promotion of the 2026-08-13 staging batch.
Problem¶
A 2026-08-14 review of the staging batch (2026-08-13 reseed + register axes + PR #248 register controls) surfaced structural inconsistency in how the tools build constraints, beyond the immediate complaint (toggle sprawl in the new Word Lists scope bar). The load-bearing findings:
- Three incompatible constraint state models. Word Lists holds filters in local
useState; Sentences uses the ZustandconstraintStore(StoreEntry[]); Contrast Sets has no constraint model — it posts raw named params to/api/contrastive. A constraint configured in one tool is inexpressible in the others. - Two philosophies for the same concept. Word Lists renders every filterable numeric property as an always-visible slider/chip-row ("active" = moved off baseline); Sentences requires picking one of six hardcoded norms, a one-sided threshold, and an explicit "Add bound." "AoA ≤ 5" is expressed two incompatible ways. Within Word Lists itself, patterns are add-a-row, property filters are always-on, and exclusions are a third thing (a space-separated text field).
- The register work is half-surfaced. The worker compiles categorical filters
(PR #248,
CATEGORICAL_WORD_PROPERTIES), but no frontend can emit one: there is no categorical variant in theConstraintunion, andBuilder.tsxhardcodes five category ids, so the newregistermetadata category is silently invisible. Only the binary "Include technical vocabulary" toggle shipped. - Weighted-sample ordering shipped with zero UI.
WEIGHTED_SAMPLE_ORDER_SQLis the server default wheneversort_byis absent (always, from Word Lists). The table header says "N Words Found" while showing a random 200 that reshuffles on every Build, and the client-side table sort re-sorts only the returned slice. - Systemic duplication. The Constraint→request bridge is written twice
(frontend
lib/rules/buildRequests.tsvs workerlib/constraintsToBody.ts) with divergent behavior (only the frontend synthesizes patterns from contrastive constraints; only the frontend knowsWordOptions).has_imageis hand-rolled three ways; four hand-written position pickers;ActiveConstraintsBaron 2 of 5 tools; Sentences uses three different chip-deletion mechanisms internally. lemmas_onlyexists in exactly one place (Word Lists' Word Shape accordion, PHON-194), rides onWordOptionsrather thanConstraint[], and is therefore unavailable to Sentences, Contrast Sets, and pack rules.
Decisions¶
| Decision | Choice |
|---|---|
| Sequencing | Redesign blocks prod promotion of the staging batch |
| Interaction grammar | Hybrid: capped ScopeBar + composer→chip for everything else |
| Base-forms semantics | Existing (root IS NULL OR root = word); lexicalized-inflection blemish fixed in the root pipeline (PHON-216), not in UI/SQL |
| Default vocabulary scope | Full bundle (see Section A); named, single-source, metadata-served |
basic_level |
Orthogonal to register (empirically: 80% of gated technical words are not_applicable; ⅔ of subordinate words are register-everyday). Composer filter only, never a scope concept |
| Chip editing | Remove-only in this pass; click-to-edit is a later enhancement |
| Lookup / Text Analysis / pack editor | Out of scope (not constraint builders; pack rule editing stays under PHON-158) |
Section A — One constraint model, one compiler (PHON-218)¶
Model. Constraint (frontend types/governance.ts, worker mirror) grows three
variants alongside bound / pattern / cv_shape / contrastive_*:
categorical—{ property, values[], mode: include|exclude }. Register axes (specialization,currency,nativization) +basic_level. Makes the worker's existing categorical compilation reachable.flag—{ property, value: boolean }.has_imageand any future metadata-declared boolean. Replaces three hand-rolled switches.scope—{ kind: 'lemmas_only' | 'include_specialized' }. Vocabulary-universe restrictions that are not per-property filters.
WordOptions stops being a side-channel for filters: has_image,
include_specialized, lemmas_only move into the constraint list. WordOptions
keeps only genuine execution options: similar_to, sort_by/sort_order, limit.
PackRules (stores StoreEntry[][]) inherits the new expressiveness for free.
Named default scope. DEFAULT_SCOPE_EXCLUSIONS defined once in
workers/src/config/properties.ts:
specialization IN ('term_of_art', 'nomenclature')— 7,253 + 99 wordscurrency != 'current'(historicism198,archaic42)nativization = 'unassimilated'— 13 words
≈ 7,550 of 48,847 canonical (~15.5%), restorable via the scope toggle or sliced
precisely via categorical filters. Explicitly in the default universe: slang
(1,054 — register-informal, not non-vocabulary; content safety is PHON-204's job)
and naturalized loanwords (2,617 — limousine-type ordinary English). The
definition is served through /api/property-metadata so the UI tooltip enumerates
it from the source — the label can never silently drift from the SQL again. This
expands the shipped PR #248 gate (which covered only term_of_art + nomenclature and
let 193 archaic/historicism words into default lists); worker-side WHERE change,
no reseed.
Compiler. The worker owns all compilation. /api/words/search accepts
constraints: Constraint[] + options (legacy WordSearchBody accepted during
migration); worker constraintsToBody.ts + wordFilter.ts become the single path
for both /api/words/search and /api/sentences. The contrastive→pattern
fallback moves from frontend buildRequests.ts into the worker so both endpoints
get identical semantics; buildRequests.ts shrinks to "serialize store entries +
options." buildRequests.parity.test.ts converts into a regression suite proving
legacy and new paths compile to identical SQL + bind params during the migration
window.
Zero visual change in this PR.
Section B — Interaction grammar (PHON-219)¶
ScopeBar — capped at three, by rule. One compact shared control row above the
sections wherever vocabulary scope applies: Base forms · Has image ·
Specialized vocab. Visually a row of small toggle-chips (label = chip text,
explanation in tooltip), not the current Paper of labeled Switches with captions.
Two rules prevent sprawl from returning: (1) a scope control must restrict the
vocabulary universe, not filter a property — anything else goes through the
composer; (2) three slots is the cap; adding a fourth requires demoting one.
Scope toggles show their own state inline (highlighted when off-default) and do
not duplicate into the chips bar. Each toggle emits a Section-A
scope/flag constraint under the hood.
One composer flow for everything else — configure → Add → removable chip:
PatternComposer— position + IPA field + include/exclude toggle. Replaces Word Lists' live editable rows and its free-text exclusion field; exclusion becomes a mode on any operator (ENDS_WITH-exclude becomes expressible in Word Lists).PropertyFilterComposer— metadata-driven property picker grouped by category (new categories appear automatically), two-sided range input (reusingPropertySlider/BucketChips), Add. Percentile handling follows the single metadata rule (use_log_scale).CategoricalComposer— value-chip multi-select for categorical properties.PositionPicker— one shared component, one option order, replacing the four hand-written pickers.- Shared IPA-field validation plumbing extracted (currently re-implemented locally
by four tools around
utils/ipaValidation.ts).
Chips bar everywhere. ActiveConstraintsBar on every constraint-building
tool; chips remove-only; one store deletion operation.
Named trade-off: users lose at-a-glance visibility of available filters (the slider wall); the category-grouped picker is the mitigation. In exchange the empty tool is visually quiet and active constraints are always an explicit, reviewable list.
Section C — Per-tool application (PHON-220–223)¶
Word Lists (PHON-220). ToolFrame → ScopeBar → three composer sections
(Phoneme Patterns incl. CV shape, Property Filters, Sound Similarity) →
chips bar → results. Six accordions collapse to three (category grouping moves
into the picker). Hardcoded five-category list deleted — categories come from
metadata, fixing the invisible register category. Base forms moves from Word
Shape to the ScopeBar. The scope toggle relabels to match the full-bundle
definition ("Include specialized & dated vocabulary" or similar; final copy at
implementation). The categorical register filter ("only term_of_art") becomes
available through the composer and composes with the scope toggle (worker
semantics from #248). State migrates off local useState onto the constraint
store.
Sampling honesty (PHON-221). Explicit ordering control next to Build:
Varied sample (default, maps to the server weighted-sample) / A–Z /
By frequency. Results header states the truth: "Showing a varied sample of
200 from N matches" + Resample affordance in sample mode; "N matches, sorted
by X" for deterministic orders. WordListTable's client-side re-sort is removed
in sample mode.
Sentences (PHON-222). Same ScopeBar (Base forms + Specialized vocab; the Has
image slot intentionally empty — sentences are not picture cards). Hardcoded
CURATED_BOUNDS (six norms, one-sided, special-cased frequency_percentile)
replaced by the shared PropertyFilterComposer (metadata-driven, two-sided).
Three inconsistent chip-deletion mechanisms collapse into one store operation.
lemmas_only is a new capability for /api/sentences — verify the lemma
predicate behaves sanely in the sentence-membership joins.
Contrast Sets (PHON-223). Input surface rebuilt on shared components:
ToolSection for the mode's field group, shared PositionPicker, ScopeBar
(replacing the bespoke Has image switch; Base forms + Specialized vocab newly
available, compiled into the pair-query's word predicates), chips bar showing the
configured contrast. Mode picker, Stepper, and the /api/contrastive request
path unchanged — a reskin onto shared parts, not a backend migration.
Out of scope. Lookup and Text Analysis (not constraint builders; Lookup's phoneme feature-search rows may adopt the composer idiom as a later cosmetic ticket). Pack rule editing (PHON-158) — though Section A's model means packs can already store the new constraint kinds.
Section D — Sequence, testing, gate¶
PR sequence (atomic, feature branches off develop): PHON-218 model+compiler
→ PHON-219 shared components → PHON-220 Word Lists → PHON-221 sampling →
PHON-222 Sentences → PHON-223 Contrast Sets. Shared components land before any
tool is rewired, and each tool converts whole in one PR, so staging stays usable
between merges (staging is live testing).
Testing bar per PR: full existing matrix (workers vitest, frontend type-check/lint/build/tests) before every push; PHON-218 adds the compiler parity suite which then guards PHON-220–223. Each PR verified on staging after merge.
No reseed anywhere in this workstream. The default-scope expansion is a query-time WHERE change. PHON-216 (root own-lexeme exemption) is the only reseed item and is related, not blocking.
Promotion gate: all six land on develop → one full staging review pass → prod promotion of everything currently held (2026-08-13 reseed + register work + this redesign) as one version bump (checklist: AppHeader chip + drawers).
Empirical appendix — register axes on canonical (48,847 rows, 2026-08-13 build)¶
| Axis | Values (count) |
|---|---|
| specialization | everyday 40,065 · term_of_art 7,253 · slang 1,054 · not_applicable 288 · nomenclature 99 · regional 88 |
| currency | current 48,607 · historicism 198 (musket, guilders, clavichord) · archaic 42 (betwixt, thrice, swink) |
| nativization | english 46,217 · naturalized 2,617 (limousine) · unassimilated 13 (amigo, señora) |
| basic_level | not_applicable 33,620 · basic 12,701 · subordinate 1,578 · superordinate 948 |
Overlap checks: 193 non-current words pass the PR #248 gate (register-everyday);
gated technical words are 80% not_applicable for basic_level; subordinate words
are ⅔ register-everyday — the axes are orthogonal enough that neither substitutes
for the other.