Therapy-Pack Rule Model — Phase 1 Design¶
Date: 2026-07-22 Status: Design (brainstorm approved; pending spec review) Initiative: Rework the therapy-pack editor to compose with the app's real constraint engine instead of a bespoke parallel path. This is Phase 1 of 3 (see §9). Parent: PHON-158; relates PHON-200/201. Grounding: architecture map 2026-07-22 (editor ↔ tools ↔ constraint-engine wiring).
1. Problem — what's shipped is architecturally wrong¶
There are two content pipelines that never meet:
- Constraint engine:
constraintStore(StoreEntry[]) →compileConstraints()→ APIConstraint[]. Used to produce results. - Pack path: a
TherapyPackstores only resolved items (PackWord/PackContrast/PackSentence[]) plus a lossyTargetSpec {mode, phones, position, processPreset}.lib/seed/packSeeder.tshand-rolls its own thin requests fromTargetSpec.
Consequences (all verified in code):
- Every handoff into a pack throws away the rules. SelectionToolbar/AddToEditorButton → toPackItems(data, …) sees only result rows; the WordSearchRequest/Constraint[] that produced them is discarded.
- The "shared" engine isn't shared. Only the Sentences tool uses constraintStore/compileConstraints. Word Lists (Builder.tsx) and Contrast Sets hold rule state in local useState and assemble requests inline. WordSearchRequest assembly is triplicated (Builder.handleBuild, useSampleWords, packSeeder.wordSearchFor).
- The seeder is a 4th request-assembler + a 2nd item-mapper (seedWords/wordSearchFor/sentenceConstraintsFor/map*ToPack* duplicate toPackItems + the tools), built solely because a pack can't hold real rules.
- A pack cannot re-run, refine, or explain how it was built. Shuffle (PR #202) re-runs from the lossy TargetSpec, not the real query.
2. Goals / Non-goals¶
Goals (Phase 1)
- A pack stores a rule set in the app's real constraint vocabulary, not TargetSpec.
- One rules→API path (shared builders) used by the tools and the pack — kill the triplication.
- Seeding, prebuilt packs, and shuffle run the stored rules.
- Clean three-layer split: composition → candidate pools → ordering/merge, with ordering a distinct, swappable layer (the automated sibling of the editor's manual reorder).
- P1–P8 ship as rule sets, exercising the model.
Non-goals (→ Phase 2/3, §9) - Tool→editor handoffs capturing their rules on add (Phase 2). - Lifting Word Lists / Contrast Sets local rule state into the shared store; editing a pack's rules in-editor via the tool UIs (Phase 3). - New capabilities: complexity sort, collections/bundling (PHON-200), stimulability input. - Text Analysis / Speech add-to-editor paths (they have none today; out of scope).
3. Architecture — three layers¶
3a. Composition — the rule representation¶
A pack stores its rules in the real vocabulary (types/governance.ts StoreEntry):
interface PackRules {
/** One or more constraint groups. A group is an AND of StoreEntry rules
* (exactly what constraintStore holds). Multiple groups are UNIONed at
* the ordering layer (§3c) — this is how "process" packs target *any of*
* several sounds without an OR operator, faithfully generalizing today's
* per-phone round-robin. Single-sound / contrast packs are one group. */
groups: StoreEntry[][];
/** Word-search-only options that are not constraints. */
wordOptions?: {
hasImage?: boolean;
lemmasOnly?: boolean;
similarTo?: { word: string; weights: { onset: number; nucleus: number; coda: number }; threshold: number; position: 'all' | 'initial' | 'final' | 'medial'; syllable_count: number };
sortBy?: string;
sortOrder?: 'asc' | 'desc';
};
}
StoreEntry[] covers patterns (multi-phoneme + include/exclude), cv_shape, bound norms, and contrastive minpair/maxopp/multopp. It round-trips through constraintStore.snapshot()/load(), so Phase 3 can load a pack's rules straight into the tool UIs to edit them. compileConstraints(group) → Constraint[] per group.
3b. Shared builders — the single rules→API surface¶
Three pure functions become the ONE place a rule set becomes an API request. The tools and the seeder all call them (this is the de-duplication that makes the engine actually shared):
// Constraint[] (compiled from a group) + options → the three API request shapes
buildWordSearchRequest(constraints: Constraint[], opts?: WordOptions): WordSearchRequest
// patterns ← pattern constraints; cv_shape ← cv_shape; filters ← bound (min_/max_norm);
// has_image/lemmas_only/similar_to/sort ← opts. When a group has only a contrastive
// constraint, derive include-patterns from its phonemes so the words surface stays populated.
buildContrastQuery(constraints: Constraint[]): ContrastRequest | null
// first contrastive_{minpair|maxopp|multopp} → the matching /api/contrastive/* call;
// null when no contrastive constraint (no contrasts surface, as today). Max/multiple
// opposition packs come free — the endpoints already ship.
buildSentenceRequest(constraints: Constraint[], topK: number): { constraints: Constraint[]; top_k: number }
// passes the FULL rule set to /api/sentences (today the seeder passes only one pattern).
Refactor targets (kill the triplication): Builder.handleBuild and useSampleWords call buildWordSearchRequest; the seeder drops wordSearchFor/sentenceConstraintsFor for these.
3c. Ordering / merge — a distinct, swappable layer¶
Composition yields a candidate pool per group. A separate ordering step produces the presented baseline. This layer is explicitly its own concern — the automated sibling of the editor's manual reorderItem, and the home of the shuffle resample.
- Merge strategy (pluggable). Default = the current round-robin/dedup drain (
packSeeder.drain), generalized from per-phone to per-group. Intended upgrade = MMR (maximal marginal relevance): iteratively pick the candidate maximizingλ·relevance − (1−λ)·max_sim(picked), where relevance = frequency rank and similarity = the learned phoneme/lexical vectors we already have — giving diverse coverage across groups instead of frequency-clumping. MMR is designed in as the strategy; shipping it fully may be a fast-follow, but the layer is built to host it. - Baseline = top-N of the merged pool (deterministic, reproducible — PR #202 invariant preserved).
- Shuffle (
sampleFresh, PR #202) = a resample operation on this same layer. - Editor reorder (
packStore.reorderItem) = the user overriding this layer by hand. Same concern, manual.
4. Data model + migration¶
TherapyPackgainsrules?: PackRules.target: TargetSpec | nullis deprecated (kept only for migration; no longer authored or read by the seed path).- Display chips derive from
rules— reuse the constraint-chip rendering (GovernedGenerationTool/constraintChips.ts/ActiveConstraintsBar), replacingPackHeader.targetChips. - Migration (
schemaVersion1→2,packMigrations.ts): existing v1 packs keep their resolved items and render statically. Where a v1 pack has a non-nulltarget, best-effort deriverulesfrom it (invert the oldTargetSpec→rules mapping) so guided packs can still shuffle. Blank/manual packs getrules: { groups: [] }.
5. Prebuilt packs — P1–P8 as rule sets¶
PREBUILT_PACKS entries carry rules: PackRules instead of target. The existing 6 convert 1:1. New packs (compact notation — P(type,phones,pos) = pattern, C(a,b) = contrastive_minpair, bound(...)):
| id | title | rules (groups) | wordOptions | featured |
|---|---|---|---|---|
| initial-s | Initial /s/ | [[P(STARTS_WITH,[s])]] |
hasImage | ✓ |
| initial-z / final-z | /z/ init / final | [[P(STARTS_WITH,[z])]] / [[P(ENDS_WITH,[z])]] |
||
| s-vs-th | s vs θ (frontal lisp) | [[C(s,θ)]] |
✓ | |
| z-vs-th | z vs ð | [[C(z,ð)]] |
||
| initial-k/final-k/initial-g/final-g | velar elicitation | [[P(STARTS_WITH,[k])]] … |
||
| g-vs-d | ɡ vs d (voiced fronting) | [[C(ɡ,d)]] |
||
| initial-th/final-th | /θ/ init / final | [[P(STARTS_WITH,[θ])]] … |
||
| voiced-th | /ð/ | [[P(CONTAINS,[ð])]] |
||
| initial-sh/ch/j | /ʃ/,/tʃ/,/dʒ/ init | [[P(STARTS_WITH,[ʃ])]] … |
✓ (sh) | |
| sh-vs-s / ch-vs-sh | palatal fronting / deaffrication | [[C(ʃ,s)]] / [[C(tʃ,ʃ)]] |
||
| initial-l/final-l | /l/ init / final | [[P(STARTS_WITH,[l])]] … |
||
| l-vs-w / r-vs-w | gliding contrasts | [[C(l,w)]] / [[C(ɹ,w)]] |
||
| s-blends | S-blends | [[P(STARTS_WITH,[s,p])],[P(STARTS_WITH,[s,t])],[P(STARTS_WITH,[s,k])],[P(STARTS_WITH,[s,l])],[P(STARTS_WITH,[s,n])],[P(STARTS_WITH,[s,w])]] |
✓ | |
| r-blends / l-blends | R-/L-blends | groups of P(STARTS_WITH,[C,ɹ]) / [C,l] |
||
| final-stops | Final consonants (FCD) | [[P(ENDS_WITH,[p])],…[ɡ]] (6 groups) |
||
| f-vs-p / s-vs-t | stopping contrasts | [[C(f,p)]] / [[C(s,t)]] |
(Cluster packs use multi-phoneme phonemes in one pattern; process/blend/FCD packs use the multi-group union.) cv_shape- and AoA-bound-scoped variants are now expressible too, but held for later batches. Featured landing set stays ~8; the rest live in "More targets" (taxonomy = PHON-201).
6. Seeder / shuffle rework¶
seedPack(rules: PackRules)(replacesseedPack(target)): for each group,compileConstraints→ builders → fetch words/contrasts/sentences; merge (§3c) → pools + baselines. Reuse the PR #202SeedPools/pool-caching shape.shufflePackruns the storedrules(via §6 seed) instead ofp.target; the curation-preserving + wrap-dedup guards from PR #202 are unchanged.- Delete
wordSearchFor,seedWords,sentenceConstraintsFor; foldmap*ToPack*intotoPackItems(one item-mapper).WelcomeView.handleBuildpassesrules.
7. Testing¶
- Builders (pure, unit): each
StoreEntrygroup → correctWordSearchRequest/contrast call/sentence request; contrastive-only group derives word patterns;wordOptionsflags applied; parity test assertingBuilder.handleBuild's old inline request ==buildWordSearchRequestoutput for representative inputs (proves the refactor is behavior-preserving). - Ordering/merge: multi-group union dedups; baseline deterministic; MMR-strategy stub is swappable and its interface is exercised.
- Seeder:
seedPack(rules)produces the same shape as before for a single-group sound pack (regression vs current); process pack unions groups. - Migration: v1 pack with
targetderivesrules; v1 blank pack → empty rules; items preserved. - Prebuilt: snapshot of
PREBUILT_PACKSrules; a representative pack seeds non-empty across surfaces. - Tool regression: Word Lists + Sentences suites stay green after the builder refactor.
8. Risks¶
- Refactoring
Builder.handleBuildonto the shared builder touches the live Word Lists tool — the parity test (§7) is the guard; keep the diff behavior-preserving. - Migration of real user packs in IndexedDB — old packs must never lose their items; rules derivation is best-effort and additive.
9. Phase map¶
- Phase 1 (this spec): rule model + shared builders + seeder rework + P1–P8 as rules.
- Phase 2: tool→editor handoffs capture the originating rule set so a pack remembers how it was built (updates
SelectionToolbar/AddToEditorButton/toPackItems). - Phase 3: lift Word Lists / Contrast Sets local rule state into the shared store; edit a pack's rules in-editor via the tool UIs — the full "tools as pickers inside the editor" inversion.