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 targetsdevelop. - 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
orderingis 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
orderingin 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); alpha → sortBy: 'word', sortOrder: 'asc'; frequency → sortBy: '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 inbeforeEach):
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 oruv.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
resultsOrderingvsordering: 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_orderare already accepted and validated on/api/words/search,frequencyandwordare both inSORTABLE_COLUMNS, and re-running redraws the weighted sample. - Out of scope: Sentences/Contrast Sets ordering; pagination/
aftercursors; theupsertCvShapemerge carried from PHON-220 (belongs with PHON-222's composer work).