Therapy Pack — Target Resolver (Phase 3a) 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: A pure, client-side target resolver that turns a clinician's typed input (/r/, s-blends, k vs t, final s, fronting) into the TargetSpec the pack store already uses — deterministically, with a hand-seeded "did you mean" for near-misses, and no embeddings/LLM/network (spec §5.2).
Architecture: Two pure TypeScript modules under src/lib/resolver/: a curated vocabulary data module (phoneme aliases, position synonyms, contrast separators, cluster/process presets, a did-you-mean map) plus a describeTarget label helper; and a parser that consumes that vocabulary to produce a discriminated ResolveResult. No React, no DOM, no I/O. The output TargetSpec plugs straight into usePackStore.newPack(target) and feeds Phase 3b seeding.
Tech Stack: TypeScript, Vitest 4. No new dependencies.
Global Constraints¶
- Pure & deterministic. No embeddings, no LLM, no network, no DOM, no
Date/Math.random. Same input → same output. (spec §5.2) - Never silently rewrite input. A near-miss produces a
suggestionresult the UI surfaces as "Did you mean …?" — it is never auto-applied. Build is enabled only on aresolvedresult. (spec §5.2) - Clinical process names are curated, not fuzzy-guessed. Process names (fronting, stopping, gliding, …) match only against the exact curated preset table; they are excluded from the fuzzy did-you-mean pass (everyday-meaning collision). (spec §5.2)
- Canonical direction is IPA. Resolve ASCII/orthographic input to IPA: ASCII
g→ɡ(U+0261),r→ɹ, digraphssh→ʃ,ch→tʃ,th→θ,zh→ʒ,ng→ŋ. Single IPA symbols pass through. (project IPA-normalization pattern) - Output type is the existing
TargetSpecfromsrc/types/therapyPack.ts:{ mode: 'sound' | 'contrast' | 'process'; phones: string[]; position?: 'initial' | 'medial' | 'final' | 'any'; processPreset?: string; level?: string }. Do NOT modify that type. - Test commands (from
packages/web/frontend/):npm test -- --run <file>,npm run type-check,npm run lint.
File Structure¶
src/lib/resolver/resolverVocab.ts(create) — curated data (alias maps, position synonyms, contrast separators, cluster families, process presets, did-you-mean map) +describeTarget(target).src/lib/resolver/resolverVocab.test.ts(create) — data-integrity +describeTargettests.src/lib/resolver/resolveTarget.ts(create) —resolveTarget(input)parser returningResolveResult.src/lib/resolver/resolveTarget.test.ts(create) — comprehensive parser tests.
Task 1: Curated resolver vocabulary + describeTarget¶
Files:
- Create: src/lib/resolver/resolverVocab.ts
- Test: src/lib/resolver/resolverVocab.test.ts
Interfaces:
- Consumes: TargetSpec from ../../types/therapyPack.
- Produces:
- export const PHONEME_ALIASES: Record<string, string> — orthographic/ASCII token → IPA (lowercased keys).
- export const IPA_PHONEMES: Set<string> — valid IPA phonemes (consonants, vowels, diphthongs, affricates) that pass through unchanged.
- export const POSITION_SYNONYMS: Record<string, 'initial' | 'medial' | 'final' | 'any'> — synonym → canonical position.
- export const CONTRAST_SEPARATORS: RegExp — matches vs / versus / v / / / - (spaced) as a splitter.
- export interface ProcessPreset { key: string; label: string; mode: 'process'; phones: string[]; }
- export const PROCESS_PRESETS: Record<string, ProcessPreset> — keyed by every recognized name/alias (lowercased) → the preset (multiple aliases may point to the same preset object).
- export const DID_YOU_MEAN: Record<string, string> — near-miss input (lowercased) → suggested canonical input string. Excludes process names.
- export function describeTarget(target: TargetSpec): string — human label, e.g. "Initial /ɹ/", "/k/ vs /t/", "Velar fronting".
- [ ] Step 1: Write the failing test
Create src/lib/resolver/resolverVocab.test.ts:
import { describe, it, expect } from 'vitest';
import {
PHONEME_ALIASES, IPA_PHONEMES, POSITION_SYNONYMS, PROCESS_PRESETS,
DID_YOU_MEAN, describeTarget,
} from './resolverVocab';
describe('resolverVocab', () => {
it('maps common orthographic tokens to IPA', () => {
expect(PHONEME_ALIASES['sh']).toBe('ʃ');
expect(PHONEME_ALIASES['ch']).toBe('tʃ');
expect(PHONEME_ALIASES['th']).toBe('θ');
expect(PHONEME_ALIASES['ng']).toBe('ŋ');
expect(PHONEME_ALIASES['r']).toBe('ɹ');
expect(PHONEME_ALIASES['g']).toBe('ɡ'); // U+0261
});
it('recognizes core IPA phonemes as pass-through', () => {
for (const p of ['ɹ', 'ʃ', 'tʃ', 'θ', 's', 'k', 't', 'l']) {
expect(IPA_PHONEMES.has(p)).toBe(true);
}
});
it('maps position synonyms to canonical positions', () => {
expect(POSITION_SYNONYMS['initial']).toBe('initial');
expect(POSITION_SYNONYMS['beginning']).toBe('initial');
expect(POSITION_SYNONYMS['start']).toBe('initial');
expect(POSITION_SYNONYMS['middle']).toBe('medial');
expect(POSITION_SYNONYMS['end']).toBe('final');
expect(POSITION_SYNONYMS['ending']).toBe('final');
});
it('has process presets for the high-demand processes', () => {
for (const name of ['fronting', 'stopping', 'gliding', 'cluster reduction', 'final consonant deletion']) {
expect(PROCESS_PRESETS[name]).toBeDefined();
expect(PROCESS_PRESETS[name].mode).toBe('process');
expect(PROCESS_PRESETS[name].phones.length).toBeGreaterThan(0);
}
// s-blends is a cluster-reduction alias
expect(PROCESS_PRESETS['s-blends'].key).toBe(PROCESS_PRESETS['cluster reduction'].key);
});
it('did-you-mean map excludes exact process names', () => {
// process names must not appear as fuzzy near-miss keys
for (const key of Object.keys(DID_YOU_MEAN)) {
expect(PROCESS_PRESETS[key]).toBeUndefined();
}
});
it('describeTarget renders readable labels for each mode', () => {
expect(describeTarget({ mode: 'sound', phones: ['ɹ'], position: 'initial' })).toBe('Initial /ɹ/');
expect(describeTarget({ mode: 'sound', phones: ['s'], position: 'any' })).toBe('/s/');
expect(describeTarget({ mode: 'contrast', phones: ['k', 't'] })).toBe('/k/ vs /t/');
expect(describeTarget({ mode: 'process', phones: ['k', 'ɡ'], processPreset: 'fronting' }))
.toBe(PROCESS_PRESETS['fronting'].label);
});
});
- [ ] Step 2: Run the test to verify it fails
Run: npm test -- --run src/lib/resolver/resolverVocab.test.ts
Expected: FAIL — module not found.
- [ ] Step 3: Implement the vocabulary
Create src/lib/resolver/resolverVocab.ts:
/**
* Curated resolver vocabulary (spec §5.2). Everything the target box
* understands is hand-listed here — no embeddings, no fuzzy matching of
* clinical terms. IPA is the canonical direction.
*/
import type { TargetSpec } from '../../types/therapyPack';
/** Orthographic / ASCII token → IPA. Keys are lowercased. */
export const PHONEME_ALIASES: Record<string, string> = {
// digraphs
sh: 'ʃ', ch: 'tʃ', th: 'θ', zh: 'ʒ', ng: 'ŋ',
// single-letter clinical names
r: 'ɹ', g: 'ɡ', j: 'dʒ', y: 'j',
// pass-through-friendly ASCII that equals IPA
p: 'p', b: 'b', t: 't', d: 'd', k: 'k', f: 'f', v: 'v',
s: 's', z: 'z', h: 'h', m: 'm', n: 'n', l: 'l', w: 'w',
};
/** Valid IPA phonemes that pass through unchanged. */
export const IPA_PHONEMES: Set<string> = new Set([
'p', 'b', 't', 'd', 'k', 'ɡ', 'f', 'v', 'θ', 'ð', 's', 'z', 'ʃ', 'ʒ',
'h', 'm', 'n', 'ŋ', 'l', 'ɹ', 'w', 'j', 'tʃ', 'dʒ',
'i', 'ɪ', 'e', 'ɛ', 'æ', 'ɑ', 'ɔ', 'o', 'ʊ', 'u', 'ʌ', 'ə', 'ɚ', 'ɝ',
'aɪ', 'aʊ', 'ɔɪ', 'eɪ', 'oʊ',
]);
export const POSITION_SYNONYMS: Record<string, 'initial' | 'medial' | 'final' | 'any'> = {
initial: 'initial', beginning: 'initial', begin: 'initial', start: 'initial',
starting: 'initial', onset: 'initial', 'word-initial': 'initial', front: 'initial',
medial: 'medial', middle: 'medial', mid: 'medial', intervocalic: 'medial',
final: 'final', end: 'final', ending: 'final', coda: 'final', 'word-final': 'final',
any: 'any', anywhere: 'any', all: 'any',
};
/** Splits a two-sound contrast expression. Spaced hyphen/slash only, so
* "s-blends" (cluster) and "/r/" (wrapped) are NOT split as contrasts. */
export const CONTRAST_SEPARATORS = /\s+(?:vs\.?|versus|v|\/|-)\s+/;
export interface ProcessPreset {
key: string;
label: string;
mode: 'process';
phones: string[];
}
const P = {
fronting: { key: 'fronting', label: 'Velar fronting', mode: 'process' as const, phones: ['k', 'ɡ'] },
stopping: { key: 'stopping', label: 'Stopping', mode: 'process' as const, phones: ['f', 'v', 's', 'z', 'ʃ', 'θ'] },
gliding: { key: 'gliding', label: 'Gliding of liquids', mode: 'process' as const, phones: ['ɹ', 'l'] },
clusterReduction: { key: 'cluster-reduction', label: 'Cluster reduction', mode: 'process' as const, phones: ['s', 'l', 'ɹ'] },
finalConsonantDeletion: { key: 'final-consonant-deletion', label: 'Final consonant deletion', mode: 'process' as const, phones: ['t', 'd', 'k', 'ɡ', 'p', 'b'] },
deaffrication: { key: 'deaffrication', label: 'Deaffrication', mode: 'process' as const, phones: ['tʃ', 'dʒ'] },
vocalicR: { key: 'vocalic-r', label: 'Vocalic /r/', mode: 'process' as const, phones: ['ɚ', 'ɝ'] },
};
/** Every recognized process name/alias → its preset. Keys lowercased. */
export const PROCESS_PRESETS: Record<string, ProcessPreset> = {
'fronting': P.fronting, 'velar fronting': P.fronting,
'stopping': P.stopping,
'gliding': P.gliding, 'gliding of liquids': P.gliding,
'cluster reduction': P.clusterReduction, 'clusters': P.clusterReduction,
's-blends': P.clusterReduction, 's blends': P.clusterReduction, 's-clusters': P.clusterReduction, 's clusters': P.clusterReduction,
'r-blends': P.clusterReduction, 'r blends': P.clusterReduction,
'l-blends': P.clusterReduction, 'l blends': P.clusterReduction,
'final consonant deletion': P.finalConsonantDeletion, 'fcd': P.finalConsonantDeletion,
'deaffrication': P.deaffrication,
'vocalic r': P.vocalicR, 'vocalic /r/': P.vocalicR, 'r-controlled': P.vocalicR,
};
/** Hand-seeded near-miss → suggested canonical input. NO process names
* (those are matched exactly; we never fuzzy-guess a clinical term). */
export const DID_YOU_MEAN: Record<string, string> = {
arr: 'r', are: 'r', ess: 's', ell: 'l', kay: 'k',
'r sound': 'r', 's sound': 's',
};
export function describeTarget(target: TargetSpec): string {
if (target.mode === 'process') {
// Prefer the preset label when the key is known.
const preset = Object.values(PROCESS_PRESETS).find((p) => p.key === target.processPreset);
if (preset) return preset.label;
return target.phones.map((p) => `/${p}/`).join(', ');
}
if (target.mode === 'contrast') {
return target.phones.map((p) => `/${p}/`).join(' vs ');
}
// sound
const slug = target.phones.map((p) => `/${p}/`).join(', ');
if (target.position && target.position !== 'any') {
const cap = target.position.charAt(0).toUpperCase() + target.position.slice(1);
return `${cap} ${slug}`;
}
return slug;
}
- [ ] Step 4: Run the test to verify it passes
Run: npm test -- --run src/lib/resolver/resolverVocab.test.ts
Expected: PASS (6/6).
- [ ] Step 5: Type-check and commit
npm run type-check
git add src/lib/resolver/resolverVocab.ts src/lib/resolver/resolverVocab.test.ts
git commit -m "feat(packs): curated target-resolver vocabulary + describeTarget (PHON-170)"
Task 2: resolveTarget parser¶
Files:
- Create: src/lib/resolver/resolveTarget.ts
- Test: src/lib/resolver/resolveTarget.test.ts
Interfaces:
- Consumes: TargetSpec from ../../types/therapyPack; all exports of ./resolverVocab.
- Produces:
- export type ResolveResult = { status: 'resolved'; target: TargetSpec; label: string } | { status: 'suggestion'; note: string; suggestedInput: string } | { status: 'empty' } | { status: 'unresolved' };
- export function resolveTarget(input: string): ResolveResult
Parse order (first match wins):
1. Empty/whitespace → { status: 'empty' }.
2. Lowercase + trim a working copy. Exact process-preset match (whole normalized string in PROCESS_PRESETS) → resolved process target.
3. Contrast: split the normalized string on CONTRAST_SEPARATORS; if exactly two non-empty parts and BOTH resolve to a single phoneme → resolved contrast target (phones [a, b], position from any position word found in the original input, default omitted).
4. Single sound (+ optional position): strip wrapping slashes; pull out a position word if present; resolve the remaining token to one phoneme → resolved sound target with that position (or any if none).
5. Did-you-mean: if the normalized string is a key in DID_YOU_MEAN, return { status: 'suggestion', suggestedInput, note: "Did you mean \"<suggestedInput>\"?" }.
6. Otherwise { status: 'unresolved' }.
A single token resolves to a phoneme via: exact IPA_PHONEMES membership, else PHONEME_ALIASES lookup. Slash-wrapping (/r/, / ʃ /) is stripped before lookup.
- [ ] Step 1: Write the failing test
Create src/lib/resolver/resolveTarget.test.ts:
import { describe, it, expect } from 'vitest';
import { resolveTarget } from './resolveTarget';
describe('resolveTarget', () => {
it('resolves a slash-wrapped phoneme', () => {
const r = resolveTarget('/r/');
expect(r.status).toBe('resolved');
if (r.status === 'resolved') {
expect(r.target).toMatchObject({ mode: 'sound', phones: ['ɹ'], position: 'any' });
expect(r.label).toBe('/ɹ/');
}
});
it('resolves a bare orthographic sound with a position word', () => {
const r = resolveTarget('final s');
expect(r.status).toBe('resolved');
if (r.status === 'resolved') {
expect(r.target).toMatchObject({ mode: 'sound', phones: ['s'], position: 'final' });
expect(r.label).toBe('Final /s/');
}
});
it('resolves a digraph with position synonym', () => {
const r = resolveTarget('beginning sh');
expect(r.status).toBe('resolved');
if (r.status === 'resolved') {
expect(r.target).toMatchObject({ mode: 'sound', phones: ['ʃ'], position: 'initial' });
}
});
it('resolves a contrast with "vs"', () => {
const r = resolveTarget('k vs t');
expect(r.status).toBe('resolved');
if (r.status === 'resolved') {
expect(r.target).toMatchObject({ mode: 'contrast', phones: ['k', 't'] });
expect(r.label).toBe('/k/ vs /t/');
}
});
it('resolves a contrast with a spaced slash', () => {
const r = resolveTarget('s / z');
expect(r.status).toBe('resolved');
if (r.status === 'resolved') expect(r.target.phones).toEqual(['s', 'z']);
});
it('resolves a process preset name', () => {
const r = resolveTarget('fronting');
expect(r.status).toBe('resolved');
if (r.status === 'resolved') {
expect(r.target).toMatchObject({ mode: 'process', processPreset: 'fronting' });
}
});
it('resolves an s-blends alias to the cluster-reduction preset', () => {
const r = resolveTarget('s-blends');
expect(r.status).toBe('resolved');
if (r.status === 'resolved') expect(r.target.processPreset).toBe('cluster-reduction');
});
it('offers a did-you-mean suggestion for a near-miss', () => {
const r = resolveTarget('arr');
expect(r.status).toBe('suggestion');
if (r.status === 'suggestion') {
expect(r.suggestedInput).toBe('r');
expect(r.note).toMatch(/did you mean/i);
}
});
it('does NOT fuzzy-guess a mistyped process name', () => {
// "froning" is not in DID_YOU_MEAN and is not an exact process preset
expect(resolveTarget('froning').status).toBe('unresolved');
});
it('returns empty for blank input', () => {
expect(resolveTarget(' ').status).toBe('empty');
});
it('returns unresolved for gibberish', () => {
expect(resolveTarget('qwxz').status).toBe('unresolved');
});
});
- [ ] Step 2: Run the test to verify it fails
Run: npm test -- --run src/lib/resolver/resolveTarget.test.ts
Expected: FAIL — module not found.
- [ ] Step 3: Implement the parser
Create src/lib/resolver/resolveTarget.ts:
import type { TargetSpec } from '../../types/therapyPack';
import {
PHONEME_ALIASES, IPA_PHONEMES, POSITION_SYNONYMS, CONTRAST_SEPARATORS,
PROCESS_PRESETS, DID_YOU_MEAN, describeTarget,
} from './resolverVocab';
export type ResolveResult =
| { status: 'resolved'; target: TargetSpec; label: string }
| { status: 'suggestion'; note: string; suggestedInput: string }
| { status: 'empty' }
| { status: 'unresolved' };
/** Strip wrapping slashes and surrounding whitespace: "/ r /" → "r". */
function stripSlashes(s: string): string {
return s.trim().replace(/^\/+/, '').replace(/\/+$/, '').trim();
}
/** Resolve a single token to one IPA phoneme, or null. */
function resolvePhoneme(token: string): string | null {
const t = stripSlashes(token).toLowerCase();
if (!t) return null;
if (IPA_PHONEMES.has(t)) return t;
if (PHONEME_ALIASES[t]) return PHONEME_ALIASES[t];
// Also allow an IPA symbol that is uppercase-insensitive no-op (already lc).
return null;
}
/** Pull the first position word out of a token list; return the canonical
* position and the remaining tokens. */
function extractPosition(tokens: string[]): {
position: 'initial' | 'medial' | 'final' | 'any';
rest: string[];
} {
const rest: string[] = [];
let position: 'initial' | 'medial' | 'final' | 'any' = 'any';
let found = false;
for (const tok of tokens) {
const key = tok.toLowerCase();
if (!found && POSITION_SYNONYMS[key]) {
position = POSITION_SYNONYMS[key];
found = true;
} else {
rest.push(tok);
}
}
return { position, rest };
}
export function resolveTarget(input: string): ResolveResult {
if (!input || !input.trim()) return { status: 'empty' };
const normalized = input.trim().toLowerCase();
// 1) Exact process preset.
const preset = PROCESS_PRESETS[normalized];
if (preset) {
const target: TargetSpec = {
mode: 'process', phones: [...preset.phones], processPreset: preset.key,
};
return { status: 'resolved', target, label: describeTarget(target) };
}
// 2) Contrast (two sounds separated by vs / versus / v / spaced slash / spaced hyphen).
const parts = normalized.split(CONTRAST_SEPARATORS).map((p) => p.trim()).filter(Boolean);
if (parts.length === 2) {
const a = resolvePhoneme(parts[0]);
const b = resolvePhoneme(parts[1]);
if (a && b) {
const target: TargetSpec = { mode: 'contrast', phones: [a, b] };
return { status: 'resolved', target, label: describeTarget(target) };
}
}
// 3) Single sound (+ optional position word).
const tokens = stripSlashes(normalized).split(/\s+/).filter(Boolean);
const { position, rest } = extractPosition(tokens);
if (rest.length === 1) {
const phone = resolvePhoneme(rest[0]);
if (phone) {
const target: TargetSpec = { mode: 'sound', phones: [phone], position };
return { status: 'resolved', target, label: describeTarget(target) };
}
}
// 4) Did-you-mean (never for process names — they're only in PROCESS_PRESETS).
const suggestion = DID_YOU_MEAN[normalized];
if (suggestion) {
return { status: 'suggestion', suggestedInput: suggestion, note: `Did you mean "${suggestion}"?` };
}
return { status: 'unresolved' };
}
- [ ] Step 4: Run the test to verify it passes
Run: npm test -- --run src/lib/resolver/resolveTarget.test.ts
Expected: PASS (11/11).
- [ ] Step 5: Lint, type-check, and commit
npm run type-check
npm run lint
git add src/lib/resolver/resolveTarget.ts src/lib/resolver/resolveTarget.test.ts
git commit -m "feat(packs): deterministic target resolver (sound/contrast/process/did-you-mean) (PHON-170)"
Self-Review notes (coverage vs spec §5.2)¶
- Phonemes + aliases (digraphs, ASCII→IPA): Task 1
PHONEME_ALIASES+ Task 2resolvePhoneme. ✓ - Positions + everyday synonyms: Task 1
POSITION_SYNONYMS+ Task 2extractPosition. ✓ - Contrast syntax (
vs///-): Task 1CONTRAST_SEPARATORS+ Task 2 contrast branch. ✓ - Clusters/blends + process presets → curated target sets: Task 1
PROCESS_PRESETS(s/r/l-blends → cluster-reduction; fronting/stopping/gliding/fcd/deaffrication/vocalic-r). ✓ - Deterministic parse → resolved chip; Build only on resolved:
ResolveResultdiscriminated union; onlyresolvedcarries a target. ✓ - Did-you-mean hand-seeded, badged, never silent; process names excluded from fuzzy: Task 1
DID_YOU_MEAN(no process keys, enforced by a test) + Task 2 suggestion branch runs only after exact matches fail. ✓ - Deferred to Phase 3b/3c (not this phase): the target box UI + chip rendering (3c); mapping a
TargetSpecto seeding API bodies (3b); named pre-built-pack resolution (Phase 4). The resolver output (TargetSpec+ label) is the contract those consume.
Decisions resolved at planning¶
thresolves toθ(voiceless, the more common initial target); theðvariant is not auto-offered in 3a (a future did-you-mean/refine nicety).- Cluster/blend targets are represented as the
cluster-reductionprocess preset (TargetSpec has no dedicated cluster mode); the phones array carries the cluster family. 3b seeding interpretsprocessPresetto build the right search. - Letter
j→dʒ(the "jump" sound clinicians usually mean) andy→j(the glide); single ambiguous vowel letters are leftunresolvedrather than guessed.