瀏覽代碼

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>
Colby McHenry 1 月之前
父節點
當前提交
a3898cdc70

+ 2 - 0
CHANGELOG.md

@@ -17,6 +17,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
 ### Fixes
 
+- `codegraph_explore` no longer spends its answer on files that merely share a word with your question. A file whose only connection to the query was an unused local variable or constant of the same name — an eval script with a `const explore` in it, say — used to count as strongly as the file that actually implements the thing, and being small enough to include whole, it could take most of the response while the real answer got trimmed. Matches are now weighted by what was matched (a function, class or route counts for far more than a local variable, and a variable nothing else references counts for almost nothing), and a file has to score within reach of the best match rather than clear a fixed low bar. Generated files are down-weighted throughout the ranking instead of only breaking ties, so a generated CRUD layer no longer outranks the hand-written workflow beside it. Test and spec files in a top-level `test/` or `spec/` directory are now recognized and kept out, which they weren't before. (#1500)
+
 - A CodeGraph process that gets force-killed — by the stuck-process watchdog, a crash, or the OS — no longer leaves the database's write-ahead log behind to grow without bound. Previously each killed session stacked more data onto the same log file and nothing ever shrank it, which on machines where sessions were killed regularly could quietly eat tens of gigabytes of disk. The log is now capped, and any oversized leftover is reclaimed automatically the next time the project is opened. Thanks @tiendungdev for the exceptional Windows report that pinned this down. (#1431)
 - The background server's watchdog no longer kills a healthy server that is just waiting on a slow disk: like indexing already does, it now checks whether the database files are still making progress before concluding the process is stuck. Fewer spurious kills also means fewer leftover write-ahead logs. (#1431)
 - `codegraph status` now shows the write-ahead log's size next to the database size and warns when killed sessions have left it oversized, and every line in the background server's log now carries a timestamp so kills and restarts can be placed in time. (#1431)

+ 51 - 31
__tests__/explore-allocation-1500.test.ts

@@ -19,10 +19,12 @@
  *     runPayrollCycleAll → BuildPayslip → Upsert chain resolving end-to-end. If
  *     the fixture rots, these fail first and say so.
  *
- *  2. **Budget allocation** — the gate, written with `it.fails` because it
- *     DOCUMENTS A BUG THAT IS STILL OPEN. Vitest passes an `it.fails` test only
- *     while its body throws, so the suite is green today and goes RED the moment
- *     allocation is fixed (CG-10 scoring + CG-12 proportional bytes).
+ *  2. **Budget allocation** — the gate. CG-10 (relevance scoring) closed most of
+ *     it: the generated CRUD now ranks and delivers BELOW the hand-written
+ *     workflow, and those assertions are live regressions. What remains is
+ *     `it.fails`, which DOCUMENTS THE PART STILL OPEN — vitest passes an
+ *     `it.fails` test only while its body throws, so it goes RED the moment
+ *     CG-12's proportional byte allocation lands.
  *     **When it goes red, delete the `.fails` — do not delete the test.**
  *
  * The same assertions run outside vitest, against the built dist and with the
@@ -202,54 +204,72 @@ describe('#1500 — generated Go CRUD beside a hand-written payroll workflow', (
     const generatedShare = () => share((p) => p.startsWith(GENERATED_PREFIX));
 
     /**
-     * BASELINE 2026-08-03 (very-tiny tier, 13,000-char budget): 23,020 chars
-     * allocated against it, cut to 16,011 by the 19,500 hard ceiling. The
-     * generated CRUD delivers 57.4%; the hand-written layer delivers 25.6%, all
-     * of it domain types. `cycle.go` is allocated the single largest slice
-     * (7,052 chars, 30.6%) and delivers ZERO — the ceiling drops its whole
-     * section — so runPayrollCycleAll, the hand-written BuildPayslip and the
-     * real Upsert never reach the agent at all.
+     * BASELINE 2026-08-03, BEFORE CG-10 (very-tiny tier, 13,000-char budget):
+     * 23,020 chars allocated, cut to 16,011 by the 19,500 hard ceiling. The
+     * generated CRUD delivered 57.4%; the hand-written layer 25.6%, all of it
+     * domain types. `cycle.go` was allocated the single largest slice (7,052
+     * chars, 30.6%) and delivered ZERO — the ceiling dropped its whole section —
+     * so runPayrollCycleAll, the hand-written BuildPayslip and the real Upsert
+     * never reached the agent.
      *
-     * Each `it.fails` below passes ONLY while that is still true.
-     * ⚠ When one goes red, the bug is fixed: remove `.fails`, keep the test.
+     * AFTER CG-10 (relevance scoring): the generated files rank #3/#4 instead of
+     * #1/#2 — kind-weighted scoring plus a generated rank PENALTY on both the
+     * score and the graph mass, rather than the old tiebreak-at-equal-score.
+     * `cycle.go` now delivers 38.9% and the generated layer 23.5%. Four of the
+     * five gates below are green and are now live regressions.
+     *
+     * STILL OPEN, for CG-12 (proportional byte allocation): the render loop
+     * still allocates by FILE SIZE within the ranked set, and `maxFiles` is 4 at
+     * this tier — so `payslip_builder.go` ranks #6 and never renders, and
+     * `func (s *Service) BuildPayslip` is absent. Its `it.fails` passes ONLY
+     * while that is still true. ⚠ When it goes red, remove `.fails`, keep it.
      */
-    it.fails('CG-12 GATE: concentrates the envelope on the hand-written workflow', () => {
+    it('CG-10 GATE: concentrates the envelope on the hand-written workflow', () => {
       expect(answerShare()).toBeGreaterThanOrEqual(0.55);
     });
 
-    it.fails('CG-12 GATE: does not spend the envelope on the generated CRUD', () => {
+    it('CG-10 GATE: does not spend the envelope on the generated CRUD', () => {
       expect(generatedShare()).toBeLessThanOrEqual(0.25);
     });
 
-    it.fails('CG-12 GATE: delivers the workflow file it allocated the most bytes to', () => {
-      expect(bytes.get('internal/usecase/payroll/cycle.go') ?? 0).toBeGreaterThan(0);
+    it('CG-10 GATE: ranks the generated CRUD below the hand-written workflow', () => {
+      // The #1500 report in one assertion: before CG-10 the generated layer both
+      // outscored AND out-delivered the use-case that implements the business rule.
+      expect(answerShare()).toBeGreaterThan(generatedShare());
     });
 
-    it.fails('CG-12 GATE: delivers the calculation the question asks about', () => {
-      expect(bytes.get('internal/usecase/payroll/payslip_builder.go') ?? 0).toBeGreaterThan(0);
+    it('CG-10 GATE: delivers the workflow file it allocated the most bytes to', () => {
+      expect(bytes.get('internal/usecase/payroll/cycle.go') ?? 0).toBeGreaterThan(0);
     });
 
-    it.fails('CG-12 GATE: puts the hand-written chain in the response, not its generated twin', () => {
-      // Bare `BuildPayslip`/`Upsert` also match the generated collisions — these
-      // needles are unique to the hand-written chain.
+    it('CG-10 GATE: puts the hand-written chain in the response, not its generated twin', () => {
+      // Bare `Upsert` also matches the generated collision — these needles are
+      // unique to the hand-written chain.
       expect(response).toContain('runPayrollCycleAll');
-      expect(response).toContain('func (s *Service) BuildPayslip');
       expect(response).toContain('s.store.Upsert(ctx, slip)');
     });
 
-    it('records the shape of the failure so a regression is legible', () => {
-      // Not a gate — an assertion-free-ish snapshot of WHY the gates above fail,
-      // so a future change that shifts the numbers shows up in the diff rather
-      // than silently flipping an it.fails.
+    it.fails('CG-12 GATE: delivers the calculation the question asks about', () => {
+      // `payslip_builder.go` ranks #6; the tier's maxFiles is 4 and the render
+      // loop spends by file size, so it never gets bytes. Score-proportional
+      // allocation (CG-12) is what closes this.
+      expect(bytes.get('internal/usecase/payroll/payslip_builder.go') ?? 0).toBeGreaterThan(0);
+      expect(response).toContain('func (s *Service) BuildPayslip');
+    });
+
+    it('records the shape of the allocation so a regression is legible', () => {
+      // Not a gate — a snapshot of the split, so a future change that shifts the
+      // numbers shows up in the diff rather than silently flipping a gate.
       const generated = generatedShare();
       const answer = answerShare();
-      const workflow = bytes.get('internal/usecase/payroll/cycle.go') ?? 0;
       expect({
         generatedWinsEnvelope: generated > answer,
-        workflowFileDeliversNothing: workflow === 0,
+        workflowFileDelivers: (bytes.get('internal/usecase/payroll/cycle.go') ?? 0) > 0,
+        builderFileDelivers: (bytes.get('internal/usecase/payroll/payslip_builder.go') ?? 0) > 0,
       }).toEqual({
-        generatedWinsEnvelope: true,
-        workflowFileDeliversNothing: true,
+        generatedWinsEnvelope: false,
+        workflowFileDelivers: true,
+        builderFileDelivers: false,
       });
     });
   });

+ 10 - 3
__tests__/explore-diagnostics.test.ts

@@ -196,11 +196,17 @@ describe('codegraph_explore allocation diagnostic', () => {
     // Totals: envelope vs budget, and the file-selection funnel with its floor.
     expect(out).toMatch(/envelope [\d,]+ chars delivered · [\d,]+ allocated of [\d,]+ budget/);
     expect(out).toMatch(/hard ceiling [\d,]+/);
-    expect(out).toMatch(/files [\d,]+ grouped .*past score floor \(>=\d+\).*in output \(maxFiles \d+\)/);
+    // The funnel runs low-value filter → score floor; the floor is fractional
+    // now that scoring is kind-weighted (CG-10).
+    expect(out).toMatch(
+      /files [\d,]+ grouped .*past low-value filter .*past score floor \(>=[\d.]+\).*in output \(maxFiles \d+\)/,
+    );
     // Per-file columns.
-    expect(out).toMatch(/#\s+alloc%\s+deliv%\s+bytes\s+score\s+graph\s+hits\s+flags\s+render\s+file/);
+    expect(out).toMatch(/#\s+alloc%\s+deliv%\s+bytes\s+score\s+graph\s+hits\s+pen\s+flags\s+render\s+file/);
     expect(out).toContain('src/session.ts');
     expect(out).toMatch(/\d+\.\d%/);
+    // Kind mix — what each file's score was bought with.
+    expect(out).toMatch(/kinds: (?:\w+:\d+ ?)+/);
   });
 
   it('appends one JSON report per call to a sidecar path', async () => {
@@ -222,7 +228,8 @@ describe('codegraph_explore allocation diagnostic', () => {
     expect(report.budget.maxOutputChars).toBeGreaterThan(0);
     expect(report.envelope.chars).toBeGreaterThan(0);
     expect(report.selection.scoreFloor).toBeGreaterThan(0);
-    expect(report.selection.filesGrouped).toBeGreaterThanOrEqual(report.selection.filesPastScoreFloor);
+    expect(report.selection.filesGrouped).toBeGreaterThanOrEqual(report.selection.filesPastLowValueFilter);
+    expect(report.selection.filesPastLowValueFilter).toBeGreaterThanOrEqual(report.selection.filesPastScoreFloor);
     expect(report.selection.filesPastScoreFloor).toBeGreaterThanOrEqual(report.selection.filesRanked);
     expect(report.selection.filesRanked).toBeGreaterThanOrEqual(report.selection.filesInFinalOutput);
     expect(report.selection.filesInFinalOutput).toBeGreaterThan(0);

+ 356 - 0
__tests__/explore-relevance-scoring.test.ts

@@ -0,0 +1,356 @@
+/**
+ * Relevance scoring for `codegraph_explore` — CG-10 / #1500.
+ *
+ * The failure this pins: a file that merely NAME-COLLIDES with the query used to
+ * score the same per match as the file that answers it, because every match in a
+ * tier counted the same regardless of what was matched. Three
+ * `scripts/agent-eval/*.mjs` harnesses took 63% of this repo's own "how does
+ * explore allocate its output budget across files" response on nothing but a
+ * local `const explore` and a `const BUDGET`.
+ *
+ * Four levers, one fixture family each:
+ *   1. KIND WEIGHT     — a match on a function/class outweighs one on a
+ *                        variable/constant/parameter.
+ *   2. ISOLATION       — a weak-kind symbol nothing calls or references is a
+ *                        pure collision and is demoted much harder.
+ *   3. RELATIVE FLOOR  — admission scales with the best file's score instead of
+ *                        an absolute `>= 3`, capped so one direct match always
+ *                        gets in and floored so a diffuse query keeps its spread.
+ *   4. RANK PENALTY    — generated and test/i18n files are discounted on BOTH
+ *                        the score and the graph mass (the sort's primary key),
+ *                        not merely tie-broken at equal score.
+ *
+ * Each fixture is a whole indexed project because the scoring reads the graph
+ * (usage edges, RWR mass, the generated flag) — there is no seam to unit-test
+ * the comparator against, and mocking one would pin the mock, not the behavior.
+ */
+import { describe, it, expect, beforeAll, afterAll } from 'vitest';
+import * as fs from 'fs';
+import * as path from 'path';
+import * as os from 'os';
+import CodeGraph from '../src/index';
+import { ToolHandler, RELEVANCE_KIND_WEIGHT } from '../src/mcp/tools';
+import { attributeSourceBytes } from '../src/mcp/explore-diagnostics';
+
+/** Build + index a throwaway project from a `{ relPath: source }` map. */
+async function buildProject(
+  prefix: string,
+  files: Record<string, string>,
+): Promise<{ dir: string; cg: CodeGraph; handler: ToolHandler }> {
+  const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
+  for (const [rel, body] of Object.entries(files)) {
+    const abs = path.join(dir, rel);
+    fs.mkdirSync(path.dirname(abs), { recursive: true });
+    fs.writeFileSync(abs, body.trimStart());
+  }
+  const cg = CodeGraph.initSync(dir);
+  await cg.indexAll();
+  return { dir, cg, handler: new ToolHandler(cg) };
+}
+
+const cleanup = (dir: string, cg?: CodeGraph) => {
+  if (cg) cg.destroy();
+  if (dir && fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true });
+};
+
+/**
+ * Where a file's source section appears in the response. Sections are emitted in
+ * final rank order, so this is the ranking assertion — which is what CG-10 owns.
+ * How many BYTES each ranked file then gets is CG-12's (`maxCharsPerFile` and the
+ * render loop still spend by file size, so a large low-ranked file can still
+ * out-byte a small high-ranked one).
+ */
+const rankOf = (text: string, filePath: string): number => {
+  const at = text.indexOf('**`' + filePath + '`**');
+  if (at < 0) return Number.POSITIVE_INFINITY;
+  return text.slice(0, at).split('**`').length;
+};
+
+describe('RELEVANCE_KIND_WEIGHT', () => {
+  it('ranks callables and types above members, and members above locals', () => {
+    const callables = ['function', 'method', 'class', 'struct', 'interface', 'route', 'component'];
+    for (const kind of callables) expect(RELEVANCE_KIND_WEIGHT[kind]).toBe(1);
+
+    for (const member of ['property', 'field', 'enum_member']) {
+      expect(RELEVANCE_KIND_WEIGHT[member]!).toBeLessThan(RELEVANCE_KIND_WEIGHT.function!);
+      expect(RELEVANCE_KIND_WEIGHT[member]!).toBeGreaterThan(RELEVANCE_KIND_WEIGHT.parameter!);
+    }
+
+    // The #1500 kinds: incidental until the graph corroborates them.
+    for (const weak of ['constant', 'variable', 'parameter']) {
+      expect(RELEVANCE_KIND_WEIGHT[weak]!).toBeLessThan(0.5);
+    }
+    expect(RELEVANCE_KIND_WEIGHT.parameter!).toBeLessThan(RELEVANCE_KIND_WEIGHT.variable!);
+  });
+});
+
+describe('explore relevance scoring — incidental name collisions (#1500)', () => {
+  let dir: string;
+  let cg: CodeGraph;
+  let handler: ToolHandler;
+
+  // Shape: one file DEFINES the dispatch mechanism; three unrelated scripts each
+  // declare a lone unused `dispatch`/`registry` binding. Before CG-10 all four
+  // cleared the floor and the three small scripts, shipping whole, took most of
+  // the envelope from the large real file, which got clipped.
+  beforeAll(async () => {
+    const noise = (n: number) => `
+const dispatch = ${n};
+const registry = 'unused-${n}';
+
+function unrelated${n}Helper(value) {
+  return value + ${n};
+}
+`;
+    ({ dir, cg, handler } = await buildProject('codegraph-cg10-collide-', {
+      'src/dispatcher.js': `
+import { lookupHandler } from './registry.js';
+
+export function dispatch(event) {
+  const handler = lookupHandler(event.type);
+  if (!handler) return null;
+  return runHandler(handler, event);
+}
+
+export function runHandler(handler, event) {
+  return handler(event.payload);
+}
+`,
+      'src/registry.js': `
+const handlers = new Map();
+
+export function registerHandler(type, fn) {
+  handlers.set(type, fn);
+}
+
+export function lookupHandler(type) {
+  return handlers.get(type);
+}
+`,
+      'scripts/report-a.js': noise(1),
+      'scripts/report-b.js': noise(2),
+      'scripts/report-c.js': noise(3),
+    }));
+  }, 120_000);
+
+  afterAll(() => cleanup(dir, cg));
+
+  const explore = async (query: string) => {
+    const result = await handler.execute('codegraph_explore', { query });
+    const text = result.content?.[0]?.text ?? '';
+    return { text, bytes: attributeSourceBytes(text) };
+  };
+
+  it('keeps files whose only match is an unused local out of the response', async () => {
+    const { bytes } = await explore('how does dispatch route an event to its handler');
+    for (const noiseFile of ['scripts/report-a.js', 'scripts/report-b.js', 'scripts/report-c.js']) {
+      expect(bytes.get(noiseFile) ?? 0, `${noiseFile} must not reach the envelope`).toBe(0);
+    }
+  });
+
+  it('spends every delivered source byte on the files that define the mechanism', async () => {
+    const { bytes } = await explore('how does dispatch route an event to its handler');
+    let answer = 0;
+    let noise = 0;
+    for (const [file, n] of bytes) {
+      if (file.startsWith('src/')) answer += n;
+      else noise += n;
+    }
+    expect(answer).toBeGreaterThan(0);
+    expect(noise).toBe(0);
+    expect(bytes.get('src/dispatcher.js') ?? 0).toBeGreaterThan(0);
+  });
+
+  it('still answers when the collision is the ONLY thing that matched', async () => {
+    // Guard against over-correction: querying the noise term alone must not
+    // produce an empty response. Under-serving costs the agent a round-trip, so
+    // the floor's backfill has to keep the best of what matched.
+    const { text } = await explore('unrelated2Helper');
+    expect(text).not.toContain('No relevant code found');
+    expect(text).toContain('unrelated2Helper');
+  });
+});
+
+describe('explore relevance scoring — generated source is penalized, not tie-broken', () => {
+  let dir: string;
+  let cg: CodeGraph;
+  let handler: ToolHandler;
+
+  // The #1500 shape in miniature: the generated layer collides on every query
+  // term AND carries more call-graph mass than the hand-written use-case, so a
+  // generated-as-tiebreak-only rule leaves it ranked first.
+  beforeAll(async () => {
+    ({ dir, cg, handler } = await buildProject('codegraph-cg10-generated-', {
+      'go.mod': 'module example.com/billing\n\ngo 1.22\n',
+      'internal/usecase/billing/invoice.go': `
+package billing
+
+// Service runs the month-end invoicing workflow.
+type Service struct {
+	store Store
+}
+
+// RunInvoiceCycle is the hand-written business rule the question is about.
+func (s *Service) RunInvoiceCycle(month string) error {
+	lines := s.CollectInvoiceLines(month)
+	total := s.CalculateInvoiceTotal(lines)
+	return s.store.Save(month, total)
+}
+
+func (s *Service) CollectInvoiceLines(month string) []int {
+	return []int{1, 2, 3}
+}
+
+func (s *Service) CalculateInvoiceTotal(lines []int) int {
+	sum := 0
+	for _, l := range lines {
+		sum += l
+	}
+	return sum
+}
+`,
+      'internal/usecase/billing/store.go': `
+package billing
+
+type Store interface {
+	Save(month string, total int) error
+}
+`,
+      // Ordinary filename — ONLY the content banner betrays it (the CG-5 case).
+      'internal/gen/billing/invoice.go': `
+// Code generated by billingkit. DO NOT EDIT.
+
+package gen
+
+type InvoiceRow struct {
+	Month string
+	Total int
+}
+
+type InvoiceCreateRequest struct {
+	Month string
+}
+
+func CreateInvoice(req InvoiceCreateRequest) InvoiceRow {
+	return BuildInvoice(req.Month, 0)
+}
+
+func BuildInvoice(month string, total int) InvoiceRow {
+	return InvoiceRow{Month: month, Total: total}
+}
+
+func CalculateInvoiceTotal(rows []InvoiceRow) int {
+	sum := 0
+	for _, r := range rows {
+		sum += r.Total
+	}
+	return sum
+}
+
+func ListInvoices(month string) []InvoiceRow {
+	return []InvoiceRow{BuildInvoice(month, 0)}
+}
+
+func CollectInvoiceLines(month string) []InvoiceRow {
+	return ListInvoices(month)
+}
+
+func RunInvoiceCycle(month string) InvoiceRow {
+	rows := CollectInvoiceLines(month)
+	return BuildInvoice(month, CalculateInvoiceTotal(rows))
+}
+`,
+    }));
+  }, 120_000);
+
+  afterAll(() => cleanup(dir, cg));
+
+  it('indexes the ordinary-named generated file via its content banner', () => {
+    expect(cg.getFile('internal/gen/billing/invoice.go')?.generated).toBe(true);
+    expect(cg.getFile('internal/usecase/billing/invoice.go')?.generated).toBe(false);
+  });
+
+  it('ranks the hand-written workflow above its generated twin', async () => {
+    const result = await handler.execute('codegraph_explore', {
+      query: 'how does the invoice cycle collect lines and calculate the total',
+    });
+    const text = result.content?.[0]?.text ?? '';
+
+    // The generated file collides on EVERY query term and carries call-graph
+    // mass of its own, so with generated status as a mere tiebreak-at-equal-score
+    // it ranked first. The penalty scales its score AND its graph mass, which is
+    // the key the comparator actually sorts on.
+    const handWritten = rankOf(text, 'internal/usecase/billing/invoice.go');
+    const generated = rankOf(text, 'internal/gen/billing/invoice.go');
+    expect(handWritten).toBeLessThan(generated);
+    expect(attributeSourceBytes(text).get('internal/usecase/billing/invoice.go') ?? 0)
+      .toBeGreaterThan(0);
+  });
+});
+
+describe('explore relevance scoring — test files never buy the envelope', () => {
+  let dir: string;
+  let cg: CodeGraph;
+  let handler: ToolHandler;
+
+  // A repo-ROOT `test/` directory — the shape express and most of npm/Go use.
+  // The old detector anchored on a leading `/`, so `test/x.js` never matched it
+  // and express's routing question spent 59% of its envelope on three test files.
+  beforeAll(async () => {
+    const spec = (n: number) => `
+const { parseRoute } = require('../lib/router.js');
+
+describe('parseRoute ${n}', () => {
+  it('parses a route ${n}', () => {
+    parseRoute('/a/${n}');
+  });
+  it('parses another route ${n}', () => {
+    parseRoute('/b/${n}');
+  });
+});
+`;
+    ({ dir, cg, handler } = await buildProject('codegraph-cg10-lowvalue-', {
+      'lib/router.js': `
+exports.parseRoute = function parseRoute(pathname) {
+  const segments = pathname.split('/').filter(Boolean);
+  return { segments, matched: matchRoute(segments) };
+};
+
+function matchRoute(segments) {
+  return segments.length > 0;
+}
+`,
+      'lib/dispatch.js': `
+const { parseRoute } = require('./router.js');
+
+exports.dispatchRoute = function dispatchRoute(pathname) {
+  return parseRoute(pathname);
+};
+`,
+      'test/router.raw.js': spec(1),
+      'test/router.json.js': spec(2),
+      'test/router.text.js': spec(3),
+    }));
+  }, 120_000);
+
+  afterAll(() => cleanup(dir, cg));
+
+  it('excludes a repo-root test/ directory from the envelope', async () => {
+    const result = await handler.execute('codegraph_explore', {
+      query: 'how does the router parse and dispatch a route',
+    });
+    const bytes = attributeSourceBytes(result.content?.[0]?.text ?? '');
+    for (const [file, n] of bytes) {
+      expect(n === 0 || !file.startsWith('test/'), `${file} took ${n} chars`).toBe(true);
+    }
+    expect(bytes.get('lib/router.js') ?? 0).toBeGreaterThan(0);
+  });
+
+  it('still returns tests when the query is about them', async () => {
+    const result = await handler.execute('codegraph_explore', {
+      query: 'which tests cover parseRoute',
+    });
+    const text = result.content?.[0]?.text ?? '';
+    expect(text).not.toContain('No relevant code found');
+  });
+});

+ 115 - 8
docs/design/explore-budget-allocation.md

@@ -90,12 +90,112 @@ being the truncation notice at the end.
 
 This is the gap the rest of the epic closes: relevance-proportional allocation with a
 relative cliff (CG-12), on top of scoring that stops rewarding incidental name collisions
-(CG-10).
+(CG-10 — landed; see below).
+
+## CG-10 — relevance scoring
+
+CG-10 changes **what gets into the response**, ahead of how bytes are split among what's
+in. Four levers, all multiplicative so they compose without ordering surprises.
+
+### 1. Kind weighting
+
+The tier a symbol reached us by (named seed `+50`, query match `+10`, adjacent to one `+3`,
+peripheral `+1`) says *how it got here*; `RELEVANCE_KIND_WEIGHT` says *whether the match is
+evidence*. Callables and types weigh 1.0, members ~0.5, and `constant`/`variable`/
+`parameter` 0.15–0.35 — a local named `explore` is a name collision until something
+corroborates it.
+
+**Isolation.** For a weak-kind symbol in the top two tiers, "is anything using it?" is the
+corroboration: no usage edge anywhere in the graph (`contains` excluded — lexical nesting
+is not usage) drops it to 0.08. Cost is bounded — only weak kinds in the tiers whose weight
+can carry a file pay for the probe, and the subgraph's own edges answer most cases for
+free. Measured: no latency change (210 vs 211 ms/call, n=12 interleaved).
+
+**Peripheral cap.** Nodes ≥2 hops from any match now accumulate into a separate bucket
+capped at 5. Uncapped, every such node added a flat `+1`, so a file grew more relevant by
+being bigger — `parse-session.mjs` reached score 22 off one incidental constant plus twelve
+unrelated symbols. Size is not evidence.
+
+### 2. Relative score floor
+
+`score >= 3` admits noise on any repo where the top file scores 50+. The floor is now
+`clamp(topScore × 0.2, 1, 10)`:
+
+- **relative** — on a diffuse question no file dominates, every candidate sits near the top,
+  and the whole spread survives; on a precise one it cuts the tail.
+- **capped at 10** — one direct query match on a callable. A single full-strength match is
+  never incidental, so no amount of concentration elsewhere may exclude it. Without this
+  cap, one named-seed-heavy file pushed the floor to 21 and dropped a file the agent had
+  named by *class* name (classes enter at `+10`, not `+50` — named seeds are callables).
+- **backfill** — if fewer than 3 files survive, the best of what the floor cut comes back,
+  but only from files with real evidence (≥ the absolute floor). If *nothing* survives, the
+  backfill drops that requirement: returning "no relevant code found" when the gather did
+  find candidates sends the agent straight back to grep.
+
+### 3. Generated status in the score, not the tiebreak
+
+`rankPenalty(file)` multiplies both the relevance score and the graph mass by 0.3 for
+generated files (0.5 for low-value ones). Applying it to the 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. The penalty is self-normalizing — in an
+all-generated repo everything scales together and relative ranking is untouched — and it
+never hard-excludes: ask about the generated API by name and the named-seed tier still puts
+it first.
+
+### 4. `excludeLowValueFiles` — the finding
+
+The per-tier flag the task asked to reconsider was **dead config**: declared on
+`ExploreOutputBudget` and set per tier, but read nowhere. A later change had already made
+the test/spec/icon/i18n exclusion unconditional at all tiers. The flag is removed.
+
+The substantive gap was in the *detector*, not the gating: `isLowValue` matched
+`/\/(tests?|__tests?__|spec)\//`, anchored on a leading slash, so a **repo-root** `test/`
+directory — express, cobra, and most of npm and Go — never matched. Express's "how does
+express route a request to a handler?" spent 59% of its envelope on three test files while
+`lib/application.js` was clipped. Anchored at `^` as well, that query now returns
+`lib/application.js` + `lib/response.js` and no tests.
+
+Two related changes: the filter now runs **before** the score floor and judges "are there
+other candidates?" on the whole gather rather than the post-floor set (judging it after was
+how the floor's keep-minimum pulled test files back in as the "spread"); and low-value
+files that survive the filter's `≥2 non-test candidates` escape hatch are down-weighted via
+`rankPenalty` rather than left at full strength.
+
+### Measured effect
+
+Before/after on the same indexes, deterministic (`CODEGRAPH_EXPLORE_DEBUG` diagnostic, both
+arms same build system, baseline = `bd86ad2`):
+
+| repo · query | before | after |
+|---|---|---|
+| this repo · self-query fixture | 72% to eval scripts, `tools.ts` 18.5% | scripts **0%**, `tools.ts` #1 |
+| this repo · `handleExplore buildFlowFromNamedSymbols …` | 82% to eval scripts | `tools.ts` 48% + `index.ts` 32% |
+| this repo · "how is error handling done" | 58% to eval scripts, `tools.ts` delivered 0 | transport/tools/cobol/api |
+| this repo · "what languages does codegraph support" | 63% to `scripts/add-lang/*` | grammars/index/cli |
+| this repo · "main components of the indexing pipeline" | — | **byte-identical** |
+| payroll-go fixture | generated 57.4%, answer 25.6% | answer **61.5%**, generated **23.5%** |
+| express · route a request | 59% to `test/*` | `application.js` + `response.js` |
+| cobra · 3 queries | — | **byte-identical** |
+
+The two byte-identical rows are the control: where the answer was already concentrated, the
+new floor prunes the same tail earlier and cheaper and arrives at the same response.
+
+**Known thin case.** Express's "how does the app object get created and what does it
+expose" drops from 4 files (top one an `examples/` file at 38%) to `lib/express.js` alone,
+2.6 KB against a 13 KB budget. `lib/application.js` matched on nothing but an unused
+file-scope `var app` — indistinguishable, at the symbol level, from the eval scripts' unused
+`const explore`; express models its API surface as properties assigned to that object,
+which the graph has no edges for. That is extraction coverage, not ranking. Backfilling it
+was tried and rejected: node-count ties handed the slot to `examples/route-middleware`
+instead, at 48% of the envelope. Thin-and-precise beats padded-with-noise — a wrong file
+does not save the agent the follow-up call it would pad against.
 
 ## The regression fixtures (CG-6)
 
-Two fixtures pin the failure mode so it can never silently return. Both **fail today** —
-that is what they are for. They become the pass gate for CG-10 + CG-12.
+Two fixtures pin the failure mode so it can never silently return. They were written to
+**fail** — that is what they were for. CG-10 closed the ranking half of both; the byte-split
+assertions still fail and are the pass gate for CG-12. The numbers quoted below are the
+**pre-CG-10 baseline**; see "Measured effect" above for where they stand now.
 
 They are declared in `scripts/agent-eval/allocation-fixtures.json` and run by
 `scripts/agent-eval/probe-allocation.mjs`, which drives the CG-4 diagnostic through a JSONL
@@ -138,15 +238,22 @@ never reach the agent, and every byte that did arrive describes either CRUD or t
 
 This fixture is hermetic: the probe copies the tree to a temp dir and re-indexes per run,
 so two runs on one build are byte-identical (verified). `__tests__/explore-allocation-1500.test.ts`
-runs the same assertions in vitest — the fixture-shape half green, the allocation half as
-`it.fails` so the suite stays green while the bug is open and goes **red when it is fixed**.
+runs the same assertions in vitest.
+
+**After CG-10** the generated files rank #3/#4 instead of #1/#2, `cycle.go` delivers 38.9%
+(it delivered nothing), and `runPayrollCycleAll` + the real `s.store.Upsert(ctx, slip)`
+reach the agent. Those assertions are now live regressions. What remains `it.fails` is
+`payslip_builder.go`: it ranks #6, the tier's `maxFiles` is 4, and the render loop still
+spends by file size — CG-12's job.
 
 **Finding, deliberately left unfixed:** `runPayrollCycleAll` calls `s.store.Upsert` on a
 `*payslipstore.Store`, but the graph resolves that edge to the **generated**
 `internal/gen/fkit/payroll/store.go` `Store.Upsert`. Same-name method resolution across two
-packages that both define `Store.Upsert` picks the wrong receiver. It is upstream of the
-allocation bug — a wrong edge pulls the generated store into the subgraph and inflates its
-score — so it belongs with CG-10's scoring work, not with the fixture.
+packages that both define `Store.Upsert` picks the wrong receiver. It is upstream of
+allocation — a wrong edge pulls the generated store into the subgraph. CG-10 mitigates the
+*symptom* (the generated store is penalized on both score and graph mass, so it no longer
+displaces the real one) without fixing the resolution bug itself, which belongs with the
+same-name method resolution work (see `samename-method-resolution-1079`).
 
 ### 2. `self-query` — the same bug with no generated code in sight
 

+ 26 - 2
scripts/agent-eval/allocation-fixtures.json

@@ -2,8 +2,14 @@
   "$comment": [
     "Regression fixtures for GitHub issue #1500 / epic CG-1 — relevance-proportional",
     "explore budget allocation. Run them with `node scripts/agent-eval/probe-allocation.mjs`",
-    "against a built dist/. BOTH FIXTURES FAIL TODAY: that is the point — they document",
-    "the bug and become the pass gate for the allocation change (CG-10 + CG-12).",
+    "against a built dist/.",
+    "",
+    "STATUS: CG-10 (relevance scoring) closed the RANKING half of both fixtures — nothing",
+    "incidental reaches the envelope any more. What still fails is the BYTE SPLIT among the",
+    "files that correctly ranked in, because the render loop spends by file size: a small",
+    "weakly-relevant file ships whole while the strongly-relevant one is clipped at",
+    "maxCharsPerFile. That is CG-12's gate. Each fixture's `baseline` records the pre-CG-10",
+    "numbers; `afterCG10` records where it stands now.",
     "",
     "`groups` partitions the files explore rendered into `answer` (what the query is",
     "actually about) and `incidental` (what wins the envelope today on name collisions).",
@@ -65,6 +71,16 @@
           "internal/usecase/payroll/cycle.go": 0.0
         },
         "verdict": "The workflow file is allocated the single largest slice (30.6%) and delivers ZERO — the hard ceiling drops its whole section. Generated CRUD takes 57.4% of what the agent actually receives; runPayrollCycleAll, BuildPayslip and the real Upsert never reach the response."
+      },
+      "afterCG10": {
+        "measuredOn": "2026-08-04",
+        "delivered": {
+          "internal/usecase/payroll/cycle.go": 0.389,
+          "internal/gen/fkit/payroll/payroll_cycle.go": 0.235,
+          "internal/domain/payroll/payslip.go": 0.226,
+          "internal/gen/fkit/payroll/payslip.go": 0.0
+        },
+        "verdict": "PASSES answerShareAtLeast (61.5%), incidentalShareAtMost (23.5%), topFileGroup, and the cycle.go + runPayrollCycleAll + real-Upsert needles. The generated files now rank #3/#4 instead of #1/#2 — kind weighting plus a 0.3x generated penalty on BOTH the score and the graph mass, which is the key the comparator sorts on. STILL FAILING for CG-12: payslip_builder.go ranks #6 against the tier's maxFiles of 4, so `func (s *Service) BuildPayslip` never renders."
       }
     },
     {
@@ -106,6 +122,14 @@
           "scripts/agent-eval/offload-eval-cost.mjs": 0.0
         },
         "verdict": "The script corpus takes 71.8% of the delivered envelope (79.4% of what was allocated) against tools.ts's 18.5%, despite tools.ts scoring 46 vs 10, carrying 2.3x the graph mass and 3x the distinct term hits."
+      },
+      "afterCG10": {
+        "measuredOn": "2026-08-04",
+        "delivered": {
+          "src/resolution/memory-budget.ts": 0.512,
+          "src/mcp/tools.ts": 0.329
+        },
+        "verdict": "PASSES incidentalShareAtMost: every scripts/agent-eval/*.mjs file is gone (0.0%), which is CG-10's acceptance bar — their sole match was an unused file-scope `explore`/`BUDGET` constant, worth 0.08x weight, and the relative floor (8.2) then cut them. tools.ts ranks #1. STILL FAILING for CG-12: memory-budget.ts scores 18 to tools.ts's 41 yet takes 51.2% of the envelope to tools.ts's 32.9%, purely because it is small enough to ship whole while tools.ts is clipped at maxCharsPerFile. Allocation still follows file size, not relevance."
       }
     }
   ]

+ 38 - 15
src/mcp/explore-diagnostics.ts

@@ -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`);

+ 298 - 41
src/mcp/tools.ts

@@ -189,15 +189,6 @@ export interface ExploreOutputBudget {
   includeCompletenessSignal: boolean;
   /** Include the explore-budget reminder at the end. */
   includeBudgetNote: boolean;
-  /**
-   * Hard-drop test/spec/icon/i18n files from the relevant-file set unless
-   * the query itself mentions tests. Today they're only deprioritized in
-   * the sort, which on tiny repos still lets one slip into the top N (e.g.
-   * cobra's `command_test.go` displaced `args.go` and contributed ~10KB of
-   * pure noise to "How does cobra parse commands?"). Off by default; on
-   * for the very-tiny tier where one slip dominates the budget.
-   */
-  excludeLowValueFiles: boolean;
 }
 
 export function getExploreOutputBudget(fileCount: number): ExploreOutputBudget {
@@ -229,7 +220,6 @@ export function getExploreOutputBudget(fileCount: number): ExploreOutputBudget {
       includeAdditionalFiles: false,
       includeCompletenessSignal: false,
       includeBudgetNote: false,
-      excludeLowValueFiles: true,
     };
   }
   if (fileCount < 500) {
@@ -245,7 +235,6 @@ export function getExploreOutputBudget(fileCount: number): ExploreOutputBudget {
       includeAdditionalFiles: false,
       includeCompletenessSignal: false,
       includeBudgetNote: false,
-      excludeLowValueFiles: true,
     };
   }
   if (fileCount < 5000) {
@@ -263,7 +252,6 @@ export function getExploreOutputBudget(fileCount: number): ExploreOutputBudget {
       includeAdditionalFiles: true,
       includeCompletenessSignal: true,
       includeBudgetNote: true,
-      excludeLowValueFiles: false,
     };
   }
   // Large + very-large repos: SAME ~24K inline ceiling (a bigger response just
@@ -282,7 +270,6 @@ export function getExploreOutputBudget(fileCount: number): ExploreOutputBudget {
       includeAdditionalFiles: true,
       includeCompletenessSignal: true,
       includeBudgetNote: true,
-      excludeLowValueFiles: false,
     };
   }
   return {
@@ -296,10 +283,148 @@ export function getExploreOutputBudget(fileCount: number): ExploreOutputBudget {
     includeAdditionalFiles: true,
     includeCompletenessSignal: true,
     includeBudgetNote: true,
-    excludeLowValueFiles: false,
   };
 }
 
+// ── Explore relevance scoring (CG-10 / #1500) ──────────────────────────────
+//
+// A file earns its slice of the explore envelope from the symbols in it that the
+// query matched. Before this weighting every match counted the same per tier, so
+// a file that merely declares a local `const explore` scored what a file that
+// DEFINES the explore pipeline scored — which is how three
+// `scripts/agent-eval/*.mjs` harnesses took 63% of this repo's own "how does
+// explore allocate its output budget across files" response on nothing but a
+// local `explore` and a `BUDGET` constant. Four levers — the first three are
+// multiplicative, so they compose without ordering surprises; the fourth decides
+// admission from the result:
+//
+//   1. KIND      — what a match on this NodeKind actually tells you (below).
+//   2. ISOLATION — a weak-kind symbol nothing calls or references is a pure name
+//                  collision; participation in the graph is the corroboration.
+//   3. PENALTY   — generated / test / i18n files are weaker answers to an
+//                  architecture question at EVERY signal, not just as the
+//                  tiebreak-at-equal-score they used to be.
+//   4. FLOOR     — admission scales with the best file's score, replacing an
+//                  absolute bar that admitted noise wherever the top score was
+//                  high.
+
+/**
+ * How strongly a match on a symbol of this kind corroborates that its FILE is
+ * what the query is about.
+ *
+ *   1.0   a callable or a type — the unit an architecture question is about
+ *   ~0.5  a member of a type, or the file node itself (a path match, not a
+ *         symbol match)
+ *   ~0.3  a variable / constant — as often a name collision as a definition
+ *   0.15  a parameter — essentially never the subject of a question
+ *
+ * Unlisted kinds fall back to `DEFAULT_RELEVANCE_KIND_WEIGHT`, so a NodeKind
+ * added later is neither free nor fatal.
+ */
+export const RELEVANCE_KIND_WEIGHT: Readonly<Record<string, number>> = {
+  // Callables and types: the answer lives in one of these.
+  function: 1, method: 1, class: 1, struct: 1, interface: 1, trait: 1,
+  protocol: 1, component: 1, route: 1, enum: 1, type_alias: 1, constructor: 1,
+  // Containers: real structure, but a whole namespace/module matching a term is
+  // a coarser signal than a callable matching it.
+  namespace: 0.8, module: 0.8,
+  // Members of a type: real, weaker on their own.
+  property: 0.5, field: 0.5, enum_member: 0.35,
+  // The file node itself — the path matched, no symbol did.
+  file: 0.5,
+  // Incidental until the graph corroborates them (see ISOLATED_ below).
+  constant: 0.35, variable: 0.3, parameter: 0.15,
+};
+const DEFAULT_RELEVANCE_KIND_WEIGHT = 0.5;
+
+/**
+ * Kinds whose evidentiary value depends on whether anything USES them. An
+ * exported `const DEFAULTS` that half the codebase references is a real
+ * definition; a `const explore` living inside one function of an eval script is
+ * a name collision. Only these kinds pay for the isolation probe.
+ */
+const WEAK_RELEVANCE_KINDS: ReadonlySet<string> = new Set([
+  'constant', 'variable', 'parameter', 'field', 'property', 'enum_member',
+]);
+
+/** Weight for a weak-kind symbol with no incoming/outgoing usage edge at all. */
+const ISOLATED_WEAK_KIND_WEIGHT = 0.08;
+
+/**
+ * Edges that mean "this symbol is used". `contains` is lexical nesting, not
+ * usage — counting it would make every file-scope constant look corroborated,
+ * which is exactly the case this guards against.
+ */
+const RELEVANCE_USAGE_EDGES: ReadonlySet<string> = new Set([
+  'calls', 'references', 'extends', 'implements', 'overrides',
+  'instantiates', 'returns', 'type_of', 'decorates',
+]);
+
+/**
+ * Cap on what PERIPHERAL nodes (in the subgraph, but neither a query match nor
+ * adjacent to one) can contribute to a file's score. Uncapped, each such node
+ * added a flat +1, so a file grew more "relevant" simply by being bigger —
+ * `parse-session.mjs` reached score 22 off ONE incidental constant plus twelve
+ * unrelated symbols. Size is not evidence; cap its contribution.
+ */
+const PERIPHERAL_SCORE_CAP = 5;
+
+/**
+ * Rank penalties, applied to BOTH the relevance score and the graph mass.
+ *
+ * Generated source used to be a tiebreak at equal score only, so a generated
+ * file that outscored the hand-written one still won — the #1500 report exactly:
+ * the FKIT CRUD layer carries every query term AND more graph mass than the
+ * use-case that implements the business rule. A multiplier demotes it on the
+ * PRIMARY sort key instead, without ever hard-excluding it (ask about the
+ * generated API by name and the named-seed tier still puts it first). It is
+ * self-normalizing: in an all-generated repo everything scales together and
+ * relative ranking is untouched.
+ */
+const GENERATED_RANK_PENALTY = 0.3;
+/**
+ * Test/spec/icon/i18n files. These are normally hard-excluded outright, but that
+ * filter stands down when fewer than 2 non-low-value candidates remain (else
+ * tests would be the only signal for the area). This is the softened form for
+ * that case: down-weighted rather than removed.
+ */
+const LOW_VALUE_RANK_PENALTY = 0.5;
+
+/**
+ * Score floor: `clamp(topScore * FRACTION, ABSOLUTE, MAX)`.
+ *
+ * An absolute floor alone (`>= 3`) admits noise on any repo where the top file
+ * scores 50+, so the bar is now a FRACTION of the best file's score and scales
+ * with how strong the best match is. On a diffuse survey question no file
+ * dominates, every candidate sits near the top score, and the whole spread gets
+ * through; on a precise question it cuts the long tail of incidental matches.
+ *
+ * ABSOLUTE is recalibrated for kind-weighted scores: the old `>= 3` assumed an
+ * unweighted tier sum where any query match was worth 10. A file whose sole
+ * match is an unused local constant now scores 0.8, so 3 had quietly become a
+ * much harsher admission bar than it was written to be — and the relative floor
+ * is what this change means to prune with anyway.
+ */
+const SCORE_FLOOR_ABSOLUTE = 1;
+const SCORE_FLOOR_FRACTION_OF_TOP = 0.2;
+/**
+ * Ceiling on the relative floor, in units of one direct query match on a
+ * callable (the `entryNodeIds` tier, weight 1.0). A single full-strength match
+ * is never incidental, so no amount of concentration elsewhere may exclude it:
+ * one named-seed-heavy file (`+50` per seed) otherwise pushed the floor to 21
+ * and dropped `BridgeInterceptor`'s file, which the agent had named — a class,
+ * so it entered at the +10 tier rather than +50. The #1500 noise this change
+ * targets scores 0.8–6, well under this ceiling.
+ */
+const SCORE_FLOOR_MAX = 10;
+/**
+ * The relative floor must never starve a question of candidates: if it would
+ * leave fewer than this, backfill with the best-scoring ones it cut. The cost of
+ * under-serving is the agent calling explore again — a whole round-trip. See the
+ * backfill itself for the two strengths it runs at (thin vs. empty).
+ */
+const SCORE_FLOOR_KEEP_MIN = 3;
+
 /**
  * Whether `codegraph_explore` should prefix source lines with their line
  * numbers (cat -n style: `<num>\t<code>`).
@@ -2850,7 +2975,9 @@ export class ToolHandler {
     }
 
     // Step 2: Group nodes by file, score by relevance
-    const fileGroups = new Map<string, { nodes: Node[]; score: number }>();
+    // `peripheral` accumulates separately so it can be capped — see
+    // PERIPHERAL_SCORE_CAP; it is folded into `score` once the loop is done.
+    const fileGroups = new Map<string, { nodes: Node[]; score: number; peripheral: number }>();
     const entryNodeIds = new Set([...subgraph.roots, ...namedSeedIds]);
 
     // Build a set of nodes directly connected to entry points (depth 1)
@@ -2860,6 +2987,46 @@ export class ToolHandler {
       if (entryNodeIds.has(edge.target)) connectedToEntry.add(edge.source);
     }
 
+    // Usage degree within the subgraph, for the weak-kind isolation test below.
+    // Free (the edges are already in hand) and it answers most cases; only a
+    // weak-kind node that looks isolated HERE pays for a DB probe.
+    const subgraphUsageDegree = new Map<string, number>();
+    for (const edge of subgraph.edges) {
+      if (!RELEVANCE_USAGE_EDGES.has(edge.kind) || edge.source === edge.target) continue;
+      subgraphUsageDegree.set(edge.source, (subgraphUsageDegree.get(edge.source) ?? 0) + 1);
+      subgraphUsageDegree.set(edge.target, (subgraphUsageDegree.get(edge.target) ?? 0) + 1);
+    }
+
+    /**
+     * Relevance weight for one matched symbol: its NodeKind, further discounted
+     * when it is a weak kind that NOTHING uses. The DB probe (full-graph, since
+     * a usage can sit outside the traversal) is paid for only by weak-kind
+     * symbols in the top two tiers — the ones whose weight can carry a whole
+     * file. A `connectedToEntry` or peripheral node is worth <= 3 either way, so
+     * probing it would buy nothing.
+     */
+    const isolationCache = new Map<string, boolean>();
+    const isUsageIsolated = (node: Node): boolean => {
+      if ((subgraphUsageDegree.get(node.id) ?? 0) > 0) return false;
+      const cached = isolationCache.get(node.id);
+      if (cached !== undefined) return cached;
+      let isolated = true;
+      try {
+        const used = (e: Edge) => RELEVANCE_USAGE_EDGES.has(e.kind);
+        isolated = !cg.getIncomingEdges(node.id).some(used)
+          && !cg.getOutgoingEdges(node.id).some(used);
+      } catch {
+        isolated = false; // a probe failure must not manufacture a penalty
+      }
+      isolationCache.set(node.id, isolated);
+      return isolated;
+    };
+    const relevanceWeight = (node: Node, probeIsolation: boolean): number => {
+      const weight = RELEVANCE_KIND_WEIGHT[node.kind] ?? DEFAULT_RELEVANCE_KIND_WEIGHT;
+      if (!probeIsolation || !WEAK_RELEVANCE_KINDS.has(node.kind)) return weight;
+      return isUsageIsolated(node) ? ISOLATED_WEAK_KIND_WEIGHT : weight;
+    };
+
     // CHANGE SURFACE (#1064): a named method's signature types — its parameter
     // and return types — are part of what you'd edit to "add a parameter to X",
     // yet they can be lexically dissimilar to the query ("add a parameter to
@@ -2901,7 +3068,7 @@ export class ToolHandler {
       // unbidden. The key still appears in the flow/symbol listing above.
       if (isConfigLeafNode(node)) continue;
 
-      const group = fileGroups.get(node.filePath) || { nodes: [], score: 0 };
+      const group = fileGroups.get(node.filePath) || { nodes: [], score: 0, peripheral: 0 };
       group.nodes.push(node);
       // Score: a NAMED-SEED node (a symbol the agent named that FTS missed, now
       // injected) is worth far more than a mere reference — its file is where the
@@ -2909,32 +3076,41 @@ export class ToolHandler {
       // (Combine.swift references request/task → score 23 from connected nodes)
       // outranks the file that DEFINES a named symbol (Validation.swift's
       // `validate` → 10) and steals its render slot. Definition ≫ reference.
+      //
+      // Each tier is then scaled by WHAT was matched (RELEVANCE_KIND_WEIGHT): the
+      // tier says how the symbol reached us, the kind weight says whether the
+      // match is evidence. A file whose only claim is an unused local `explore`
+      // constant is a name collision, not an answer (#1500).
       if (namedSeedIds.has(node.id)) {
-        group.score += 50;
+        group.score += 50 * relevanceWeight(node, true);
       } else if (entryNodeIds.has(node.id)) {
-        group.score += 10;
+        group.score += 10 * relevanceWeight(node, true);
       } else if (connectedToEntry.has(node.id)) {
-        group.score += 3;
+        group.score += 3 * relevanceWeight(node, false);
       } else {
-        group.score += 1;
+        // Peripheral: in the subgraph but ≥2 hops from anything the query
+        // matched. Accumulated separately and capped below, so a file cannot
+        // buy relevance with size alone.
+        group.peripheral += relevanceWeight(node, false);
       }
       fileGroups.set(node.filePath, group);
     }
 
-    // Only include files that have entry points or nodes directly connected to entry points
-    const SCORE_FLOOR = 3;
-    let relevantFiles = [...fileGroups.entries()].filter(([, group]) => group.score >= SCORE_FLOOR);
-    diag?.setScoreFloor(SCORE_FLOOR, fileGroups.size, relevantFiles.length);
-
     // Extract query terms for relevance checking
     const queryTerms = query.toLowerCase().split(/\s+/).filter(t => t.length >= 3);
 
-    // Test/spec/icon/i18n file detector — used both for the pre-sort hard
-    // filter (tiny tier) and the comparator deprioritization (all tiers).
+    // Test/spec/icon/i18n file detector — used by the pre-floor hard filter, the
+    // rank penalty, and the comparator deprioritization.
+    //
+    // The directory pattern is anchored at `^` as well as `/`: a repo-ROOT
+    // `test/` or `spec/` directory (express, cobra, and most of npm/Go) produced
+    // paths like `test/express.raw.js`, which the old leading-`/` form could
+    // never match — so express's routing question spent 59% of its envelope on
+    // three test files while `lib/router/index.js` never rendered.
     const isLowValue = (p: string) => {
       const lp = p.toLowerCase();
       return (
-        /\/(tests?|__tests?__|spec)\//.test(lp) ||
+        /(?:^|\/)(tests?|__tests?__|specs?)\//.test(lp) ||
         /_test\.go$/.test(lp) ||
         /(?:^|\/)test_[^/]+\.py$/.test(lp) ||
         /_test\.py$/.test(lp) ||
@@ -2950,7 +3126,35 @@ export class ToolHandler {
       );
     };
 
-    // Hard-exclude test/spec files (ALL tiers, not just tiny). One slipped test
+    // One DB probe over every file the query touched, then O(1) per lookup.
+    // Unions the index-time content-banner flag (CG-5) with the filename
+    // convention, so a Go monorepo's generated CRUD (`payroll.go` carrying a
+    // DO-NOT-EDIT banner and nothing in its name) down-ranks the same way
+    // `.pb.go` always has (#1500). Covers the whole subgraph, not just the
+    // grouped files, because the graph-mass penalty below is keyed on it too.
+    const isGeneratedCandidate = cg.generatedFilePredicate(new Set([
+      ...fileGroups.keys(),
+      ...[...subgraph.nodes.values()].map((n) => n.filePath),
+    ]));
+
+    /**
+     * Rank penalty for a file, applied to its relevance score AND (below) to its
+     * graph mass — the two signals the sort actually keys on. Applying it to the
+     * score alone would leave the #1500 case unfixed: the generated CRUD carries
+     * MORE graph mass than the hand-written use-case, and graph mass outranks
+     * score in the comparator.
+     */
+    const rankPenalty = (filePath: string): number =>
+      (isGeneratedCandidate(filePath) ? GENERATED_RANK_PENALTY : 1)
+      * (isLowValue(filePath) ? LOW_VALUE_RANK_PENALTY : 1);
+
+    for (const [filePath, group] of fileGroups) {
+      group.score = (group.score + Math.min(PERIPHERAL_SCORE_CAP, group.peripheral))
+        * rankPenalty(filePath);
+    }
+
+    // Hard-exclude test/spec files (ALL tiers — the per-tier `excludeLowValueFiles`
+    // flag this used to be gated on was dead config and is gone). One slipped test
     // file dominates the per-file budget on small repos (cobra's `command_test.go`
     // displaced `args.go`) AND wastes budget on large ones (Django's
     // `custom_lookups/tests.py` ate ~2.3 KB of the 28 KB cap, crowding out the
@@ -2958,17 +3162,51 @@ export class ToolHandler {
     // an architecture question. Skip when the query itself is about tests — the
     // legitimate "explore the tests" case — and only cut if ≥2 non-test candidates
     // remain (else tests are the only signal for this area).
+    //
+    // Runs BEFORE the score floor, on the whole gather. Judging "are there other
+    // candidates?" on the post-floor set was too late: express's routing question
+    // left one non-test file past the floor, the guard stood down, and the floor's
+    // keep-minimum then pulled two test files back in as the "spread".
+    let candidateFiles = [...fileGroups.entries()];
     {
       const queryMentionsTests = /\b(test|tests|testing|spec|verify|verifies)\b/i.test(query);
       if (!queryMentionsTests) {
-        const nonLow = relevantFiles.filter(([p]) => !isLowValue(p));
+        const nonLow = candidateFiles.filter(([p]) => !isLowValue(p));
         if (nonLow.length >= 2) {
-          relevantFiles = nonLow;
+          candidateFiles = nonLow;
         }
       }
-      diag?.setLowValueFiltered(relevantFiles.length);
+      diag?.setLowValueFiltered(fileGroups.size, candidateFiles.length);
     }
 
+    // Relative score floor — see SCORE_FLOOR_* for why it is a fraction of the
+    // best file's score and why that fraction is clamped at both ends.
+    const topScore = Math.max(0, ...candidateFiles.map(([, g]) => g.score));
+    const scoreFloor = Math.max(
+      SCORE_FLOOR_ABSOLUTE,
+      Math.min(SCORE_FLOOR_MAX, topScore * SCORE_FLOOR_FRACTION_OF_TOP),
+    );
+    let relevantFiles = candidateFiles.filter(([, group]) => group.score >= scoreFloor);
+    if (relevantFiles.length < SCORE_FLOOR_KEEP_MIN) {
+      // Backfill from what the RELATIVE floor cut, best first, at two strengths:
+      //
+      //  - THIN (1-2 files survived): only files with real evidence. A file whose
+      //    entire claim is one isolated variable scores 0.8 and stays out —
+      //    express's `examples/route-middleware` matched nothing but a local
+      //    `app` and would otherwise have taken 48% of that envelope. Padding a
+      //    precise answer with a wrong file doesn't save the agent the follow-up
+      //    call it would pad against.
+      //  - EMPTY (nothing survived): take the best of whatever matched. Returning
+      //    "no relevant code found" when the gather DID find candidates is the
+      //    worst outcome on the board — the agent falls straight back to grep.
+      const minEvidence = relevantFiles.length === 0 ? Number.EPSILON : SCORE_FLOOR_ABSOLUTE;
+      relevantFiles = candidateFiles
+        .filter(([, group]) => group.score >= minEvidence)
+        .sort((a, b) => b[1].score - a[1].score || b[1].nodes.length - a[1].nodes.length)
+        .slice(0, Math.max(SCORE_FLOOR_KEEP_MIN, relevantFiles.length));
+    }
+    diag?.setScoreFloor(scoreFloor, relevantFiles.length);
+
     // Secondary signal: how many DISTINCT query terms each file matches (path +
     // symbol names). Kept only as a tiebreak — the PRIMARY relevance is graph
     // connectivity below. (Term counting alone tied the real central file with
@@ -2991,6 +3229,11 @@ export class ToolHandler {
     const nodeRwr = this.computeGraphRelevance(
       [...subgraph.nodes.keys()], subgraph.edges, entryNodeIds,
     );
+    //
+    // Carries `rankPenalty` too, so generated/low-value files are demoted on the
+    // sort's PRIMARY key rather than only at the tiebreak. Everything downstream
+    // (centrality, the relevance gate, the buried-rescue test, the comparator)
+    // reads this map, so the penalty applies once and applies everywhere.
     const fileGraphScore = new Map<string, number>();
     for (const node of subgraph.nodes.values()) {
       fileGraphScore.set(
@@ -2998,6 +3241,7 @@ export class ToolHandler {
         (fileGraphScore.get(node.filePath) ?? 0) + (nodeRwr.get(node.id) ?? 0),
       );
     }
+    for (const [fp, mass] of fileGraphScore) fileGraphScore.set(fp, mass * rankPenalty(fp));
     const maxGraph = Math.max(0, ...fileGraphScore.values());
 
     // Central file(s): the 1-2 most graph-central files that also match the
@@ -3042,7 +3286,7 @@ export class ToolHandler {
       changeSurfaceFiles.add(fp);
       if (!subgraph.nodes.has(t.id)) subgraph.nodes.set(t.id, t);
       let group = fileGroups.get(fp);
-      if (!group) { group = { nodes: [], score: 0 }; fileGroups.set(fp, group); }
+      if (!group) { group = { nodes: [], score: 0, peripheral: 0 }; fileGroups.set(fp, group); }
       if (!group.nodes.some((n) => n.id === t.id)) group.nodes.push(t);
       group.score = Math.max(group.score, 45);
       if (!relevantFiles.some(([f]) => f === fp)) relevantFiles.push([fp, group]);
@@ -3114,12 +3358,6 @@ export class ToolHandler {
       (fileTermHits.get(fp) ?? 0) >= 2 &&
       (entryFiles.has(fp) || centralFiles.has(fp));
 
-    // One DB probe over the ranked candidates, then O(1) per comparison. Unions
-    // the index-time content-banner flag with the filename convention, so a Go
-    // monorepo's generated CRUD (`payroll.go` carrying a DO-NOT-EDIT banner and
-    // nothing in its name) down-ranks the same way `.pb.go` always has (#1500).
-    const isGeneratedCandidate = cg.generatedFilePredicate(relevantFiles.map(([fp]) => fp));
-
     const sortedFiles = relevantFiles.sort((a, b) => {
       const aPath = a[0].toLowerCase();
       const bPath = b[0].toLowerCase();
@@ -3153,7 +3391,11 @@ export class ToolHandler {
       // when asking about the actual flow, and dumping their bodies inflates
       // the response (the cosmos Q3 explore otherwise leads with
       // `expected_keepers_mocks.go`, displacing the real `tally.go` content
-      // and forcing the agent to Read tally.go anyway).
+      // and forcing the agent to Read tally.go anyway). Both this and the
+      // low-value key above are now BACKSTOPS: `rankPenalty` has already scaled
+      // the score and the graph mass these files reach this comparison with, so
+      // a generated file no longer outranks a hand-written one just by scoring
+      // higher (#1500). This still settles the exact ties the penalty leaves.
       const aGen = isGeneratedCandidate(a[0]);
       const bGen = isGeneratedCandidate(b[0]);
       if (aGen !== bGen) return aGen ? 1 : -1;
@@ -3228,6 +3470,14 @@ export class ToolHandler {
     // the diagnostic can show what each file's share of the envelope was BOUGHT
     // with (score, graph mass, term hits, flags) — not just what it cost.
     if (diag) {
+      const kindMix = (nodes: Node[]): string => {
+        const counts = new Map<string, number>();
+        for (const n of nodes) counts.set(n.kind, (counts.get(n.kind) ?? 0) + 1);
+        return [...counts.entries()]
+          .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
+          .map(([k, c]) => `${k}:${c}`)
+          .join(' ');
+      };
       sortedFiles.forEach(([fp, group], i) => {
         diag.noteCandidate(fp, {
           rank: i + 1,
@@ -3241,6 +3491,8 @@ export class ToolHandler {
           spine: group.nodes.some((n) => flow.pathNodeIds.has(n.id)),
           lowValue: isLowValue(fp),
           generated: isGeneratedCandidate(fp),
+          penalty: rankPenalty(fp),
+          kinds: kindMix(group.nodes),
         });
       });
     }
@@ -3869,8 +4121,13 @@ export class ToolHandler {
     // in the source section, and a trailing pointer list is pure overhead.
     if (budget.includeAdditionalFiles) {
       const remainingRelevant = sortedFiles.slice(filesIncluded);
+      // Ranked files are already covered by `remainingRelevant`; the rest of the
+      // gather (below the floor) becomes the pointer list. The Set guards the
+      // one overlap case — a file the SCORE_FLOOR_KEEP_MIN fallback pulled in
+      // despite scoring under the relative floor.
+      const rankedPaths = new Set(sortedFiles.map(([fp]) => fp));
       const peripheralFiles = [...fileGroups.entries()]
-        .filter(([, group]) => group.score < 3)
+        .filter(([fp, group]) => group.score < scoreFloor && !rankedPaths.has(fp))
         .sort((a, b) => b[1].score - a[1].score);
       const remainingFiles = [...remainingRelevant, ...peripheralFiles];
       if (remainingFiles.length > 0) {