feat(mcp): per-file allocation diagnostic for explore (CG-4)
How codegraph_explore divides its byte envelope among files was unobservable — you could read a response and guess, but not say "this file took 16% and that one took 20%." Nothing else in the budget- allocation epic is measurable without that. CODEGRAPH_EXPLORE_DEBUG now emits one report per explore call (stderr table, stderr JSON, or a JSONL sidecar path). Per file: relevance score, graph mass, term hits, ranking flags, render mode, bytes allocated vs delivered, both shares, and whether it was clipped — plus why a ranked candidate never rendered. Totals cover envelope vs maxOutputChars vs the hard ceiling, the source/meta split, the selection funnel, and the score floor and relevance-gate thresholds applied. Allocated and delivered are reported separately on purpose: they diverge exactly when the 25K ceiling truncates, and conflating them is how a dropped trailing file goes unnoticed. Off by default and byte-identical when off — it ships in the product binary, and a diagnostic that perturbs the response by one byte would invalidate every A/B taken with it on. ExploreDiagnostics.start() returns null unless the env var is set, so every call site is a `diag?.` no-op. Baseline recorded in docs/design/explore-budget-allocation.md: on this repo, src/mcp/tools.ts gets 15.8% of the envelope while three weakly- relevant agent-eval scripts take 61% between them — despite tools.ts carrying 5.4x the score and 2.6x the graph mass of any of them. Small files ship whole; the large answer file is clipped at maxCharsPerFile. Rank ordering is correct and buys nothing. The loop also allocated 23,193 chars against an 18,000 budget. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
49c11fc2e0
commit
b37f191f5a
@@ -0,0 +1,294 @@
|
|||||||
|
/**
|
||||||
|
* Per-file allocation diagnostic for codegraph_explore (CG-4).
|
||||||
|
*
|
||||||
|
* The instrument ships in the product binary, so the load-bearing property is
|
||||||
|
* NOT what it reports — it's that it reports NOTHING unless asked. An explore
|
||||||
|
* response is the agent's context; a diagnostic that perturbs it by one byte
|
||||||
|
* invalidates every A/B measurement taken with it on, which is the exact thing
|
||||||
|
* the rest of the budget-allocation work depends on.
|
||||||
|
*
|
||||||
|
* So the first block pins byte-identical output across on/off, and only then
|
||||||
|
* do we assert the report's shape and internal consistency.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import * as os from 'os';
|
||||||
|
import { ToolHandler } from '../src/mcp/tools';
|
||||||
|
import { attributeSourceBytes } from '../src/mcp/explore-diagnostics';
|
||||||
|
import CodeGraph from '../src/index';
|
||||||
|
|
||||||
|
const DEBUG_ENV = 'CODEGRAPH_EXPLORE_DEBUG';
|
||||||
|
|
||||||
|
/** Restore the env var to "unset" — `delete` matters; '' is a distinct case. */
|
||||||
|
function clearDebugEnv(): void {
|
||||||
|
delete process.env[DEBUG_ENV];
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('attributeSourceBytes', () => {
|
||||||
|
it('attributes a fenced block to the file section header above it', () => {
|
||||||
|
const text = [
|
||||||
|
'**Exploration: x**',
|
||||||
|
'',
|
||||||
|
'**`src/a.ts`** — foo(function)',
|
||||||
|
'',
|
||||||
|
'```typescript',
|
||||||
|
'1\tconst a = 1;',
|
||||||
|
'2\tconst b = 2;',
|
||||||
|
'```',
|
||||||
|
'',
|
||||||
|
'**`src/b.ts`** — bar(function)',
|
||||||
|
'',
|
||||||
|
'```typescript',
|
||||||
|
'1\tconst c = 3;',
|
||||||
|
'```',
|
||||||
|
'',
|
||||||
|
].join('\n');
|
||||||
|
const bytes = attributeSourceBytes(text);
|
||||||
|
expect(bytes.get('src/a.ts')).toBe('1\tconst a = 1;\n2\tconst b = 2;'.length);
|
||||||
|
expect(bytes.get('src/b.ts')).toBe('1\tconst c = 3;'.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sums multiple fenced blocks under one file header', () => {
|
||||||
|
const text = [
|
||||||
|
'**`src/a.ts`** — foo(function)',
|
||||||
|
'',
|
||||||
|
'```ts',
|
||||||
|
'aa',
|
||||||
|
'```',
|
||||||
|
'',
|
||||||
|
'```ts',
|
||||||
|
'bbb',
|
||||||
|
'```',
|
||||||
|
].join('\n');
|
||||||
|
expect(attributeSourceBytes(text).get('src/a.ts')).toBe('aa'.length + 'bbb'.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts an unterminated block — the ceiling can cut mid-fence', () => {
|
||||||
|
const text = ['**`src/a.ts`** — foo(function)', '', '```ts', 'x'.repeat(40)].join('\n');
|
||||||
|
expect(attributeSourceBytes(text).get('src/a.ts')).toBe(40);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns nothing for text with no file sections', () => {
|
||||||
|
expect(attributeSourceBytes('No relevant code found for "zzz"').size).toBe(0);
|
||||||
|
expect(attributeSourceBytes('').size).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('codegraph_explore allocation diagnostic', () => {
|
||||||
|
let testDir: string;
|
||||||
|
let sidecarDir: string;
|
||||||
|
let cg: CodeGraph;
|
||||||
|
let handler: ToolHandler;
|
||||||
|
|
||||||
|
const QUERY = 'Session method helper callSession';
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-explore-diag-'));
|
||||||
|
sidecarDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-explore-diag-out-'));
|
||||||
|
const srcDir = path.join(testDir, 'src');
|
||||||
|
fs.mkdirSync(srcDir);
|
||||||
|
|
||||||
|
// One fat file plus several small callers, so the render loop exercises
|
||||||
|
// more than one allocation branch (clusters for the fat file, whole-file
|
||||||
|
// for the small ones) and there is a real per-file split to report.
|
||||||
|
const fatLines: string[] = ['export class Session {'];
|
||||||
|
for (let i = 0; i < 30; i++) {
|
||||||
|
fatLines.push(` method${i}(arg: string): string {`);
|
||||||
|
fatLines.push(` return this.helper${i}(arg) + "${i}";`);
|
||||||
|
fatLines.push(` }`);
|
||||||
|
fatLines.push(` private helper${i}(arg: string): string {`);
|
||||||
|
fatLines.push(` return arg.repeat(${i + 1});`);
|
||||||
|
fatLines.push(` }`);
|
||||||
|
}
|
||||||
|
fatLines.push('}');
|
||||||
|
fs.writeFileSync(path.join(srcDir, 'session.ts'), fatLines.join('\n'));
|
||||||
|
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(srcDir, `support${i}.ts`),
|
||||||
|
`import { Session } from './session';\n` +
|
||||||
|
`export function callSession${i}(s: Session) {\n` +
|
||||||
|
` return s.method${i}('hi');\n` +
|
||||||
|
`}\n`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearDebugEnv();
|
||||||
|
cg = CodeGraph.initSync(testDir, { config: { include: ['**/*.ts'], exclude: [] } });
|
||||||
|
await cg.indexAll();
|
||||||
|
handler = new ToolHandler(cg);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
clearDebugEnv();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
clearDebugEnv();
|
||||||
|
if (cg) cg.destroy();
|
||||||
|
for (const dir of [testDir, sidecarDir]) {
|
||||||
|
if (dir && fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const explore = async (): Promise<string> => {
|
||||||
|
const result = await handler.execute('codegraph_explore', { query: QUERY });
|
||||||
|
return result.content?.[0]?.text ?? '';
|
||||||
|
};
|
||||||
|
|
||||||
|
it('produces byte-identical output whether the diagnostic is on or off', async () => {
|
||||||
|
clearDebugEnv();
|
||||||
|
const off = await explore();
|
||||||
|
expect(off.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Sanity: the tool itself is deterministic, so a difference below is
|
||||||
|
// attributable to the diagnostic and not to explore's own variance.
|
||||||
|
expect(await explore()).toBe(off);
|
||||||
|
|
||||||
|
vi.spyOn(process.stderr, 'write').mockImplementation((() => true) as typeof process.stderr.write);
|
||||||
|
const sidecar = path.join(sidecarDir, 'identical.jsonl');
|
||||||
|
for (const value of ['1', 'json', sidecar]) {
|
||||||
|
process.env[DEBUG_ENV] = value;
|
||||||
|
const on = await explore();
|
||||||
|
clearDebugEnv();
|
||||||
|
expect(on).toBe(off);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes nothing to stderr when the env var is unset', async () => {
|
||||||
|
clearDebugEnv();
|
||||||
|
const writes: string[] = [];
|
||||||
|
vi.spyOn(process.stderr, 'write').mockImplementation(((chunk: unknown) => {
|
||||||
|
writes.push(String(chunk));
|
||||||
|
return true;
|
||||||
|
}) as typeof process.stderr.write);
|
||||||
|
await explore();
|
||||||
|
expect(writes.join('')).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stays off for every falsy env value', async () => {
|
||||||
|
const writes: string[] = [];
|
||||||
|
vi.spyOn(process.stderr, 'write').mockImplementation(((chunk: unknown) => {
|
||||||
|
writes.push(String(chunk));
|
||||||
|
return true;
|
||||||
|
}) as typeof process.stderr.write);
|
||||||
|
for (const value of ['', '0', 'false', 'off', 'no', 'OFF', ' 0 ']) {
|
||||||
|
process.env[DEBUG_ENV] = value;
|
||||||
|
await explore();
|
||||||
|
}
|
||||||
|
expect(writes.join('')).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prints a per-file table to stderr when enabled', async () => {
|
||||||
|
const writes: string[] = [];
|
||||||
|
vi.spyOn(process.stderr, 'write').mockImplementation(((chunk: unknown) => {
|
||||||
|
writes.push(String(chunk));
|
||||||
|
return true;
|
||||||
|
}) as typeof process.stderr.write);
|
||||||
|
|
||||||
|
process.env[DEBUG_ENV] = '1';
|
||||||
|
await explore();
|
||||||
|
const out = writes.join('');
|
||||||
|
|
||||||
|
expect(out).toContain('codegraph explore 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+\)/);
|
||||||
|
// 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).toContain('src/session.ts');
|
||||||
|
expect(out).toMatch(/\d+\.\d%/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('appends one JSON report per call to a sidecar path', async () => {
|
||||||
|
const sidecar = path.join(sidecarDir, 'reports.jsonl');
|
||||||
|
process.env[DEBUG_ENV] = sidecar;
|
||||||
|
await explore();
|
||||||
|
await explore();
|
||||||
|
clearDebugEnv();
|
||||||
|
|
||||||
|
const rows = fs.readFileSync(sidecar, 'utf-8').trim().split('\n');
|
||||||
|
expect(rows).toHaveLength(2);
|
||||||
|
|
||||||
|
const report = JSON.parse(rows[0]!);
|
||||||
|
expect(report.tool).toBe('codegraph_explore');
|
||||||
|
expect(report.query).toBe(QUERY);
|
||||||
|
|
||||||
|
// Totals the task asks for: envelope vs maxOutputChars, files considered
|
||||||
|
// vs included, and the score floor that was applied.
|
||||||
|
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.filesPastScoreFloor).toBeGreaterThanOrEqual(report.selection.filesRanked);
|
||||||
|
expect(report.selection.filesRanked).toBeGreaterThanOrEqual(report.selection.filesInFinalOutput);
|
||||||
|
expect(report.selection.filesInFinalOutput).toBeGreaterThan(0);
|
||||||
|
expect(report.selection.filesInFinalOutput).toBeLessThanOrEqual(report.budget.maxFiles);
|
||||||
|
|
||||||
|
// Per-file: score, bytes, share, clipped, spine.
|
||||||
|
const shown = report.files.filter((f: { finalChars: number }) => f.finalChars > 0);
|
||||||
|
expect(shown.length).toBeGreaterThan(0);
|
||||||
|
for (const f of shown) {
|
||||||
|
expect(typeof f.path).toBe('string');
|
||||||
|
expect(typeof f.score).toBe('number');
|
||||||
|
expect(typeof f.graphScore).toBe('number');
|
||||||
|
expect(typeof f.clipped).toBe('boolean');
|
||||||
|
expect(typeof f.spine).toBe('boolean');
|
||||||
|
expect(f.finalChars).toBeGreaterThan(0);
|
||||||
|
expect(f.share).toBeGreaterThan(0);
|
||||||
|
expect(f.share).toBeLessThanOrEqual(1);
|
||||||
|
expect(f.render).toBeTruthy();
|
||||||
|
}
|
||||||
|
expect(shown.some((f: { path: string }) => f.path === 'src/session.ts')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('attributes the envelope consistently — per-file bytes sum to the reported source total', async () => {
|
||||||
|
const sidecar = path.join(sidecarDir, 'consistency.jsonl');
|
||||||
|
process.env[DEBUG_ENV] = sidecar;
|
||||||
|
const text = await explore();
|
||||||
|
clearDebugEnv();
|
||||||
|
|
||||||
|
const report = JSON.parse(fs.readFileSync(sidecar, 'utf-8').trim());
|
||||||
|
expect(report.envelope.chars).toBe(text.length);
|
||||||
|
|
||||||
|
const summed = report.files.reduce(
|
||||||
|
(s: number, f: { finalChars: number }) => s + f.finalChars, 0,
|
||||||
|
);
|
||||||
|
expect(summed).toBe(report.envelope.sourceChars);
|
||||||
|
expect(report.envelope.sourceChars + report.envelope.metaChars).toBe(report.envelope.chars);
|
||||||
|
// Shares are fractions of the delivered envelope, so they can't exceed it.
|
||||||
|
const shareSum = report.files.reduce((s: number, f: { share: number }) => s + f.share, 0);
|
||||||
|
expect(shareSum).toBeLessThanOrEqual(1.0001);
|
||||||
|
expect(shareSum).toBeCloseTo(report.envelope.sourceShare, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('survives an unwritable sink without failing the explore call', async () => {
|
||||||
|
clearDebugEnv();
|
||||||
|
const expected = await explore();
|
||||||
|
|
||||||
|
// A directory is never a valid append target.
|
||||||
|
process.env[DEBUG_ENV] = sidecarDir;
|
||||||
|
const result = await handler.execute('codegraph_explore', { query: QUERY });
|
||||||
|
clearDebugEnv();
|
||||||
|
|
||||||
|
expect(result.isError).toBeFalsy();
|
||||||
|
expect(result.content?.[0]?.text).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records a report even when explore finds nothing', async () => {
|
||||||
|
const sidecar = path.join(sidecarDir, 'empty.jsonl');
|
||||||
|
process.env[DEBUG_ENV] = sidecar;
|
||||||
|
const result = await handler.execute('codegraph_explore', {
|
||||||
|
query: 'zzzznonexistentsymbolzzzz',
|
||||||
|
});
|
||||||
|
clearDebugEnv();
|
||||||
|
|
||||||
|
expect(result.content?.[0]?.text).toContain('No relevant code found');
|
||||||
|
const report = JSON.parse(fs.readFileSync(sidecar, 'utf-8').trim());
|
||||||
|
expect(report.note).toContain('no relevant code found');
|
||||||
|
expect(report.files).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
# Explore budget allocation — the instrument and the baseline
|
||||||
|
|
||||||
|
`codegraph_explore` has a fixed byte envelope (`getExploreOutputBudget().maxOutputChars`,
|
||||||
|
hard-capped at 25K so the host never externalizes the result). **How that envelope gets
|
||||||
|
divided among files** is decided by a long chain of gates, tiers and caps spread across
|
||||||
|
`handleExplore` — and until CG-4 that chain was unobservable. You could read an explore
|
||||||
|
response and guess; you could not say "this file took 16% and that one took 20%."
|
||||||
|
|
||||||
|
This document covers the diagnostic that makes it measurable, and the baseline it recorded.
|
||||||
|
|
||||||
|
## The diagnostic
|
||||||
|
|
||||||
|
Set `CODEGRAPH_EXPLORE_DEBUG` and every `codegraph_explore` call (MCP tool or
|
||||||
|
`codegraph explore` CLI) emits one report:
|
||||||
|
|
||||||
|
| value | sink |
|
||||||
|
|---|---|
|
||||||
|
| `1` / `true` / `on` / `yes` / `stderr` | human-readable table on stderr |
|
||||||
|
| `json` | one pretty-printed JSON report on stderr |
|
||||||
|
| anything else | treated as a path — one JSON report per line, appended (JSONL) |
|
||||||
|
| unset / `0` / `false` / `off` / `no` / empty | **off** |
|
||||||
|
|
||||||
|
Per file it reports: relevance score, graph (RWR) mass, distinct query-term hits, ranking
|
||||||
|
flags (named / entry / central / spine / low-value / generated), render mode, bytes of
|
||||||
|
source allocated, bytes actually delivered, both shares, and whether it was clipped. For
|
||||||
|
files that never rendered it reports why (`max-files`, `budget-90pct`, `budget-whole-file`,
|
||||||
|
`budget-clusters`, `unreadable`, `no-ranges`). Totals cover the envelope (delivered vs
|
||||||
|
allocated vs `maxOutputChars` vs hard ceiling), the source/meta split, the file-selection
|
||||||
|
funnel at each stage, and the thresholds applied (score floor, graph-relevance gate).
|
||||||
|
|
||||||
|
**It is off by default and produces byte-identical output when off** — it ships in the
|
||||||
|
product binary, and a diagnostic that perturbs the response by one byte would invalidate
|
||||||
|
every A/B measurement taken with it on. `ExploreDiagnostics.start()` returns `null` unless
|
||||||
|
the env var is set, so every call site is a `diag?.` no-op. Pinned by
|
||||||
|
`__tests__/explore-diagnostics.test.ts`.
|
||||||
|
|
||||||
|
Two envelope numbers, deliberately kept separate:
|
||||||
|
|
||||||
|
- **allocated** — what the render loop chose to emit, before the final hard-ceiling cut.
|
||||||
|
This is the allocator's own decision, and the number budget work is about.
|
||||||
|
- **delivered** — what the agent actually received.
|
||||||
|
|
||||||
|
They diverge exactly when the ceiling truncates. Conflating them is how a dropped trailing
|
||||||
|
file goes unnoticed.
|
||||||
|
|
||||||
|
## Baseline (2026-08-03, this repo at `main`)
|
||||||
|
|
||||||
|
```
|
||||||
|
codegraph explore "how does explore allocate its output budget across files" --path .
|
||||||
|
```
|
||||||
|
|
||||||
|
469 files indexed → small tier (`maxOutputChars` 18,000, `maxCharsPerFile` 3,800,
|
||||||
|
`defaultMaxFiles` 5). Envelope: **23,196 delivered / 23,193 allocated against an 18,000
|
||||||
|
budget — 29% over**, absorbed only because the 25K hard ceiling sits above it.
|
||||||
|
|
||||||
|
| # | share | bytes | score | graph | hits | flags | render | file |
|
||||||
|
|---|---|---|---|---|---|---|---|---|
|
||||||
|
| 4 | 21.2% | 4,928 | 10 | 0.125 | 1 | entry | whole | `scripts/agent-eval/offload-eval-hook.mjs` |
|
||||||
|
| 5 | 20.1% | 4,665 | 10 | 0.125 | 1 | entry | whole | `scripts/agent-eval/offload-eval-metrics.mjs` |
|
||||||
|
| 3 | 19.8% | 4,585 | 22 | 0.125 | 1 | entry central | whole | `scripts/agent-eval/parse-session.mjs` |
|
||||||
|
| 1 | **15.8%** | 3,659 | **54** | **0.322** | **4** | entry central | clusters* | `src/mcp/tools.ts` |
|
||||||
|
| 2 | 15.0% | 3,479 | 34 | 0.082 | 2 | entry | clusters* | `src/index.ts` |
|
||||||
|
|
||||||
|
\* clipped. Ranked but never rendered: `scripts/agent-eval/offload-eval-cost.mjs` (#6) and
|
||||||
|
`src/resolution/lru-cache.ts` (#7), both cut by `maxFiles`.
|
||||||
|
|
||||||
|
Files: 17 grouped → 10 past the score floor (≥3) → 10 past the low-value filter → 7 past
|
||||||
|
the relevance gate (graph ≥ 0.0193, 6% of max 0.3215) → 5 in the output.
|
||||||
|
|
||||||
|
### What the baseline shows
|
||||||
|
|
||||||
|
**Score does not drive allocation.** `src/mcp/tools.ts` — the file that actually answers
|
||||||
|
the query — carries 5.4× the relevance score, 2.6× the graph mass and 4× the term hits of
|
||||||
|
any `.mjs` script, and gets a *smaller* share than each of them. The three agent-eval
|
||||||
|
scripts take **61%** of the envelope between them; the answer file takes 16%.
|
||||||
|
|
||||||
|
The mechanism is that the two allocation paths are decided by **file size, not relevance**:
|
||||||
|
a small file clears `WHOLE_FILE_MAX_LINES`/`WHOLE_FILE_MAX_CHARS` and ships entirely, while
|
||||||
|
a large file falls through to cluster selection and is clipped at `maxCharsPerFile`. So a
|
||||||
|
weakly-relevant 130-line script gets 100% of itself; the strongly-relevant 5,000-line file
|
||||||
|
gets 3,800 chars. Rank ordering is correct (tools.ts sorts #1) and buys nothing, because
|
||||||
|
rank has no effect on how many bytes a file receives.
|
||||||
|
|
||||||
|
**The envelope is over-subscribed.** 23,193 allocated against an 18,000 budget means the
|
||||||
|
per-file caps do not compose into the total cap; the total is enforced only by the 25K
|
||||||
|
ceiling silently dropping whole trailing sections. Under a slightly different index state
|
||||||
|
(one more candidate file) the same query allocated 27,518 chars and the ceiling dropped a
|
||||||
|
7,678-char section — the single largest allocation in the response — with the only trace
|
||||||
|
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).
|
||||||
|
|
||||||
|
### Reproducing
|
||||||
|
|
||||||
|
The query explores this repo, so **uncommitted edits to `src/mcp/tools.ts` change the
|
||||||
|
result** — the index picks them up and scores shift (the same query on the CG-4 working
|
||||||
|
tree reported tools.ts at 13–19% depending on the sync state). Measure against a clean
|
||||||
|
tree: restore `src/mcp/tools.ts` from `main`, remove `src/mcp/explore-diagnostics.ts`,
|
||||||
|
`codegraph sync`, then run the built `dist/` binary (which still carries the instrument).
|
||||||
|
Restore afterwards.
|
||||||
@@ -0,0 +1,528 @@
|
|||||||
|
/**
|
||||||
|
* Per-file allocation diagnostic for `codegraph_explore` (CG-4).
|
||||||
|
*
|
||||||
|
* The explore response is a fixed byte envelope (`budget.maxOutputChars`, hard-
|
||||||
|
* capped at 25K so the host never externalizes the result). WHICH files fill it,
|
||||||
|
* and in what proportion, is decided by a long chain of gates, tiers and caps
|
||||||
|
* spread across `handleExplore`. That chain is currently unobservable: you can
|
||||||
|
* read the output and guess, but you cannot say "this file took 16% of the
|
||||||
|
* envelope and that one took 21%" without hand-counting.
|
||||||
|
*
|
||||||
|
* This module is the instrument. Enabled by `CODEGRAPH_EXPLORE_DEBUG`, it
|
||||||
|
* records, for one explore call:
|
||||||
|
* - per candidate file: relevance score, graph (RWR) mass, distinct query-term
|
||||||
|
* hits, ranking flags, render mode, bytes of source actually emitted, that
|
||||||
|
* file's share of the final envelope, whether it was clipped, whether it
|
||||||
|
* carries a flow-spine symbol — and for the ones that didn't render, why;
|
||||||
|
* - totals: envelope vs `maxOutputChars` vs the hard ceiling, source bytes vs
|
||||||
|
* meta-text overhead, files considered at each filter stage, and the score
|
||||||
|
* floor / relevance-gate thresholds that were applied.
|
||||||
|
*
|
||||||
|
* HARD CONSTRAINT — this ships in the product binary: when the env var is unset
|
||||||
|
* the diagnostic must not exist. `start()` returns `null`, every call site is a
|
||||||
|
* `diag?.` no-op, and the agent-facing response is byte-identical. The
|
||||||
|
* diagnostic never mutates render state, and every method is wrapped so a bug in
|
||||||
|
* here can never fail an explore call.
|
||||||
|
*
|
||||||
|
* Sinks (value of `CODEGRAPH_EXPLORE_DEBUG`):
|
||||||
|
* `1` / `true` / `on` / `yes` / `stderr` → human-readable table on stderr
|
||||||
|
* `json` → one JSON object on stderr
|
||||||
|
* anything else → treated as a path; one JSON object
|
||||||
|
* per line appended (JSONL sidecar)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { appendFileSync } from 'fs';
|
||||||
|
|
||||||
|
/** How a file's source was rendered into the response. */
|
||||||
|
export type ExploreRenderMode =
|
||||||
|
| 'whole' // whole-file window
|
||||||
|
| 'clusters' // ranked contiguous clusters
|
||||||
|
| 'focused' // per-symbol view, named/spine bodies full
|
||||||
|
| 'skeleton' // per-symbol view, signatures only
|
||||||
|
| 'stale-omitted' // drifted on disk; source deliberately withheld
|
||||||
|
| 'dropped'; // rendered into `lines` but cut by the final hard ceiling
|
||||||
|
|
||||||
|
/** Why a ranked candidate never reached the output. */
|
||||||
|
export type ExploreSkipReason =
|
||||||
|
| 'max-files' // maxFiles reached before this file
|
||||||
|
| 'budget-90pct' // incidental file past the 90%-of-budget soft stop
|
||||||
|
| 'budget-whole-file' // incidental whole-file render wouldn't fit
|
||||||
|
| 'budget-clusters' // incidental cluster render wouldn't fit
|
||||||
|
| 'unreadable' // outside root, missing, or read error
|
||||||
|
| 'no-ranges'; // no renderable line ranges in this file
|
||||||
|
|
||||||
|
/** Ranking inputs for one candidate file, captured before the render loop. */
|
||||||
|
export interface ExploreCandidateMeta {
|
||||||
|
rank: number;
|
||||||
|
score: number;
|
||||||
|
graphScore: number;
|
||||||
|
termHits: number;
|
||||||
|
nodes: number;
|
||||||
|
named: boolean;
|
||||||
|
central: boolean;
|
||||||
|
entry: boolean;
|
||||||
|
spine: boolean;
|
||||||
|
lowValue: boolean;
|
||||||
|
generated: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FileRecord extends ExploreCandidateMeta {
|
||||||
|
path: string;
|
||||||
|
render?: ExploreRenderMode;
|
||||||
|
/** Source chars the render loop handed to `lines` (pre-final-truncation). */
|
||||||
|
emittedChars: number;
|
||||||
|
/** Source chars present in the FINAL text — authoritative, truncation-aware. */
|
||||||
|
finalChars: number;
|
||||||
|
/** Share of the DELIVERED envelope, as a fraction (0–1). */
|
||||||
|
share: number;
|
||||||
|
/** Share of what the render loop ALLOCATED, before the hard-ceiling cut. */
|
||||||
|
allocatedShare: number;
|
||||||
|
clipped: boolean;
|
||||||
|
skipped?: ExploreSkipReason;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 graph-relevance gate. */
|
||||||
|
pastRelevanceGate: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Budget fields the diagnostic reports. Structural, to avoid a cyclic import. */
|
||||||
|
interface BudgetShape {
|
||||||
|
maxOutputChars: number;
|
||||||
|
maxCharsPerFile: number;
|
||||||
|
defaultMaxFiles: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One file's line in the report. Also the JSONL sidecar's per-file shape. */
|
||||||
|
export interface ExploreDiagnosticFile extends ExploreCandidateMeta {
|
||||||
|
path: string;
|
||||||
|
render: ExploreRenderMode | null;
|
||||||
|
skipped: ExploreSkipReason | null;
|
||||||
|
clipped: boolean;
|
||||||
|
emittedChars: number;
|
||||||
|
finalChars: number;
|
||||||
|
share: number;
|
||||||
|
allocatedShare: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The full report — one per explore call, JSON-serialized to the sink. */
|
||||||
|
export interface ExploreDiagnosticReport {
|
||||||
|
tool: 'codegraph_explore';
|
||||||
|
query: string;
|
||||||
|
projectRoot: string;
|
||||||
|
indexedFileCount: number;
|
||||||
|
note?: string;
|
||||||
|
budget: {
|
||||||
|
maxOutputChars: number;
|
||||||
|
maxCharsPerFile: number;
|
||||||
|
maxFiles: number;
|
||||||
|
hardCeiling: number;
|
||||||
|
};
|
||||||
|
envelope: {
|
||||||
|
/** Chars actually returned to the agent (post-truncation). */
|
||||||
|
chars: number;
|
||||||
|
/** Chars the render loop produced, BEFORE the hard-ceiling cut. */
|
||||||
|
allocatedChars: number;
|
||||||
|
overBudget: boolean;
|
||||||
|
truncated: boolean;
|
||||||
|
sourceChars: number;
|
||||||
|
sourceShare: number;
|
||||||
|
metaChars: number;
|
||||||
|
metaShare: number;
|
||||||
|
};
|
||||||
|
selection: {
|
||||||
|
scoreFloor: number;
|
||||||
|
maxGraph: number;
|
||||||
|
graphGateThreshold: number;
|
||||||
|
graphGateApplied: boolean;
|
||||||
|
filesGrouped: number;
|
||||||
|
filesPastScoreFloor: number;
|
||||||
|
filesPastLowValueFilter: number;
|
||||||
|
filesRanked: number;
|
||||||
|
filesRenderedByLoop: number;
|
||||||
|
filesInFinalOutput: number;
|
||||||
|
};
|
||||||
|
files: ExploreDiagnosticFile[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type Sink =
|
||||||
|
| { kind: 'stderr'; json: boolean }
|
||||||
|
| { kind: 'file'; path: string };
|
||||||
|
|
||||||
|
const OFF = new Set(['', '0', 'false', 'off', 'no']);
|
||||||
|
const STDERR_TABLE = new Set(['1', 'true', 'on', 'yes', 'stderr']);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the sink from the environment. `null` means the diagnostic is off —
|
||||||
|
* read per call (not memoized) so a test can toggle it between invocations.
|
||||||
|
*/
|
||||||
|
function resolveSink(): Sink | null {
|
||||||
|
const raw = process.env.CODEGRAPH_EXPLORE_DEBUG;
|
||||||
|
if (raw === undefined) return null;
|
||||||
|
const value = raw.trim();
|
||||||
|
const lower = value.toLowerCase();
|
||||||
|
if (OFF.has(lower)) return null;
|
||||||
|
if (STDERR_TABLE.has(lower)) return { kind: 'stderr', json: false };
|
||||||
|
if (lower === 'json') return { kind: 'stderr', json: true };
|
||||||
|
return { kind: 'file', path: value };
|
||||||
|
}
|
||||||
|
|
||||||
|
const num = (n: number) => Math.round(n).toLocaleString('en-US');
|
||||||
|
const pct = (f: number) => `${(f * 100).toFixed(1)}%`;
|
||||||
|
|
||||||
|
export class ExploreDiagnostics {
|
||||||
|
private readonly files = new Map<string, FileRecord>();
|
||||||
|
private readonly stages: StageCounts = {
|
||||||
|
grouped: 0, pastScoreFloor: 0, pastLowValueFilter: 0, pastRelevanceGate: 0,
|
||||||
|
};
|
||||||
|
private scoreFloor = 0;
|
||||||
|
private maxGraph = 0;
|
||||||
|
private graphGateThreshold = 0;
|
||||||
|
private graphGateApplied = false;
|
||||||
|
private note = '';
|
||||||
|
|
||||||
|
private constructor(
|
||||||
|
private readonly sink: Sink,
|
||||||
|
private readonly query: string,
|
||||||
|
private readonly projectRoot: string,
|
||||||
|
private readonly budget: BudgetShape,
|
||||||
|
private readonly maxFiles: number,
|
||||||
|
private readonly indexedFileCount: number,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns `null` when `CODEGRAPH_EXPLORE_DEBUG` is unset/off — the whole
|
||||||
|
* instrument then costs one env read per explore call and nothing else.
|
||||||
|
*/
|
||||||
|
static start(
|
||||||
|
query: string,
|
||||||
|
projectRoot: string,
|
||||||
|
budget: BudgetShape,
|
||||||
|
maxFiles: number,
|
||||||
|
indexedFileCount: number,
|
||||||
|
): ExploreDiagnostics | null {
|
||||||
|
try {
|
||||||
|
const sink = resolveSink();
|
||||||
|
if (!sink) return null;
|
||||||
|
return new ExploreDiagnostics(sink, query, projectRoot, budget, maxFiles, indexedFileCount);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Candidate count after the initial `group.score >= floor` filter. */
|
||||||
|
setScoreFloor(floor: number, grouped: number, kept: number): void {
|
||||||
|
this.scoreFloor = floor;
|
||||||
|
this.stages.grouped = grouped;
|
||||||
|
this.stages.pastScoreFloor = kept;
|
||||||
|
this.stages.pastLowValueFilter = kept;
|
||||||
|
this.stages.pastRelevanceGate = kept;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Candidate count after the test/spec/icon/i18n hard-exclude. */
|
||||||
|
setLowValueFiltered(kept: number): void {
|
||||||
|
this.stages.pastLowValueFilter = kept;
|
||||||
|
this.stages.pastRelevanceGate = kept;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Graph-relevance gate: threshold, whether it actually pruned, what survived. */
|
||||||
|
setRelevanceGate(maxGraph: number, threshold: number, applied: boolean, kept: number): void {
|
||||||
|
this.maxGraph = maxGraph;
|
||||||
|
this.graphGateThreshold = threshold;
|
||||||
|
this.graphGateApplied = applied;
|
||||||
|
this.stages.pastRelevanceGate = kept;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Record one ranked candidate's scoring inputs, in final sort order. */
|
||||||
|
noteCandidate(path: string, meta: ExploreCandidateMeta): void {
|
||||||
|
this.files.set(path, {
|
||||||
|
path, ...meta,
|
||||||
|
emittedChars: 0, finalChars: 0, share: 0, allocatedShare: 0, clipped: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A candidate rendered source into the response. */
|
||||||
|
recordRender(path: string, render: ExploreRenderMode, sourceChars: number, clipped: boolean): void {
|
||||||
|
const rec = this.files.get(path);
|
||||||
|
if (!rec) return;
|
||||||
|
rec.render = render;
|
||||||
|
rec.emittedChars = sourceChars;
|
||||||
|
rec.clipped = clipped;
|
||||||
|
rec.skipped = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A candidate was passed over before rendering. First reason wins — the
|
||||||
|
* blanket `max-files` sweep must not overwrite a file's specific reason.
|
||||||
|
*/
|
||||||
|
recordSkip(path: string, reason: ExploreSkipReason): void {
|
||||||
|
const rec = this.files.get(path);
|
||||||
|
if (!rec || rec.render || rec.skipped) return;
|
||||||
|
rec.skipped = reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Explore returned early (no subgraph). Emits a minimal record. */
|
||||||
|
finishEmpty(reason: string): void {
|
||||||
|
this.note = reason;
|
||||||
|
this.emit(this.buildReport('', 0, 0, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Final pass: attribute the FINAL text's bytes back to files (so the hard
|
||||||
|
* ceiling's truncation is reflected in what each file actually delivered),
|
||||||
|
* then emit.
|
||||||
|
*
|
||||||
|
* `allocatedChars` is the pre-truncation length — the size the render loop
|
||||||
|
* *chose*. Reporting both is the point: the allocator's decision and the
|
||||||
|
* agent's delivered payload diverge exactly when the ceiling cuts, and
|
||||||
|
* conflating them is how a dropped trailing file goes unnoticed.
|
||||||
|
*/
|
||||||
|
finish(finalText: string, allocatedChars: number, hardCeiling: number, filesIncluded: number): void {
|
||||||
|
try {
|
||||||
|
const perFile = attributeSourceBytes(finalText);
|
||||||
|
const envelope = finalText.length;
|
||||||
|
for (const rec of this.files.values()) {
|
||||||
|
rec.finalChars = perFile.get(rec.path) ?? 0;
|
||||||
|
rec.share = envelope > 0 ? rec.finalChars / envelope : 0;
|
||||||
|
rec.allocatedShare = allocatedChars > 0 ? rec.emittedChars / allocatedChars : 0;
|
||||||
|
// Rendered into `lines` but absent from the final text → the hard
|
||||||
|
// ceiling dropped its whole section.
|
||||||
|
if (rec.render && rec.render !== 'stale-omitted' && rec.finalChars === 0) {
|
||||||
|
rec.render = 'dropped';
|
||||||
|
rec.clipped = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.emit(this.buildReport(finalText, allocatedChars, hardCeiling, filesIncluded));
|
||||||
|
} catch {
|
||||||
|
// A diagnostic must never fail an explore call.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildReport(
|
||||||
|
finalText: string,
|
||||||
|
allocatedChars: number,
|
||||||
|
hardCeiling: number,
|
||||||
|
filesIncluded: number,
|
||||||
|
): ExploreDiagnosticReport {
|
||||||
|
const envelope = finalText.length;
|
||||||
|
const records = [...this.files.values()];
|
||||||
|
const rendered = records.filter((r) => r.finalChars > 0);
|
||||||
|
const sourceChars = rendered.reduce((s, r) => s + r.finalChars, 0);
|
||||||
|
return {
|
||||||
|
tool: 'codegraph_explore',
|
||||||
|
query: this.query,
|
||||||
|
projectRoot: this.projectRoot,
|
||||||
|
indexedFileCount: this.indexedFileCount,
|
||||||
|
note: this.note || undefined,
|
||||||
|
budget: {
|
||||||
|
maxOutputChars: this.budget.maxOutputChars,
|
||||||
|
maxCharsPerFile: this.budget.maxCharsPerFile,
|
||||||
|
maxFiles: this.maxFiles,
|
||||||
|
hardCeiling,
|
||||||
|
},
|
||||||
|
envelope: {
|
||||||
|
chars: envelope,
|
||||||
|
allocatedChars,
|
||||||
|
overBudget: allocatedChars > this.budget.maxOutputChars,
|
||||||
|
truncated: allocatedChars > hardCeiling,
|
||||||
|
sourceChars,
|
||||||
|
sourceShare: envelope > 0 ? sourceChars / envelope : 0,
|
||||||
|
metaChars: envelope - sourceChars,
|
||||||
|
metaShare: envelope > 0 ? (envelope - sourceChars) / envelope : 0,
|
||||||
|
},
|
||||||
|
selection: {
|
||||||
|
scoreFloor: this.scoreFloor,
|
||||||
|
maxGraph: this.maxGraph,
|
||||||
|
graphGateThreshold: this.graphGateThreshold,
|
||||||
|
graphGateApplied: this.graphGateApplied,
|
||||||
|
filesGrouped: this.stages.grouped,
|
||||||
|
filesPastScoreFloor: this.stages.pastScoreFloor,
|
||||||
|
filesPastLowValueFilter: this.stages.pastLowValueFilter,
|
||||||
|
filesRanked: this.stages.pastRelevanceGate,
|
||||||
|
filesRenderedByLoop: filesIncluded,
|
||||||
|
filesInFinalOutput: rendered.length,
|
||||||
|
},
|
||||||
|
files: records
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => b.emittedChars - a.emittedChars || b.finalChars - a.finalChars || a.rank - b.rank)
|
||||||
|
.map((r) => ({
|
||||||
|
path: r.path,
|
||||||
|
rank: r.rank,
|
||||||
|
score: r.score,
|
||||||
|
graphScore: round6(r.graphScore),
|
||||||
|
termHits: r.termHits,
|
||||||
|
nodes: r.nodes,
|
||||||
|
named: r.named,
|
||||||
|
central: r.central,
|
||||||
|
entry: r.entry,
|
||||||
|
spine: r.spine,
|
||||||
|
lowValue: r.lowValue,
|
||||||
|
generated: r.generated,
|
||||||
|
render: r.render ?? null,
|
||||||
|
skipped: r.skipped ?? null,
|
||||||
|
clipped: r.clipped,
|
||||||
|
emittedChars: r.emittedChars,
|
||||||
|
finalChars: r.finalChars,
|
||||||
|
share: round6(r.share),
|
||||||
|
allocatedShare: round6(r.allocatedShare),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private emit(report: ExploreDiagnosticReport): void {
|
||||||
|
try {
|
||||||
|
if (this.sink.kind === 'file') {
|
||||||
|
appendFileSync(this.sink.path, JSON.stringify(report) + '\n', 'utf-8');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.sink.json) {
|
||||||
|
process.stderr.write(JSON.stringify(report, null, 2) + '\n');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
process.stderr.write(renderTable(report) + '\n');
|
||||||
|
} catch {
|
||||||
|
// Unwritable sidecar / closed stderr must not fail the explore call.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const round6 = (n: number) => Math.round(n * 1e6) / 1e6;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attribute the final response's source bytes back to files by walking the
|
||||||
|
* rendered markdown: a ``**`path`**`` section header followed by a fenced code
|
||||||
|
* block. Reading the FINAL text (rather than trusting the render loop's running
|
||||||
|
* total) is what makes the numbers truthful — it accounts for the hard-ceiling
|
||||||
|
* truncation that can drop whole trailing sections after they were "emitted".
|
||||||
|
*
|
||||||
|
* Line numbering is on by default, so a source line that is itself a ``` fence
|
||||||
|
* arrives as `42\t```` and cannot close the block early.
|
||||||
|
*/
|
||||||
|
export function attributeSourceBytes(finalText: string): Map<string, number> {
|
||||||
|
const out = new Map<string, number>();
|
||||||
|
if (!finalText) return out;
|
||||||
|
const lines = finalText.split('\n');
|
||||||
|
let current: string | null = null;
|
||||||
|
let inFence = false;
|
||||||
|
let acc: string[] = [];
|
||||||
|
const flush = () => {
|
||||||
|
if (current && acc.length > 0) {
|
||||||
|
out.set(current, (out.get(current) ?? 0) + acc.join('\n').length);
|
||||||
|
}
|
||||||
|
acc = [];
|
||||||
|
};
|
||||||
|
for (const line of lines) {
|
||||||
|
if (!inFence) {
|
||||||
|
const header = /^\*\*`([^`]+)`\*\*/.exec(line);
|
||||||
|
if (header) {
|
||||||
|
current = header[1]!;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (current && line.startsWith('```')) {
|
||||||
|
inFence = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (line === '```') {
|
||||||
|
inFence = false;
|
||||||
|
flush();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
acc.push(line);
|
||||||
|
}
|
||||||
|
// Unterminated fence (final-ceiling truncation cut mid-block): count it.
|
||||||
|
if (inFence) flush();
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Human-readable stderr rendering of the JSON report. */
|
||||||
|
export function renderTable(report: ExploreDiagnosticReport): string {
|
||||||
|
const { budget, envelope: env, selection: sel, files } = report;
|
||||||
|
|
||||||
|
const out: string[] = [];
|
||||||
|
out.push('');
|
||||||
|
out.push(`codegraph explore diagnostic — "${report.query}"`);
|
||||||
|
out.push(` project ${report.projectRoot} · ${num(report.indexedFileCount)} files indexed`);
|
||||||
|
if (report.note) out.push(` note: ${report.note}`);
|
||||||
|
out.push(
|
||||||
|
` envelope ${num(env.chars)} chars delivered · ${num(env.allocatedChars)} allocated` +
|
||||||
|
` of ${num(budget.maxOutputChars)} budget (hard ceiling ${num(budget.hardCeiling)})` +
|
||||||
|
`${env.overBudget ? ' [over budget]' : ''}${env.truncated ? ' [TRUNCATED]' : ''}`,
|
||||||
|
);
|
||||||
|
out.push(
|
||||||
|
` source ${num(env.sourceChars)} (${pct(env.sourceShare)})` +
|
||||||
|
` · meta ${num(env.metaChars)} (${pct(env.metaShare)})` +
|
||||||
|
` · per-file cap ${num(budget.maxCharsPerFile)}`,
|
||||||
|
);
|
||||||
|
out.push(
|
||||||
|
` files ${num(sel.filesGrouped)} grouped` +
|
||||||
|
` → ${num(sel.filesPastScoreFloor)} past score floor (>=${sel.scoreFloor})` +
|
||||||
|
` → ${num(sel.filesPastLowValueFilter)} past low-value filter` +
|
||||||
|
` → ${num(sel.filesRanked)} past relevance gate` +
|
||||||
|
` → ${num(sel.filesInFinalOutput)} in output (maxFiles ${num(budget.maxFiles)})`,
|
||||||
|
);
|
||||||
|
out.push(
|
||||||
|
` relevance gate ${sel.graphGateApplied ? 'applied' : 'not applied'}` +
|
||||||
|
` at graph >= ${sel.graphGateThreshold.toFixed(5)} (6% of max ${sel.maxGraph.toFixed(5)})`,
|
||||||
|
);
|
||||||
|
out.push('');
|
||||||
|
|
||||||
|
// Allocated (not delivered) is the allocator's own decision — the number the
|
||||||
|
// budget work is about. Delivered is what the agent got. They differ only
|
||||||
|
// 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');
|
||||||
|
for (const f of shown) {
|
||||||
|
out.push(
|
||||||
|
' ' +
|
||||||
|
String(f.rank).padStart(2) + ' ' +
|
||||||
|
pct(f.allocatedShare).padStart(6) + ' ' +
|
||||||
|
pct(f.share).padStart(6) + ' ' +
|
||||||
|
num(f.emittedChars).padStart(7) + ' ' +
|
||||||
|
String(f.score).padStart(5) + ' ' +
|
||||||
|
f.graphScore.toFixed(5).padStart(7) + ' ' +
|
||||||
|
String(f.termHits).padStart(4) + ' ' +
|
||||||
|
flagString(f).padEnd(19) + ' ' +
|
||||||
|
((f.render ?? '-') + (f.clipped ? '*' : '')).padEnd(9) + ' ' +
|
||||||
|
f.path,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
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)');
|
||||||
|
} else {
|
||||||
|
out.push(' (no file source in the final output)');
|
||||||
|
}
|
||||||
|
|
||||||
|
const skipped = files.filter((f) => f.emittedChars === 0 && f.finalChars === 0);
|
||||||
|
if (skipped.length > 0) {
|
||||||
|
out.push('');
|
||||||
|
out.push(' ranked but never rendered:');
|
||||||
|
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})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (skipped.length > 15) out.push(` … and ${skipped.length - 15} more`);
|
||||||
|
}
|
||||||
|
return out.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function flagString(f: ExploreDiagnosticFile): string {
|
||||||
|
const flags: string[] = [];
|
||||||
|
if (f.named) flags.push('named');
|
||||||
|
if (f.entry) flags.push('entry');
|
||||||
|
if (f.central) flags.push('central');
|
||||||
|
if (f.spine) flags.push('spine');
|
||||||
|
if (f.lowValue) flags.push('low-value');
|
||||||
|
if (f.generated) flags.push('generated');
|
||||||
|
return flags.join(' ') || '-';
|
||||||
|
}
|
||||||
+66
-6
@@ -42,6 +42,7 @@ import { clamp, validatePathWithinRoot, validateProjectPath, isConfigLeafNode, C
|
|||||||
import { isGeneratedFile } from '../extraction/generated-detection';
|
import { isGeneratedFile } from '../extraction/generated-detection';
|
||||||
import { scanDynamicDispatch } from './dynamic-boundaries';
|
import { scanDynamicDispatch } from './dynamic-boundaries';
|
||||||
import { getUpdateNotice } from '../upgrade/update-check';
|
import { getUpdateNotice } from '../upgrade/update-check';
|
||||||
|
import { ExploreDiagnostics } from './explore-diagnostics';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An expected, recoverable "codegraph can't serve this" condition — most
|
* An expected, recoverable "codegraph can't serve this" condition — most
|
||||||
@@ -2630,13 +2631,20 @@ export class ToolHandler {
|
|||||||
// largest-tier defaults if stats aren't available, which preserves
|
// largest-tier defaults if stats aren't available, which preserves
|
||||||
// pre-#185 behavior for callers that hit the rare stats failure.
|
// pre-#185 behavior for callers that hit the rare stats failure.
|
||||||
let budget: ExploreOutputBudget;
|
let budget: ExploreOutputBudget;
|
||||||
|
let indexedFileCount = -1;
|
||||||
try {
|
try {
|
||||||
budget = getExploreOutputBudget(cg.getStats().fileCount);
|
indexedFileCount = cg.getStats().fileCount;
|
||||||
|
budget = getExploreOutputBudget(indexedFileCount);
|
||||||
} catch {
|
} catch {
|
||||||
budget = getExploreOutputBudget(Infinity);
|
budget = getExploreOutputBudget(Infinity);
|
||||||
}
|
}
|
||||||
const maxFiles = clamp((args.maxFiles as number) || budget.defaultMaxFiles, 1, 20);
|
const maxFiles = clamp((args.maxFiles as number) || budget.defaultMaxFiles, 1, 20);
|
||||||
|
|
||||||
|
// Per-file allocation diagnostic (CG-4). `null` unless CODEGRAPH_EXPLORE_DEBUG
|
||||||
|
// is set — every `diag?.` below is then a no-op and the response is
|
||||||
|
// byte-identical. It only OBSERVES: it must never feed back into rendering.
|
||||||
|
const diag = ExploreDiagnostics.start(query, projectRoot, budget, maxFiles, indexedFileCount);
|
||||||
|
|
||||||
// Step 1: Find relevant context with generous parameters.
|
// Step 1: Find relevant context with generous parameters.
|
||||||
// Use a large maxNodes budget — explore has its own 35k char output limit
|
// Use a large maxNodes budget — explore has its own 35k char output limit
|
||||||
// that prevents context bloat, so more nodes just means better coverage
|
// that prevents context bloat, so more nodes just means better coverage
|
||||||
@@ -2649,6 +2657,7 @@ export class ToolHandler {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (subgraph.nodes.size === 0) {
|
if (subgraph.nodes.size === 0) {
|
||||||
|
diag?.finishEmpty('no relevant code found — empty subgraph');
|
||||||
return this.textResult(`No relevant code found for "${query}"`);
|
return this.textResult(`No relevant code found for "${query}"`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2913,7 +2922,9 @@ export class ToolHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Only include files that have entry points or nodes directly connected to entry points
|
// Only include files that have entry points or nodes directly connected to entry points
|
||||||
let relevantFiles = [...fileGroups.entries()].filter(([, group]) => group.score >= 3);
|
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
|
// Extract query terms for relevance checking
|
||||||
const queryTerms = query.toLowerCase().split(/\s+/).filter(t => t.length >= 3);
|
const queryTerms = query.toLowerCase().split(/\s+/).filter(t => t.length >= 3);
|
||||||
@@ -2955,6 +2966,7 @@ export class ToolHandler {
|
|||||||
relevantFiles = nonLow;
|
relevantFiles = nonLow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
diag?.setLowValueFiltered(relevantFiles.length);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Secondary signal: how many DISTINCT query terms each file matches (path +
|
// Secondary signal: how many DISTINCT query terms each file matches (path +
|
||||||
@@ -3057,6 +3069,9 @@ export class ToolHandler {
|
|||||||
|| (fileTermHits.get(fp) ?? 0) >= 2,
|
|| (fileTermHits.get(fp) ?? 0) >= 2,
|
||||||
);
|
);
|
||||||
if (gated.length >= 2) relevantFiles = gated;
|
if (gated.length >= 2) relevantFiles = gated;
|
||||||
|
diag?.setRelevanceGate(maxGraph, maxGraph * 0.06, gated.length >= 2, relevantFiles.length);
|
||||||
|
} else {
|
||||||
|
diag?.setRelevanceGate(maxGraph, 0, false, relevantFiles.length);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort files: graph-central first, then distinct-term match, then the
|
// Sort files: graph-central first, then distinct-term match, then the
|
||||||
@@ -3202,6 +3217,27 @@ export class ToolHandler {
|
|||||||
// off-spine peers skeletonize.
|
// off-spine peers skeletonize.
|
||||||
const flow = this.buildFlowFromNamedSymbols(cg, query);
|
const flow = this.buildFlowFromNamedSymbols(cg, query);
|
||||||
|
|
||||||
|
// Snapshot every ranked candidate's scoring inputs, in final sort order, so
|
||||||
|
// 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) {
|
||||||
|
sortedFiles.forEach(([fp, group], i) => {
|
||||||
|
diag.noteCandidate(fp, {
|
||||||
|
rank: i + 1,
|
||||||
|
score: group.score,
|
||||||
|
graphScore: fileGraphScore.get(fp) ?? 0,
|
||||||
|
termHits: fileTermHits.get(fp) ?? 0,
|
||||||
|
nodes: group.nodes.length,
|
||||||
|
named: namedSeedFiles.has(fp),
|
||||||
|
central: centralFiles.has(fp),
|
||||||
|
entry: entryFiles.has(fp),
|
||||||
|
spine: group.nodes.some((n) => flow.pathNodeIds.has(n.id)),
|
||||||
|
lowValue: isLowValue(fp),
|
||||||
|
generated: isGeneratedFile(fp),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Polymorphic-sibling detector for adaptive sizing. A class that implements/
|
// Polymorphic-sibling detector for adaptive sizing. A class that implements/
|
||||||
// extends a supertype shared by >= MIN_SIBLINGS classes is one of many
|
// extends a supertype shared by >= MIN_SIBLINGS classes is one of many
|
||||||
// INTERCHANGEABLE implementations (OkHttp's 14 `: Interceptor` classes —
|
// INTERCHANGEABLE implementations (OkHttp's 14 `: Interceptor` classes —
|
||||||
@@ -3278,7 +3314,10 @@ export class ToolHandler {
|
|||||||
const staleOmitted: string[] = [];
|
const staleOmitted: string[] = [];
|
||||||
|
|
||||||
for (const [filePath, group] of sortedFiles) {
|
for (const [filePath, group] of sortedFiles) {
|
||||||
if (filesIncluded >= maxFiles) break;
|
if (filesIncluded >= maxFiles) {
|
||||||
|
if (diag) for (const [fp] of sortedFiles) diag.recordSkip(fp, 'max-files');
|
||||||
|
break;
|
||||||
|
}
|
||||||
// A file DEFINES a named/spine symbol (the answer) vs merely references the
|
// A file DEFINES a named/spine symbol (the answer) vs merely references the
|
||||||
// flow. Past 90% budget, stop pulling INCIDENTAL files — but keep scanning
|
// flow. Past 90% budget, stop pulling INCIDENTAL files — but keep scanning
|
||||||
// for necessary ones, which render even past the cap (bounded by maxFiles).
|
// for necessary ones, which render even past the cap (bounded by maxFiles).
|
||||||
@@ -3287,15 +3326,22 @@ export class ToolHandler {
|
|||||||
// validate-logic file (Alamofire's Validation.swift).
|
// validate-logic file (Alamofire's Validation.swift).
|
||||||
const fileNecessary = group.nodes.some(n =>
|
const fileNecessary = group.nodes.some(n =>
|
||||||
entryNodeIds.has(n.id) || flow.pathNodeIds.has(n.id) || flow.uniqueNamedNodeIds.has(n.id));
|
entryNodeIds.has(n.id) || flow.pathNodeIds.has(n.id) || flow.uniqueNamedNodeIds.has(n.id));
|
||||||
if (!fileNecessary && totalChars > budget.maxOutputChars * 0.9) continue;
|
if (!fileNecessary && totalChars > budget.maxOutputChars * 0.9) {
|
||||||
|
diag?.recordSkip(filePath, 'budget-90pct');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const absPath = validatePathWithinRoot(projectRoot, filePath);
|
const absPath = validatePathWithinRoot(projectRoot, filePath);
|
||||||
if (!absPath || !existsSync(absPath)) continue;
|
if (!absPath || !existsSync(absPath)) {
|
||||||
|
diag?.recordSkip(filePath, 'unreadable');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
let fileContent: string;
|
let fileContent: string;
|
||||||
try {
|
try {
|
||||||
fileContent = readFileSync(absPath, 'utf-8');
|
fileContent = readFileSync(absPath, 'utf-8');
|
||||||
} catch {
|
} catch {
|
||||||
|
diag?.recordSkip(filePath, 'unreadable');
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3419,6 +3465,8 @@ export class ToolHandler {
|
|||||||
: 'skeleton (signatures only — codegraph_explore a name for its full body; do NOT Read)';
|
: 'skeleton (signatures only — codegraph_explore a name for its full body; do NOT Read)';
|
||||||
lines.push(fileSectionHeader(filePath, `${names} · ${tag}`), '', '```' + lang, skel.join('\n'), '```', '');
|
lines.push(fileSectionHeader(filePath, `${names} · ${tag}`), '', '```' + lang, skel.join('\n'), '```', '');
|
||||||
totalChars += skel.join('\n').length + 120;
|
totalChars += skel.join('\n').length + 120;
|
||||||
|
// Always "clipped": the per-symbol view elides bodies by construction.
|
||||||
|
diag?.recordRender(filePath, bodyIds.size > 0 ? 'focused' : 'skeleton', skel.join('\n').length, true);
|
||||||
renderedFilePaths.push(filePath);
|
renderedFilePaths.push(filePath);
|
||||||
filesIncluded++;
|
filesIncluded++;
|
||||||
continue;
|
continue;
|
||||||
@@ -3470,10 +3518,12 @@ export class ToolHandler {
|
|||||||
// fit is skipped; a necessary one (below) renders in full. Half a file
|
// fit is skipped; a necessary one (below) renders in full. Half a file
|
||||||
// forces the Read this is meant to prevent.
|
// forces the Read this is meant to prevent.
|
||||||
anyFileTrimmed = true;
|
anyFileTrimmed = true;
|
||||||
|
diag?.recordSkip(filePath, 'budget-whole-file');
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
lines.push(wholeHeader, '', '```' + lang, wholeSection, '```', '');
|
lines.push(wholeHeader, '', '```' + lang, wholeSection, '```', '');
|
||||||
totalChars += wholeSection.length + 200;
|
totalChars += wholeSection.length + 200;
|
||||||
|
diag?.recordRender(filePath, 'whole', wholeSection.length, false);
|
||||||
renderedFilePaths.push(filePath);
|
renderedFilePaths.push(filePath);
|
||||||
filesIncluded++;
|
filesIncluded++;
|
||||||
if (fileStale) staleRendered.push(filePath);
|
if (fileStale) staleRendered.push(filePath);
|
||||||
@@ -3492,6 +3542,7 @@ export class ToolHandler {
|
|||||||
'',
|
'',
|
||||||
);
|
);
|
||||||
totalChars += 260;
|
totalChars += 260;
|
||||||
|
diag?.recordRender(filePath, 'stale-omitted', 0, true);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3567,7 +3618,10 @@ export class ToolHandler {
|
|||||||
|
|
||||||
ranges.sort((a, b) => a.start - b.start);
|
ranges.sort((a, b) => a.start - b.start);
|
||||||
|
|
||||||
if (ranges.length === 0) continue;
|
if (ranges.length === 0) {
|
||||||
|
diag?.recordSkip(filePath, 'no-ranges');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const gapThreshold = budget.gapThreshold;
|
const gapThreshold = budget.gapThreshold;
|
||||||
const clusters: Array<{ start: number; end: number; symbols: string[]; score: number; maxImportance: number; hasSpine: boolean; spineCallLine?: number }> = [];
|
const clusters: Array<{ start: number; end: number; symbols: string[]; score: number; maxImportance: number; hasSpine: boolean; spineCallLine?: number }> = [];
|
||||||
@@ -3764,6 +3818,7 @@ export class ToolHandler {
|
|||||||
// Keep scanning for necessary files (which bypass this cap and render in
|
// Keep scanning for necessary files (which bypass this cap and render in
|
||||||
// full, bounded by the hard ceiling).
|
// full, bounded by the hard ceiling).
|
||||||
anyFileTrimmed = true;
|
anyFileTrimmed = true;
|
||||||
|
diag?.recordSkip(filePath, 'budget-clusters');
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3775,6 +3830,7 @@ export class ToolHandler {
|
|||||||
lines.push('');
|
lines.push('');
|
||||||
|
|
||||||
totalChars += fileSection.length + 200;
|
totalChars += fileSection.length + 200;
|
||||||
|
diag?.recordRender(filePath, 'clusters', fileSection.length, chosenIndices.size < clusters.length);
|
||||||
renderedFilePaths.push(filePath);
|
renderedFilePaths.push(filePath);
|
||||||
filesIncluded++;
|
filesIncluded++;
|
||||||
}
|
}
|
||||||
@@ -3896,6 +3952,10 @@ export class ToolHandler {
|
|||||||
: `Found ${subgraph.nodes.size} symbol${subgraph.nodes.size === 1 ? '' : 's'} across ${fileGroups.size} file${fileGroups.size === 1 ? '' : 's'}.`;
|
: `Found ${subgraph.nodes.size} symbol${subgraph.nodes.size === 1 ? '' : 's'} across ${fileGroups.size} file${fileGroups.size === 1 ? '' : 's'}.`;
|
||||||
finalText = finalText.replace(SUMMARY_SENTINEL, summaryLine);
|
finalText = finalText.replace(SUMMARY_SENTINEL, summaryLine);
|
||||||
|
|
||||||
|
// Emit the allocation diagnostic from the FINAL text, so per-file bytes and
|
||||||
|
// shares account for the hard-ceiling truncation above (CG-4).
|
||||||
|
diag?.finish(finalText, output.length, hardCeiling, filesIncluded);
|
||||||
|
|
||||||
return this.textResult(finalText);
|
return this.textResult(finalText);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user