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:
co-authored by
Claude Fable 5
parent
be55b93d02
commit
35611b92bb
@@ -1156,6 +1156,16 @@ program
|
||||
// scored, each hit re-verified to exist (see getSegmentMatches). The
|
||||
// payload names the symbols but does NOT run explore — the agent owns
|
||||
// the query where the hook's confidence is only "these are related".
|
||||
//
|
||||
// A database indexed before the vocab table existed starts with it
|
||||
// EMPTY, and only sync() backfills it — which this hook never runs
|
||||
// (#1142). Heal it here: on a populated vocab this is one SELECT;
|
||||
// the actual backfill is a one-time batched pass whose cost the MCP
|
||||
// server's own catch-up sync usually pays first (it runs at every
|
||||
// session start). A distinct noop outcome keeps a dormant vocab
|
||||
// from polluting the noop-unverified recall signal.
|
||||
const vocabReady = await cg.healSegmentVocabIfEmpty().catch(() => false);
|
||||
if (!vocabReady) { gate('noop-vocab-empty'); return; }
|
||||
const related = cg.getSegmentMatches(proseWords);
|
||||
if (related.length === 0) { gate('noop-unverified'); return; }
|
||||
const lines = related
|
||||
|
||||
+50
-11
@@ -319,8 +319,19 @@ export class QueryBuilder {
|
||||
// before use, and a full index clears the table at its start. File nodes
|
||||
// are excluded: a file's basename duplicates the symbols inside it
|
||||
// (state-machine.ts / OrderStateMachine), which double-counts every
|
||||
// concept and defeats the singleton-vs-cluster rarity statistics.
|
||||
if (node.kind !== 'file') this.insertNameSegments(node.name);
|
||||
// concept and defeats the singleton-vs-cluster rarity statistics. Import
|
||||
// nodes are excluded too (#1144): they're named after module specifiers
|
||||
// ("external-unindexed-pkg", "./utils/helpers"), not symbols — an
|
||||
// import-only name can never be surfaced (getSegmentMatches requires a
|
||||
// real definition), so its rows would only inflate the rarity statistics.
|
||||
if (this.isSegmentableKind(node.kind)) this.insertNameSegments(node.name);
|
||||
}
|
||||
|
||||
/** Which node kinds contribute their name to the segment vocabulary — the
|
||||
* single gate shared by insertNode, updateNode, and the rebuild page query
|
||||
* (getDistinctNodeNames), so the write paths can't drift apart. */
|
||||
private isSegmentableKind(kind: string): boolean {
|
||||
return kind !== 'file' && kind !== 'import';
|
||||
}
|
||||
|
||||
/** Write `name`'s segments into name_segment_vocab (idempotent). */
|
||||
@@ -412,6 +423,16 @@ export class QueryBuilder {
|
||||
returnType: node.returnType ?? null,
|
||||
updatedAt: node.updatedAt ?? Date.now(),
|
||||
});
|
||||
|
||||
// updateNode is a second real write path to `nodes` — framework
|
||||
// post-extract passes rewrite names through it (NestJS route prefixing),
|
||||
// and a renamed node's new name must reach the segment vocabulary just
|
||||
// like an inserted one's (#1141). Without this the rename left the new
|
||||
// name permanently unsearchable: the old name's rows became honest-gate
|
||||
// orphans and the only backfill is gated on the vocab being EMPTY.
|
||||
// insertNameSegments is idempotent (in-memory set + INSERT OR IGNORE),
|
||||
// so no name-changed check is needed.
|
||||
if (this.isSegmentableKind(node.kind)) this.insertNameSegments(node.name);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -461,11 +482,12 @@ export class QueryBuilder {
|
||||
return row === undefined;
|
||||
}
|
||||
|
||||
/** One page of distinct non-file node names, for batched vocab rebuilds
|
||||
* (file basenames are excluded from the vocab — see insertNode). */
|
||||
/** One page of distinct segmentable node names, for batched vocab rebuilds
|
||||
* (file basenames and import specifiers are excluded from the vocab — see
|
||||
* insertNode). */
|
||||
getDistinctNodeNames(limit: number, offset: number): string[] {
|
||||
const rows = this.db
|
||||
.prepare("SELECT DISTINCT name FROM nodes WHERE kind != 'file' ORDER BY name LIMIT ? OFFSET ?")
|
||||
.prepare("SELECT DISTINCT name FROM nodes WHERE kind NOT IN ('file', 'import') ORDER BY name LIMIT ? OFFSET ?")
|
||||
.all(limit, offset) as Array<{ name: string }>;
|
||||
return rows.map((r) => r.name);
|
||||
}
|
||||
@@ -478,17 +500,29 @@ export class QueryBuilder {
|
||||
}
|
||||
|
||||
/**
|
||||
* Names whose segments cover at least `minSegments` of the given segments —
|
||||
* Names whose segments cover at least `minWords` distinct PROMPT WORDS —
|
||||
* the co-occurrence probe behind the prompt hook's medium tier: the words
|
||||
* "state" and "machine" both being segments of `OrderStateMachine` is strong
|
||||
* evidence the prompt names that symbol in prose. Ordered by coverage.
|
||||
*
|
||||
* Takes (segment variant → original word) pairs and folds variants back to
|
||||
* their word INSIDE the SQL: a name matching both `service` and `services`
|
||||
* counts ONE word, not two. Counting raw variants let plural-variant pairs
|
||||
* of a single word tie with genuine two-word matches and — because ORDER
|
||||
* BY/LIMIT run here, before any JS-side re-check — crowd a real match past
|
||||
* the LIMIT on vocab-heavy repos (#1146).
|
||||
*/
|
||||
getSegmentCoOccurrence(segments: string[], minSegments: number, limit: number): Array<{ name: string; matches: number }> {
|
||||
if (segments.length === 0) return [];
|
||||
const placeholders = segments.map(() => '?').join(', ');
|
||||
getSegmentCoOccurrence(
|
||||
variants: Array<{ segment: string; word: string }>,
|
||||
minWords: number,
|
||||
limit: number,
|
||||
): Array<{ name: string; matches: number }> {
|
||||
if (variants.length === 0) return [];
|
||||
const placeholders = variants.map(() => '?').join(', ');
|
||||
const whens = variants.map(() => 'WHEN ? THEN ?').join(' ');
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
`SELECT name, COUNT(DISTINCT segment) AS matches
|
||||
`SELECT name, COUNT(DISTINCT CASE segment ${whens} END) AS matches
|
||||
FROM name_segment_vocab
|
||||
WHERE segment IN (${placeholders})
|
||||
GROUP BY name
|
||||
@@ -496,7 +530,12 @@ export class QueryBuilder {
|
||||
ORDER BY matches DESC, length(name) ASC
|
||||
LIMIT ?`,
|
||||
)
|
||||
.all(...segments, minSegments, limit) as Array<{ name: string; matches: number }>;
|
||||
.all(
|
||||
...variants.flatMap((v) => [v.segment, v.word]),
|
||||
...variants.map((v) => v.segment),
|
||||
minWords,
|
||||
limit,
|
||||
) as Array<{ name: string; matches: number }>;
|
||||
return rows;
|
||||
}
|
||||
|
||||
|
||||
+49
-6
@@ -935,10 +935,14 @@ export class CodeGraph {
|
||||
}
|
||||
const variants = [...variantToWord.keys()];
|
||||
|
||||
// Tier A: co-occurrence. minSegments=2 counts VARIANTS, so fold a name's
|
||||
// matched variants back to distinct words before trusting the coverage.
|
||||
// Tier A: co-occurrence. The SQL folds variants back to their original
|
||||
// word (#1146), so minWords=2 means two distinct PROMPT WORDS — a name
|
||||
// matching both `service` and `services` can't tie with (or crowd past
|
||||
// the LIMIT) a genuine two-word match. The JS re-check below recomputes
|
||||
// the fold from live segments as the honesty layer.
|
||||
const variantPairs = [...variantToWord.entries()].map(([segment, word]) => ({ segment, word }));
|
||||
const candidates: Array<{ name: string; matchedWords: Set<string> }> = [];
|
||||
for (const hit of this.queries.getSegmentCoOccurrence(variants, 2, 24)) {
|
||||
for (const hit of this.queries.getSegmentCoOccurrence(variantPairs, 2, 24)) {
|
||||
const matched = this.wordsMatchingName(hit.name, variantToWord);
|
||||
if (matched.size >= 2) candidates.push({ name: hit.name, matchedWords: matched });
|
||||
}
|
||||
@@ -970,7 +974,12 @@ export class CodeGraph {
|
||||
}
|
||||
|
||||
// Verify against nodes (the honesty gate) and pick a representative
|
||||
// definition per name — prefer a real symbol over a file/import node.
|
||||
// definition per name. A name whose only nodes are file/import kind has
|
||||
// no real definition to point at — surfacing the import statement instead
|
||||
// reads as a matched symbol but isn't one (#1144) — so it's skipped, the
|
||||
// same way an orphaned vocab row is. (Import names no longer enter the
|
||||
// vocab at write time, but rows written before that exclusion persist
|
||||
// until the next full index.)
|
||||
const out: SegmentMatch[] = [];
|
||||
const seen = new Set<string>();
|
||||
candidates.sort((a, b) => b.matchedWords.size - a.matchedWords.size || a.name.length - b.name.length);
|
||||
@@ -980,7 +989,8 @@ export class CodeGraph {
|
||||
seen.add(candidate.name);
|
||||
const nodes = this.queries.getNodesByName(candidate.name);
|
||||
if (nodes.length === 0) continue; // orphaned vocab row — name no longer exists
|
||||
const rep = nodes.find((n) => n.kind !== 'file' && n.kind !== 'import') ?? nodes[0]!;
|
||||
const rep = nodes.find((n) => n.kind !== 'file' && n.kind !== 'import');
|
||||
if (!rep) continue; // no real definition — don't surface an import/file as one
|
||||
out.push({
|
||||
name: candidate.name,
|
||||
kind: rep.kind,
|
||||
@@ -1009,10 +1019,43 @@ export class CodeGraph {
|
||||
return matched;
|
||||
}
|
||||
|
||||
/**
|
||||
* One-shot upgrade heal for callers that open the graph WITHOUT syncing —
|
||||
* concretely the prompt hook, whose MEDIUM tier reads the segment
|
||||
* vocabulary: a database migrated from before the vocab table existed
|
||||
* starts with it empty, and the only other backfill lives inside `sync()`,
|
||||
* which such callers never run (#1142). Returns true when the vocab is
|
||||
* usable (already populated — the overwhelmingly common one-SELECT case —
|
||||
* or healed here); false when it isn't (empty graph, or another process
|
||||
* holds the index lock — that process's own sync heals it).
|
||||
*/
|
||||
async healSegmentVocabIfEmpty(): Promise<boolean> {
|
||||
const empty = (() => {
|
||||
try { return this.queries.isNameSegmentVocabEmpty(); } catch { return false; }
|
||||
})();
|
||||
if (!empty) return true;
|
||||
if (this.queries.getNodeAndEdgeCount().nodes === 0) return false;
|
||||
return this.indexMutex.withLock(async () => {
|
||||
try {
|
||||
this.fileLock.acquire();
|
||||
} catch {
|
||||
return false; // an index/sync is running — it backfills the vocab itself
|
||||
}
|
||||
try {
|
||||
if (!this.queries.isNameSegmentVocabEmpty()) return true; // raced: healed meanwhile
|
||||
await this.rebuildNameSegmentVocab();
|
||||
return true;
|
||||
} finally {
|
||||
this.fileLock.release();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild the segment vocabulary from the current graph, batched and
|
||||
* yielding — the upgrade-heal path for indexes built before the vocab table
|
||||
* existed. Runs inside sync's mutex/lock (callers hold them).
|
||||
* existed. Runs inside the index mutex/lock (sync and
|
||||
* healSegmentVocabIfEmpty hold them).
|
||||
*/
|
||||
private async rebuildNameSegmentVocab(): Promise<void> {
|
||||
const maybeYield = createYielder();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user