feat(mcp): relevance scoring overhaul for explore — kill incidental name-collision matches (CG-10, #1500)
Explore's per-file relevance awarded +50/+10/+3/+1 by match class and admitted
anything scoring >= 3. Neither half held up: the tier said HOW a symbol reached
us, never whether the match was evidence, and an absolute floor admits noise on
any repo where the top file scores 50+. Three scripts/agent-eval/*.mjs harnesses
took 63% of this repo's own "how does explore allocate its output budget" answer
on nothing but an unused `const explore` and a `const BUDGET`.
Four levers:
- KIND WEIGHT (RELEVANCE_KIND_WEIGHT): callables and types 1.0, members ~0.5,
variable/constant/parameter 0.15-0.35. A weak-kind symbol with no usage edge
anywhere in the graph (`contains` excluded — nesting is not usage) drops to
0.08. Only weak kinds in the top two tiers pay for the DB probe; the subgraph's
own edges answer most cases free. No measurable latency change (210 vs 211
ms/call, n=12 interleaved).
- PERIPHERAL CAP: nodes >=2 hops from any match accumulate into a bucket capped
at 5. Uncapped they added a flat +1 each, so a file grew more relevant by being
bigger — parse-session.mjs reached 22 off one constant plus twelve unrelated
symbols.
- RANK PENALTY: generated files x0.3, low-value x0.5, applied to the score AND
the graph mass. Score alone would not have fixed #1500 — the generated CRUD
carries MORE graph mass than the hand-written use-case, and graph mass outranks
score in the comparator. Self-normalizing, never a hard exclusion.
- RELATIVE FLOOR: clamp(topScore * 0.2, 1, 10). Capped at one full-strength
direct match so concentration elsewhere can never exclude one (without it a
named-seed-heavy file pushed the floor to 21 and dropped a file the agent had
named by class name). Backfills to 3 candidates when it would leave fewer, and
drops the evidence requirement rather than return nothing at all.
excludeLowValueFiles was dead config — declared per tier, read nowhere; the
test/spec exclusion has been unconditional for a while. Removed. The real gap was
the detector: `isLowValue` anchored on a leading `/`, so a repo-ROOT `test/` dir
(express, cobra, most of npm and Go) never matched — express's routing question
spent 59% of its envelope on three test files. Anchored at `^` too, and the
filter now runs before the floor and judges "are there other candidates?" on the
whole gather.
Measured before/after on the same indexes (baseline bd86ad2):
- payroll-go fixture: generated 57.4% -> 23.5%; answer 25.6% -> 61.5%; cycle.go
delivered 0 -> 38.9%. Generated ranks #3/#4, was #1/#2.
- self-query fixture: eval scripts 72% -> 0%; tools.ts ranks #1.
- express "route a request": 59% to test/* -> lib/application.js + lib/response.js
- cobra x3, codegraph "indexing pipeline": byte-identical (control)
Diagnostic gains a per-file penalty multiplier and NodeKind mix, so "why did this
file score X" is legible. Selection stages reordered to match the pipeline.
CG-6's gates flip from it.fails to live regressions except the byte-split ones,
which stay open for CG-12 (allocation still follows file size within the ranked
set).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
bd86ad2061
commit
a3898cdc70
@@ -64,6 +64,20 @@ export interface ExploreCandidateMeta {
|
||||
spine: boolean;
|
||||
lowValue: boolean;
|
||||
generated: boolean;
|
||||
/**
|
||||
* Multiplier `rankPenalty` applied to BOTH `score` and `graphScore` (1 = no
|
||||
* penalty). Generated and test/i18n files rank on discounted signals, so the
|
||||
* raw values are `score / penalty` — worth reporting, since "why did this
|
||||
* generated file lose?" is otherwise invisible in the numbers (CG-10).
|
||||
*/
|
||||
penalty: number;
|
||||
/**
|
||||
* Which NodeKinds the file's matched symbols were, most-numerous first
|
||||
* (`function:4 constant:1`). The scoring is kind-weighted, so this is the
|
||||
* breakdown that explains a score — a file carried by one isolated `constant`
|
||||
* is the #1500 failure, and it is legible here at a glance.
|
||||
*/
|
||||
kinds: string;
|
||||
}
|
||||
|
||||
interface FileRecord extends ExploreCandidateMeta {
|
||||
@@ -81,13 +95,14 @@ interface FileRecord extends ExploreCandidateMeta {
|
||||
skipped?: ExploreSkipReason;
|
||||
}
|
||||
|
||||
/** Candidate counts down the selection pipeline, in the order it runs. */
|
||||
interface StageCounts {
|
||||
/** Files with at least one gathered node. */
|
||||
grouped: number;
|
||||
/** Survived the `group.score >= scoreFloor` filter. */
|
||||
pastScoreFloor: number;
|
||||
/** Survived the test/spec/icon/i18n hard-exclude. */
|
||||
pastLowValueFilter: number;
|
||||
/** Survived the `group.score >= scoreFloor` filter. */
|
||||
pastScoreFloor: number;
|
||||
/** Survived the graph-relevance gate. */
|
||||
pastRelevanceGate: number;
|
||||
}
|
||||
@@ -142,8 +157,8 @@ export interface ExploreDiagnosticReport {
|
||||
graphGateThreshold: number;
|
||||
graphGateApplied: boolean;
|
||||
filesGrouped: number;
|
||||
filesPastScoreFloor: number;
|
||||
filesPastLowValueFilter: number;
|
||||
filesPastScoreFloor: number;
|
||||
filesRanked: number;
|
||||
filesRenderedByLoop: number;
|
||||
filesInFinalOutput: number;
|
||||
@@ -216,18 +231,21 @@ export class ExploreDiagnostics {
|
||||
}
|
||||
}
|
||||
|
||||
/** Candidate count after the initial `group.score >= floor` filter. */
|
||||
setScoreFloor(floor: number, grouped: number, kept: number): void {
|
||||
this.scoreFloor = floor;
|
||||
/**
|
||||
* Candidate count after the test/spec/icon/i18n hard-exclude — the FIRST
|
||||
* selection stage, ahead of the score floor.
|
||||
*/
|
||||
setLowValueFiltered(grouped: number, kept: number): void {
|
||||
this.stages.grouped = grouped;
|
||||
this.stages.pastScoreFloor = kept;
|
||||
this.stages.pastLowValueFilter = kept;
|
||||
this.stages.pastScoreFloor = kept;
|
||||
this.stages.pastRelevanceGate = kept;
|
||||
}
|
||||
|
||||
/** Candidate count after the test/spec/icon/i18n hard-exclude. */
|
||||
setLowValueFiltered(kept: number): void {
|
||||
this.stages.pastLowValueFilter = kept;
|
||||
/** Candidate count after the `group.score >= floor` filter. */
|
||||
setScoreFloor(floor: number, kept: number): void {
|
||||
this.scoreFloor = floor;
|
||||
this.stages.pastScoreFloor = kept;
|
||||
this.stages.pastRelevanceGate = kept;
|
||||
}
|
||||
|
||||
@@ -342,8 +360,8 @@ export class ExploreDiagnostics {
|
||||
graphGateThreshold: this.graphGateThreshold,
|
||||
graphGateApplied: this.graphGateApplied,
|
||||
filesGrouped: this.stages.grouped,
|
||||
filesPastScoreFloor: this.stages.pastScoreFloor,
|
||||
filesPastLowValueFilter: this.stages.pastLowValueFilter,
|
||||
filesPastScoreFloor: this.stages.pastScoreFloor,
|
||||
filesRanked: this.stages.pastRelevanceGate,
|
||||
filesRenderedByLoop: filesIncluded,
|
||||
filesInFinalOutput: rendered.length,
|
||||
@@ -364,6 +382,8 @@ export class ExploreDiagnostics {
|
||||
spine: r.spine,
|
||||
lowValue: r.lowValue,
|
||||
generated: r.generated,
|
||||
penalty: round6(r.penalty),
|
||||
kinds: r.kinds,
|
||||
render: r.render ?? null,
|
||||
skipped: r.skipped ?? null,
|
||||
clipped: r.clipped,
|
||||
@@ -463,8 +483,8 @@ export function renderTable(report: ExploreDiagnosticReport): string {
|
||||
);
|
||||
out.push(
|
||||
` files ${num(sel.filesGrouped)} grouped` +
|
||||
` → ${num(sel.filesPastScoreFloor)} past score floor (>=${sel.scoreFloor})` +
|
||||
` → ${num(sel.filesPastLowValueFilter)} past low-value filter` +
|
||||
` → ${num(sel.filesPastScoreFloor)} past score floor (>=${sel.scoreFloor.toFixed(1)})` +
|
||||
` → ${num(sel.filesRanked)} past relevance gate` +
|
||||
` → ${num(sel.filesInFinalOutput)} in output (maxFiles ${num(budget.maxFiles)})`,
|
||||
);
|
||||
@@ -479,7 +499,7 @@ export function renderTable(report: ExploreDiagnosticReport): string {
|
||||
// when the ceiling truncated; showing both makes that divergence obvious.
|
||||
const shown = files.filter((f) => f.emittedChars > 0 || f.finalChars > 0);
|
||||
if (shown.length > 0) {
|
||||
out.push(' # alloc% deliv% bytes score graph hits flags render file');
|
||||
out.push(' # alloc% deliv% bytes score graph hits pen flags render file');
|
||||
for (const f of shown) {
|
||||
out.push(
|
||||
' ' +
|
||||
@@ -487,13 +507,15 @@ export function renderTable(report: ExploreDiagnosticReport): string {
|
||||
pct(f.allocatedShare).padStart(6) + ' ' +
|
||||
pct(f.share).padStart(6) + ' ' +
|
||||
num(f.emittedChars).padStart(7) + ' ' +
|
||||
String(f.score).padStart(5) + ' ' +
|
||||
f.score.toFixed(1).padStart(5) + ' ' +
|
||||
f.graphScore.toFixed(5).padStart(7) + ' ' +
|
||||
String(f.termHits).padStart(4) + ' ' +
|
||||
f.penalty.toFixed(2).padStart(4) + ' ' +
|
||||
flagString(f).padEnd(19) + ' ' +
|
||||
((f.render ?? '-') + (f.clipped ? '*' : '')).padEnd(9) + ' ' +
|
||||
f.path,
|
||||
);
|
||||
out.push(' kinds: ' + (f.kinds || '-'));
|
||||
}
|
||||
out.push(' (bytes = source allocated by the render loop; deliv% = 0 means the hard ceiling dropped the section)');
|
||||
out.push(' (* = clipped: some source in this file was elided, windowed, or its section dropped)');
|
||||
@@ -508,7 +530,8 @@ export function renderTable(report: ExploreDiagnosticReport): string {
|
||||
for (const f of skipped.slice(0, 15)) {
|
||||
out.push(
|
||||
` #${String(f.rank).padStart(2)} ${f.path} — ${f.skipped ?? f.render ?? 'not reached'}` +
|
||||
` (score ${f.score}, graph ${f.graphScore.toFixed(5)}, hits ${f.termHits})`,
|
||||
` (score ${f.score.toFixed(1)}, graph ${f.graphScore.toFixed(5)}, hits ${f.termHits},` +
|
||||
` pen ${f.penalty.toFixed(2)}, ${f.kinds || '-'})`,
|
||||
);
|
||||
}
|
||||
if (skipped.length > 15) out.push(` … and ${skipped.length - 15} more`);
|
||||
|
||||
Reference in New Issue
Block a user