Skip to content

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() → API Constraint[]. Used to produce results.
  • Pack path: a TherapyPack stores only resolved items (PackWord/PackContrast/PackSentence[]) plus a lossy TargetSpec {mode, phones, position, processPreset}. lib/seed/packSeeder.ts hand-rolls its own thin requests from TargetSpec.

Consequences (all verified in code): - Every handoff into a pack throws away the rules. SelectionToolbar/AddToEditorButtontoPackItems(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

  • TherapyPack gains rules?: PackRules. target: TargetSpec | null is 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), replacing PackHeader.targetChips.
  • Migration (schemaVersion 1→2, packMigrations.ts): existing v1 packs keep their resolved items and render statically. Where a v1 pack has a non-null target, best-effort derive rules from it (invert the old TargetSpec→rules mapping) so guided packs can still shuffle. Blank/manual packs get rules: { 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) (replaces seedPack(target)): for each group, compileConstraints → builders → fetch words/contrasts/sentences; merge (§3c) → pools + baselines. Reuse the PR #202 SeedPools/pool-caching shape.
  • shufflePack runs the stored rules (via §6 seed) instead of p.target; the curation-preserving + wrap-dedup guards from PR #202 are unchanged.
  • Delete wordSearchFor, seedWords, sentenceConstraintsFor; fold map*ToPack* into toPackItems (one item-mapper). WelcomeView.handleBuild passes rules.

7. Testing

  • Builders (pure, unit): each StoreEntry group → correct WordSearchRequest/contrast call/sentence request; contrastive-only group derives word patterns; wordOptions flags applied; parity test asserting Builder.handleBuild's old inline request == buildWordSearchRequest output 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 target derives rules; v1 blank pack → empty rules; items preserved.
  • Prebuilt: snapshot of PREBUILT_PACKS rules; a representative pack seeds non-empty across surfaces.
  • Tool regression: Word Lists + Sentences suites stay green after the builder refactor.

8. Risks

  • Refactoring Builder.handleBuild onto 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.