Therapy-Pack Seeding Variability 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: Give returning users a deliberate "↻ Shuffle / More like this" that re-draws a seeded pack's words, contrasts, and sentences from a wider candidate pool, while the first seed stays a reproducible baseline and user edits are never destroyed.
Architecture: Each seeded surface fetches a 200-deep candidate pool (words lemma-deduped); the deterministic baseline is the pool's top-N. Shuffle draws a rank-weighted sample of the not-yet-shown remainder of the cached pool and replaces only the auto-seeded (provenance.sourceTool === 'guided') items, preserving everything the user added. Pool + shown-keys live session-scoped in the pack store (no schema migration); graceful degradation is automatic when a pool is smaller than the batch.
Tech Stack: React + TypeScript + Zustand + MUI (frontend, vitest); Hono + D1 on Cloudflare Workers (vitest-pool-workers).
Global Constraints¶
- Pool depth per surface: 200 (app-standard candidate cap). Constant
POOL_DEPTH = 200. - Baseline batch sizes: words 20, contrasts 12, sentences 10 (unchanged from today).
- Words pool must be lemma-deduped: pass
lemmas_only: true(already supported byWordSearchRequest, PHON-194). - Baseline path uses no RNG (reproducible). Only shuffle uses randomness (frontend
Math.random, injectable for tests). - "Seeded" =
provenance.sourceTool === 'guided'. Shuffle replaces only these; all other provenance is preserved. - No D1 schema change, no reseed, no new prebuilt packs (that is the separate catalog-expansion research spec).
- Terminology: "feature vectors," never "embeddings."
Task 1: Pin the words sort with a tie-breaker (Worker)¶
Makes baseline ordering guaranteed-stable instead of incidentally stable (equal-frequency rows currently fall back to SQLite default order).
Files:
- Modify: packages/web/workers/src/routes/words.ts:214-217
- Test: packages/web/workers/src/__tests__/words.test.ts (add one case; file already exists)
Interfaces:
- Produces: no new symbols — a behavior change to the /api/words/search ORDER BY clause.
- [ ] Step 1: Write the failing test
Add to packages/web/workers/src/__tests__/words.test.ts (inside the existing top-level describe). This asserts equal-frequency results are alphabetically ordered and stable across two identical calls:
import { SELF } from 'cloudflare:test';
it('orders equal-frequency words deterministically by word (tie-breaker)', async () => {
const call = async () => {
const res = await SELF.fetch('https://x/api/words/search', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ patterns: [{ type: 'STARTS_WITH', phoneme: 's' }], sort_by: 'frequency', sort_order: 'desc', limit: 50 }),
});
expect(res.status).toBe(200);
return (await res.json()) as { items: { word: string }[] };
};
const first = await call();
const second = await call();
expect(first.items.map((w) => w.word)).toEqual(second.items.map((w) => w.word));
});
- [ ] Step 2: Run test to verify it passes-or-fails against current code
Run: cd packages/web/workers && npx vitest run src/__tests__/words.test.ts -t "tie-breaker"
Expected: PASS today only by luck (SQLite is incidentally stable). This test locks the guarantee; proceed to make the ordering explicit so it can never regress.
- [ ] Step 3: Add the tie-breaker to the ORDER BY
In packages/web/workers/src/routes/words.ts, change the sort block (currently lines 214-217):
if (sortBy) {
const prefix = isWordsTableColumn(sortBy) ? 'w' : 'wp';
// `, w.word` pins tie ordering so equal-value rows are deterministic,
// not SQLite-default (baseline reproducibility, seeding-variability spec §5.2).
orderSQL = ` ORDER BY ${prefix}.${sortBy} IS NULL, ${prefix}.${sortBy} ${sortOrder}, w.word`;
}
- [ ] Step 4: Run the words test suite
Run: cd packages/web/workers && npx vitest run src/__tests__/words.test.ts
Expected: PASS (all cases).
- [ ] Step 5: Commit
git add packages/web/workers/src/routes/words.ts packages/web/workers/src/__tests__/words.test.ts
git commit -m "fix(words): pin sort tie-breaker on frequency for stable baseline ordering"
Task 2: sampleFresh — pure weighted sample-without-replacement helper (Frontend)¶
The one home for the sampling + shown-tracking + graceful-degradation logic. Pure and injectable-random so it is fully unit-testable.
Files:
- Create: packages/web/frontend/src/lib/seed/sampleFresh.ts
- Test: packages/web/frontend/src/lib/seed/sampleFresh.test.ts
Interfaces:
- Produces:
- sampleFresh<T>(pool: T[], n: number, shown: Set<string>, keyOf: (t: T) => string, rand?: () => number): { items: T[]; shown: Set<string> }
- Behavior: samples n items weighted toward the front of the source array (linear rank decay), without replacement within the draw; excludes items whose key is in shown; when fewer than n unshown items remain, resets shown and samples the full pool (wrap). Returns the new items and the updated shown-set (never mutates the input set). rand defaults to Math.random.
- [ ] Step 1: Write the failing tests
Create packages/web/frontend/src/lib/seed/sampleFresh.test.ts:
import { describe, it, expect } from 'vitest';
import { sampleFresh } from './sampleFresh';
const key = (s: string) => s;
// Deterministic RNG: cycles a fixed sequence so tests are reproducible.
function seqRand(seq: number[]): () => number {
let i = 0;
return () => seq[i++ % seq.length];
}
describe('sampleFresh', () => {
const pool = Array.from({ length: 50 }, (_, i) => `w${i}`);
it('returns n items, none already shown', () => {
const shown = new Set(['w0', 'w1', 'w2']);
const { items } = sampleFresh(pool, 10, shown, key, seqRand([0.1, 0.4, 0.9, 0.2, 0.7]));
expect(items).toHaveLength(10);
expect(items.some((w) => shown.has(w))).toBe(false);
expect(new Set(items).size).toBe(10); // no dup within a draw
});
it('accumulates shown across successive draws until pool is exhausted', () => {
let shown = new Set<string>();
const seen = new Set<string>();
for (let d = 0; d < 5; d++) {
const r = sampleFresh(pool, 10, shown, key, seqRand([0.3, 0.6, 0.1, 0.8, 0.5]));
r.items.forEach((w) => seen.add(w));
shown = r.shown;
}
expect(seen.size).toBe(50); // 5 draws × 10 covered the whole pool with no repeats
});
it('wraps (resets shown) when remainder is smaller than n', () => {
const shown = new Set(pool.slice(0, 45)); // only 5 unshown, need 10
const { items, shown: next } = sampleFresh(pool, 10, shown, key, seqRand([0.2, 0.5, 0.9]));
expect(items).toHaveLength(10); // still returns a full batch by wrapping
expect(next.size).toBe(10); // shown was reset, then this batch added
});
it('degrades gracefully: pool smaller than n returns the whole pool every time', () => {
const small = ['a', 'b', 'c'];
const { items } = sampleFresh(small, 10, new Set(), key, seqRand([0.5]));
expect(new Set(items)).toEqual(new Set(small));
});
it('does not mutate the input shown set', () => {
const shown = new Set(['w0']);
sampleFresh(pool, 5, shown, key, seqRand([0.4]));
expect(shown).toEqual(new Set(['w0']));
});
});
- [ ] Step 2: Run tests to verify they fail
Run: cd packages/web/frontend && npx vitest run src/lib/seed/sampleFresh.test.ts
Expected: FAIL ("Failed to resolve import './sampleFresh'").
- [ ] Step 3: Implement
sampleFresh
Create packages/web/frontend/src/lib/seed/sampleFresh.ts:
/**
* Rank-weighted sample without replacement, with shown-tracking and automatic
* wrap. Seeding-variability spec §3: pools arrive pre-sorted by each surface's
* ranking signal (frequency / feature_distance / rarity), so a linear rank
* decay biases fresh draws toward the top of the pool without needing the raw
* score. Pure; `rand` is injectable for deterministic tests.
*/
export function sampleFresh<T>(
pool: T[],
n: number,
shown: Set<string>,
keyOf: (t: T) => string,
rand: () => number = Math.random,
): { items: T[]; shown: Set<string> } {
const unshown = pool.filter((t) => !shown.has(keyOf(t)));
// Wrap when the remainder can't fill a batch: reset shown, draw from full pool.
const wrap = unshown.length < n;
const source = wrap ? pool : unshown;
const nextShown = wrap ? new Set<string>() : new Set(shown);
const items = weightedDraw(source, Math.min(n, source.length), rand);
for (const it of items) nextShown.add(keyOf(it));
return { items, shown: nextShown };
}
/** Draw `k` distinct items, weight = (len - index) so earlier items are likelier. */
function weightedDraw<T>(source: T[], k: number, rand: () => number): T[] {
const pool = source.map((item, index) => ({ item, weight: source.length - index }));
const out: T[] = [];
for (let picked = 0; picked < k && pool.length > 0; picked++) {
const total = pool.reduce((s, e) => s + e.weight, 0);
let r = rand() * total;
let idx = 0;
while (idx < pool.length - 1 && r >= pool[idx].weight) {
r -= pool[idx].weight;
idx++;
}
out.push(pool[idx].item);
pool.splice(idx, 1);
}
return out;
}
- [ ] Step 4: Run tests to verify they pass
Run: cd packages/web/frontend && npx vitest run src/lib/seed/sampleFresh.test.ts
Expected: PASS (5 tests).
- [ ] Step 5: Commit
git add packages/web/frontend/src/lib/seed/sampleFresh.ts packages/web/frontend/src/lib/seed/sampleFresh.test.ts
git commit -m "feat(seed): sampleFresh rank-weighted sampler with shown-tracking + wrap"
Task 3: Build 200-deep pools + lemma dedup in the seeder (Frontend)¶
Enlarge seeding to fetch a pool per surface, keep the deterministic top-N baseline, and export the pools + stable key functions the store needs to shuffle.
Files:
- Modify: packages/web/frontend/src/lib/seed/packSeeder.ts
- Test: packages/web/frontend/src/lib/seed/packSeeder.test.ts (add cases; file already exists)
Interfaces:
- Consumes: nothing new (uses existing SeedDeps injection).
- Produces:
- POOL_DEPTH = 200 (exported const)
- SeedPools { words: PackWord[]; contrasts: PackContrast[]; sentences: PackSentence[] }
- SeededContent gains pools: SeedPools (baseline words/contrasts/sentences fields unchanged, and are exactly pools.*.slice(0, N) — same object identities)
- wordKey(w: PackWord) => string, contrastKey(c: PackContrast) => string, sentenceKey(s: PackSentence) => string
- [ ] Step 1: Write the failing tests
Add to packages/web/frontend/src/lib/seed/packSeeder.test.ts:
import { seedPack, POOL_DEPTH, wordKey } from './packSeeder';
import type { WordSearchRequest } from '../../services/apiClient';
it('requests a lemma-deduped 200-deep word pool and slices a 20-word baseline', async () => {
const requests: WordSearchRequest[] = [];
const makeWords = (n: number) =>
Array.from({ length: n }, (_, i) => ({ word: `word${i}`, ipa: `w${i}`, has_image: false }));
const seeded = await seedPack(
{ mode: 'sound', phones: ['s'], position: 'initial' },
{
searchWords: async (req) => { requests.push(req); return { items: makeWords(POOL_DEPTH), total: POOL_DEPTH, offset: 0, limit: req.limit ?? 0 } as any; },
getMinimalPairs: async () => [],
fetchSentences: async () => ({ corpus_matches: [] } as any),
now: () => 1000,
},
);
expect(requests.every((r) => r.lemmas_only === true)).toBe(true); // lemma dedup on
expect(seeded.pools.words.length).toBe(POOL_DEPTH); // full pool present
expect(seeded.words.length).toBe(20); // baseline top-20
expect(seeded.words).toEqual(seeded.pools.words.slice(0, 20)); // baseline is pool head
});
it('exposes a stable word key independent of item id', () => {
expect(wordKey({ word: 'Red' } as any)).toBe(wordKey({ word: 'red' } as any));
});
- [ ] Step 2: Run tests to verify they fail
Run: cd packages/web/frontend && npx vitest run src/lib/seed/packSeeder.test.ts -t "200-deep"
Expected: FAIL (POOL_DEPTH/pools not exported).
- [ ] Step 3: Add pool constant, keys, and pool-returning seed
In packages/web/frontend/src/lib/seed/packSeeder.ts:
(a) Add near the top (after imports):
export const POOL_DEPTH = 200;
export const wordKey = (w: PackWord): string => w.word.toLowerCase();
export const contrastKey = (c: PackContrast): string => `${c.a}|${c.b}`.toLowerCase();
export const sentenceKey = (s: PackSentence): string => s.text;
export interface SeedPools {
words: PackWord[];
contrasts: PackContrast[];
sentences: PackSentence[];
}
(b) Change seedWords's total default to POOL_DEPTH and add lemmas_only: true to both search requests. In roundFor's request object add lemmas_only: true:
() => searchWords({
patterns: [{ type: patternTypeFor(pos), phoneme: phone }],
sort_by: 'frequency', sort_order: 'desc',
lemmas_only: true,
...(withImage ? { has_image: true } : {}),
limit,
}).then((r) => r.items),
and the signature: async function seedWords(target, searchWords, at, total = POOL_DEPTH).
(c) Extend SeededContent and rewrite seedPack to build pools and slice baselines:
export interface SeededContent {
words: PackWord[];
contrasts: PackContrast[];
sentences: PackSentence[];
pools: SeedPools;
}
export async function seedPack(
target: TargetSpec,
deps: Partial<SeedDeps> = {},
): Promise<SeededContent> {
const searchWords = deps.searchWords ?? api.searchWords.bind(api);
const getMinimalPairs = deps.getMinimalPairs ?? api.getMinimalPairs.bind(api);
const fetchSents = deps.fetchSentences ?? fetchSentences;
const now = deps.now ?? Date.now;
const at = now();
const wordsPoolP = safe(() => seedWords(target, searchWords, at, POOL_DEPTH), [] as PackWord[]);
const contrastsPoolP = target.mode === 'contrast' && target.phones.length >= 2
? safe(async () => {
const pairs = await getMinimalPairs({
phoneme1: target.phones[0],
phoneme2: target.phones[1],
position: target.position && target.position !== 'any' ? target.position : undefined,
limit: POOL_DEPTH,
});
return pairs.map((p) => mapPairToPackContrast(p, at));
}, [] as PackContrast[])
: Promise.resolve([] as PackContrast[]);
const sentencesPoolP = safe(async () => {
const res = await fetchSents({ constraints: sentenceConstraintsFor(target), top_k: POOL_DEPTH });
return res.corpus_matches.map((m) => mapSentenceToPackSentence(m, at));
}, [] as PackSentence[]);
const [wordsPool, contrastsPool, sentencesPool] = await Promise.all([wordsPoolP, contrastsPoolP, sentencesPoolP]);
return {
words: wordsPool.slice(0, 20),
contrasts: contrastsPool.slice(0, 12),
sentences: sentencesPool.slice(0, 10),
pools: { words: wordsPool, contrasts: contrastsPool, sentences: sentencesPool },
};
}
- [ ] Step 4: Run the seeder tests
Run: cd packages/web/frontend && npx vitest run src/lib/seed/packSeeder.test.ts
Expected: PASS (existing cases still green — baseline .words/.contrasts/.sentences fields preserved — plus the two new cases).
- [ ] Step 5: Commit
git add packages/web/frontend/src/lib/seed/packSeeder.ts packages/web/frontend/src/lib/seed/packSeeder.test.ts
git commit -m "feat(seed): 200-deep lemma-deduped pools per surface + stable item keys"
Task 4: shufflePack store action + session pool cache (Frontend)¶
Cache pools + shown-keys per pack (session-scoped, not persisted), and add the action that re-draws every populated surface while preserving user edits.
Files:
- Modify: packages/web/frontend/src/store/packStore.ts
- Test: packages/web/frontend/src/store/packStore.test.ts (add cases; file already exists)
Interfaces:
- Consumes: sampleFresh (Task 2); seedPack, SeedPools, wordKey, contrastKey, sentenceKey (Task 3).
- Produces: PackState.shufflePack: () => Promise<void>.
- [ ] Step 1: Write the failing tests
Add to packages/web/frontend/src/store/packStore.test.ts (follow the file's existing setup for resetting the store + fake-IndexedDB; construct a seeded pack via createSeededPack). Core assertions:
import { wordKey } from '../lib/seed/packSeeder';
function poolWords(prefix: string, n: number) {
return Array.from({ length: n }, (_, i) => ({
kind: 'word' as const, id: `${prefix}${i}`, word: `${prefix}${i}`,
provenance: { sourceTool: 'guided' as const, addedAt: 1, reason: 'seeded target word' },
}));
}
it('shuffle replaces guided words with fresh ones and keeps user-added items', async () => {
const seeded = {
words: poolWords('w', 20),
contrasts: [], sentences: [],
pools: { words: poolWords('w', 200), contrasts: [], sentences: [] },
};
const store = usePackStore.getState();
store.createSeededPack({ mode: 'sound', phones: ['s'], position: 'initial' }, seeded, 'S set');
// user swaps in a favorite (manual provenance)
store.addItem({ kind: 'word', id: 'mine', word: 'sunshine', provenance: { sourceTool: 'manual', addedAt: 2 } });
const before = new Set(usePackStore.getState().activePack!.words.filter((w) => w.provenance.sourceTool === 'guided').map(wordKey));
await usePackStore.getState().shufflePack();
const after = usePackStore.getState().activePack!.words;
expect(after.some((w) => w.word === 'sunshine')).toBe(true); // user item preserved
const afterGuided = new Set(after.filter((w) => w.provenance.sourceTool === 'guided').map(wordKey));
expect([...afterGuided].some((k) => !before.has(k))).toBe(true); // at least some fresh words
});
it('shuffle is a no-op when the pack has no target', async () => {
usePackStore.getState().newPack(null);
await expect(usePackStore.getState().shufflePack()).resolves.toBeUndefined();
});
- [ ] Step 2: Run tests to verify they fail
Run: cd packages/web/frontend && npx vitest run src/store/packStore.test.ts -t "shuffle"
Expected: FAIL (shufflePack is not a function).
- [ ] Step 3: Implement the session cache + action
In packages/web/frontend/src/store/packStore.ts:
(a) Update imports:
import { seedPack, wordKey, contrastKey, sentenceKey, type SeededContent, type SeedPools } from '../lib/seed/packSeeder';
import { sampleFresh } from '../lib/seed/sampleFresh';
(b) Add above export const usePackStore (module scope):
/** Session-scoped (NOT persisted) pools + shown-keys per pack, so shuffle
* resamples in-memory. Rebuilt on demand if absent (e.g. after a reload). */
interface SeedSession {
pools: SeedPools;
shown: { words: Set<string>; contrasts: Set<string>; sentences: Set<string> };
}
const seedSessions = new Map<string, SeedSession>();
function sessionFromSeed(seeded: SeededContent): SeedSession {
return {
pools: seeded.pools,
shown: {
words: new Set(seeded.words.map(wordKey)),
contrasts: new Set(seeded.contrasts.map(contrastKey)),
sentences: new Set(seeded.sentences.map(sentenceKey)),
},
};
}
(c) Add shufflePack: () => Promise<void>; to the PackState interface.
(d) In createSeededPack, after commit(pack);, stash the session:
commit(pack);
seedSessions.set(pack.id, sessionFromSeed(seeded));
(e) Add the action (place near startOver):
async shufflePack() {
const p = get().activePack;
if (!p || !p.target) return;
let session = seedSessions.get(p.id);
if (!session) {
// Pools weren't cached (e.g. pack reloaded from disk) — rebuild them.
const seeded = await seedPack(p.target);
session = sessionFromSeed(seeded);
seedSessions.set(p.id, session);
}
// Keep everything the user curated; only 'guided' auto-seeds are refreshed.
const keepW = p.words.filter((w) => w.provenance.sourceTool !== 'guided');
const keepC = p.contrasts.filter((c) => c.provenance.sourceTool !== 'guided');
const keepS = p.sentences.filter((s) => s.provenance.sourceTool !== 'guided');
// Exclude kept items' keys from the draw so a fresh guided pick can't
// duplicate a word the user already added.
const wShown = new Set([...session.shown.words, ...keepW.map(wordKey)]);
const cShown = new Set([...session.shown.contrasts, ...keepC.map(contrastKey)]);
const sShown = new Set([...session.shown.sentences, ...keepS.map(sentenceKey)]);
const wr = sampleFresh(session.pools.words, 20, wShown, wordKey);
const cr = sampleFresh(session.pools.contrasts, 12, cShown, contrastKey);
const sr = sampleFresh(session.pools.sentences, 10, sShown, sentenceKey);
session.shown = { words: wr.shown, contrasts: cr.shown, sentences: sr.shown };
commit({
...p,
words: [...wr.items, ...keepW],
contrasts: [...cr.items, ...keepC],
sentences: [...sr.items, ...keepS],
});
track('pack_shuffled');
},
- [ ] Step 4: Run the store tests
Run: cd packages/web/frontend && npx vitest run src/store/packStore.test.ts
Expected: PASS (existing + 2 new). If the track('pack_shuffled') call trips a strict analytics mock, note Task 5 adds it to the allowlist; the frontend track never throws on unknown names (it queues), so the store test passes regardless.
- [ ] Step 5: Commit
git add packages/web/frontend/src/store/packStore.ts packages/web/frontend/src/store/packStore.test.ts
git commit -m "feat(pack): shufflePack action — resample guided items, preserve user edits"
Task 5: Shuffle control in the editor + telemetry event (Frontend + Worker)¶
Surface the action, and allowlist its analytics event.
Files:
- Modify: packages/web/frontend/src/components/pack/PackHeader.tsx
- Modify: packages/web/frontend/src/components/pack/PackEditor.tsx
- Modify: packages/web/workers/src/config/analyticsEvents.ts
- Test: packages/web/frontend/src/components/pack/PackHeader.test.tsx (add case; file exists)
Interfaces:
- Consumes: usePackStore().shufflePack (Task 4).
- Produces: PackHeaderProps.onShuffle: () => void; allowlisted event pack_shuffled.
- [ ] Step 1: Write the failing test
Add to packages/web/frontend/src/components/pack/PackHeader.test.tsx (mirror the existing render helper / props in that file):
it('shows Shuffle for a seeded pack and calls onShuffle', async () => {
const onShuffle = vi.fn();
const pack = { ...basePack, target: { mode: 'sound', phones: ['s'], position: 'initial' } };
renderHeader({ pack, onShuffle }); // extend the file's render helper to pass onShuffle
await userEvent.click(screen.getByRole('button', { name: /shuffle/i }));
expect(onShuffle).toHaveBeenCalledTimes(1);
});
- [ ] Step 2: Run test to verify it fails
Run: cd packages/web/frontend && npx vitest run src/components/pack/PackHeader.test.tsx -t "Shuffle"
Expected: FAIL (no Shuffle button / onShuffle not a prop).
- [ ] Step 3: Add the button + prop
In packages/web/frontend/src/components/pack/PackHeader.tsx: add onShuffle: () => void; to PackHeaderProps, destructure it, and add the button as the first action (only for seeded packs, i.e. when a target exists):
<Stack direction="row" spacing={1} sx={{ mt: 1.5, flexWrap: 'wrap', gap: 1 }}>
{pack.target && (
<Button size="small" variant="outlined" onClick={onShuffle}>↻ Shuffle</Button>
)}
<Button size="small" variant="outlined" onClick={onOpenMyPacks}>My Materials</Button>
- [ ] Step 4: Wire it in
PackEditor.tsx
Find where PackHeader is rendered in packages/web/frontend/src/components/pack/PackEditor.tsx and add the prop, calling the store action (grab shufflePack from usePackStore the same way the file already reads other actions):
onShuffle={() => { void usePackStore.getState().shufflePack(); }}
- [ ] Step 5: Allowlist the telemetry event
In packages/web/workers/src/config/analyticsEvents.ts, add next to pack_edited:
/** Returning-user "↻ Shuffle / More like this" re-draw of a seeded pack. */
pack_shuffled: { props: {} },
- [ ] Step 6: Run the affected tests
Run: cd packages/web/frontend && npx vitest run src/components/pack/PackHeader.test.tsx
Run: cd packages/web/workers && npx vitest run src/__tests__/events.test.ts
Expected: PASS.
- [ ] Step 7: Commit
git add packages/web/frontend/src/components/pack/PackHeader.tsx packages/web/frontend/src/components/pack/PackHeader.test.tsx packages/web/frontend/src/components/pack/PackEditor.tsx packages/web/workers/src/config/analyticsEvents.ts
git commit -m "feat(pack): Shuffle control in editor header + pack_shuffled telemetry"
Task 6: Full-suite verification + version bump¶
Files: - Modify: version chip / drawer per the version-bump checklist (AppHeader chip + drawers) if this ships as a user-visible increment.
Interfaces: none.
- [ ] Step 1: Frontend type-check + lint + tests (the CI matrix)
Run: cd packages/web/frontend && npm run build && npx vitest run
Expected: build succeeds; all tests pass.
- [ ] Step 2: Worker type-check + tests
Run: cd packages/web/workers && npx tsc --noEmit && npx vitest run
Expected: PASS.
- [ ] Step 3: Manual smoke (dev servers)
Start worker + frontend dev servers (see CLAUDE.md Dev Setup). Seed "Initial /ɹ/", confirm the baseline is the same on a fresh seed; click ↻ Shuffle and confirm the guided words change; add a manual word, shuffle again, confirm the manual word survives; confirm no two inflections of one lemma appear in a seeded pack.
- [ ] Step 4: Version bump (if shipping as an increment)
Follow the version-bump checklist: bump the AppHeader version chip + any "what's new" drawer copy. Confirm exact steps against docs/memory feedback_version_bump_checklist before editing.
- [ ] Step 5: Commit
git add -A
git commit -m "chore: version bump for pack seeding variability"
Self-Review¶
- Spec coverage: on-demand refresh (Tasks 4–5) ✓; deterministic baseline = pool top-N (Task 3) ✓; 200 pool depth + lemma dedup (Task 3) ✓; tie-breaker (Task 1) ✓; rank-weighted sample of unshown remainder + wrap + graceful degradation (Task 2) ✓; curation preservation via
provenance(Task 4) ✓; all three surfaces (Tasks 3–4) ✓; session-scoped cache, no schema migration (Task 4) ✓; testing matrix (Task 6) ✓. - Deviation from spec, deliberate: the spec sketched a
seeded/userAdded/removedKeyspartition; the codebase already carriesprovenance.sourceTool('guided'vs the rest), so the plan reuses that seam instead — same intent (shuffle touches only auto-seeds; removed words stay gone because their key is inshownuntil wrap). Noted so a reviewer isn't surprised the literal field names differ. - Type consistency:
POOL_DEPTH,SeedPools,SeededContent.pools,wordKey/contrastKey/sentenceKey,sampleFresh,shufflePack,onShuffle,pack_shuffledare defined once and consumed with matching signatures across tasks. - Placeholder scan: no TBD/TODO; each code step shows the code; each run step shows the command + expected result.