Skip to content

PHON-221: Sampling Honesty 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: Stop Word Lists from misrepresenting its results. Today the header says "3,412 Words Found · showing first 200" while the rows are a weighted random draw that reshuffles on every Build, and the table lets you re-sort that random 200 as if it were a ranked top-200. This PR adds an explicit ordering control (Varied sample / A–Z / By frequency), makes the results header state what the set actually is, and gives sample mode a Resample affordance instead of silent reshuffling.

Architecture: No worker change — /api/words/search already accepts sort_by/sort_order (validated against SORTABLE_COLUMNS) and already falls back to WEIGHTED_SAMPLE_ORDER_SQL when absent, and re-running the query already redraws. All work is frontend: WordListTable gains ordering awareness (truthful header + Resample slot + no column-sort in sample mode), and Builder owns the ordering state, passes it through WordOptions.sortBy/sortOrder, and re-runs handleBuild for Resample.

Tech Stack: React + TS + MUI, vitest + @testing-library/react.

Spec: docs/superpowers/specs/2026-08-14-constraint-ui-unification-design.md §C "Sampling honesty".

Global Constraints

  • Branch: feat/phon-221-sampling-honesty (created off develop at a24342ed). PR targets develop.
  • Worker untouched. No files under packages/web/workers/ change. If a worker change appears necessary, stop and report — it means the plan is wrong.
  • Other WordListTable consumers must not regress. Grep before editing: the component is also used by Contrast Sets / similarity surfaces. The new props are optional; when ordering is absent the component behaves exactly as today (including column sorting), so every other caller is untouched and un-edited.
  • Word Lists is the only caller that passes ordering in this PR. Sentences/Contrast Sets ordering is out of scope.
  • Copy rules (spec §C): sample mode says it is a sample, names the draw size and the true match count, and offers Resample. Deterministic modes name the ordering. Never the word "first" for a sampled page.
  • Test commands: cd packages/web/frontend && npx tsc --noEmit && npm run lint && npm test && npm run build; worker suite run once at the end to prove it is untouched.
  • Commits end with: Co-Authored-By: Claude Fable 5 noreply@anthropic.com

Task 1: WordListTable ordering awareness

Files: - Modify: packages/web/frontend/src/components/shared/WordListTable.tsx - Test: packages/web/frontend/src/components/shared/WordListTable.test.tsx (create if absent; extend if present — grep first)

Interfaces: - Consumes: existing props (words, total, showSimilarity, enableSelection, defaultSort, onSelectionChange, exportFilename). - Produces (Task 2 consumes):

export type ResultOrdering =
  | { mode: 'sample'; onResample?: () => void }
  | { mode: 'sorted'; label: string };

// added to WordListTableProps:
  /** How the server ordered this page. Omit for the legacy behavior:
   *  "N Words Found" + "showing first N" + client-side column sorting.
   *  When mode==='sample' the page is a weighted random draw, so the header
   *  says so and column sorting is disabled (re-sorting a draw implies a
   *  ranking that does not exist — pick a sorted ordering to get one). */
  ordering?: ResultOrdering;

Behavior (drives the tests): - ordering absent → today's rendering exactly (header N Words Found, showing first N chip when capped, sortable column headers). - ordering.mode === 'sorted' → header reads `${total} matches, sorted by ${label}` when capped, `${n} matches, sorted by ${label}` when not; NO "showing first" chip (the page genuinely is the first N of a defined order — say showing top ${n} instead when capped); column sorting stays enabled. - ordering.mode === 'sample' → header reads `Varied sample of ${words.length} from ${total} matches` when capped, and `Varied sample of ${n} words` when the sample is the whole match set (total == null || total <= words.length). No "showing first" chip. A Resample button (icon Casino or Shuffle, small, outlined) renders beside the header when onResample is provided. Column headers are NOT clickable: render them as plain text with a title/tooltip "Sorting is unavailable for a varied sample — choose A–Z or By frequency to order results." The collapse chip (Showing all N / Showing N of M) is unchanged in all modes.

  • [ ] Step 1: Write the failing tests

Grep for an existing WordListTable.test.tsx first and extend it rather than replacing. Cases (render the real component with a small words fixture; wrap in PropertyMetadataProvider with a mocked metadata fetch if the component requires it — copy the harness from Builder.test.tsx):

describe('WordListTable ordering honesty (PHON-221)', () => {
  it('without `ordering`, renders the legacy header and sortable columns', () => {
    // header matches /\d+ Words Found/, "showing first" chip present when capped,
    // the Word column header is a button (sortable)
  });

  it('sample mode names the draw and the true match count, never "first"', () => {
    // ordering={{ mode: 'sample' }} with words.length=3, total=3412
    // header text matches /varied sample of 3 from 3,412 matches/i
    // expect(screen.queryByText(/showing first/i)).toBeNull()
  });

  it('sample mode without a cap does not claim a subset', () => {
    // total omitted (or equal to words.length) → /varied sample of 3 words/i
    // and no "from ... matches" clause
  });

  it('sample mode renders Resample and fires the callback', () => {
    // ordering={{ mode:'sample', onResample }} → click → toHaveBeenCalledTimes(1)
  });

  it('sample mode disables column sorting', () => {
    // the Word column header is NOT a button/clickable; clicking its text does
    // not reorder the rendered rows (assert first row's word is unchanged)
  });

  it('sorted mode names the ordering and keeps column sorting', () => {
    // ordering={{ mode:'sorted', label:'A–Z' }} + total=3412
    // header matches /3,412 matches, sorted by A–Z/i, no "varied sample",
    // Word column header IS a button
  });
});
  • [ ] Step 2: Run to verify failure

Run: cd packages/web/frontend && npx vitest run src/components/shared/WordListTable.test.tsx Expected: the six new cases FAIL (prop unknown / old copy).

  • [ ] Step 3: Implement

In WordListTable.tsx: export the ResultOrdering type, add the optional prop, and replace the header Typography + capped chip block (around the {(total ?? words.length).toLocaleString()} Words Found line and the isCapped chip) with a mode switch that produces headerText plus an optional Resample button. Gate handleSort wiring: when ordering?.mode === 'sample', render column headers as plain Typography/TableCell content (no TableSortLabel/click handler) with the tooltip copy above. Keep sortedWords computation intact for the other modes; in sample mode sortedWords must equal words order (do not silently apply defaultSort to a sample — if the existing useMemo sorts by defaultSort, bypass it in sample mode).

  • [ ] Step 4: Run tests + full frontend matrix

Run: cd packages/web/frontend && npx vitest run src/components/shared/WordListTable.test.tsx && npx tsc --noEmit && npm run lint && npm test Expected: PASS, and every other suite that renders WordListTable (Contrast Sets, similarity) still green — those pass no ordering and must be unaffected.

  • [ ] Step 5: Commit
git add packages/web/frontend/src/components/shared/WordListTable.tsx packages/web/frontend/src/components/shared/WordListTable.test.tsx
git commit -m "feat(phon-221): WordListTable states how results were ordered"

Task 2: Word Lists ordering control + Resample wiring

Files: - Modify: packages/web/frontend/src/components/Builder.tsx - Test: packages/web/frontend/src/components/Builder.test.tsx (extend)

Interfaces: - Consumes: Task 1's ResultOrdering; WordOptions.sortBy/sortOrder (already plumbed through buildWordSearchRequest); SORTABLE_COLUMNS on the worker accepts word and frequency. - Produces: the user-facing ordering choice.

Behavior: - New local state const [ordering, setOrdering] = useState<'sample' | 'alpha' | 'frequency'>('sample'). - Control: a small ToggleButtonGroup (size small, aria-label="Result ordering") with three buttons — Varied sample (value sample), A–Z (alpha), By frequency (frequency) — rendered in the StickyActionBar's status slot (it already accepts a ReactNode for content above the buttons), preceded by a short caption Order:. Do not invent a new layout region. - Request mapping in handleBuild: sample → omit sortBy/sortOrder (server draws); alphasortBy: 'word', sortOrder: 'asc'; frequencysortBy: 'frequency', sortOrder: 'desc'. - Results wiring: pass ordering={ordering === 'sample' ? { mode: 'sample', onResample: handleBuild } : { mode: 'sorted', label: ordering === 'alpha' ? 'A–Z' : 'frequency' }} to WordListTable, and stop passing defaultSort="word" when in sample mode (pass it only for sorted modes, preserving today's behavior there). - Changing the ordering does NOT auto-run a search (no surprise queries); it applies on the next Build. The already-rendered results keep the ordering they were fetched with — so store the ordering used for the current results in a separate state (resultsOrdering) set at fetch time, and pass THAT to the table. This prevents the header from lying about a result set fetched under a different ordering. - handleClear resets ordering to 'sample' and resultsOrdering to null. - Analytics: include ordering in the existing search_executed payload (a low-cardinality string, consistent with the event's counts-only policy — no constraint content).

  • [ ] Step 1: Write the failing tests (extend Builder.test.tsx, reusing its harness and the store reset in beforeEach):
describe('Word Lists result ordering (PHON-221)', () => {
  it('defaults to varied sample: no sort_by in the request', async () => {
    // Build with no ordering interaction → request.sort_by undefined
  });

  it('A–Z sends sort_by word asc', async () => {
    // click the "A–Z" toggle, Build → { sort_by: 'word', sort_order: 'asc' }
  });

  it('By frequency sends sort_by frequency desc', async () => {
    // → { sort_by: 'frequency', sort_order: 'desc' }
  });

  it('the results header describes the ordering the results were fetched with', async () => {
    // Build in sample mode → header matches /varied sample/i;
    // then switch the toggle to A–Z WITHOUT rebuilding → header still says
    // varied sample (it describes the fetched set, not the pending choice)
  });

  it('Resample re-runs the search', async () => {
    // Build (1 call), click Resample → api.searchWords called twice
  });
});
  • [ ] Step 2: Run to verify failure — new cases fail (no control, no ordering prop).

  • [ ] Step 3: Implement per the behavior spec above.

  • [ ] Step 4: Run npx vitest run src/components/Builder.test.tsx, then the full frontend matrix (npx tsc --noEmit && npm run lint && npm test && npm run build).

  • [ ] Step 5: Manual smoke (local worker + frontend dev, or curl): Build in sample mode twice and confirm the two result sets differ (the draw is fresh); switch to A–Z, Build, confirm alphabetical and that the header names the ordering. Kill dev servers after.

  • [ ] Step 6: Commit

git add packages/web/frontend/src/components/Builder.tsx packages/web/frontend/src/components/Builder.test.tsx
git commit -m "feat(phon-221): explicit result ordering + Resample in Word Lists"

Task 3: Full matrix + push (PR after final review)

  • [ ] Step 1: cd packages/web/workers && npx tsc --noEmit && npm test (must be untouched and green); cd ../frontend && npx tsc --noEmit && npm run lint && npm test && npm run build; cd ../../.. && uv run python -m pytest packages/data/tests/ --ignore=packages/data/tests/test_datasets.py --ignore=packages/data/tests/test_new_loaders.py.
  • [ ] Step 2: confirm branch feat/phon-221-sampling-honesty; git log --oneline origin/develop..HEAD; git push -u origin feat/phon-221-sampling-honesty. Never stage the pre-existing untracked repo-root files or uv.lock.
  • [ ] Step 3 (controller, after clean final review): open the PR, noting the behavior change (results header copy + no column sorting in sample mode) and that no worker/API change was needed.

Self-Review Notes

  • Spec §C coverage: ordering control with the three named options → T2; truthful header for both modes → T1; Resample → T1 (affordance) + T2 (handler); client-side re-sort removed in sample mode → T1.
  • The subtle correctness point is resultsOrdering vs ordering: the header must describe the fetched set, never the pending toggle — otherwise switching the toggle relabels results it did not produce, which is the same class of lie this PR exists to remove. Pinned by a test.
  • No worker change is a deliberate constraint, verified while planning: sort_by/sort_order are already accepted and validated on /api/words/search, frequency and word are both in SORTABLE_COLUMNS, and re-running redraws the weighted sample.
  • Out of scope: Sentences/Contrast Sets ordering; pagination/after cursors; the upsertCvShape merge carried from PHON-220 (belongs with PHON-222's composer work).