PHON-220: Word Lists Rebuilt on the Shared Grammar Implementation Plan¶
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Rebuild Word Lists' input surface on the shared grammar: ScopeBar + three composer sections + chips bar, state on a per-tool constraint store, categories from metadata (killing the hardcoded five-category accordion list), register categorical filters surfaced, ENDS_WITH-exclude expressible. Also root-fixes PHON-224 (contextual_diversity percentile buckets) inside the shared composer, before Word Lists adopts it.
Architecture: constraintStore.ts becomes a factory (createConstraintStore()); Sentences keeps its existing instance unchanged, Word Lists gets its own (useWordListConstraintStore) so the two tools never share chip state. Builder's input side is replaced: patterns/exclusions/CV-shape/bounds/categorical live as StoreEntrys; ScopeBar state (base forms / has image / specialized) and the Similar-To rule stay local component state and travel via WordOptions (they are universe scope and execution options, not chips — per spec §B). handleBuild = compileConstraints(entries) + opts → buildWordSearchRequest. Results side (WordListTable, export, selection) untouched.
Tech Stack: React + TS + MUI + zustand, vitest + @testing-library/react.
Spec: docs/superpowers/specs/2026-08-14-constraint-ui-unification-design.md (§C Word Lists; §B grammar). PHON-224 root-fix decision is made here (see Task 2).
Global Constraints¶
- Branch:
feat/phon-220-word-lists-grammar(created off develop at 1dd5c5ea). PR targetsdevelop. - Sentences must be byte-identically unaffected:
GovernedGenerationTool/*files untouched; theuseConstraintStoreexport keeps its name, its module-level singleton identity, and its behavior (the factory refactor is internal). - Results side untouched:
WordListTable, selection, export,resultTotalcopy. Ordering/sampling is PHON-221 — do NOT add sort controls here. useSampleWords.tsandlib/seed/packSeeder.tsuntouched (they callbuildWordSearchRequestdirectly).- Payload compatibility: for equivalent user input, the request body must carry the same constraints the current Builder emits (the worker contract is unchanged). New capabilities (categorical, ENDS_WITH-exclude, cv_shape exclude) are additive.
- PHON-224 ruling (this plan's authority): percentile-ness =
use_log_scale || bucketsForProperty(id)?.mode === 'percentile'— bucket mode is authoritative where a bucket set exists (option 2 from the ticket; flippinguse_log_scalewould change slider display semantics beyond intent). Applied inPropertyFilterComposeronly; Builder's old duplicate logic is deleted by this PR. - Test commands: frontend
cd packages/web/frontend && npx tsc --noEmit && npm run lint && npm test && npm run build; worker suite must also stay green (cd packages/web/workers && npm test) — no worker files change, run once at the end. - Commits end with: Co-Authored-By: Claude Fable 5 noreply@anthropic.com
Task 1: Constraint-store factory + useWordListConstraintStore + upsertBound¶
Files:
- Modify: packages/web/frontend/src/store/constraintStore.ts
- Modify: packages/web/frontend/src/lib/entriesToChips.ts (add upsertBound beside upsertCategorical)
- Test: packages/web/frontend/src/store/constraintStore.test.ts (create), packages/web/frontend/src/lib/entriesToChips.test.ts (extend)
Interfaces:
- Consumes: existing ConstraintStoreState shape (entries/add/remove/removeAt/clear/load/snapshot/topK/setTopK).
- Produces:
- createConstraintStore(): UseBoundStore<StoreApi<ConstraintStoreState>> — factory returning an independent store hook.
- export const useConstraintStore = createConstraintStore(); — UNCHANGED name + module-singleton semantics (Sentences' instance).
- export const useWordListConstraintStore = createConstraintStore(); — Word Lists' instance (Task 3 consumes).
- upsertBound(entries: StoreEntry[], entry: Extract<StoreEntry, { type: 'bound' }>): StoreEntry[] — same (norm, direction) replaces the value (one chip per norm+direction), else appends; immutable.
- [ ] Step 1: Write the failing tests
Create src/store/constraintStore.test.ts:
import { describe, it, expect, beforeEach } from 'vitest';
import { createConstraintStore, useConstraintStore, useWordListConstraintStore } from './constraintStore';
describe('constraint store factory', () => {
beforeEach(() => {
useConstraintStore.getState().clear();
useWordListConstraintStore.getState().clear();
});
it('sentences and word-list stores are independent instances', () => {
useConstraintStore.getState().add({ type: 'cv_shape', shapes: ['CVC'] });
expect(useConstraintStore.getState().entries).toHaveLength(1);
expect(useWordListConstraintStore.getState().entries).toHaveLength(0);
});
it('factory instances do not share state either', () => {
const a = createConstraintStore();
const b = createConstraintStore();
a.getState().add({ type: 'cv_shape', shapes: ['CV'] });
expect(b.getState().entries).toHaveLength(0);
});
it('duplicate add is still a no-op per instance', () => {
const s = createConstraintStore();
s.getState().add({ type: 'cv_shape', shapes: ['CVC'] });
s.getState().add({ type: 'cv_shape', shapes: ['CVC'] });
expect(s.getState().entries).toHaveLength(1);
});
});
Extend entriesToChips.test.ts:
import { upsertBound } from './entriesToChips';
describe('upsertBound', () => {
const bound = (direction: 'min' | 'max', value: number): Extract<StoreEntry, { type: 'bound' }> =>
({ type: 'bound', norm: 'aoa', direction, value });
it('same (norm, direction) replaces the value — one chip per side', () => {
const out = upsertBound([bound('max', 5)], bound('max', 3));
expect(out).toEqual([bound('max', 3)]);
});
it('opposite direction appends', () => {
const out = upsertBound([bound('max', 5)], bound('min', 2));
expect(out).toHaveLength(2);
});
it('different norm appends and does not mutate input', () => {
const input = [bound('max', 5)];
const out = upsertBound(input, { type: 'bound', norm: 'valence', direction: 'min', value: 4 });
expect(out).toHaveLength(2);
expect(input).toHaveLength(1);
});
});
- [ ] Step 2: Run to verify failure
Run: cd packages/web/frontend && npx vitest run src/store/constraintStore.test.ts src/lib/entriesToChips.test.ts
Expected: FAIL (exports missing).
- [ ] Step 3: Implement
constraintStore.ts — wrap the existing create<ConstraintStoreState>((set, get) => ({...})) body in a factory, byte-identical initializer:
export function createConstraintStore() {
return create<ConstraintStoreState>((set, get) => ({
// ... the existing initializer, unchanged ...
}));
}
/** Sentences' instance — the original module singleton, name preserved. */
export const useConstraintStore = createConstraintStore();
/** Word Lists' instance (PHON-220). Separate store: chips added in one tool
* must never appear in another. */
export const useWordListConstraintStore = createConstraintStore();
entriesToChips.ts — add below upsertCategorical:
/** Replace-or-append a bound entry keyed by (norm, direction) — one chip per
* side per property; re-adding "AoA ≤ 3" after "AoA ≤ 5" updates the chip
* instead of stacking a dead one (the worker's filters map is last-wins for
* the same key, so a stacked chip would lie). Immutable. */
export function upsertBound(
entries: StoreEntry[],
entry: Extract<StoreEntry, { type: 'bound' }>,
): StoreEntry[] {
const idx = entries.findIndex(
(e) => e.type === 'bound' && e.norm === entry.norm && e.direction === entry.direction,
);
if (idx === -1) return [...entries, entry];
return entries.map((e, i) => (i === idx ? entry : e));
}
- [ ] Step 4: Run tests + full frontend suite + tsc
Run: cd packages/web/frontend && npx vitest run src/store/constraintStore.test.ts src/lib/entriesToChips.test.ts && npx tsc --noEmit && npm test
Expected: PASS; Sentences suites (GovernedGenerationTool tests, if any exercise the store) unaffected.
- [ ] Step 5: Commit
git add packages/web/frontend/src/store/constraintStore.ts packages/web/frontend/src/store/constraintStore.test.ts packages/web/frontend/src/lib/entriesToChips.ts packages/web/frontend/src/lib/entriesToChips.test.ts
git commit -m "feat(phon-220): constraint-store factory + word-list instance + upsertBound"
Task 2: PropertyFilterComposer — PHON-224 percentile authority + Infinity clamp¶
Files:
- Modify: packages/web/frontend/src/components/shared/PropertyFilterComposer.tsx
- Test: packages/web/frontend/src/components/shared/PropertyFilterComposer.test.tsx (extend)
Interfaces:
- Consumes: bucketsForProperty (already imported by the component), PropertyBucketSet.mode.
- Produces: the corrected percentile decision every tool inherits: isPercentile(prop) = prop.use_log_scale || bucketsForProperty(prop.id)?.mode === 'percentile'. When percentile via bucket-mode, the norm gets the _percentile suffix and baseline [0,100] exactly as use_log_scale percentile props do. handleAdd emits only finite moved sides (Number.isFinite guard) — closes the propertyBuckets Infinity-sentinel trap.
- [ ] Step 1: Write the failing tests
Extend PropertyFilterComposer.test.tsx with a fixture property mirroring the real contextual_diversity shape — use_log_scale: false in metadata but a percentile-mode bucket set. Follow the file's existing fixture/mock idiom (it already mocks bucketsForProperty or uses real buckets — check which; if it uses the real lib/propertyBuckets, add the fixture prop under a REAL bucketed id with mode: 'percentile', e.g. use contextual_diversity itself in the metadata fixture). Binding assertions:
it('a percentile-mode bucket set forces percentile semantics even when use_log_scale is false (PHON-224)', async () => {
// select the contextual_diversity-shaped prop, click a bucket chip
// (e.g. the top quintile), click Add filter, then:
expect(onAdd).toHaveBeenCalledWith([
{ type: 'bound', norm: 'contextual_diversity_percentile', direction: 'min', value: 80 },
]);
});
it('non-finite range sides are never emitted', async () => {
// fixture bucket with max: Infinity selected → only the min bound emits
// (assert no entry whose value is Infinity / null lands in onAdd's payload)
});
Write full test bodies per the file's established interaction idioms (Select open via mouseDown, bucket chips via role/button text). If no current bucket exposes max: Infinity, drive the Infinity case through the slider path by stubbing the metadata range — the contract is "no non-finite value in any emitted entry", assert it however the fixture best reaches it, and say which route you took.
-
[ ] Step 2: Run to verify failure — the PHON-224 case must fail with the current
use_log_scale-only logic (emittingmin_contextual_diversity = 80style bounds). -
[ ] Step 3: Implement
In PropertyFilterComposer.tsx: centralize the decision (it currently derives percentile-ness from use_log_scale alone around the normId/baseline block):
// PHON-224: a percentile-mode bucket set is authoritative — its labels
// describe distribution position ("Top 20%"), so the emitted bound must be
// the _percentile norm regardless of use_log_scale. use_log_scale remains
// the signal for slider-rendered percentile props.
const isPercentile = (p: PropertyDef) =>
p.use_log_scale || bucketsForProperty(p.id)?.mode === 'percentile';
…and use it wherever use_log_scale gated the suffix/baseline/slider-mode. In handleAdd, guard each side: emit the min entry only when Number.isFinite(value[0]) && moved; max likewise.
- [ ] Step 4: Run the component suite + full frontend matrix
Run: cd packages/web/frontend && npx vitest run src/components/shared/PropertyFilterComposer.test.tsx && npx tsc --noEmit && npm run lint && npm test
Expected: PASS.
- [ ] Step 5: Commit
git add packages/web/frontend/src/components/shared/PropertyFilterComposer.tsx packages/web/frontend/src/components/shared/PropertyFilterComposer.test.tsx
git commit -m "fix(phon-224): bucket mode is authoritative for percentile bounds; clamp non-finite sides"
Task 3: Builder rebuilt on the grammar (input side + test migration)¶
Files:
- Modify: packages/web/frontend/src/components/Builder.tsx (input side replaced; results side, ToolFrame/StickyActionBar usage, WordListTable block preserved)
- Modify: packages/web/frontend/src/components/Builder.test.tsx (rewritten to drive the new flow)
Interfaces:
- Consumes: useWordListConstraintStore, upsertBound, upsertCategorical, entriesToChips, compileConstraints, ScopeBar, PatternComposer, PropertyFilterComposer, CategoricalComposer, CategoricalRule (CV shapes), SimilarToRule, usePropertyMetadata().defaultScope.
- Produces: the rebuilt Word Lists. Request contract: buildWordSearchRequest(compileConstraints(entries), { hasImage, lemmasOnly, includeSpecialized, similarTo? }, 200).
Target structure of Builder.tsx (the whole input side — write exactly this):
State:
const { propertyMap, loaded, error: metadataError, defaultScope } = usePropertyMetadata();
const { entries, add, removeAt, load, clear } = useWordListConstraintStore();
// Universe scope + execution options — NOT chips (spec §B): the ScopeBar
// shows its own state inline; similar-to is an execution option with its
// own section. Everything else lives in the constraint store.
const [hasPictureCard, setHasPictureCard] = useState(false);
const [baseFormsOnly, setBaseFormsOnly] = useState(false);
const [includeSpecialized, setIncludeSpecialized] = useState(false);
const [similarTo, setSimilarTo] = useState<SimilarToValue>({ /* unchanged initial */ });
// CV-shape composer draft (committed to the store via Add)
const [cvDraft, setCvDraft] = useState<string[]>([]);
const [cvMode, setCvMode] = useState<ConstraintMode>('include');
// Results state — unchanged (results/resultTotal/loading/error).
Deleted entirely: patterns, filters/filtersInitialized + init effect, excludePhonemeInput, patternWarnings/exclusionWarning, phoneme-picker state + handlers, propsByCategory, activeFilterIds, isPercentileProp/propertyDefById, patternsBlock, exclusionsBlock, the five property accordions, the old inline scopeBar, the old chips memo, addPattern/removePattern/updatePattern, the PhonemePickerDialog at the bottom (PatternComposer owns its own). Imports pruned accordingly (Select/MenuItem/TextField/Checkbox/Switch/IconButton/Add/Delete/Keyboard/PhonemePickerDialog/validatePhonemeInput/PropertySlider/BucketChips/bucketsForProperty go unless still referenced).
Wiring:
const handleAddBounds = (bounds: Array<Extract<StoreEntry, { type: 'bound' }>>) => {
let next = useWordListConstraintStore.getState().entries;
for (const b of bounds) next = upsertBound(next, b);
load(next);
};
const handleUpsertCategorical = (entry: Extract<StoreEntry, { type: 'categorical' }>) =>
load(upsertCategorical(useWordListConstraintStore.getState().entries, entry));
const handleAddCvShapes = () => {
if (cvDraft.length === 0) return;
add({ type: 'cv_shape', shapes: cvDraft, mode: cvMode });
setCvDraft([]);
};
const chips = useMemo<ChipDescriptor[]>(() => {
const base = entriesToChips(entries, propertyMap, removeAt);
if (similarTo.word.trim()) {
base.push({
id: 'similar-to',
label: `Similar to: ${similarTo.word.trim()}`,
color: 'info',
onDelete: () => setSimilarTo((prev) => ({ ...prev, word: '' })),
});
}
return base;
}, [entries, propertyMap, removeAt, similarTo]);
const badges = useMemo(() => ({
patterns: entries.filter((e) => e.type === 'pattern' || e.type === 'cv_shape').length,
properties: entries.filter((e) => e.type === 'bound' || e.type === 'categorical').length,
soundSimilarity: similarTo.word.trim() ? 1 : 0,
}), [entries, similarTo.word]);
handleBuild (same try/catch/track skeleton; replace the constraint assembly):
const constraints = compileConstraints(useWordListConstraintStore.getState().entries);
const opts: WordOptions = {
hasImage: hasPictureCard,
lemmasOnly: baseFormsOnly,
includeSpecialized,
similarTo: similarTo.word.trim() ? similarTo : undefined,
};
const request = buildWordSearchRequest(constraints, opts, PAGE_LIMIT);
// analytics constraint_count: entries.length + (opts.hasImage?1:0) +
// (opts.lemmasOnly?1:0) + (opts.similarTo?1:0)
handleClear: clear() + reset the four local states + results/error, unchanged skeleton.
Sections JSX:
const sections = (
<>
<MetadataErrorAlert error={metadataError} />
<ScopeBar
baseForms={{ value: baseFormsOnly, onChange: setBaseFormsOnly }}
hasImage={{ value: hasPictureCard, onChange: setHasPictureCard }}
specialized={{ value: includeSpecialized, onChange: setIncludeSpecialized }}
defaultScope={defaultScope}
/>
<ToolSection title="Phoneme Patterns" defaultExpanded badgeCount={badges.patterns} idPrefix="builder-phoneme-rules">
<Stack spacing={3}>
<PatternComposer subject="word" onAdd={add} />
<Stack spacing={1.5}>
<CategoricalRule
label="CV shape"
presetsLabel="Common shapes:"
presets={['V', 'CV', 'VC', 'CVC', 'CCV', 'CCVC', 'CVCC', 'CCVCC', 'CV-CV', 'CV-CVC', 'CCV-CV']}
value={cvDraft}
onChange={setCvDraft}
allowCustom
customValidator={(s) => /^[CV]+(-[CV]+)*$/.test(s)}
/>
<Stack direction="row" spacing={1.5} sx={{ alignItems: 'center' }}>
<ToggleButtonGroup value={cvMode} exclusive size="small" aria-label="Include or exclude mode"
onChange={(_, v: ConstraintMode | null) => { if (v) setCvMode(v); }}>
<ToggleButton value="include" color="info">Include</ToggleButton>
<ToggleButton value="exclude" color="error">Exclude</ToggleButton>
</ToggleButtonGroup>
<Button size="small" variant="outlined" startIcon={<AddIcon />} onClick={handleAddCvShapes} disabled={cvDraft.length === 0}>
Add shapes
</Button>
</Stack>
</Stack>
</Stack>
</ToolSection>
<ToolSection title="Property Filters" badgeCount={badges.properties} idPrefix="builder-property-filters"
helperText="Pick a property, set a range, add it as a filter">
{!loaded ? (
<Box sx={{ display: 'flex', justifyContent: 'center', p: 3 }}><CircularProgress size={24} /></Box>
) : (
<Stack spacing={3}>
<PropertyFilterComposer onAdd={handleAddBounds} />
<CategoricalComposer onUpsert={handleUpsertCategorical} />
</Stack>
)}
</ToolSection>
<ToolSection title="Sound Similarity" badgeCount={badges.soundSimilarity} idPrefix="builder-sound-similarity">
<SimilarToRule value={similarTo} onChange={setSimilarTo} />
</ToolSection>
</>
);
Return block: same ToolFrame usage (activeBar = <ActiveConstraintsBar chips={chips} onClearAll={handleClear} />, actionBar and results unchanged) WITHOUT the trailing PhonemePickerDialog. Update the file-header comment to describe the new surface (grammar sections, store-backed, PHON-220) and drop the stale five-accordion description.
Test migration (Builder.test.tsx): keep the render harness (PropertyMetadataProvider + mocked api), reset useWordListConstraintStore in beforeEach/afterEach (useWordListConstraintStore.getState().clear() — a module-level store leaks state across tests otherwise). The metadata mock must now include default_scope (it already does post-PHON-218) and, for categorical coverage, a categorical prop with values. Required cases (payload assertions on api.searchWords's request.constraints, same style as today):
- ScopeBar: toggling Has image →
{ type:'flag', property:'has_image', value:true }in the payload; off → absent. (Replaces the current has-image switch tests.) - ScopeBar: Base forms →
{ type:'scope', kind:'lemmas_only' }; Specialized →{ type:'scope', kind:'include_specialized' }. - PatternComposer path: type
s, Add rule, Build → pattern constraint STARTS_WITH include. - Exclusion path: mode Exclude + operator Ends with +
t, Add rule, Build →{ type:'pattern', pattern_type:'ENDS_WITH', phonemes:['t'], mode:'exclude' }— the newly-expressible case, pin it. - Property filter: select a slider prop, move a thumb, Add filter, Build → bound constraint with correct norm (percentile-suffixed for a
use_log_scalefixture prop). - Categorical: select the categorical fixture prop, pick a value, Add, Build → categorical constraint.
- CV shapes: pick
CVC, Add shapes, Build →{ type:'cv_shape', shapes:['CVC'], mode:'include' }; exclude-mode variant. - Chips: adding a pattern renders its chip; deleting the chip removes the entry (Build sends no pattern).
- Clear all resets store + scope toggles (Build sends
constraints: undefined).
Adapt DOM mechanics from the shared components' own test files (they already solved the MUI interactions); assertions above are binding.
- [ ] Step 1: Rewrite
Builder.test.tsxfirst (RED: the new tests fail against the old Builder — runnpx vitest run src/components/Builder.test.tsxand confirm the failures are the expected missing-UI ones). - [ ] Step 2: Rebuild
Builder.tsxper the target structure above. - [ ] Step 3: Run
npx vitest run src/components/Builder.test.tsx→ GREEN, then the full frontend matrix (npx tsc --noEmit && npm run lint && npm test && npm run build). - [ ] Step 4: Manual smoke on local dev (worker
npm run devin packages/web/workers, frontend dev server, or curl the worker directly): Build with a pattern + a property filter + Base forms ON and verify results return and the POST body carries the expected constraints. Kill dev servers after. - [ ] Step 5: Commit
git add packages/web/frontend/src/components/Builder.tsx packages/web/frontend/src/components/Builder.test.tsx
git commit -m "feat(phon-220): Word Lists rebuilt on the shared grammar"
Task 4: Full matrix + push (PR after final review)¶
- [ ] Step 1:
cd packages/web/workers && npx tsc --noEmit && npm test;cd ../frontend && npx tsc --noEmit && npm run lint && npm test && npm run build;cd ../../.. && uv run python -m pytest packages/data/tests/ --ignore=packages/data/tests/test_datasets.py --ignore=packages/data/tests/test_new_loaders.py. Any failure = stop. - [ ] Step 2:
git branch --show-current= feat/phon-220-word-lists-grammar;git log --oneline origin/develop..HEAD;git push -u origin feat/phon-220-word-lists-grammar. Do not commit pre-existing untracked repo-root files or uv.lock. - [ ] Step 3 (controller, after clean final review): PR to develop, noting: five-accordion surface replaced (register category now visible; ENDS_WITH-exclude + CV-shape exclude newly expressible); PHON-224 root-fixed in the shared composer (transition the ticket after merge); slider wall removed per spec §B trade-off; ordering/sampling untouched (PHON-221 next).
Self-Review Notes¶
- Spec §C coverage: ScopeBar promotion (base forms out of Word Shape; relabel via component) → T3; five hardcoded categories → metadata-driven composer → T3; register categorical surfaced → T3 (CategoricalComposer); exclusion as mode on any operator → T3 (case 4 pinned); CV shape stays under Patterns → T3; state onto constraint store → T1+T3; sampling untouched → global constraint.
- Carried items closed: PHON-224 (T2, option 2 ruling), upsertBound (T1), Infinity clamp (T2).
- Type consistency:
handleAddBoundsconsumes PropertyFilterComposer'sonAdd(entries: Array<bound StoreEntry>)(PHON-219 contract);handleUpsertCategoricalconsumesonUpsert(entry);PatternComposer.onAddtakes a single pattern StoreEntry = storeadddirectly. - Known UX deltas, deliberate (spec-approved): slider wall gone; property filters are add-flow; exclusions field gone (composer exclude mode); scope toggles no longer duplicate into chips.