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:
Colby McHenry
2026-08-03 22:51:18 -05:00
co-authored by Claude Opus 5
parent 49c11fc2e0
commit b37f191f5a
4 changed files with 990 additions and 6 deletions
+294
View File
@@ -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([]);
});
});
+102
View File
@@ -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 1319% 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.
+528
View File
@@ -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 (01). */
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
View File
@@ -42,6 +42,7 @@ import { clamp, validatePathWithinRoot, validateProjectPath, isConfigLeafNode, C
import { isGeneratedFile } from '../extraction/generated-detection';
import { scanDynamicDispatch } from './dynamic-boundaries';
import { getUpdateNotice } from '../upgrade/update-check';
import { ExploreDiagnostics } from './explore-diagnostics';
/**
* 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
// pre-#185 behavior for callers that hit the rare stats failure.
let budget: ExploreOutputBudget;
let indexedFileCount = -1;
try {
budget = getExploreOutputBudget(cg.getStats().fileCount);
indexedFileCount = cg.getStats().fileCount;
budget = getExploreOutputBudget(indexedFileCount);
} catch {
budget = getExploreOutputBudget(Infinity);
}
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.
// Use a large maxNodes budget — explore has its own 35k char output limit
// that prevents context bloat, so more nodes just means better coverage
@@ -2649,6 +2657,7 @@ export class ToolHandler {
});
if (subgraph.nodes.size === 0) {
diag?.finishEmpty('no relevant code found — empty subgraph');
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
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
const queryTerms = query.toLowerCase().split(/\s+/).filter(t => t.length >= 3);
@@ -2955,6 +2966,7 @@ export class ToolHandler {
relevantFiles = nonLow;
}
}
diag?.setLowValueFiltered(relevantFiles.length);
}
// Secondary signal: how many DISTINCT query terms each file matches (path +
@@ -3057,6 +3069,9 @@ export class ToolHandler {
|| (fileTermHits.get(fp) ?? 0) >= 2,
);
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
@@ -3202,6 +3217,27 @@ export class ToolHandler {
// off-spine peers skeletonize.
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/
// extends a supertype shared by >= MIN_SIBLINGS classes is one of many
// INTERCHANGEABLE implementations (OkHttp's 14 `: Interceptor` classes —
@@ -3278,7 +3314,10 @@ export class ToolHandler {
const staleOmitted: string[] = [];
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
// flow. Past 90% budget, stop pulling INCIDENTAL files — but keep scanning
// 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).
const fileNecessary = group.nodes.some(n =>
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);
if (!absPath || !existsSync(absPath)) continue;
if (!absPath || !existsSync(absPath)) {
diag?.recordSkip(filePath, 'unreadable');
continue;
}
let fileContent: string;
try {
fileContent = readFileSync(absPath, 'utf-8');
} catch {
diag?.recordSkip(filePath, 'unreadable');
continue;
}
@@ -3419,6 +3465,8 @@ export class ToolHandler {
: 'skeleton (signatures only — codegraph_explore a name for its full body; do NOT Read)';
lines.push(fileSectionHeader(filePath, `${names} · ${tag}`), '', '```' + lang, skel.join('\n'), '```', '');
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);
filesIncluded++;
continue;
@@ -3470,10 +3518,12 @@ export class ToolHandler {
// fit is skipped; a necessary one (below) renders in full. Half a file
// forces the Read this is meant to prevent.
anyFileTrimmed = true;
diag?.recordSkip(filePath, 'budget-whole-file');
continue;
}
lines.push(wholeHeader, '', '```' + lang, wholeSection, '```', '');
totalChars += wholeSection.length + 200;
diag?.recordRender(filePath, 'whole', wholeSection.length, false);
renderedFilePaths.push(filePath);
filesIncluded++;
if (fileStale) staleRendered.push(filePath);
@@ -3492,6 +3542,7 @@ export class ToolHandler {
'',
);
totalChars += 260;
diag?.recordRender(filePath, 'stale-omitted', 0, true);
continue;
}
@@ -3567,7 +3618,10 @@ export class ToolHandler {
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 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
// full, bounded by the hard ceiling).
anyFileTrimmed = true;
diag?.recordSkip(filePath, 'budget-clusters');
continue;
}
@@ -3775,6 +3830,7 @@ export class ToolHandler {
lines.push('');
totalChars += fileSection.length + 200;
diag?.recordRender(filePath, 'clusters', fileSection.length, chosenIndices.size < clusters.length);
renderedFilePaths.push(filePath);
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'}.`;
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);
}