Skip to content

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 / Constraint union (types/governance.ts); reuse compileConstraints (lib/constraintCompiler.ts) — do not invent a parallel constraint type.
  • Refactors of Builder.handleBuild and useSampleWords MUST 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 schemaVersion bumps 1→2 (frontend IndexedDB only); no D1 schema/seed change.
  • WordSearchRequest.patterns[].phoneme is a single string (space-separated for sequences, e.g. "s t"); PatternConstraint.phonemes is a string[] — the word builder joins with a space.
  • IPA ɡ is U+0261 (not ASCII g). 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 to buildRequests.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 failnpx 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;
}
Add 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 passnpx 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 (minpairgetMinimalPairs, maxoppfindMaximalOppositionWordLists, multoppgenerateMultipleOppositionSets), 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 failseedPack still takes TargetSpec; 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 testsnpx vitest run src/lib/seed/packSeeder.test.ts (rewrite the PR-#202 pool/shuffle-related cases to the new seedPack(rules) signature; the sampleFresh/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) createSeededPack sets activePack.rules; (b) shufflePack on a reloaded pack with rules (session cache cold) calls seedPack(rules) and refreshes guided items; (c) migration: a v1 pack {schemaVersion:1, target:{mode:'sound',phones:['ɹ'],position:'initial'}, words:[…]} → v2 with rules.groups[0] equal to soundRule('ɹ','initial') and items preserved; a v1 pack with target:nullrules:{groups:[]}.

  • [ ] Step 2: Run to verify fail.

  • [ ] Step 3: Implement

  • packStore.ts: change createSeededPack(target, …)createSeededPack(rules, seeded, title); set pack.rules = rules (drop pack.target assignment, or set both during transition); shufflePack guard on p.rules (not p.target) and rebuild via seedPack(p.rules). Keep seedSessions, curation-preserving filter, wrap-dedup from PR #202 unchanged.
  • packMigrations.ts: add if (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: handleBuild receives a pack whose target is now rules (Task 8 changes PrebuiltPack); call seedPack(pack.rules) and createSeededPack(pack.rules, seeded, pack.title).

  • [ ] Step 4: Run tests — packStore + packMigrations suites green; tsc clean.

  • [ ] 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 SAME WordSearchRequest (modulo key order). Include the exclusion case (CONTAINS mode exclude).

  • [ ] Step 2: Run to verify fail (or reveal a gap to fix in buildWordSearchRequest).

  • [ ] Step 3: Refactor Builder.handleBuild to construct its Constraint[]/WordOptions and call buildWordSearchRequest(...) instead of the inline request object (Builder.tsx:404-423). Refactor useSampleWords to call buildWordSearchRequest([], { sortBy: propId, sortOrder }, limit) with the bound filters expressed as bound constraints. Keep all other Builder behavior identical.

  • [ ] Step 4: Run tests — parity test PASS; full Word Lists suite (npx vitest run src/components/Builder.test.tsx and any useSampleWords tests) PASS; tsc clean.

  • [ ] 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_PACKS entry has non-empty rules.groups; the 6 originals map correctly (e.g. initial-r.rules.groups[0] == soundRule('ɹ','initial'), stopping has 6 groups); the S-blends pack has 6 cluster groups with phonemes length 2. (b) PackHeader renders a Shuffle button + derived chips for a rules-based pack.

  • [ ] Step 2: Run to verify fail.

  • [ ] Step 3: Implement — rewrite PREBUILT_PACKS per the spec §5 table using soundRule/contrastRule/patternRule (existing 6 converted + P1–P8 new; featured = current 5 + Initial /s/, S-blends, Initial /ʃ/). Update PrebuiltPack interface (targetrules). Update PackHeader to compute chips via rulesChips(pack.rules) instead of targetChips(pack.target); guard the Shuffle button on pack.rules (Task 6 made shuffle rules-based). Update TargetTiles/MoreTargetsMenu/WelcomeView prop types if they reference pack.target.

  • [ ] Step 4: Run tests — prebuilt + PackHeader suites green; tsc clean.

  • [ ] 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 --noEmit clean; 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).