Therapy-Pack Rule Model — Phase 1 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: Make a therapy pack store and run a real rule set (the app's StoreEntry[] constraint vocabulary) through shared rules→API builders, replacing the bespoke packSeeder/lossy TargetSpec path, with P1–P8 shipping as rule sets.
Architecture: Three layers — (1) composition: PackRules { groups: StoreEntry[][], wordOptions }; (2) shared builders buildWordSearchRequest/buildContrastQuery/buildSentenceRequest used by the tools AND the seeder; (3) ordering/merge (per-group union, round-robin default, MMR-capable) feeding the deterministic baseline that PR #202's pool/shuffle already sits on.
Tech Stack: React + TypeScript + Zustand + MUI (frontend), vitest. No Worker/D1/API change — the /api/words/search, /api/contrastive/*, /api/sentences endpoints already accept everything here.
Global Constraints¶
- Rule vocabulary is the EXISTING
StoreEntry/Constraintunion (types/governance.ts); reusecompileConstraints(lib/constraintCompiler.ts) — do not invent a parallel constraint type. - Refactors of
Builder.handleBuildanduseSampleWordsMUST be behavior-preserving — proven by parity tests, not eyeballing. - Preserve PR #202 invariants: deterministic reproducible baseline (no RNG), curation-preserving shuffle (only
provenance.sourceTool === 'guided'replaced), wrap-path dedup vs kept items. - Pack
schemaVersionbumps 1→2 (frontend IndexedDB only); no D1 schema/seed change. WordSearchRequest.patterns[].phonemeis a single string (space-separated for sequences, e.g."s t");PatternConstraint.phonemesis a string[] — the word builder joins with a space.- IPA
ɡis U+0261 (not ASCIIg). Terminology: "feature vectors," never "embeddings." - Batch sizes unchanged: words 20, contrasts 12, sentences 10; pool depth 200.
Task 1: Shared word-search + sentence builders¶
Files:
- Create: packages/web/frontend/src/lib/rules/buildRequests.ts
- Test: packages/web/frontend/src/lib/rules/buildRequests.test.ts
Interfaces:
- Consumes: Constraint[] (from types/governance), WordSearchRequest/Pattern (from services/apiClient).
- Produces:
- interface WordOptions { hasImage?: boolean; lemmasOnly?: boolean; similarTo?: WordSearchRequest['similar_to']; sortBy?: string; sortOrder?: 'asc' | 'desc' }
- buildWordSearchRequest(constraints: Constraint[], opts?: WordOptions, limit?: number): WordSearchRequest
- buildSentenceRequest(constraints: Constraint[], topK: number): { constraints: Constraint[]; top_k: number }
- [ ] Step 1: Write the failing tests
import { describe, it, expect } from 'vitest';
import { buildWordSearchRequest, buildSentenceRequest } from './buildRequests';
import type { Constraint } from '../../types/governance';
describe('buildWordSearchRequest', () => {
it('maps pattern constraints (phonemes[] → space-joined phoneme string) with mode', () => {
const c: Constraint[] = [{ type: 'pattern', pattern_type: 'STARTS_WITH', phonemes: ['s', 't'], mode: 'include' }];
const r = buildWordSearchRequest(c, {}, 200);
expect(r.patterns).toEqual([{ type: 'STARTS_WITH', phoneme: 's t', mode: 'include' }]);
expect(r.limit).toBe(200);
});
it('maps cv_shape and bound(min/max) constraints and wordOptions flags', () => {
const c: Constraint[] = [
{ type: 'cv_shape', shapes: ['CCVC'] },
{ type: 'bound', norm: 'aoa', max_value: 5 },
];
const r = buildWordSearchRequest(c, { hasImage: true, lemmasOnly: true, sortBy: 'frequency', sortOrder: 'desc' });
expect(r.cv_shape).toEqual(['CCVC']);
expect(r.filters).toEqual({ max_aoa: 5 });
expect(r.has_image).toBe(true);
expect(r.lemmas_only).toBe(true);
expect(r.sort_by).toBe('frequency');
expect(r.sort_order).toBe('desc');
});
it('derives include-patterns from a contrastive constraint when no pattern constraint is present', () => {
const c: Constraint[] = [{ type: 'contrastive_minpair', phoneme1: 'k', phoneme2: 't', position: 'any' }];
const r = buildWordSearchRequest(c, {}, 200);
// both phonemes become include CONTAINS patterns so the words surface is populated
expect(r.patterns).toEqual([
{ type: 'CONTAINS', phoneme: 'k', mode: 'include' },
{ type: 'CONTAINS', phoneme: 't', mode: 'include' },
]);
});
it('omits empties (no patterns/cv_shape/filters → undefined)', () => {
const r = buildWordSearchRequest([], {}, 200);
expect(r.patterns).toBeUndefined();
expect(r.cv_shape).toBeUndefined();
expect(r.filters).toEqual({});
});
});
describe('buildSentenceRequest', () => {
it('passes the full constraint list through with top_k', () => {
const c: Constraint[] = [{ type: 'pattern', pattern_type: 'CONTAINS', phonemes: ['ɹ'], mode: 'include' }];
expect(buildSentenceRequest(c, 10)).toEqual({ constraints: c, top_k: 10 });
});
});
- [ ] Step 2: Run tests to verify they fail
Run: cd packages/web/frontend && npx vitest run src/lib/rules/buildRequests.test.ts
Expected: FAIL (module missing).
- [ ] Step 3: Implement
buildRequests.ts
/**
* Shared rules → API request builders. The ONE place a compiled Constraint[]
* becomes each surface's request. Word Lists (Builder), useSampleWords, and
* the pack seeder all call these — no more triplicated request assembly.
*/
import type { Constraint } from '../../types/governance';
import type { Pattern, WordSearchRequest } from '../../services/apiClient';
export interface WordOptions {
hasImage?: boolean;
lemmasOnly?: boolean;
similarTo?: WordSearchRequest['similar_to'];
sortBy?: string;
sortOrder?: 'asc' | 'desc';
}
export function buildWordSearchRequest(
constraints: Constraint[],
opts: WordOptions = {},
limit = 200,
): WordSearchRequest {
const patterns: Pattern[] = [];
const cvShapes: string[] = [];
const filters: Record<string, number> = {};
for (const c of constraints) {
if (c.type === 'pattern') {
patterns.push({ type: c.pattern_type, phoneme: c.phonemes.join(' '), mode: c.mode ?? 'include' });
} else if (c.type === 'cv_shape') {
cvShapes.push(...c.shapes);
} else if (c.type === 'bound') {
if (c.min_value != null) filters[`min_${c.norm}`] = c.min_value;
if (c.max_value != null) filters[`max_${c.norm}`] = c.max_value;
}
}
// A contrastive-only group still needs a populated words surface: derive
// include patterns from the contrast's phonemes when no explicit pattern exists.
if (patterns.length === 0) {
for (const c of constraints) {
if (c.type === 'contrastive_minpair' || c.type === 'contrastive_maxopp') {
patterns.push({ type: 'CONTAINS', phoneme: c.phoneme1, mode: 'include' });
patterns.push({ type: 'CONTAINS', phoneme: c.phoneme2, mode: 'include' });
} else if (c.type === 'contrastive_multopp') {
patterns.push({ type: 'CONTAINS', phoneme: c.substitute, mode: 'include' });
}
}
}
return {
patterns: patterns.length ? patterns : undefined,
filters,
cv_shape: cvShapes.length ? cvShapes : undefined,
has_image: opts.hasImage ? true : undefined,
lemmas_only: opts.lemmasOnly ? true : undefined,
similar_to: opts.similarTo,
sort_by: opts.sortBy,
sort_order: opts.sortOrder,
limit,
};
}
export function buildSentenceRequest(constraints: Constraint[], topK: number) {
return { constraints, top_k: topK };
}
- [ ] Step 4: Run tests to verify they pass
Run: cd packages/web/frontend && npx vitest run src/lib/rules/buildRequests.test.ts
Expected: PASS.
- [ ] Step 5: Commit
git add packages/web/frontend/src/lib/rules/buildRequests.ts packages/web/frontend/src/lib/rules/buildRequests.test.ts
git commit -m "feat(rules): shared word-search + sentence request builders"
Task 2: Contrast-query builder¶
Files:
- Modify: packages/web/frontend/src/lib/rules/buildRequests.ts
- Test: packages/web/frontend/src/lib/rules/buildRequests.test.ts
Interfaces:
- Produces: type ContrastQuery = { kind: 'minpair'; phoneme1: string; phoneme2: string; position?: string } | { kind: 'maxopp'; phoneme1: string; phoneme2: string; position?: string } | { kind: 'multopp'; substitute: string; targets: string[]; position?: string } | null and buildContrastQuery(constraints: Constraint[]): ContrastQuery.
- [ ] Step 1: Write the failing tests
import { buildContrastQuery } from './buildRequests';
describe('buildContrastQuery', () => {
it('returns a minpair descriptor from a contrastive_minpair constraint', () => {
expect(buildContrastQuery([{ type: 'contrastive_minpair', phoneme1: 'k', phoneme2: 't', position: 'any' }]))
.toEqual({ kind: 'minpair', phoneme1: 'k', phoneme2: 't', position: 'any' });
});
it('returns a maxopp descriptor', () => {
expect(buildContrastQuery([{ type: 'contrastive_maxopp', phoneme1: 'm', phoneme2: 's', position: 'initial', min_sonorant_diff: 1 }]))
.toEqual({ kind: 'maxopp', phoneme1: 'm', phoneme2: 's', position: 'initial' });
});
it('returns a multopp descriptor', () => {
expect(buildContrastQuery([{ type: 'contrastive_multopp', substitute: 't', targets: ['k', 'ʃ', 's'], position: 'initial' }]))
.toEqual({ kind: 'multopp', substitute: 't', targets: ['k', 'ʃ', 's'], position: 'initial' });
});
it('returns null when there is no contrastive constraint', () => {
expect(buildContrastQuery([{ type: 'pattern', pattern_type: 'STARTS_WITH', phonemes: ['s'], mode: 'include' }])).toBeNull();
});
});
- [ ] Step 2: Run to verify fail
Run: cd packages/web/frontend && npx vitest run src/lib/rules/buildRequests.test.ts -t "buildContrastQuery"
Expected: FAIL (not exported).
- [ ] Step 3: Implement
buildContrastQuery(append tobuildRequests.ts)
export type ContrastQuery =
| { kind: 'minpair'; phoneme1: string; phoneme2: string; position?: string }
| { kind: 'maxopp'; phoneme1: string; phoneme2: string; position?: string }
| { kind: 'multopp'; substitute: string; targets: string[]; position?: string }
| null;
export function buildContrastQuery(constraints: Constraint[]): ContrastQuery {
for (const c of constraints) {
if (c.type === 'contrastive_minpair') return { kind: 'minpair', phoneme1: c.phoneme1, phoneme2: c.phoneme2, position: c.position };
if (c.type === 'contrastive_maxopp') return { kind: 'maxopp', phoneme1: c.phoneme1, phoneme2: c.phoneme2, position: c.position };
if (c.type === 'contrastive_multopp') return { kind: 'multopp', substitute: c.substitute, targets: c.targets, position: c.position };
}
return null;
}
- [ ] Step 4: Run to verify pass
Run: cd packages/web/frontend && npx vitest run src/lib/rules/buildRequests.test.ts
Expected: PASS (all).
- [ ] Step 5: Commit
git add packages/web/frontend/src/lib/rules/buildRequests.ts packages/web/frontend/src/lib/rules/buildRequests.test.ts
git commit -m "feat(rules): buildContrastQuery — contrastive constraint → endpoint descriptor"
Task 3: PackRules types + authoring helpers + display chips¶
Files:
- Modify: packages/web/frontend/src/types/therapyPack.ts
- Create: packages/web/frontend/src/lib/packs/packRules.ts (authoring helpers + chip derivation)
- Test: packages/web/frontend/src/lib/packs/packRules.test.ts
Interfaces:
- Consumes: StoreEntry (governance), WordOptions (Task 1).
- Produces:
- types/therapyPack.ts: interface PackRules { groups: StoreEntry[][]; wordOptions?: WordOptions }; add rules?: PackRules to TherapyPack; mark target?: TargetSpec | null deprecated (JSDoc); PACK_SCHEMA_VERSION = 2.
- packRules.ts: authoring helpers soundRule(phone, position) → StoreEntry[], contrastRule(a, b, position?) → StoreEntry[], patternRule(patternType, phonemes, mode?) → StoreEntry[]; rulesChips(rules: PackRules): string[] (display chips derived from rules).
- [ ] Step 1: Write the failing tests
import { describe, it, expect } from 'vitest';
import { soundRule, contrastRule, patternRule, rulesChips } from './packRules';
describe('packRules authoring helpers', () => {
it('soundRule → a STARTS_WITH/ENDS_WITH/CONTAINS include pattern group', () => {
expect(soundRule('ɹ', 'initial')).toEqual([{ type: 'pattern', patternType: 'STARTS_WITH', phonemes: ['ɹ'], mode: 'include' }]);
expect(soundRule('s', 'final')).toEqual([{ type: 'pattern', patternType: 'ENDS_WITH', phonemes: ['s'], mode: 'include' }]);
expect(soundRule('l', 'any')).toEqual([{ type: 'pattern', patternType: 'CONTAINS', phonemes: ['l'], mode: 'include' }]);
});
it('contrastRule → a minpair group', () => {
expect(contrastRule('k', 't')).toEqual([{ type: 'contrastive_minpair', phoneme1: 'k', phoneme2: 't', position: 'any' }]);
});
it('patternRule with a multi-phoneme cluster', () => {
expect(patternRule('STARTS_WITH', ['s', 't'])).toEqual([{ type: 'pattern', patternType: 'STARTS_WITH', phonemes: ['s', 't'], mode: 'include' }]);
});
});
describe('rulesChips', () => {
it('renders phoneme + position chips for a sound pack', () => {
expect(rulesChips({ groups: [soundRule('ɹ', 'initial')] })).toEqual(['/ɹ/', 'initial']);
});
it('renders a contrast chip', () => {
expect(rulesChips({ groups: [contrastRule('k', 't')] })).toEqual(['contrast', '/k/', '/t/']);
});
});
-
[ ] Step 2: Run to verify fail —
npx vitest run src/lib/packs/packRules.test.ts→ FAIL (module missing). -
[ ] Step 3: Implement types + helpers
In types/therapyPack.ts: bump PACK_SCHEMA_VERSION to 2; add import type { WordOptions } from '../lib/rules/buildRequests' and import type { StoreEntry } from './governance'; add:
export interface PackRules {
/** AND-groups; multiple groups are UNIONed at the ordering layer. */
groups: StoreEntry[][];
wordOptions?: WordOptions;
}
rules?: PackRules; to TherapyPack. Add JSDoc @deprecated use rules above target in TherapyPack (keep the field for migration).
Create lib/packs/packRules.ts:
import type { StoreEntry } from '../../types/governance';
import type { PackRules } from '../../types/therapyPack';
type Pos = 'initial' | 'medial' | 'final' | 'any';
const patternTypeFor = (p: Pos) =>
p === 'initial' ? 'STARTS_WITH' : p === 'final' ? 'ENDS_WITH' : p === 'medial' ? 'CONTAINS_MEDIAL' : 'CONTAINS';
export function soundRule(phone: string, position: Pos = 'any'): StoreEntry[] {
return [{ type: 'pattern', patternType: patternTypeFor(position), phonemes: [phone], mode: 'include' }];
}
export function patternRule(
patternType: 'STARTS_WITH' | 'ENDS_WITH' | 'CONTAINS' | 'CONTAINS_MEDIAL',
phonemes: string[],
mode: 'include' | 'exclude' = 'include',
): StoreEntry[] {
return [{ type: 'pattern', patternType, phonemes, mode }];
}
export function contrastRule(a: string, b: string, position: Pos = 'any'): StoreEntry[] {
return [{ type: 'contrastive_minpair', phoneme1: a, phoneme2: b, position }];
}
export function rulesChips(rules: PackRules): string[] {
const chips: string[] = [];
const first = rules.groups[0] ?? [];
const contrast = first.find((e) => e.type === 'contrastive_minpair' || e.type === 'contrastive_maxopp');
if (contrast && (contrast.type === 'contrastive_minpair' || contrast.type === 'contrastive_maxopp')) {
chips.push('contrast', `/${contrast.phoneme1}/`, `/${contrast.phoneme2}/`);
return chips;
}
const phones = new Set<string>();
let position: string | undefined;
for (const group of rules.groups) {
for (const e of group) {
if (e.type === 'pattern') {
e.phonemes.forEach((p) => phones.add(p));
if (e.patternType === 'STARTS_WITH') position = 'initial';
else if (e.patternType === 'ENDS_WITH') position = 'final';
}
}
}
phones.forEach((p) => chips.push(`/${p}/`));
if (position) chips.push(position);
return chips;
}
-
[ ] Step 4: Run to verify pass —
npx vitest run src/lib/packs/packRules.test.ts+npx tsc --noEmit. Expected: PASS + clean. -
[ ] Step 5: Commit
git add packages/web/frontend/src/types/therapyPack.ts packages/web/frontend/src/lib/packs/packRules.ts packages/web/frontend/src/lib/packs/packRules.test.ts
git commit -m "feat(pack): PackRules type + authoring helpers + rules→chips derivation"
Task 4: Ordering / merge layer¶
Files:
- Create: packages/web/frontend/src/lib/rules/mergeResults.ts
- Test: packages/web/frontend/src/lib/rules/mergeResults.test.ts
Interfaces:
- Produces: mergeGroups<T>(groups: T[][], keyOf: (t: T) => string, strategy?: MergeStrategy): T[] — unions per-group result lists, dedups by key, ordered by the strategy. type MergeStrategy = 'round-robin' (default; MMR added later). Round-robin = take index 0 of each group, then index 1, … (the generalization of the current packSeeder.drain), skipping already-seen keys.
- [ ] Step 1: Write failing tests
import { describe, it, expect } from 'vitest';
import { mergeGroups } from './mergeResults';
const key = (s: string) => s;
describe('mergeGroups (round-robin)', () => {
it('interleaves groups and dedups by key', () => {
const out = mergeGroups([['a', 'b', 'c'], ['b', 'd'], ['e']], key);
expect(out).toEqual(['a', 'b', 'e', 'c', 'd']); // idx0 of each (a,b,e), then idx1 (c, d), b already seen
});
it('handles a single group as identity (minus dups)', () => {
expect(mergeGroups([['a', 'a', 'b']], key)).toEqual(['a', 'b']);
});
it('empty groups → empty', () => {
expect(mergeGroups([], key)).toEqual([]);
});
});
-
[ ] Step 2: Run to verify fail — FAIL (module missing).
-
[ ] Step 3: Implement
mergeResults.ts
/**
* Ordering/merge layer — combine per-group candidate lists into one ordered,
* deduped list. This is a distinct concern from rule composition: the
* automated sibling of the editor's manual reorder, and where shuffle
* resamples. Default 'round-robin' generalizes the old per-phone drain;
* 'mmr' (diversity via the learned phoneme-similarity vectors) is the
* intended upgrade and slots in here without touching callers.
*/
export type MergeStrategy = 'round-robin';
export function mergeGroups<T>(
groups: T[][],
keyOf: (t: T) => string,
strategy: MergeStrategy = 'round-robin',
): T[] {
// strategy switch is here so 'mmr' can be added without changing callers
const seen = new Set<string>();
const out: T[] = [];
const maxLen = groups.reduce((m, g) => Math.max(m, g.length), 0);
for (let i = 0; i < maxLen; i++) {
for (const g of groups) {
const item = g[i];
if (!item) continue;
const k = keyOf(item);
if (seen.has(k)) continue;
seen.add(k);
out.push(item);
}
}
return out;
}
-
[ ] Step 4: Run to verify pass — PASS.
-
[ ] Step 5: Commit
git add packages/web/frontend/src/lib/rules/mergeResults.ts packages/web/frontend/src/lib/rules/mergeResults.test.ts
git commit -m "feat(rules): ordering/merge layer (round-robin union, MMR-ready)"
Task 5: Seeder rework — run rules through the builders¶
Files:
- Modify: packages/web/frontend/src/lib/seed/packSeeder.ts
- Test: packages/web/frontend/src/lib/seed/packSeeder.test.ts
Interfaces:
- Consumes: Task 1/2 builders, Task 4 mergeGroups, compileConstraints, existing api.searchWords/getMinimalPairs/…, fetchSentences, toPackItems.
- Produces: seedPack(rules: PackRules, deps?): Promise<SeededContent> (same SeededContent/SeedPools shape as PR #202). Removes wordSearchFor, seedWords, sentenceConstraintsFor, mapWordToPackWord/mapPairToPackContrast/mapSentenceToPackSentence (fold into toPackItems).
Implementation notes (behavior contract):
- Words: for each group in rules.groups, compileConstraints(group) → buildWordSearchRequest(constraints, rules.wordOptions, POOL_DEPTH) → searchWords; collect per-group item lists (via toPackItems(items, 'words', null, 'guided', at)); mergeGroups(perGroupItems, wordKey) → pool; baseline = slice(0, 20). Keep the image-first behavior by defaulting wordOptions.hasImage handling as today (round-1 has_image, round-2 backfill) — implement as two merge passes if hasImage unset, matching current pool composition.
- Contrasts: buildContrastQuery(compileConstraints(groups.flat())); if non-null, call the matching api method (minpair→getMinimalPairs, maxopp→findMaximalOppositionWordLists, multopp→generateMultipleOppositionSets), map via toPackItems(..., 'pairs'|'groups', ...); else empty.
- Sentences: buildSentenceRequest(compileConstraints(groups.flat()), 10) → fetchSentences; map via toPackItems(..., 'sentences', ...).
- [ ] Step 1: Write the failing/《regression》tests
import { seedPack } from './packSeeder';
import { soundRule, contrastRule } from '../packs/packRules';
it('seedPack(rules) for a single-sound pack issues a STARTS_WITH search and fills a 200-pool + 20 baseline', async () => {
const reqs: any[] = [];
const seeded = await seedPack(
{ groups: [soundRule('s', 'initial')], wordOptions: { hasImage: true } },
{
searchWords: async (r) => { reqs.push(r); return { items: Array.from({ length: 200 }, (_, i) => ({ word: `s${i}` })), total: 200, offset: 0, limit: r.limit ?? 0 } as any; },
getMinimalPairs: async () => [], fetchSentences: async () => ({ corpus_matches: [] } as any), now: () => 1,
},
);
expect(reqs[0].patterns[0]).toMatchObject({ type: 'STARTS_WITH', phoneme: 's' });
expect(seeded.pools.words.length).toBe(200);
expect(seeded.words.length).toBe(20);
});
it('seedPack(rules) for a contrast pack calls getMinimalPairs and unions the two phones for words', async () => {
const calls: any = { pairs: 0 };
const seeded = await seedPack(
{ groups: [contrastRule('k', 't')] },
{
searchWords: async (r) => ({ items: [{ word: r.patterns?.[0]?.phoneme ?? 'x' }], total: 1, offset: 0, limit: 1 } as any),
getMinimalPairs: async () => { calls.pairs++; return [{ word1: { word: 'key' }, word2: { word: 'tea' }, phoneme1: 'k', phoneme2: 't' }] as any; },
fetchSentences: async () => ({ corpus_matches: [] } as any), now: () => 1,
},
);
expect(calls.pairs).toBe(1);
expect(seeded.contrasts.length).toBe(1);
});
-
[ ] Step 2: Run to verify fail —
seedPackstill takesTargetSpec; new signature/tests fail. -
[ ] Step 3: Implement the reworked seeder
Rewrite seedPack to the signature seedPack(rules: PackRules, deps: Partial<SeedDeps> = {}) per the behavior contract above. Delete wordSearchFor, seedWords, sentenceConstraintsFor, and the three map*ToPack* functions; use toPackItems for all mapping (add a thin overload path if toPackItems needs a sourceTool of 'guided'). Keep POOL_DEPTH, wordKey/contrastKey/sentenceKey, and the SeededContent/SeedPools shape unchanged (PR #202 consumers depend on them). Retain the image-first two-pass pool composition.
-
[ ] Step 4: Run tests —
npx vitest run src/lib/seed/packSeeder.test.ts(rewrite the PR-#202 pool/shuffle-related cases to the newseedPack(rules)signature; thesampleFresh/pool assertions stay). Expected: PASS. -
[ ] Step 5: Commit
git add packages/web/frontend/src/lib/seed/packSeeder.ts packages/web/frontend/src/lib/seed/packSeeder.test.ts packages/web/frontend/src/lib/pack/toPackItems.ts
git commit -m "refactor(seed): seedPack runs PackRules through shared builders + merge; retire bespoke request/mappers"
Task 6: packStore + WelcomeView + migration¶
Files:
- Modify: packages/web/frontend/src/store/packStore.ts, packages/web/frontend/src/lib/packMigrations.ts, packages/web/frontend/src/components/WelcomeView.tsx
- Test: packages/web/frontend/src/store/packStore.test.ts, packages/web/frontend/src/lib/packMigrations.test.ts
Interfaces:
- Consumes: Task 5 seedPack(rules), Task 3 PackRules.
- Produces: createSeededPack(rules: PackRules, seeded, title?) stores pack.rules = rules; shufflePack rebuilds pools via seedPack(p.rules) (not p.target); migratePack v1→v2 derives rules from target.
-
[ ] Step 1: Write failing tests — (a)
createSeededPacksetsactivePack.rules; (b)shufflePackon a reloaded pack withrules(session cache cold) callsseedPack(rules)and refreshes guided items; (c) migration: a v1 pack{schemaVersion:1, target:{mode:'sound',phones:['ɹ'],position:'initial'}, words:[…]}→ v2 withrules.groups[0]equal tosoundRule('ɹ','initial')and items preserved; a v1 pack withtarget:null→rules:{groups:[]}. -
[ ] Step 2: Run to verify fail.
-
[ ] Step 3: Implement
packStore.ts: changecreateSeededPack(target, …)→createSeededPack(rules, seeded, title); setpack.rules = rules(droppack.targetassignment, or set both during transition);shufflePackguard onp.rules(notp.target) and rebuild viaseedPack(p.rules). KeepseedSessions, curation-preserving filter, wrap-dedup from PR #202 unchanged.packMigrations.ts: addif (pack.schemaVersion < 2) { pack = { ...pack, rules: deriveRulesFromTarget(pack.target), schemaVersion: 2 } }.deriveRulesFromTarget(target): null →{groups:[]};mode:'sound'→{groups:[soundRule(phones[0], position)]};mode:'contrast'→{groups:[contrastRule(phones[0], phones[1], position)]};mode:'process'→{groups: phones.map(p => soundRule(p, position ?? 'initial'))}.-
WelcomeView.tsx:handleBuildreceives a pack whose target is nowrules(Task 8 changesPrebuiltPack); callseedPack(pack.rules)andcreateSeededPack(pack.rules, seeded, pack.title). -
[ ] Step 4: Run tests — packStore + packMigrations suites green;
tscclean. -
[ ] Step 5: Commit
git add packages/web/frontend/src/store/packStore.ts packages/web/frontend/src/lib/packMigrations.ts packages/web/frontend/src/lib/packMigrations.test.ts packages/web/frontend/src/store/packStore.test.ts packages/web/frontend/src/components/WelcomeView.tsx
git commit -m "feat(pack): store/shuffle/migration run PackRules; v1→v2 target→rules migration"
Task 7: Refactor Word Lists + useSampleWords onto the shared builder (parity)¶
Files:
- Modify: packages/web/frontend/src/components/Builder.tsx, packages/web/frontend/src/hooks/useSampleWords.ts
- Test: packages/web/frontend/src/lib/rules/buildRequests.parity.test.ts
Interfaces: none new — behavior-preserving.
-
[ ] Step 1: Write the parity test — capture Builder's current request shape for representative inputs (patterns + exclusions + filters + cv_shape + has_image + lemmas_only + similar_to) and assert
buildWordSearchRequest(compileConstraints(equivalentStoreEntries), opts)produces the SAMEWordSearchRequest(modulo key order). Include the exclusion case (CONTAINSmodeexclude). -
[ ] Step 2: Run to verify fail (or reveal a gap to fix in
buildWordSearchRequest). -
[ ] Step 3: Refactor Builder.handleBuild to construct its
Constraint[]/WordOptionsand callbuildWordSearchRequest(...)instead of the inlinerequestobject (Builder.tsx:404-423). RefactoruseSampleWordsto callbuildWordSearchRequest([], { sortBy: propId, sortOrder }, limit)with the bound filters expressed asboundconstraints. Keep all other Builder behavior identical. -
[ ] Step 4: Run tests — parity test PASS; full Word Lists suite (
npx vitest run src/components/Builder.test.tsxand anyuseSampleWordstests) PASS;tscclean. -
[ ] Step 5: Commit
git add packages/web/frontend/src/components/Builder.tsx packages/web/frontend/src/hooks/useSampleWords.ts packages/web/frontend/src/lib/rules/buildRequests.parity.test.ts
git commit -m "refactor(wordlists): Builder + useSampleWords compose via shared buildWordSearchRequest"
Task 8: Prebuilt packs as rule sets + rules→chips in the header¶
Files:
- Modify: packages/web/frontend/src/lib/packs/prebuiltPacks.ts, packages/web/frontend/src/components/pack/PackHeader.tsx
- Test: packages/web/frontend/src/lib/packs/prebuiltPacks.test.ts, packages/web/frontend/src/components/pack/PackHeader.test.tsx
Interfaces:
- Produces: interface PrebuiltPack { id; title; description; rules: PackRules; featured } (replaces target); PackHeader renders rulesChips(pack.rules).
-
[ ] Step 1: Write failing tests — (a) each
PREBUILT_PACKSentry has non-emptyrules.groups; the 6 originals map correctly (e.g.initial-r.rules.groups[0]==soundRule('ɹ','initial'),stoppinghas 6 groups); the S-blends pack has 6 cluster groups withphonemeslength 2. (b)PackHeaderrenders a Shuffle button + derived chips for arules-based pack. -
[ ] Step 2: Run to verify fail.
-
[ ] Step 3: Implement — rewrite
PREBUILT_PACKSper the spec §5 table usingsoundRule/contrastRule/patternRule(existing 6 converted + P1–P8 new; featured = current 5 + Initial /s/, S-blends, Initial /ʃ/). UpdatePrebuiltPackinterface (target→rules). UpdatePackHeaderto compute chips viarulesChips(pack.rules)instead oftargetChips(pack.target); guard the Shuffle button onpack.rules(Task 6 made shuffle rules-based). UpdateTargetTiles/MoreTargetsMenu/WelcomeViewprop types if they referencepack.target. -
[ ] Step 4: Run tests — prebuilt + PackHeader suites green;
tscclean. -
[ ] Step 5: Commit
git add packages/web/frontend/src/lib/packs/prebuiltPacks.ts packages/web/frontend/src/lib/packs/prebuiltPacks.test.ts packages/web/frontend/src/components/pack/PackHeader.tsx packages/web/frontend/src/components/pack/PackHeader.test.tsx
git commit -m "feat(pack): prebuilt packs as rule sets (P1-P8) + header chips from rules"
Task 9: Full-suite verification¶
- [ ] Step 1:
cd packages/web/frontend && VITE_API_URL=https://staging-api.phonolex.com VITE_GENERATED_IMAGE_BASE_URL=https://aac.phonolex.com npm run build→ succeeds. - [ ] Step 2:
cd packages/web/frontend && npx vitest run→ all pass;npx tsc --noEmitclean;npm run lint(≤50 warnings). - [ ] Step 3:
cd packages/web/workers && npx vitest run && npx tsc --noEmit→ unchanged/green (no worker edits expected). - [ ] Step 4: Manual dev smoke: seed "Initial /ɹ/" (rules), Shuffle, add a manual word, Shuffle again (survives); open a contrast pack (k vs t) and an S-blends pack — confirm words/contrasts/sentences populate.
- [ ] Step 5: Version bump per checklist (AppHeader chip + drawer) if shipping as an increment. Commit.
Self-Review¶
- Spec coverage: rule representation (Task 3) ✓; shared builders used by tools + seeder (Tasks 1/2/5/7) ✓; ordering/merge as its own layer, MMR-ready (Task 4) ✓; seeder/shuffle run stored rules (Tasks 5/6) ✓; migration v1→v2 keeping items (Task 6) ✓; prebuilt P1–P8 as rules + chips from rules (Task 8) ✓; PR #202 pool/shuffle invariants preserved (Tasks 5/6 notes) ✓; verification (Task 9) ✓.
- Deferred (spec §9, not in this plan): tool→editor handoffs capturing rules (Phase 2); lifting tool state into shared store + in-editor rule editing (Phase 3); full MMR strategy (interface shipped, algorithm is the fast-follow).
- Type consistency:
PackRules/WordOptions/buildWordSearchRequest/buildContrastQuery/buildSentenceRequest/mergeGroups/soundRule/contrastRule/patternRule/rulesChips/seedPack(rules)are defined once and consumed with matching signatures across tasks. - Placeholder scan: new pure modules (Tasks 1–4) carry complete code + tests; refactor/integration tasks (5–8) specify exact target signatures + parity/regression tests as the behavior contract (the extraction reads the cited current lines).