Skip to content

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 by WordSearchRequest, 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/removedKeys partition; the codebase already carries provenance.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 in shown until 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_shuffled are 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.