Skip to content

Therapy-Pack Seeding Variability — Design

Date: 2026-07-21 Status: Design (approved in brainstorm; pending spec review) Scope: Frontend seeding layer + two small Worker/API touch-ups. No data pipeline, no reseed. Related: 2026-07-19-therapy-pack-mvp-design.md, 2026-07-20-landing-target-tiles-design.md, PHON-194 (lemmas_only).

1. Problem

Clicking a prebuilt target tile (e.g. "Initial /ɹ/") seeds a pack with the same 20 words in the same order every time, and the same is true for the pack's contrasts and sentences. Two failure modes:

  1. No variability. A returning user who wants "more of the same" for /ɹ/ has no way to get a fresh set — the seeder sorts frequency DESC, caps at 20, and there is no RANDOM(), seed, or shuffle anywhere (packSeeder.ts, packStore.createSeededPack).
  2. Inflection duplicates. The seeder never requests lemmas_only, so run / runs / running can all occupy slots in one pack. The dedup that exists is exact-word only (seen.has(w.word.toLowerCase())).
  3. Incidental (not pinned) ordering. ORDER BY wp.frequency DESC has no tie-breaker, so equal-frequency ordering is SQLite-default — stable for a given D1 snapshot but not guaranteed.

2. Goals / Non-goals

Goals - A returning user can deliberately get a fresh but comparable set of items for the same target. - The first seed of a target stays deterministic, reproducible, and shareable — a stable clinical baseline. - Kill inflection duplicates in seeded packs. - Pin sort order so "stable" is guaranteed, not incidental. - User curation is never destroyed by a refresh. - Apply to all three seeded surfaces: words, contrasts, sentences.

Non-goals - No new prebuilt packs, no literature/demand-driven target expansion — that is the separate "ice cream" research thread (memos already gathered; own spec). - No user-facing tuning knobs (pool depth stays an internal constant). - No seeded-RNG reproducibility of shuffled results — shuffles are intentionally non-reproducible; the saved pack freezes its actual items (versioned local-first draft), so shareability rides on the deterministic baseline, not on replayable randomness.

3. Core model: pool → deterministic baseline → sampled shuffle

Each seeded surface follows one pattern.

  1. Fetch a pool of up to 200 candidates (the app-standard candidate cap), using that surface's existing ranking signal as the order, lemmas_only: true for the word surface.
  2. Baseline = the first N of the pool in ranked order (deterministic). N = the current per-surface cap: words 20, contrasts 12, sentences 10.
  3. Shuffle = a ranking-weighted sample of N drawn without replacement from the not-yet-shown remainder of the cached pool. Track shownKeys per surface. When the remainder drops below N (or is empty), reset shownKeys and resample the full pool.
  4. Graceful degradation, for free. If a target's pool has ≤ N real candidates, every draw returns the same items — indistinguishable from the deterministic baseline. No "not enough data" branch, no disabled control. The Shuffle button always exists and is always safe to press.

The pool is cached on the seeded pack so Shuffle resamples in-memory — instant, offline-friendly, and consistent with the local-first ethos. No refetch per Shuffle click.

Weighting

Sampling is weighted by each surface's native ranking so fresh draws stay clinically sensible rather than uniform-random into the tail: - Words: weight by frequency (image-bearing words already sort first in the pool; see §5.1). - Contrasts: weight by the existing minimal-pair ranking (pairs.feature_distance ordering as fetched). - Sentences: weight by the existing rarity_score / match ranking.

Implementation: weighted sampling without replacement over the unshown remainder, client-side (Math.random; frontend, not a Workflow context — no CSP restriction). Baseline uses no RNG at all, guaranteeing reproducibility.

4. Curation preservation (safety rail)

A pack's item list for each surface is partitioned into: - seeded — auto-generated items the user has not touched. - userAdded — items the user explicitly added. - removedKeys — items the user explicitly removed (so they never resurrect).

Shuffle regenerates only the seeded partition. userAdded items are kept; removedKeys are excluded from every future draw. A clinician who swapped in three favorite /ɹ/ words and dropped one keeps all their edits across any number of shuffles. This partition logic runs per surface, so a pack-level Shuffle that a clinician presses after curating only their words still leaves those words alone while refreshing untouched contrasts and sentences.

5. Component changes

5.1 packSeeder.ts (frontend, primary change)

  • seedWords: request lemmas_only: true, limit: 200, keep the two-round image-first structure so the pool orders image-bearing (freq desc) then non-image (freq desc) up to 200 distinct lemmas; keep the round-robin interleave for multi-phone targets. Return the full pool plus the baseline top-20, not just 20.
  • Add seedContrasts / seedSentences pool fetches: raise getMinimalPairs (limit) and fetchSentences (top_k) to the same 200 pool depth, return pool + baseline (top 12 / top 10). If a surface's endpoint returns fewer than 200 it degrades per §3.4 with no special-casing.
  • New pure helper sampleFresh(pool, n, shownKeys, removedKeys, weightFn){ items, shownKeys }, shared by all three surfaces. This is the sole home of the sampling + degradation logic and is unit-testable in isolation.

5.2 words.ts (Worker, tiny)

  • Add a pinned tie-breaker to the words sort: ORDER BY ${prefix}.${sortBy} IS NULL, ${prefix}.${sortBy} ${sortOrder}, w.word. Makes baseline ordering guaranteed-stable, not incidentally stable.
  • No new endpoint. lemmas_only is already supported by compileWordFilter (PHON-194); the seeder simply starts passing it.

5.3 packStore.ts (frontend state)

  • Extend the seeded-pack shape to carry, per surface: pool, shownKeys, and the seeded / userAdded / removedKeys partition.
  • createSeededPack stores the pool + baseline as the initial seeded set with shownKeys = baseline keys.
  • New action shufflePack(packId) (or shuffleSurface(packId, surface) if we expose per-surface later) that runs sampleFresh for each populated surface, replacing only the seeded partition and updating shownKeys.
  • Add/remove word actions maintain the userAdded / removedKeys partitions.

5.4 Editor UI

  • A single pack-level "↻ Shuffle / More like this" control in the pack editor header, calling shufflePack. Always visible; harmless on small pools (§3.4).
  • Per-surface Shuffle is a trivial future extension (the store action and partition logic are already per-surface) but is out of scope for v1 to keep the surface small.

6. Testing

Unit (sampleFresh + seeder helpers): - Determinism: baseline for a fixed pool is identical across repeated calls (no RNG in the baseline path). - Dedup: a seeded word set contains no two inflections of one lemma (lemmas_only path). - Novelty: successive shuffles do not repeat an item until the unshown remainder is exhausted, then reset cleanly. - Degradation: pool ≤ N returns the same items every shuffle without error. - Curation: userAdded survives shuffle; removedKeys never reappear; only the seeded partition changes. - Tie-breaker: Worker test asserting the emitted ORDER BY includes the w.word secondary key.

7. Explainability / clinical notes

  • The baseline is the same reproducible "top-N most common" set clinicians can print and share; Shuffle is a deliberate, labeled action, not a silent surprise — consistent with "data we display must be explainable."
  • Weighted-toward-frequency sampling keeps shuffled words common and child-friendly; the 200-cap plus cheap word-swapping (user can edit freely) means the deep tail is bounded and low-stakes.

8. Out of scope (explicit)

  • New packs / demand- or literature-driven target expansion (separate research spec).
  • Any change to the prebuilt target list, constraint system, or D1 schema/seed.
  • Reproducible (seeded) randomness for shuffled results.