Full-codebase audit — develop @ 2026-07-03¶
Multi-agent audit of develop (branch feature/phon-156-mic-permission-scoping, = develop + one test commit).
Method: 10 finder agents (workers correctness + security, frontend correctness + design parity, data
pipeline, audio serving, dead code/bloat, testing gaps, copy, CI/config) produced 105 raw findings; the
top 45 by severity each got an independent adversarial verifier (default stance: refute) that re-read the
cited code. All 45 survived. Copy and low-severity bloat findings below the verification cap were
spot-checked by hand where cited.
Overall: the core D1 lexicon tools are in good shape. Findings cluster in the pre-production audio surface (Speech Analysis frontend + serving) and in stale frontend metadata.
Priority 1 — the three high-severity findings¶
- Mic stays live after unmount while recording —
packages/web/frontend/src/components/tools/AudioAnalysisTool/CaptureControls.tsx:63. Stream released only in the MediaRecorderstophandler; no unmount cleanup. Sidebar navigation swaps the tool component, so Record → navigate = mic indicator on until reload, chunks accumulating. The privacy exposure PHON-156 exists to prevent. - Lookup hides ~99.8% of association edges —
packages/web/frontend/src/components/tools/LookupTool.tsx:115.EDGE_TYPE_INFOhardcodes removed edge types (USF/MEN/SPP/SimLex) and omits Qwensim (1.63M of 1.6M edges): not default-enabled, zero-width strength bar (fallback chain never readsqwensim), dead MEN/SimLex columns render—./api/edge-types+apiClient.getEdgeTypes()exist unused. - No audio-duration cap → one clip OOM-kills the shared audio container —
packages/audio/src/phonolex_audio/feature_emitter.py:151. Worker caps bytes (10 MB) not duration; 10 MB opus ≈ 20–40 min audio; wav2vec2 attention is O(T²) on a 4 GiB instance with ~3.0 GiB baseline. Singledefaultinstance serves everyone; 5xx→"warming" makes the client re-send the killer clip ~14×. Fix: reject over-duration clips right after decode in the host.
Priority 2 — medium clusters¶
Speech Analysis frontend¶
- Double-click race on Record acquires two streams, leaks the first (
CaptureControls.tsx:51). - Mic denial silently swallowed — no user feedback (
CaptureControls.tsx:70). - TargetField: no stale-response guard on debounced coverage fetch — out-of-order response can verify the
wrong word (
TargetField.tsx:56); clearing the field never notifies the parent — new clips attach to the previous word (TargetField.tsx:53). - Batch: duplicate production ids for repeated targets, results cross-patch cards (
AudioAnalysisTool.tsx:111);BatchReviewcaptures rows in auseStateinitializer without a key — second batch shows first batch's files (BatchReview.tsx:57). - Errors rendered without
role="alert"/aria-live; tool bypasses ToolFrame/StickyActionBar (ProductionCard.tsx:123). checkCoveragemaps any non-2xx to "Not in our dictionary." (audioAnalysisApi.ts:51).
Audio serving¶
- Every host 5xx classified as cold-start "warming" → ~5-min client retry loop on permanent failures (
workers/src/routes/audio.ts:34). async defendpoints run multi-second CPU-bound torch inference synchronously — blocks the event loop and/health(packages/audio/src/phonolex_audio/server.py:169).- No timeout/AbortSignal in
audioFetch(workers/src/lib/audioHostFetch.ts:19). /api/audio/attributeforwards unbounded, shape-unvalidatedfeaturesarray (workers/src/routes/audio.ts:448).- No auth/rate-limit on any
/api/audio/*— cost + saturation exposure; prod-cutover blocker (workers/src/routes/audio.ts:87).
Worker API correctness¶
CONTAINS_MEDIALwrong in both routes: Sentences edge-anchor veto rejects words with a genuine medial occurrence when the sequence also appears at an edge (sentences.ts:207); words-search exclude mode wrongly excludes edge-only occurrences (lib/patterns.ts:76)./api/similarity/searchslices tolimitbefore the frequency-availability filter — short result lists; frequency gate contradicts full-vocab scope (similarity.ts:141).computeHighlightscan exceed D1's 100-bind cap when include rules carry >20 params → 500 after the main query succeeded (sentences.ts:489).- Resource exhaustion: unbounded constraint count in
/api/sentences(sentences.ts:701); no text-size cap in/api/text/analyze(text.ts:23).
Design parity¶
- Sentences hardcodes 7-norm
BOUNDSwhile siblings are metadata-driven (PsycholinguisticsSection.tsx:43); offers a Socialness bound the server silently drops (dead control). - Sentences phoneme inputs skip
validatePhonemeInput(PatternConstraints.tsx:142). - Contrast Sets renders results in the
sectionsslot, notresults— loses aria-live + sticky-bar layout (ContrastiveInterventionTool.tsx:758); IPA-keyboard buttons missingaria-labelthere and in Lookup. usePropertyMetadatafailure invisible — no consumer readserror(usePropertyMetadata.tsx:79).- Sentences tool card advertises CV-shape constraints that don't exist in the tool (
App_new.tsx:93).
Testing gaps¶
- Workers integration suite vacuous in CI:
expect([200, 500]).toContain(status)+ CI never seeds D1 (__tests__/api.test.ts:283). - Zero coverage: entire contrastive route (455 lines, 5 endpoints) (
routes/contrastive.ts); core soft- Levenshtein DP (lib/similarity.ts:134). - PHON-156 branch tests: no
{ audio: true }argument assertion; banner comment overclaims app-wide/mount coverage; denial path renders untested;afterEachstampsmediaDevices: undefinedinstead of deleting;vi.restoreAllMocks()is dead code; duplicated file fixture (CaptureControls.test.tsx).
Priority 3 — bloat, copy, CI, pipeline drift¶
Dead code / bloat¶
workers/src/lib/filters.ts— dead duplicate ofcompileWordFilter.workers/src/lib/serverStatus.ts— RunPod-era leftover, zero imports.frontend/src/components/ToolGrid.tsx+ExpandableToolCard.tsx(311 lines) — dead pre-sidebar nav.frontend/src/utils/phonemeUtils.ts— zero imports.- Six never-called
apiClientmethods; four unreachable Worker endpoints (/api/edge-types,GET /api/words,POST /api/words/batch,/api/associations/:word/confusability). - Five audio endpoints with no in-repo client (
/pronounce,/feature-review,/acoustic,/transcribe,/compare) drag ~160 KB JSON + three scoring libs into every prod deploy (routes/audio.ts:162). GET /api/phonemes/rates— zero callers, uncached full 125K-row scan per request (routes/phonemes.ts:95).- PHON-94 selectional layer (
pipeline/extract_triples.py,canonical_spacy.py, WordStore selectional methods) — artifact no longer emitted, nothing consumes it. packages/data/scripts/build_naturalness_reference.py— builds artifact for the retired v5.2 reranker.packages/audio/deploy/handler.py+ RunPod Dockerfile — decommissioned path.python-Levenshteindeclared inpackages/features/pyproject.toml, never imported..gitignorerules for deletedpackages/generation/;VITE_GENERATION_API_URLstill exported in both deploy workflows.- Repo-root hygiene: resumes,
Paido/,StanfordEnglish.zip,HANDOFF.md,audio-sizes*.json,pediatric_speech_annotation_spec_review.mduntracked and unignored.
Copy (spot-checked)¶
- About drawer: "44K words" chip (stale; live is 125K/47K canonical) (
AppHeader.tsx:252); tool named "Database" there vs "Lookup" everywhere else (AppHeader.tsx:290); Tools list omits Speech Analysis. - Terms of Service still advertises "constraint-driven generation" (
TermsOfService.tsx:49); rootpyproject.tomldescription says "governed language generation platform"; version strings disagree (root 5.1.0 / worker root 5.0.0 / web packages 6.0.0). - Raw HTTP codes + JSON bodies shown verbatim as user-facing errors (
apiClient.ts:178); audio host developer errors surface on the production card (routes/audio.ts:82); ErrorBoundary prints raw JS exception text (ErrorBoundary.tsx:40). - Cold-start copy inconsistent: "about a minute" vs "a couple of minutes" vs ~5-min actual retry window
(
ProductionCard.tsx:118). Use IPA ->ASCII arrow in Lookup (LookupTool.tsx:675,1091) vs→in Word Lists; active-constraint chips show raw column keys while section chips show human labels (constraintChips.ts:11); Contrast Sets helper prose telegraphic + says "generate" for corpus retrieval (ContrastiveInterventionTool.tsx:326); Word Lists headers mix sentence case and Title Case (Builder.tsx:602).
CI / deploy / pipeline drift¶
- Production D1 reseed non-atomic: DROP-first chunks against live DB, no rollback, Worker deployed after
seed; no concurrency guard (
.github/workflows/deploy.yml:57). config.pyvsproperties.tsdrift on 6 properties' filterable/surfaced flags (scripts/config.py:725).derived.pyPERCENTILE_PROPERTIES violates its own must-match invariant: omitscv_shape(min/max_cv_shape_percentilefilters → live "no such column" 500), includes non-filterablesocialness(pipeline/derived.py:16).store.py:75omits the zero-as-NULL rule forfreq_*— WordStore percentiles diverge from D1.
Future directions¶
- Enforce metadata-driven UI — wire
/api/edge-types+ property metadata into Lookup and Sentences, then add a lint/test preventing hardcoded property/edge lists. - Audio prod-gate checklist (companion to PHON-153): duration cap,
audioFetchtimeout, 5xx≠warming, rate limiting on/api/audio/*, dev-only endpoints behind an env flag. useMicCapturehook owning acquire/record/release: unmount cleanup, denial feedback, acquiring-state guard — recording will spread to more surfaces as the therapy loop lands.- Mini D1 fixture in CI (few hundred words, plain SQL) so workers tests stop accepting 500s; unlocks honest contrastive + similarity tests.
- Curriculum recommender groundwork: attribution output as diagnostic profile → percentile-graded target sequence → delivery via contrastive/sentences. Thin API over existing tables.
- Version + copy sweep: single version source (header chip, worker root, package.json); ToS refresh (incl. Just Semantics entity check); About-drawer stats from live metadata.
Appendix: all 45 verified findings¶
(Verbatim from the adversarially-verified audit run wf_832b7006-62d.)
[HIGH] packages/web/frontend/src/components/tools/AudioAnalysisTool/CaptureControls.tsx:63¶
Unmounting while recording leaves the MediaRecorder running and the microphone stream live indefinitely.
resource-leak — found by frontend-correctness
The mic stream acquired at line 51 is released only inside the MediaRecorder 'stop' event handler (lines 59-65: stream.getTracks().forEach(t => t.stop())). There is no useEffect cleanup in the component. App_new.tsx swaps the whole tool component on sidebar selection (App_new.tsx:261-283, handleToolSelect), so: user clicks Record, then clicks any other tool (or Home) -> CaptureControls unmounts, nothing ever calls mediaRecorderRef.current.stop(), the stream tracks are never stopped, and the browser mic indicator stays on until page reload. On the very branch that is about mic-permission scoping (PHON-156), this is the exact privacy exposure the scoping was meant to prevent. Fix: useEffect(() => () => { mediaRecorderRef.current?.stream.getTracks().forEach(t => t.stop()); }, []) or store the stream in a ref and stop it on unmount.
[HIGH] packages/web/frontend/src/components/tools/LookupTool.tsx:115¶
Lookup hardcodes a stale edge-type list (USF/MEN/SPP/SimLex, all removed from the data) and omits Qwensim, so ~99.8% of association edges are hidden by default and render with zero strength.
hardcoded-metadata — found by frontend-design-parity
EDGE_TYPE_INFO (LookupTool.tsx:115-122) lists USF, MEN, ECCC, SPP, SimLex, WordSim. The live edge graph contains only Qwensim/ECCC/WordSim (workers/src/config/edgeTypes.ts; edges_schema in packages/data/src/phonolex_data/runtime/schema.py:105-121 has only qwensim/eccc/wordsim columns; pipeline/edges.py appends the literal 'Qwensim'). Consequences: (1) ALL_EDGE_TYPES (line 124) seeds enabledEdgeTypes without 'Qwensim', so filteredAssociations (lines 228-230) drops Qwensim-only edges — per AppHeader.tsx:348 Qwensim is ~1.63M of 1.6M edges (99.8%) — the Word Associations table appears near-empty until the user clicks the fallback-gray Qwensim chip. (2) The strength fallback chain (lines 441-443) reads men_relatedness/eccc/simlex/wordsim but never qwensim, so even enabled Qwensim rows show a 0-width StrengthBar. (3) Dead MEN/SimLex table columns (lines 430-431, 464-471) render '—' for every row. (4) EdgeResult in services/apiClient.ts:54-65 still declares men_relatedness/simlex_similarity and lacks qwensim even though the worker emits it (workers/src/routes/associations.ts:20). The metadata-driven fix already exists: the worker serves /api/edge-types (workers/src/routes/meta.ts:61) and apiClient.getEdgeTypes() (apiClient.ts:215) is defined but never called by any component.
[HIGH] packages/audio/src/phonolex_audio/feature_emitter.py:151¶
No audio-duration cap anywhere in the pipeline: a long (or compressed) clip within the Worker's 10 MB byte cap decodes to minutes of audio and OOM-kills the 4 GiB standard-1 container for all users.
memory-dos — found by audio-serving
The Worker caps upload BYTES at 10 MB (packages/web/workers/src/routes/audio.ts:26,406) but never duration. _decode_audio (feature_emitter.py:146-154) loads the whole file via librosa with no length check, and _emit (lines 169-183) runs the full clip through wav2vec2-large in one forward pass — self-attention memory is O(T^2). A 10 MB opus/webm recording (what MediaRecorder produces) at ~40 kbps is 20-40 minutes of audio → ~60-120k encoder frames → attention matrices in the hundreds of GB; even a legitimate 1-2 minute recording left running exceeds the ~1 GiB headroom wrangler.toml documents ('measured peak RSS ~3.0 GiB' on 4 GiB, packages/web/workers/wrangler.toml:24-33). Because audioHostFetch routes ALL traffic to the single 'default' instance (audioHostFetch.ts:19), one such request kills serving for everyone; the OOM-restart then surfaces as 5xx → 'warming', so the client retries the same clip 14 times over ~5 min, re-killing the container each boot. Fix at the root: reject clips over a duration ceiling right after decode in the host (and optionally check Blob duration client-side).
[MEDIUM] packages/web/frontend/src/components/tools/AudioAnalysisTool/CaptureControls.tsx:51¶
Double-click race on Record acquires two mic streams and permanently leaks the first one.
race-condition — found by frontend-correctness
handleRecordToggle is async and setRecording(true) only runs after await navigator.mediaDevices.getUserMedia(...) resolves (lines 51-69). While the first getUserMedia is pending, recording is still false, so a second click enters the else-branch again and acquires a second stream. mediaRecorderRef.current is overwritten with the second recorder (line 67), so the first MediaRecorder and its stream become unreachable: clicking Stop stops only the second recorder; the first stream's tracks are never stopped and the mic stays live until page reload. Concrete scenario: user double-clicks Record (common when the permission prompt makes the first click feel unresponsive) -> two streams, one leaked live forever, and two overlapping recordings each firing onClip with the same target. Fix: guard with a ref flag set synchronously before the await (e.g. acquiringRef.current), or disable the button while acquisition is pending.
[MEDIUM] .github/workflows/deploy.yml:57¶
Production D1 reseed is non-atomic (DROP-first seed executed against the live database) with no rollback, and the Worker is only type-checked/deployed after the seed is applied.
deploy-atomicity — found by ci-config
The 'Seed production D1' step runs wrangler d1 execute phonolex --file scripts/d1-seed.sql --remote where d1-seed.sql begins with DROP VIEW/DROP TABLE IF EXISTS for every table (verified in packages/web/workers/scripts/d1-seed.sql header) before re-inserting ~440 MB of data. D1's remote import is not transactional across the whole file, and the workflow's own comments (deploy.yml:49-56, the D1_RESET_DO cache-bust) acknowledge that import sessions get cancelled mid-flight. Failure scenario: an import fails or times out (timeout_minutes: 20, max_attempts: 3) after the DROPs — production D1 is left empty/partially populated while the live Worker keeps serving it, i.e. a full user-visible outage of every D1-backed endpoint until a human re-runs the job. Even in the success path, the DROP→reimport window is a guaranteed multi-minute outage on every seed deploy. Additionally, seeding (line 57) runs BEFORE 'Type check Workers' (line 77) and 'Deploy Workers' (line 81): if type-check fails after a successful reseed, the new schema is live under the old Worker code. Same structure in deploy-staging.yml:58-71. There is no shadow-DB/rename or export-based rollback step in either workflow.
[MEDIUM] packages/web/workers/src/routes/sentences.ts:207¶
CONTAINS_MEDIAL sentence matching wrongly rejects words where the phoneme sequence occurs BOTH medially and at a word edge, because the NOT LIKE edge anchors veto the word even though a genuine medial occurrence exists.
correctness — found by workers-correctness
patternMatchSql's CONTAINS_MEDIAL branch (sentences.ts:198-214) emits phonemes_str LIKE '%|seq|%' AND phonemes_str NOT LIKE '|seq|%' AND phonemes_str NOT LIKE '%|seq|'. The two NOT LIKE anchors reject any word whose sequence ALSO appears initially or finally, even when a separate strictly-medial occurrence exists. Concrete failure: medial-/ə/ include rule — 'banana' (|b|ə|n|æ|n|ə|) has a medial ə at position 1 but ends in ə, so NOT LIKE '%|ə|' fails and the word is dropped; sentences containing it are silently missing from include results (fewer matches, wrong match_count) and, for exclude-mode rules (compileRules pushes the same SQL into excludeMatches at line 369), such words fail to ban sentences that should be excluded. The reachable UI path is the Sentences tool's 'Medial only' checkbox (PatternConstraints.tsx). The correct SQL predicate is 'the sequence appears at some position other than first/last', e.g. checking an interior-trimmed string or an EXISTS over positions, not global edge-anchored NOT LIKEs.
[MEDIUM] packages/web/workers/src/routes/similarity.ts:141¶
/api/similarity/search slices the ranked list to limit BEFORE the fetchMergedWordRows({requireFrequency:true}) availability filter, so frequency-NULL words (a real population in the unified 125K tables) consume result slots and are silently dropped — responses come back short of limit while valid lower-ranked candidates are never returned, and the frequency gate itself contradicts the full-vocabulary scope for sound similarity.
null-handling — found by workers-correctness
similarity.ts:139-158: ranked = ...sort().slice(0, limit) then fetchMergedWordRows(..., { requirePhonology: true, requireFrequency: true }) and a final .filter(([w]) => rowMap.has(w)). word_syllables (the scan universe) is emitted full-vocab, but per the unified-tables refactor ~21K non-canonical words have word_properties.frequency NULL (queries.ts:190-192 drops them when requireFrequency is set). Any such word scoring in the top-N occupies a slice slot and is then discarded: a limit=50 request can return e.g. 43 items with no backfill, and the dropped words' better-scoring positions displace candidates that would have been returned. Per CLAUDE.md, sound similarity is supposed to use the full vocabulary — either drop requireFrequency: true here, or filter for row availability before applying the limit slice.
[MEDIUM] packages/web/workers/src/routes/audio.ts:87¶
All /api/audio/* endpoints proxy compute-heavy ML inference to the CF Container with no authentication or rate limiting, allowing anyone to saturate the audio backend and run up container cost.
resource-exhaustion — found by workers-security
/transcribe, /compare, /pronounce, /feature-review, /acoustic, /analyze, and /attribute are unauthenticated POST routes that each forward up to a 10 MB audio clip to the AudioHost container (wrangler.toml caps max_instances = 3, instance_type standard-1 with ~1 GiB headroom per the config comment). A single scripted client looping 10 MB uploads keeps the scale-to-zero container permanently warm (billed) and, at 3 instances, denies inference to legitimate users. There is no per-IP throttle, no Turnstile/token gate, and CORS reflection of any *.pages.dev origin (index.ts:35) means any third-party page can also drive this from visitors' browsers. Concrete scenario: attacker POSTs 10 MB clips to /api/audio/analyze in a loop -> all 3 container instances busy -> real users get warming/503 indefinitely while container compute accrues.
[MEDIUM] packages/web/workers/src/routes/audio.ts:448¶
/api/audio/attribute forwards an unbounded, shape-unvalidated features array verbatim to the inference host — no cap on array length, vector dimension, or element type.
input-validation — found by workers-security
The only check is Array.isArray(payload.features) && payload.features.length === 0 (audio.ts:448). Unlike the multipart routes, there is no MAX_BYTES equivalent: a JSON body with e.g. 100k vectors of 10k floats (or strings/nested objects) is re-serialized and POSTed to the container's /attribute, which mean-pools in Python on a standard-1 instance documented as having ~1 GiB memory headroom (wrangler.toml:24-26). Concrete scenario: attacker POSTs a multi-hundred-MB features payload -> Python host allocates the array -> container OOMs and restarts, dropping in-flight requests from real users. Fix: cap features.length and per-vector length, and require finite numbers, before forwarding.
[MEDIUM] packages/web/workers/src/routes/sentences.ts:701¶
/api/sentences accepts an unbounded number of constraints; each include rule compiles to its own CTE with a GROUP BY over the ~2.1M-row corpus_sentences table plus an N-way INNER JOIN, so one request can construct an arbitrarily expensive D1 query.
resource-exhaustion — found by workers-security
compileRules (sentences.ts:354-439) and the constraint loop at sentences.ts:601-635 iterate body.patterns / body.constraints with no count cap. For each include rule the route emits include_match_N AS (SELECT cs.sentence_id, COUNT(DISTINCT cs.surface) ... GROUP BY cs.sentence_id) (lines 701-714) — a full scan+aggregate of corpus_sentences (~2.1M rows) — and joins them all (line 727). Contrastive rules are worse: each is a self-join of corpus_sentences×pairs×corpus_sentences (minpairMatchSql line 241). A request with ~40 single-phoneme include patterns (each 1 bind param, well under D1's 100-param cap) forces 40 aggregate scans + a 40-way join in one D1 statement. Concrete scenario: attacker loops such requests -> D1 query latency balloons, exhausting the Worker/D1 capacity for real traffic. Fix: cap constraints (the UI never sends more than a handful) before compiling.
[MEDIUM] packages/web/workers/src/routes/text.ts:23¶
/api/text/analyze has no limit on input text size or unique-token count, so one large request fans out into thousands of D1 subrequests.
resource-exhaustion — found by workers-security
body.text is tokenized with no length cap (text.ts:18-25); every unique token becomes part of an 80-word chunk queried against 3 tables (lines 53-90), i.e. ceil(N/80)*3 D1 queries. A pasted body with ~30k unique strings ('a1 a2 a3 ...' — the regex admits any [a-z']+ token) yields ~1,125 subrequests, blowing past the Workers 1,000-subrequest cap mid-flight: the request burns ~1,000 D1 reads and then 500s. Repeated in a loop this is a cheap amplification vector (bytes in vs D1 rows read). Fix: cap input length (e.g. 50 KB) and/or unique tokens (e.g. 2,000) with a 413 response.
[MEDIUM] packages/web/frontend/src/components/tools/AudioAnalysisTool/CaptureControls.tsx:70¶
getUserMedia denial is swallowed silently with no user feedback.
error-handling — found by frontend-correctness
The catch block at lines 70-72 is empty ('Microphone access denied or unavailable — silently ignore'). If the user denies the permission prompt, or the mic is in use by another app, or the page is served over non-HTTPS, clicking Record does nothing: no error state, no helper text, the button just silently fails every time. Same for the guard at line 43 (!navigator.mediaDevices returns with no feedback while the Record button still renders enabled). A clinician has no way to distinguish 'app broken' from 'permission denied'. Fix: set an error state in the catch (NotAllowedError vs NotFoundError etc.) and render it under the controls, mirroring how ProductionCard surfaces analyze errors.
[MEDIUM] packages/web/frontend/src/components/tools/AudioAnalysisTool/TargetField.tsx:56¶
Debounced coverage fetch has no stale-response guard, so an out-of-order response can resolve the WRONG target word for a recording.
race-condition — found by frontend-correctness
The effect cleanup (line 61) clears only the 300ms timer, not the in-flight checkCoverage fetch. Once the timer fires, await checkCoverage(trimmed) (line 56) unconditionally calls setCoverage and onResolvedRef.current({ target: trimmed, ... }) (lines 57-58) with the closure's old trimmed. Scenario: type 'cat', pause >300ms (fetch A fires), continue to 'catalog', pause (fetch B fires); if A returns after B, the parent's resolved state becomes { target: 'cat' } while the field shows 'catalog' — the next recorded clip is created and analyzed against 'cat' (AudioAnalysisTool.handleClip uses resolved.target). Also, checkCoverage rejection (network error) inside the setTimeout is an unhandled promise rejection with no UI feedback. Fix: request-id/cancelled-flag guard like useSampleWords.ts:62-91 already does, plus a try/catch.
[MEDIUM] packages/web/frontend/src/components/tools/AudioAnalysisTool/TargetField.tsx:53¶
Clearing the target field never notifies the parent, so Record/Upload stay enabled and new clips attach to the previous word.
state-bug — found by frontend-correctness
The debounced effect early-returns on empty input (line 53: if (!trimmed) return;) and onResolved is never called with an unsupported/empty resolution. TargetField derives its own empty UI (effectiveCoverage, line 71), but the parent AudioAnalysisTool's resolved state (AudioAnalysisTool.tsx:61, set only via onResolved) keeps the last supported word. Scenario: user resolves 'cat', deletes the field text, then hits Record — CaptureControls is still enabled (disabled={!resolved?.supported}, AudioAnalysisTool.tsx:182) and handleClip creates a production for 'cat' even though the target field is visibly empty. Fix: call onResolved with { target: '', canonical: [], supported: false } when input is cleared.
[MEDIUM] packages/web/frontend/src/components/tools/AudioAnalysisTool/AudioAnalysisTool.tsx:111¶
Batch run assigns duplicate production ids for repeated targets because productionsRef is only synced post-render.
state-bug — found by frontend-correctness
handleBatchRun (line 138) calls addProduction synchronously in a forEach. addProduction computes the id via idFor(target, productionsRef.current) (line 111), but productionsRef is synced in a useEffect (lines 70-72) that runs only after render — so every call in the same batch sees the same stale snapshot. Two batch rows with the same target word (multiple productions of one target is the normal SLP flow the -2/-3 suffix logic in idFor exists for) both get the bare id, producing (a) duplicate React keys in the productions map (line 205-207) and (b) patchProduction(id, ...) (line 75) patching BOTH rows, so the second clip's analysis result overwrites the first clip's card and the two clips become indistinguishable. Fix: compute all ids against a locally accumulated list inside handleBatchRun, or make idFor allocate from a counter/uuid.
[MEDIUM] packages/web/frontend/src/components/tools/AudioAnalysisTool/BatchReview.tsx:57¶
BatchReview initializes row state from props once, so uploading a second batch while a review is open keeps showing the first batch's files.
state-bug — found by frontend-correctness
rowStates is created in a lazy useState initializer from the rows prop (lines 57-59) and nothing ever syncs it when rows changes. The parent renders
[MEDIUM] packages/web/frontend/src/components/tools/GovernedGenerationTool/PsycholinguisticsSection.tsx:43¶
Sentences hardcodes its norm/property list (BOUNDS: 7 norms with hand-typed labels, ranges, steps) while Word Lists, Text Analysis, and Lookup drive the same properties from /api/property-metadata.
hardcoded-metadata — found by frontend-design-parity
BOUNDS (lines 43-91) duplicates label/min/max/description for aoa, concreteness, familiarity, socialness, frequency_percentile, valence, arousal. Builder.tsx (line 64) and TextAnalysisTool.tsx (line 113) get the same definitions via usePropertyMetadata, and CLAUDE.md's Key Patterns section states 'No hardcoded property lists in the frontend' with properties.ts as single source of truth. If a norm's range, label, or interpretation changes in workers/src/config/properties.ts, the Sentences accordion silently drifts (e.g. aoa description '1 = 0-2y, 7 = 13+y' is duplicated prose the API already serves). Concrete divergence path: a property renamed/rescaled in metadata updates Word Lists but not Sentences, producing different thresholds for the same clinical filter across two tools.
[MEDIUM] packages/web/frontend/src/hooks/usePropertyMetadata.tsx:79¶
When /api/property-metadata fails, the hook sets error + loaded:true but no consumer reads error, so Word Lists renders empty accordions and Text Analysis loses its feature dropdown with zero user feedback.
error-state-parity — found by frontend-design-parity
The provider catches the failure and stores state.error (usePropertyMetadata.tsx:76-81), but every consumer destructures without it: Builder.tsx:64 { categories, ranges, filterableProperties, loaded }, TextAnalysisTool.tsx:113, LookupTool.tsx:170, WordListTable.tsx:182, WordProfileContext.tsx:79. Failure scenario: worker returns 500 or the fetch times out on load → loaded flips true with empty categories → Builder's five property accordions render with no sliders and no message (the !loaded spinner at Builder.tsx:627 also disappears), Text Analysis's 'Highlight by feature' select is empty, Lookup's property card shows nothing — all silently. Every tool surfaces its own query failures via Alert, but this shared dependency failure is invisible in all of them.
[MEDIUM] packages/web/frontend/src/components/tools/ContrastiveInterventionTool.tsx:758¶
Contrast Sets renders its results inside ToolFrame's sections slot instead of the results slot, losing the aria-live announcement region and placing results above the sticky action bar — unlike Word Lists and Sentences.
layout-parity — found by frontend-design-parity
ToolFrame's contract (shared/ToolFrame.tsx:33-57) puts results below the action bar in a Box with aria-live="polite", and Builder (Builder.tsx:818-828) and Sentences (GovernedGenerationTool/index.tsx:96) use it. ContrastiveInterventionTool passes all result blocks (minimal pairs table, maximal pair pickers, multiple-opposition sets — lines 758-906) inside the sections prop of the ToolFrame call at line 346-347, with no results prop. Failure scenario: a screen-reader user clicks Generate — new results are never announced (no live region), and visually the results appear ABOVE the sticky Generate bar while in the sibling tools they appear below it, so the same action produces results in a different place per tool.
[MEDIUM] packages/web/frontend/src/components/tools/ContrastiveInterventionTool.tsx:453¶
The IPA-keyboard IconButtons in Contrast Sets (6 fields) and Lookup (3 fields) have no aria-label, while the identical buttons in Word Lists and Sentences carry aria-label="Open IPA keyboard".
accessibility — found by frontend-design-parity
Builder.tsx:482 and :577 and Sentences' PatternConstraints.tsx:159 label the keyboard adornment button 'Open IPA keyboard'. The same KeyboardIcon IconButton appears unlabeled in ContrastiveInterventionTool.tsx at lines 452-460 (phoneme1), 497-505 (phoneme2), 561-569 (sonorants), 605-613 (obstruents), 670-678 (substitute), 718-726 (targets), and in LookupTool.tsx at lines 681-688 (single phoneme), 1096-1103 and 1131-1138 (compare). Failure scenario: a screen-reader user in Contrast Sets hears an unnamed button after each phoneme field and cannot discover the picker, while the same control is discoverable in Word Lists — the a11y fix was applied to two tools and never propagated to their siblings.
[MEDIUM] packages/web/frontend/src/components/tools/AudioAnalysisTool/CaptureControls.tsx:70¶
Microphone-permission denial in Speech Analysis is silently swallowed — clicking Record does nothing, with no error surfaced, while every sibling tool surfaces failures via alerts.
error-state-parity — found by frontend-design-parity
handleRecordToggle wraps getUserMedia in try { ... } catch { // Microphone access denied or unavailable — silently ignore } (CaptureControls.tsx:50-73). Failure scenario: user has blocked mic access (or the browser prompt is dismissed) → clicking Record produces no state change, no recording indicator, no message — a dead button. The tool's own upload path and analysis path surface errors (ProductionCard error state, TargetField helper text), and every other tool renders API/user errors in an Alert; this is the only user-visible failure in the app with no feedback at all. Notably the current branch (feature/phon-156-mic-permission-scoping) is about scoping mic permission to the Record click, making the denial path more likely to be exercised.
[MEDIUM] packages/web/frontend/src/services/audioAnalysisApi.ts:51¶
checkCoverage maps any non-2xx response (worker 500, outage) to supported:false, so an API failure displays as 'Not in our dictionary.' — and a network-level fetch rejection escapes unhandled from TargetField's debounce.
error-state-parity — found by frontend-design-parity
checkCoverage returns { supported: false, ... } for !res.ok (audioAnalysisApi.ts:50-51) with no distinction between 404-style not-found and 5xx/outage. TargetField renders supported:false as the red error state 'Not in our dictionary.' (TargetField.tsx:73-74, 83). Failure scenario: worker is briefly down → SLP types a perfectly valid word like 'cat' and is told it isn't in the dictionary; in BatchReview every row flags 'Fix target', blocking the whole batch with a misdiagnosis. Additionally, if fetch itself rejects (offline), the awaited call inside the debounce setTimeout (TargetField.tsx:55-59) has no try/catch → unhandled promise rejection and the row sticks at 'Checking…' forever. Sibling tools distinguish these: their catch blocks surface err.message in an error Alert.
[MEDIUM] packages/web/frontend/src/components/tools/AudioAnalysisTool/AudioAnalysisTool.tsx:136¶
Batch run assigns duplicate production ids when two batch rows resolve to the same target, because idFor reads productionsRef.current which is only synced after render — results then cross-patch both cards.
correctness — found by frontend-design-parity
handleBatchRun calls ready.forEach((p) => addProduction(...)) synchronously (lines 136-142). addProduction computes idFor(target, productionsRef.current) (line 111), but productionsRef is synced to state in a useEffect (lines 69-72) that runs only after commit — during the forEach all calls see the same stale snapshot. idFor's de-dup suffix logic (lines 51-58) therefore returns the same base id (e.g. 'cat') for two rows whose targets are both 'cat' (easy to hit: files cat.wav + cat.m4a, or the SLP corrects two rows to the same word in BatchReview). Result: two Production entries share one id → duplicate React keys on ProductionCard (line 205-207), and patchProduction (lines 74-76) matches by id and applies each analysis result to BOTH cards, so one clip's analysis silently overwrites/duplicates the other's.
[MEDIUM] packages/web/frontend/src/components/tools/AudioAnalysisTool/BatchReview.tsx:57¶
BatchReview captures its rows prop only in the useState initializer and is rendered without a key, so uploading a second batch while the review panel is open leaves the panel showing the first batch's files.
correctness — found by frontend-design-parity
rowStates is initialized from rows once (useState(() => rows.map(...)), BatchReview.tsx:57-59) and never reconciled when the prop changes; AudioAnalysisTool renders <BatchReview rows={draftRows} ...> with no key (AudioAnalysisTool.tsx:194-198), and BatchUpload's onRows handler replaces draftRows wholesale (line 187). Failure scenario: user uploads 3 clips, review panel opens, then clicks 'Upload audio files' again and picks 5 different clips → setDraftRows replaces the prop but BatchReview keeps rendering the original 3 rows; the 5 new files are silently ignored and 'Analyze N ready' runs the stale set. Fix is either keying BatchReview by batch identity or the prev-prop sync pattern the same feature already uses in TargetField.tsx:42-46.
[MEDIUM] packages/web/frontend/src/App_new.tsx:93¶
The Sentences tool card promises 'CV-shape' constraints but the tool has no CV-shape section and the frontend Constraint union has no CV-shape type — the advertised control does not exist.
capability-mismatch — found by frontend-design-parity
TOOL_DEFS describes Sentences as retrieving sentences 'that satisfy phoneme, CV-shape, frequency, and contrastive constraints' (App_new.tsx:93), and CLAUDE.md's Five Tools list also names CV shapes in Sentences' constraint vocabulary. But GovernedGenerationTool/index.tsx mounts only PatternConstraints, PsycholinguisticsSection, and ContrastiveSection (lines 70-74); types/governance.ts's Constraint union (lines 70-76) contains only bound/contrastive_*/pattern; constraintCompiler.ts compiles nothing CV-shaped; and the worker adapter constraintsToBody.ts has no cvshape branch — while the highlights doc comment (governance.ts:107) still references cv_shape rule hits and Builder exposes a full CV-shape picker (Builder.tsx:663-671). Failure scenario: an SLP reads the card, opens Sentences to filter by CVC shape, and finds no such control anywhere — the capability exists in the backend WordSearchBody (workers/src/types.ts:147) but was dropped from this tool's UI without updating the copy.
[MEDIUM] packages/web/frontend/src/components/tools/GovernedGenerationTool/PatternConstraints.tsx:142¶
Sentences' phoneme input performs no IPA validation, while the equivalent inputs in Word Lists, Contrast Sets, and Lookup all run validatePhonemeInput and show ASCII-to-IPA suggestion warnings.
input-validation-parity — found by frontend-design-parity
PatternConstraints' TextField (lines 142-167) and ContrastiveSection's phoneme fields accept any string with no validatePhonemeInput call (the util is imported by Builder.tsx:51, ContrastiveInterventionTool.tsx:50, and LookupTool.tsx:61, but nowhere under GovernedGenerationTool/). The worker normalizes only ASCII g→ɡ (routes/sentences.ts:176-177 via normalizePhoneme), so common mistakes the other tools catch — typing 'sh' for ʃ, 'ch' for tʃ, 'th' for θ (ipaValidation.ts ASCII_TO_IPA_SUGGESTIONS) — silently produce zero corpus matches in Sentences. Failure scenario: SLP types 'sh' as a starts-with rule in Word Lists and gets a warning suggesting ʃ; types the same in Sentences and gets 'No attested sentences match these constraints' with no hint the input was the problem.
[MEDIUM] packages/web/frontend/src/components/tools/AudioAnalysisTool/ProductionCard.tsx:123¶
Speech Analysis renders errors as plain Typography color="error" with no role="alert"/aria-live — the exact unannounced-error pattern StickyActionBar was introduced to eliminate — because the tool bypasses the shared ToolFrame/StickyActionBar primitives entirely.
accessibility — found by frontend-design-parity
StickyActionBar's doc comment (shared/StickyActionBar.tsx:10-14) says its Alert with role=alert/aria-live=assertive 'fixes the <Typography variant="body2" color="error">{error}</Typography> (ProductionCard.tsx:121-125). More broadly AudioAnalysisTool.tsx:178 lays out the whole tool in a bespoke <Box maxWidth 760> — no ToolFrame, no ToolSection, no aria-live results region — while ToolFrame.tsx:2 documents itself as the 'outer layout wrapper for every left-panel tool tab'. Failure scenario: analysis fails after the cold-start retries; a screen-reader user gets no announcement that anything went wrong — the same class of user is notified in all five other tools.
[MEDIUM] packages/web/workers/src/routes/audio.ts:34¶
isWarmingStatus classifies EVERY host 5xx as cold-start 'warming', so genuine inference failures (undecodable audio, analyzer exceptions) trigger a ~5-minute client auto-retry loop of a permanently failing request instead of an error.
correctness — found by audio-serving
audio.ts:34-36 treats all 500-599 as 'warming' based on the comment's premise that the host 'always returns 200' when up. That premise is false: server.py's /analyze (server.py:169-200) calls analyzer.analyze with no try/except, and TrajectoryAnalyzer._emit_audio → FeatureEmitter._decode_audio (feature_emitter.py:146-154) raises on any clip librosa cannot decode (truncated upload, unsupported codec, non-audio blob with empty/audio-* content type — the Worker's content-type check at audio.ts:56 passes empty types). The resulting FastAPI 500 becomes { warming: true } 503, and the frontend's analyzeProductionWithRetry (packages/web/frontend/src/services/audioAnalysisApi.ts:92-127) re-sends the same doomed clip 14 times over ~5 minutes, showing the user 'warming up' before ending in an opaque warming outcome. Real host bugs are also rendered invisible in Worker logs. Distinguish cold-start (fetch rejection / 502-503 from the binding before the port is ready) from an application 500 returned by FastAPI, e.g. by having the host wrap decode failures in a 400/422.
[MEDIUM] packages/audio/src/phonolex_audio/server.py:169¶
All endpoints are async def but run multi-second CPU-bound torch inference synchronously, blocking uvicorn's event loop so concurrent requests (and /health) stall behind each inference on the single shared container instance.
correctness — found by audio-serving
server.py:88-215 declares every route async def and calls transcriber.transcribe / analyzer.analyze / analyzer.analyze_variants / acoustic.extract directly — all synchronous, CPU-bound torch/librosa work (feature_emitter.py:169-183 runs under torch.no_grad on CPU). In FastAPI, async def handlers execute ON the event loop (plain def would be dispatched to the threadpool), so a single long /analyze freezes the whole server: a second user's request and any /health probe get no response until the forward pass finishes. Since audioHostFetch.ts pins all traffic to one instance via getByName('default'), this serializes every user of the live Speech Analysis Beta, and combined with the no-timeout Worker fetch (see separate finding) it produces indefinite hangs under mild concurrency. Fix: declare the inference endpoints as plain def (threadpool) or offload with run_in_executor/anyio.to_thread.
[MEDIUM] packages/web/workers/src/lib/audioHostFetch.ts:19¶
audioFetch has no timeout/AbortSignal on either the container-binding fetch or the dev URL fetch, so a wedged or event-loop-blocked host hangs the Worker request (and the browser fetch) indefinitely.
missing-timeout — found by audio-serving
audioHostFetch.ts:12-26 forwards with the caller's RequestInit, and no caller in routes/audio.ts passes a signal — every audioFetch (audio.ts:68, 138, 289, 360, 429, 453) waits unboundedly. The host serializes CPU-bound inference on the event loop (see server.py finding), so a queue of a few analyze requests means later requests wait minutes; the browser fetch in analyzeProduction (frontend audioAnalysisApi.ts:77) also has no timeout, so the user sees a permanent spinner rather than the warming/retry path. Concrete scenario: user A submits a 60 s clip, user B submits immediately after — B's request hangs for the entire duration of A's inference with no bound. Add AbortSignal.timeout(~30-60 s) in audioFetch and map the abort to the warming/error contract.
[MEDIUM] packages/web/workers/src/routes/audio.ts:446¶
/attribute forwards payload.features with no element validation or size cap; ragged or wrong-length vectors make the host's np.mean/classify raise (500 → masked as 'warming'), and an unbounded array is a host memory sink.
input-validation — found by audio-serving
The Worker checks only that features is a non-empty array (audio.ts:448) — not that elements are equal-length numeric vectors of the 6 attribution features, and with no count cap. The host does np.mean(np.array(feats, dtype=float), axis=0) (server.py:214): ragged lists or non-numeric entries raise ValueError → unhandled 500, which audio.ts:460 converts to { warming: true } 503 (the misleading cold-start signal); a wrong-length but rectangular payload (e.g. vectors of length 5) reaches AttributionModel.classify where (feats - self.mean) (attribution.py:59) raises a broadcast ValueError → same masked 500. A very large features array (Workers accept ~100 MB bodies) is forwarded verbatim and materialized as a float ndarray on the memory-tight container. Validate shape (N x 6, floats, N capped) in the Worker or return 400 from the host.
[MEDIUM] packages/web/workers/src/__tests__/api.test.ts:283¶
Every D1-dependent workers integration test accepts a 500 as a pass (expect([200, 500]).toContain(response.status)), and CI never seeds D1, so the entire /api/sentences and /api/words/search behavioral suite is vacuous in CI.
vacuous-tests — found by testing-gaps
The file header (lines 5-9) documents that D1 is unseeded in CI, and .github/workflows/ci.yml line 75 runs npm test with no seed/chunk-apply step. Consequently the only tests that assert real behavior for sentence ranking (match_count tiering, lines 360-412), top_k capping (line 301), pattern include/exclude (lines 323-357), and the words/search similar_to path (lines 197-221) are all wrapped in if (response.status === 200) blocks that never execute in CI. A regression that makes every one of these endpoints return 500 on every request would pass CI green. Only developers who happen to run tests against a locally-seeded miniflare D1 get the real assertions. The fix direction is a small seeded test fixture (a few dozen rows applied in a vitest-pool-workers setup file) so the 200 branch always runs; the sentences.patterns.test.ts comment (lines 5-8) shows the team already works around this by unit-testing emitted SQL instead.
[MEDIUM] packages/web/workers/src/routes/contrastive.ts:243¶
The entire contrastive route (455 lines, 5 endpoints backing the Contrast Sets tool) has zero test coverage, including the pure greedy max-min target-selection algorithm and the multiple-opposition set-building logic.
zero-coverage-route — found by testing-gaps
No test file imports from or fetches any /api/contrastive endpoint (grep across src for 'api/contrastive' in tests returns nothing; api.test.ts covers meta/words/sentences only). This is one of the five live tools. The greedy max-min selection in /multiple-opposition/targets (the while loop at ~lines 276-300) and the phoneme-position candidate query branching + set assembly in /multiple-opposition/sets (lines 304+) are deterministic algorithmic logic embedded in route handlers — the highest-value unit-test targets in the file — but they are not extracted or exported, so nothing exercises tie-breaking, the targets.length <= count early return (line 258), missing pairs-map entries (documented at lines 38-41 as 'caller must handle missing entries'), or the count clamp (line 256). Extract the greedy selection and set-builder into lib functions and unit-test them the way sentences.ts exports patternMatchSql/compileRules for sentences.patterns.test.ts.
[MEDIUM] packages/web/workers/src/lib/similarity.ts:134¶
The core two-level soft-Levenshtein DP (componentSimilarity, softLevenshteinSimilarity, weightedSyllableSimilarity, extractSyllables) has zero unit tests — the only test references to this module are import type { PhonemeCache }.
untested-core-algorithm — found by testing-gaps
grep shows softLevenshteinSimilarity/componentSimilarity/weightedSyllableSimilarity/extractSyllables are referenced only by lib/similarity.ts and routes/similarity.ts — no test file. This is the flagship similarity algorithm (CLAUDE.md 'Key Patterns') behind /api/similarity/search and the similar_to path of /api/words/search, and it is pure, dependency-free logic that is trivially unit-testable with a synthetic PhonemeCache (pronunciationScore.test.ts already builds exactly such a synthCache for the audio path, proving the pattern). Untested edge cases with real failure potential: extractSyllables 'medial' returning [] for words of ≤2 syllables (line 126) which then hits the n===0,m>0 asymmetric DP boundary; the empty-vs-empty 1.0 vs empty-vs-nonempty 0.0 branches in componentSimilarity (lines 61-62); zero totalWeight returning 0 (line 109); and unknown-phoneme cosine falling back to 0 (lines 41-43). A sign error or boundary regression here silently reorders every similarity result with no test to catch it.
[LOW] packages/web/workers/src/routes/sentences.ts:489¶
computeHighlights chunks sentence IDs at a fixed 80 but appends every include-rule bind param to the same query, so total binds exceed D1's 100-param cap once include rules carry >20 params, failing the whole /api/sentences request with a 500 after the main query already succeeded.
d1-bind-limit — found by workers-correctness
The highlight query at sentences.ts:489-497 binds ...chunk (up to 80 sentence ids; frontend TOP_K=100 so the first chunk is a full 80) plus ...ruleParams (the union of ALL include rules' params: STARTS_WITH/ENDS_WITH = 2 params each, CONTAINS_MEDIAL = 3, a cv_shape include = 1 per shape). 11 starts-with rules (22 params) or 7 medial rules (21) or a cv_shape list of 21 shapes pushes the query to 101+ binds, which D1 rejects; the error propagates as an unhandled 500 for the entire request even though the candidate query succeeded. The pair-witness loop at line 513 has the same fixed-80 flaw (multopp: 80 + 2 + 2*targets + optional position exceeds 100 at 9+ targets). Contrast with words.ts:139 in the same repo, which correctly computes maxChunk = 100 - params.length - 5 before chunking — computeHighlights should do the same (shrink the sentence-id chunk by ruleParams.length).
[LOW] packages/web/workers/src/lib/patterns.ts:76¶
Exclude-mode CONTAINS_MEDIAL in /api/words/search compiles to variants_str NOT LIKE '%|seq|%', which excludes words containing the sequence ANYWHERE — words with only word-edge occurrences (which should pass a medial-exclude) are wrongly filtered out and the medial post-filter can never resurrect them.
correctness — found by workers-correctness
buildMatchClause (patterns.ts:72-79) shares the CONTAINS clause for CONTAINS_MEDIAL, so mode=exclude emits a blanket NOT LIKE. For rule {type:'CONTAINS_MEDIAL', phoneme:'k', mode:'exclude'}, 'cat' (|k|æ|t|, k initial-only, no medial k) should pass but is removed at the SQL level; matchesMedialPattern (the post-filter that implements the correct exclude semantics, patterns.ts:124-134) only sees rows the SQL already kept, so it cannot restore them. The fix is to emit no SQL restriction (or the include-style broad LIKE is wrong too — simply omit the clause) for exclude-mode CONTAINS_MEDIAL and rely entirely on the registered medialSequences post-filter, which already carries the mode. Reachable by any direct API caller of POST /api/words/search; the current Word Lists UI only sends exclude as plain CONTAINS, so exposure today is API-level.
[LOW] packages/web/workers/scripts/config.py:725¶
config.py and properties.ts disagree on filterable/surfaced flags for 6 properties (semantic_diversity, semd_vn, semd_h13, freq_cyplex_7_9, freq_cyplex_10_12, freq_cyplex_13), so the two 'must stay in sync' property lists have materially drifted.
parity-drift — found by data-pipeline
Diffed both lists. Python: semantic_diversity (config.py:380-389), semd_vn (401-411), semd_h13 (412-422), and freq_cyplex_ (665-698) all default filterable=True, surfaced=True. TypeScript marks the same six filterable:false, surfaced:false (properties.ts:281-287, 299-314, 367-389 — retired from UI in PHON-117). Consequence: Python FILTERABLE_PROPERTIES (config.py:725) is a 6-property superset of the TS list, so (a) derived.py computes and the seed ships semantic_diversity_percentile, semd_vn_percentile, semd_h13_percentile, and three freq_cyplex__percentile columns (~125K rows each in word_percentiles) that the Worker can never expose or filter on — dead weight in the 440MB LFS seed; (b) the seed's metadata 'property_coverage' stats (export-to-d1.py:269-272) report coverage for filters that don't exist on the live surface. The retirement decisions were applied only on the TS side; the Python mirror was never updated.
[LOW] packages/data/src/phonolex_data/pipeline/derived.py:16¶
derived.py PERCENTILE_PROPERTIES violates its own 'MUST match FILTERABLE_PROPERTIES' invariant in both directions: it omits cv_shape (which TS treats as percentile-eligible, producing a D1 'no such column' error for min/max_cv_shape_percentile filters) and includes socialness (config.py filterable=False).
correctness — found by data-pipeline
derived.py:13-15 says the list MUST match config.py FILTERABLE_PROPERTIES. It doesn't: config.py includes cv_shape (filterable=True, config.py:110-125) but derived.py never computes cv_shape_percentile (correct — it's a string), so word_percentiles has no cv_shape_percentile column. Meanwhile TS PERCENTILE_PROPERTIES = FILTERABLE_PROPERTIES (properties.ts:532-537) includes cv_shape, and partitionFilterColumns (packages/web/workers/src/lib/queries.ts:86-91) whitelists any '
[LOW] packages/data/src/phonolex_data/runtime/store.py:75¶
attach_runtime_percentiles claims to match derived._compute_percentiles but omits the zero-as-NULL rule for freq columns, so freq_age_ percentiles computed through WordStore diverge from the D1 word_percentiles values and reproduce the documented zero-cluster artifact.
percentile-formula-deviation — found by data-pipeline
store.py:78-79 states the computation matches phonolex_data.pipeline.derived.compute_percentiles. derived.py:48-57 + 62-69 excludes value==0.0 for any prop starting with 'freq' from both the denominator and rank (explicitly to avoid ~13K freq_age_2y=0 words pinning at ~57th percentile); CLAUDE.md documents this as the word_percentiles contract. store.py:84-92 ranks the raw column with rank(method='max')/n_non_null and never filters zeros, so every freq_age_* target in _RUNTIME_PERCENTILE_TARGETS (store.py:62-72) gets exactly the mid-percentile zero-cluster the derived.py docstring warns about, and disagrees with the shipped D1 percentiles for all nonzero words too (inflated denominator). In-repo consumers today are the runtime tests (packages/data/tests/runtime/test_store.py etc.) — no live route reads this path since CSP retirement — but the function is public API of phonolex_data.runtime with a false parity claim; any research or future consumer inherits wrong percentiles silently.
[LOW] Neumann_Resume_ML_2026.pdf:1¶
Personal and unrelated artifacts sit untracked and unignored in the repo root: two resume PDFs, an unrelated project directory (Paido/, 1.8 MB), StanfordEnglish.zip, HANDOFF.md, audio-sizes.json, audio-sizes2.json, pediatric_speech_annotation_spec_review.md.
repo-hygiene — found by dead-code-bloat
git status shows all of these as untracked in the repository root and git check-ignore matches none of them, so a single git add . (or an agent commit) would push personal documents (resumes) and unrelated project code into the proprietary PhonoLex repo. None belong to the project; they should be moved out or added to .gitignore. Sizes verified via du: Paido 1.8M, StanfordEnglish.zip 184K, resumes ~100K each.
[LOW] packages/web/workers/src/routes/audio.ts:162¶
Five audio endpoints (/pronounce, /feature-review, /acoustic, /transcribe, /compare) have no in-repo client and drag ~160 KB of JSON config plus three scoring libraries into every production Worker deploy.
dead-code — found by dead-code-bloat
The frontend calls only /api/audio/analyze and /api/audio/attribute (packages/web/frontend/src/services/audioAnalysisApi.ts:77,131). No file outside packages/web/workers/src/tests/ references /api/audio/{transcribe,compare,pronounce,feature-review,acoustic}, and main.tsx registers no dev routes at all — the /dev pages that consumed these endpoints were never merged. Yet routes/audio.ts:20-22 statically imports l1Prior.json (53 KB), hillenbrandNorms.json (52 KB), and via lib/attribution.ts:20 attributionPriors.json (54 KB), plus lib/pronunciationScore.ts, lib/attribution.ts, and lib/acousticOverlay.ts, all of which serve only the unreachable endpoints (/attribute forwards to the host and does not use lib/attribution.ts). These ship in the prod Worker bundle as unauthenticated endpoints with zero consumers. If they are intentionally retained for future dev pages, that should be documented; otherwise remove routes + libs + JSON.
[LOW] packages/web/workers/src/routes/phonemes.ts:95¶
GET /api/phonemes/rates has zero callers anywhere in the repo yet performs an uncached full-table scan of all ~125K words (SELECT phonemes_str, frequency with no LIMIT) on every request.
dead-code — found by dead-code-bloat
grep across packages/web (frontend + workers, including tests) finds no reference to '/rates' or 'phonemes/rates' outside the route definition itself; the frontend apiClient has no method for it. The handler streams the entire words⋈word_properties join into Worker memory and builds per-word phoneme Sets per request — an unauthenticated, compute-heavy D1 scan exposed on production for an endpoint nothing uses. Delete it, or precompute the rates into the metadata table if the data is wanted.
[LOW] packages/data/src/phonolex_data/pipeline/extract_triples.py:1¶
The PHON-94 selectional layer (pipeline/extract_triples.py, pipeline/canonical_spacy.py, WordStore selectional methods, selectional_schema) survives in live phonolex_data although the selectional.parquet artifact is no longer emitted and nothing in live code consumes it.
dead-code — found by dead-code-bloat
runtime/emit_parquet.py:10 states 'selectional.parquet + skeletons.parquet no longer emitted' (retired with the CSP stack, PR #113). Grep over packages/data/src, packages/data/scripts, and packages/web/workers/scripts shows zero live callers of extract_triples, canonical_spacy, attach_selectional, or from_selectional_parquet — the only consumers are tests (tests/test_extract_triples.py, test_canonical_spacy.py, test_build_selectional.py, test_merge_shards.py, test_e2e_selectional.py), and test_e2e_selectional.py:21-27 sys.path-hacks into research/2026-05-06-phon-94-corpus-parse/ to import build_selectional/merge_shards from research scripts. Live-package code exists solely to support tests of a retired artifact: runtime/schema.py:145 selectional_schema, runtime/store.py:282/294 (from_selectional_parquet/attach_selectional plus subcat views around lines 123-205), runtime/init.py:10 re-export.
[LOW] packages/web/frontend/src/services/apiClient.ts:198¶
Six apiClient methods are never called (healthCheck, getEdgeTypes, listWords, batchWords, getConfusability, generateMinimalPairs), and four of their Worker endpoints are unreachable from the app: GET /api/edge-types, GET /api/words, POST /api/words/batch, GET /api/associations/:word/confusability.
dead-code — found by dead-code-bloat
Call-site grep across packages/web/frontend/src (excluding apiClient.ts itself and tests) finds zero invocations of healthCheck (line 198), getEdgeTypes (215), listWords (227), batchWords (246), getConfusability (288), and generateMinimalPairs (350, which is a pure alias of getMinimalPairs). On the Worker side the matching routes — meta.ts:61 (/edge-types), words.ts:49 (GET /), words.ts:264 (POST /batch), associations.ts:79 (/:word/confusability) — have no other in-repo consumer (only apiClient and worker tests reference them). /api/health is legitimately kept for monitoring; the rest of the endpoints plus the client methods are removable surface (or, if kept as public API like norms-dump, should be marked as such).
[LOW] packages/web/workers/src/lib/filters.ts:17¶
lib/filters.ts is a dead duplicate of the min_/max_ filter-to-SQL translation that now lives in lib/wordFilter.ts (compileWordFilter); it has zero import sites.
duplicate-implementation — found by dead-code-bloat
grep across packages/web/workers/src finds no import of lib/filters.ts anywhere (wordFilter.ts, words.ts, contrastive.ts, sentences.ts all use compileWordFilter/queries instead). Because buildFilterClauses duplicates the FILTERABLE_PROPERTIES column-validation / SQL-injection-guard logic of the live compiler, a stale second copy invites future divergence if someone imports the wrong one. Delete the file.