Skip to content

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 Zustand constraintStore (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 the Constraint union, and Builder.tsx hardcodes five category ids, so the new register metadata category is silently invisible. Only the binary "Include technical vocabulary" toggle shipped.
  • Weighted-sample ordering shipped with zero UI. WEIGHTED_SAMPLE_ORDER_SQL is the server default whenever sort_by is 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.ts vs worker lib/constraintsToBody.ts) with divergent behavior (only the frontend synthesizes patterns from contrastive constraints; only the frontend knows WordOptions). has_image is hand-rolled three ways; four hand-written position pickers; ActiveConstraintsBar on 2 of 5 tools; Sentences uses three different chip-deletion mechanisms internally.
  • lemmas_only exists in exactly one place (Word Lists' Word Shape accordion, PHON-194), rides on WordOptions rather than Constraint[], 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_image and 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 words
  • currency != 'current' (historicism 198, archaic 42)
  • 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 (reusing PropertySlider / 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.