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,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 { 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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user