fix(prompt-hook): close the segment-vocab integrity gaps (#1141, #1142, #1144, #1145, #1146) (#1150)

Five hardening fixes to the #1136 MEDIUM (graph-derived) tier:

- #1141: updateNode() now writes the segment vocabulary like insertNode()
  does — framework post-extract renames (NestJS route prefixing) left the
  new name permanently unsearchable (the old rows orphaned, the backfill
  gated on an EMPTY vocab, so even a full re-index re-created the drift).
- #1142: new CodeGraph.healSegmentVocabIfEmpty() — the hook opens the
  graph without sync, so a database migrated from pre-vocab schema kept
  the MEDIUM tier dormant until some unrelated sync ran. The hook heals
  on first use (one SELECT when populated; lock-aware, defers to a
  running sync) and records noop-vocab-empty when it can't.
- #1144: a name whose only nodes are file/import kind is skipped instead
  of falling back to surfacing an import statement as a matched symbol;
  import specifiers no longer enter the vocab at all (shared
  isSegmentableKind gate across insertNode/updateNode/rebuild page query)
  since they can never be surfaced and only inflate rarity statistics.
- #1145: plural variant folding is keyed on English plural spelling —
  bare-s plurals no longer mint a bogus -es sibling (services→servic),
  unambiguous sibilant-es plurals no longer mint a bogus -s sibling
  (classes→classe), trailing -ss singulars no longer strip (class→clas);
  genuinely ambiguous endings (caches/databases) still emit both keys.
- #1146: getSegmentCoOccurrence folds variants to their original word
  inside the SQL (CASE mapping + COUNT(DISTINCT word)) so a plural pair
  of ONE word can't tie with a genuine two-word match and crowd it past
  the pre-fold ORDER BY/LIMIT; the JS re-check stays as the honesty layer.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-07-02 17:23:54 -05:00
committed by GitHub
co-authored by Claude Fable 5
parent be55b93d02
commit 35611b92bb
7 changed files with 233 additions and 20 deletions
+23 -3
View File
@@ -129,12 +129,32 @@ export function extractProseCandidates(prompt: string): string[] {
/**
* Lookup variants for a prose word: the word itself plus light plural folding
* ("services" → service, "dependencies" → dependencie/dependency is NOT
* attempted — only trailing s/es strip), so common plurals still hit their
* attempted — only a trailing s/es strip), so common plurals still hit their
* singular segment. Returned variants map back to the same original word.
*
* The strips are keyed on English plural spelling (#1145), in three classes:
* - UNAMBIGUOUS `-es` (after x/sh/ss/zz: boxes, hashes, classes, quizzes) —
* strip 2 only. Stripping 1 minted a bogus sibling ("classes" → classe).
* - AMBIGUOUS endings (`-ches`/`-ses`/`-zes`/`-oes`): spelling alone can't
* split patches(+es) from caches(+s), lenses from databases, heroes from
* shoes — emit BOTH candidate keys and let the vocab lookup decide; a miss
* is an ignored key, a wrong exclusive guess would LOSE the real match.
* - Everything else ending in `-s` — a bare `-s` plural (services, machines,
* cookies): strip 1 only. Stripping 2 minted "services" → servic.
* A trailing `-ss` is a singular (class, process), not a plural: no strip —
* that used to mint "class" → clas.
*/
export function segmentLookupVariants(word: string): string[] {
const variants = [word];
if (word.endsWith('es') && word.length >= MIN_PROSE_CHARS + 2) variants.push(word.slice(0, -2));
if (word.endsWith('s') && word.length >= MIN_PROSE_CHARS + 1) variants.push(word.slice(0, -1));
const canStrip2 = word.length >= MIN_PROSE_CHARS + 2;
const canStrip1 = word.length >= MIN_PROSE_CHARS + 1;
if (/(?:x|sh|ss|zz)es$/.test(word)) {
if (canStrip2) variants.push(word.slice(0, -2));
} else if (/(?:ch|s|z|o)es$/.test(word)) {
if (canStrip2) variants.push(word.slice(0, -2));
if (canStrip1) variants.push(word.slice(0, -1));
} else if (word.endsWith('s') && !word.endsWith('ss')) {
if (canStrip1) variants.push(word.slice(0, -1));
}
return variants;
}