Skip to content

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 suggestion result the UI surfaces as "Did you mean …?" — it is never auto-applied. Build is enabled only on a resolved result. (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ɹ, digraphs shʃ, ch, thθ, zhʒ, ngŋ. Single IPA symbols pass through. (project IPA-normalization pattern)
  • Output type is the existing TargetSpec from src/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 + describeTarget tests.
  • src/lib/resolver/resolveTarget.ts (create) — resolveTarget(input) parser returning ResolveResult.
  • 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 2 resolvePhoneme. ✓
  • Positions + everyday synonyms: Task 1 POSITION_SYNONYMS + Task 2 extractPosition. ✓
  • Contrast syntax (vs / / / -): Task 1 CONTRAST_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: ResolveResult discriminated union; only resolved carries 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 TargetSpec to 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

  1. th resolves to θ (voiceless, the more common initial target); the ð variant is not auto-offered in 3a (a future did-you-mean/refine nicety).
  2. Cluster/blend targets are represented as the cluster-reduction process preset (TargetSpec has no dedicated cluster mode); the phones array carries the cluster family. 3b seeding interprets processPreset to build the right search.
  3. Letter j (the "jump" sound clinicians usually mean) and yj (the glide); single ambiguous vowel letters are left unresolved rather than guessed.