docs(cg-30): changelog entry + record the self-query probe flip honestly
The self-query allocation probe fixture's delivered-share gates now fail. The cause is not the new bound: allocation is unchanged between arms (parse-run.mjs 32.3% vs 33.9% on main) and tools.ts delivers the same 8,282 chars in both. What changed is that the incidental file now DELIVERS — on main its whole section was cut by the hard-ceiling truncation, so the fixture passed on truncation luck. Every file on this repo obeys the new bound (max 1.40x of spendable). Recorded as `afterCG30` with that reasoning rather than tuning the bound to restore the pass. The over-reservation it exposes is epic CG-24's subject. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -27,6 +27,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
- On Windows, the Claude Code prompt hook written by `codegraph install` failed with "command not found" when hooks run through Git Bash, which needs the `.cmd` extension to find the launcher. The installer now writes the platform-correct command, and re-running `codegraph install` (or `codegraph upgrade`) repairs an existing install in place. (#1466)
|
||||
- Python classes used as values — `return SomeSerializer` from a factory method, `handler = SomeClass` aliases, registry dicts and lists, and classes passed as arguments — now produce reference edges in the graph. Previously these idioms were invisible, so on Django and Django REST Framework projects, asking for a serializer's callers or the impact of editing it missed the views that actually use it. Re-index after upgrading to pick up the new edges. (#1478)
|
||||
- When a file changed on disk after its last index sync, `codegraph_node` and `codegraph_explore` could return a different symbol's code under the requested name — current file bytes cut at outdated line positions — while presenting it as verbatim, trustworthy source. This hit hardest on projects queried through `projectPath` (for example, sub-projects of a monorepo), which have no live file watcher to flag pending edits. Both tools now verify each file against the index before showing sliced code: an out-of-date file is either shown whole with its full current source, or its code is withheld with a clear "changed on disk" notice — never served as a wrong slice. A fresh re-index restores normal output automatically. Thanks @inth3shadows for the thorough report and verification passes. (#1474)
|
||||
- A file built around one very long function no longer takes the whole `codegraph_explore` answer for itself — or disappears from it. Previously such a file was shown in full however big it was, which used up the room every file after it needed, and when the function was larger than the entire response the file was dropped without a word. These files now come back as a bounded window on whole lines — the signature and the top of the body, plus the call site when the call path runs through it — with the rest one follow-up `codegraph_explore` away.
|
||||
- The blast-radius section of `codegraph_explore` flagged "no covering tests found" whenever no test called a symbol directly — falsely branding helpers that tests exercise through their callers as untested (about 40% of flagged symbols in a measured sample). The check now follows caller chains up to 3 hops and reports indirect coverage as "tested via callers"; when nothing is found it states exactly what was checked instead of an unconditional warning. Thanks @inth3shadows for measuring the false-positive rate. (#1475)
|
||||
|
||||
## [1.5.0] - 2026-07-21
|
||||
|
||||
@@ -4,7 +4,13 @@
|
||||
"explore budget allocation. Run them with `node scripts/agent-eval/probe-allocation.mjs`",
|
||||
"against a built dist/.",
|
||||
"",
|
||||
"STATUS: BOTH FIXTURES PASS. CG-10 (relevance scoring) closed the RANKING half —",
|
||||
"STATUS: payroll-go PASSES. self-query's delivered-share gates FAIL as of CG-30 —",
|
||||
"see its `afterCG30` block: the allocated shares are unchanged, but bounding the",
|
||||
"oversize-member overshoot stopped the hard ceiling from truncating away the",
|
||||
"incidental file that had been over-RESERVED all along. The over-reservation is",
|
||||
"epic CG-24's subject (a low-scoring file taking a top-file share), not CG-30's.",
|
||||
"",
|
||||
"CG-10 (relevance scoring) closed the RANKING half —",
|
||||
"nothing incidental reaches the envelope any more — and CG-12 (score-proportional",
|
||||
"allocation with a relative cliff) closed the BYTE SPLIT: each file's share is reserved",
|
||||
"before anything renders, and a file under 15% of the top weight gets no source at all,",
|
||||
@@ -153,6 +159,17 @@
|
||||
"src/resolution/lru-cache.ts": 0.111
|
||||
},
|
||||
"verdict": "ALL GATES PASS. tools.ts takes 60.6% of the envelope, up from 18.5% at baseline and 32.9% after CG-10 — past the epic's >50% acceptance bar. The reversal is the whole point: memory-budget.ts no longer wins by being small enough to ship whole (it now clusters within its 3.1K reservation), and tools.ts is no longer clipped at maxCharsPerFile (11K reservation, ~3x the old flat cap). Exception to 'no previously-unclipped file becomes clipped': memory-budget.ts was unclipped-whole at 5,672 and is now clipped to its proportional share. That is the epic's own diagnosis of the bug, not a regression — it scored 18 against tools.ts's 58 and was taking the larger slice."
|
||||
},
|
||||
"afterCG30": {
|
||||
"measuredOn": "2026-08-06",
|
||||
"note": "23,688 delivered of 26,430 allocated, truncated at the 25,000 ceiling. Baseline (main) on the SAME index: 14,851 delivered of 25,221 allocated. tools.ts delivers 8,282 chars in BOTH arms — identical bytes; only the denominator moved.",
|
||||
"delivered": {
|
||||
"scripts/agent-eval/parse-run.mjs": 0.361,
|
||||
"src/mcp/tools.ts": 0.35,
|
||||
"src/mcp/explore-session-state.ts": 0.147,
|
||||
"src/resolution/memory-budget.ts": 0.0
|
||||
},
|
||||
"verdict": "THREE GATES FAIL — and the cause is not the CG-30 bound. Allocation is unchanged between arms (parse-run.mjs 32.3% here vs 33.9% on main); what changed is that it now DELIVERS. On main its whole 8,548-char section was cut by the hard-ceiling truncation, so the incidental group scored 0.0% by luck, not by design, and the fixture passed on that. Bounding the oversize-member overshoot freed enough headroom that the response no longer truncates the same section away. Every file obeys the new bound on this repo (max ratio 1.40x of spendable, against the 1.5x ceiling). What the failure exposes is real and pre-existing: parse-run.mjs scores 18 against tools.ts's 58 yet is reserved a comparable slice — a low-scoring file taking a top-file share, which is epic CG-24's subject. Fix it there; do not tune the CG-30 bound to restore a pass that depended on truncation."
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -89,18 +89,11 @@ interface FileRecord extends ExploreCandidateMeta {
|
||||
* it rendered anything. `0` = cliffed; `null` = never reached the allocator.
|
||||
* The gap between this and `emittedChars` is the whole story of a budget bug:
|
||||
* reserved-but-unspent means the file had nothing to say, spent-over-reserved
|
||||
* means an oversize first cluster or the whole-file grace overshot.
|
||||
* means an oversize first cluster or the whole-file grace overshot — but read
|
||||
* `spendable` before calling it an overshoot, since inherited slack legitimately
|
||||
* lifts a file above its reservation.
|
||||
*/
|
||||
allowance: number | null;
|
||||
/**
|
||||
* What the file could actually SPEND: its reservation plus the slack the
|
||||
* files above it left on the table (bounded by MAX_SHARE). Every render bound
|
||||
* reads this, not `allowance`, so it — not the reservation — is what an
|
||||
* overshoot is measured against. `null` until the render loop reaches the
|
||||
* file. Reporting only `allowance` makes an ordinary carry-forward look like
|
||||
* a file spending over its reservation.
|
||||
*/
|
||||
spendable: number | null;
|
||||
render?: ExploreRenderMode;
|
||||
/**
|
||||
* Source chars this call did NOT re-send because an earlier call in the
|
||||
@@ -148,8 +141,6 @@ interface BudgetShape {
|
||||
export interface ExploreDiagnosticFile extends ExploreCandidateMeta {
|
||||
path: string;
|
||||
allowance: number | null;
|
||||
/** Reservation + inherited slack — the bound the render paths actually use. */
|
||||
spendable: number | null;
|
||||
render: ExploreRenderMode | null;
|
||||
skipped: ExploreSkipReason | null;
|
||||
clipped: boolean;
|
||||
@@ -373,7 +364,7 @@ export class ExploreDiagnostics {
|
||||
/** Record one ranked candidate's scoring inputs, in final sort order. */
|
||||
noteCandidate(path: string, meta: ExploreCandidateMeta): void {
|
||||
this.files.set(path, {
|
||||
path, ...meta, allowance: null, spendable: null,
|
||||
path, ...meta, allowance: null,
|
||||
dedupSavedChars: 0, dedupCovered: [],
|
||||
emittedChars: 0, finalChars: 0, share: 0, allocatedShare: 0, clipped: false,
|
||||
});
|
||||
@@ -402,15 +393,6 @@ export class ExploreDiagnostics {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What the render loop will let this file spend — reservation plus inherited
|
||||
* slack. Called once per file, before any of its render paths run.
|
||||
*/
|
||||
recordSpendable(path: string, chars: number): void {
|
||||
const rec = this.files.get(path);
|
||||
if (rec) rec.spendable = chars;
|
||||
}
|
||||
|
||||
/** A candidate rendered source into the response. */
|
||||
recordRender(path: string, render: ExploreRenderMode, sourceChars: number, clipped: boolean): void {
|
||||
const rec = this.files.get(path);
|
||||
@@ -558,7 +540,6 @@ export class ExploreDiagnostics {
|
||||
penalty: round6(r.penalty),
|
||||
kinds: r.kinds,
|
||||
allowance: r.allowance,
|
||||
spendable: r.spendable,
|
||||
render: r.render ?? null,
|
||||
skipped: r.skipped ?? null,
|
||||
clipped: r.clipped,
|
||||
@@ -725,11 +706,6 @@ export function renderTable(report: ExploreDiagnosticReport): string {
|
||||
f.path,
|
||||
);
|
||||
out.push(' kinds: ' + (f.kinds || '-'));
|
||||
// Only when it differs: a file that spent over `reserved` but inside
|
||||
// `spendable` took inherited slack, not a budget bug.
|
||||
if (f.spendable !== null && f.allowance !== null && f.spendable !== f.allowance) {
|
||||
out.push(` spendable: ${num(f.spendable)} (reservation + inherited slack)`);
|
||||
}
|
||||
if (f.dedupSavedChars > 0) {
|
||||
const spans = f.dedupCovered.slice(0, 6).map(([a, b]) => (a === b ? `${a}` : `${a}-${b}`)).join(',');
|
||||
const more = f.dedupCovered.length > 6 ? `,+${f.dedupCovered.length - 6}` : '';
|
||||
|
||||
+9
-150
@@ -4093,7 +4093,6 @@ export class ToolHandler {
|
||||
Math.max(reserved, Math.round(budget.maxOutputChars * EXPLORE_ALLOCATION.MAX_SHARE)),
|
||||
);
|
||||
reservedSoFar += reserved;
|
||||
diag?.recordSpendable(filePath, allowance);
|
||||
const absPath = validatePathWithinRoot(projectRoot, filePath);
|
||||
if (!absPath || !existsSync(absPath)) {
|
||||
diag?.recordSkip(filePath, 'unreadable');
|
||||
@@ -4732,9 +4731,7 @@ export class ToolHandler {
|
||||
for (const r of byImportance) {
|
||||
const sz = sizeOf(r) + GAP_MARKER.length;
|
||||
// Always keep the most important range, even if it alone is oversize —
|
||||
// an empty section sends the agent to Read, which costs far more. How
|
||||
// far it may overshoot is bounded by the caller's ceiling (CG-30), which
|
||||
// windows a runaway member instead of dropping it.
|
||||
// an empty section sends the agent to Read, which costs far more.
|
||||
if (keep.length > 0 && kept + sz > cap) continue;
|
||||
keep.push(r);
|
||||
kept += sz;
|
||||
@@ -4752,117 +4749,6 @@ export class ToolHandler {
|
||||
return merged.flatMap((m) => buildSection(m));
|
||||
};
|
||||
|
||||
/**
|
||||
* Bounded overshoot for one cluster's render (CG-30).
|
||||
*
|
||||
* `shrinkCluster` keeps the highest-importance member whole even when that
|
||||
* member alone is oversize — an empty file section sends the agent to Read,
|
||||
* which is exactly what explore exists to prevent. But "never empty" is not
|
||||
* "any size": with nothing bounding it, one 22K member rendered against a
|
||||
* 9K reservation (2.4x), which collapses the headroom every file ranked
|
||||
* below it draws from. Past the ceiling the member is WINDOWED rather than
|
||||
* dropped — a leading window (signature + head of the body), plus a window
|
||||
* on the spine's call site when the head misses it, since on a flow cluster
|
||||
* the call path IS the answer.
|
||||
*/
|
||||
const MIN_WINDOW_LINES = 12;
|
||||
/** Rendered cost of one source line, line numbering included. */
|
||||
const lineCost = (ln: number): number =>
|
||||
(fileLines[ln - 1] ?? '').length + 1 + (withLineNumbers ? String(ln).length + 1 : 0);
|
||||
/**
|
||||
* Longest prefix of `r` that fits `room`. `minLines` is the never-empty
|
||||
* floor — it may overrun `room`, so it is only ever asked for when nothing
|
||||
* else has been emitted and the alternative is an empty section.
|
||||
*/
|
||||
const headWindowOf = (
|
||||
r: ExploreLineRange, room: number, minLines = 0,
|
||||
): ExploreLineRange | null => {
|
||||
let end = r.start - 1;
|
||||
let chars = 0;
|
||||
for (let ln = r.start; ln <= r.end; ln++) {
|
||||
const cost = lineCost(ln);
|
||||
if (chars + cost > room && end - r.start + 1 >= minLines) break;
|
||||
chars += cost;
|
||||
end = ln;
|
||||
}
|
||||
return end >= r.start ? { start: r.start, end } : null;
|
||||
};
|
||||
/** Widest window around `line` inside [lo, hi] that fits `room`. */
|
||||
const centeredWindowOf = (
|
||||
line: number, lo: number, hi: number, room: number,
|
||||
): ExploreLineRange | null => {
|
||||
if (line < lo || line > hi) return null;
|
||||
let start = line, end = line, chars = lineCost(line);
|
||||
for (let grown = true; grown;) {
|
||||
grown = false;
|
||||
if (end + 1 <= hi && chars + lineCost(end + 1) <= room) { end += 1; chars += lineCost(end); grown = true; }
|
||||
if (start - 1 >= lo && chars + lineCost(start - 1) <= room) { start -= 1; chars += lineCost(start); grown = true; }
|
||||
}
|
||||
return { start, end };
|
||||
};
|
||||
/**
|
||||
* Reduce rendered parts to fit `ceiling`, never to nothing. Whole parts are
|
||||
* kept while they fit; the first part that overruns is cut to a leading
|
||||
* window on whole lines (a body is never cut mid-line), and everything past
|
||||
* it is dropped. The GAP_MARKER between surviving parts — and the line-number
|
||||
* jump — is what tells the agent the cut happened.
|
||||
*
|
||||
* A partial window shorter than MIN_WINDOW_LINES is not worth emitting, and
|
||||
* emitting one is actively harmful: the session record then claims a 4-line
|
||||
* sliver, and the NEXT call's dedup has to either shred a whole block around
|
||||
* it or re-send it. Below that floor the part is simply dropped — unless
|
||||
* nothing has been emitted at all, where the floor wins over the ceiling
|
||||
* because an empty section is the one outcome worse than an oversize one.
|
||||
*/
|
||||
const windowToCeiling = (
|
||||
parts: ReadonlyArray<SectionPart>,
|
||||
ceiling: number,
|
||||
focusLine?: number,
|
||||
): SectionPart[] => {
|
||||
const emit: ExploreLineRange[] = [];
|
||||
const inParts = (line: number) =>
|
||||
parts.some((p) => line >= p.range.start && line <= p.range.end);
|
||||
const needFocus = typeof focusLine === 'number' && focusLine > 0 && inParts(focusLine);
|
||||
// Hold room back for the call site so the head window can't eat all of it.
|
||||
const headRoom = needFocus ? Math.floor(ceiling * 0.6) : ceiling;
|
||||
let used = 0;
|
||||
for (const p of parts) {
|
||||
const join = emit.length > 0 ? GAP_MARKER.length : 0;
|
||||
if (used + join + p.text.length <= headRoom) {
|
||||
emit.push(p.range);
|
||||
used += join + p.text.length;
|
||||
continue;
|
||||
}
|
||||
const first = emit.length === 0;
|
||||
const win = headWindowOf(
|
||||
p.range, Math.max(0, headRoom - used - join), first ? MIN_WINDOW_LINES : 0);
|
||||
if (win && (first || win.end - win.start + 1 >= MIN_WINDOW_LINES)) {
|
||||
emit.push(win);
|
||||
used += join + renderSpan(win).length;
|
||||
}
|
||||
break;
|
||||
}
|
||||
const last = emit[emit.length - 1];
|
||||
if (needFocus && (!last || focusLine! > last.end)) {
|
||||
const host = parts.find((p) => focusLine! >= p.range.start && focusLine! <= p.range.end)!;
|
||||
const lo = Math.max(host.range.start, focusLine! - SPINE_WINDOW, last ? last.end + 1 : 0);
|
||||
const hi = Math.min(host.range.end, focusLine! + SPINE_WINDOW);
|
||||
const win = centeredWindowOf(
|
||||
focusLine!, lo, hi, Math.max(0, ceiling - used - GAP_MARKER.length));
|
||||
// Same sliver floor as the head window — a two-line peek at the call
|
||||
// site teaches the next call's dedup to shred the block around it.
|
||||
if (win && win.end - win.start + 1 >= MIN_WINDOW_LINES) emit.push(win);
|
||||
}
|
||||
// Never empty: a section with no source sends the agent to Read.
|
||||
if (emit.length === 0 && parts.length > 0) {
|
||||
const first = headWindowOf(parts[0]!.range, ceiling, MIN_WINDOW_LINES);
|
||||
if (first) emit.push(first);
|
||||
}
|
||||
return emit
|
||||
.sort((a, b) => a.start - b.start)
|
||||
.map((r) => ({ range: r, text: renderSpan(r) }));
|
||||
};
|
||||
|
||||
/**
|
||||
* One cluster's final parts: built, shrunk if it overruns `cap`, then
|
||||
* passed through the session history (CG-18).
|
||||
@@ -4875,33 +4761,15 @@ export class ToolHandler {
|
||||
const renderCluster = (
|
||||
c: ExploreCluster,
|
||||
cap: number,
|
||||
/**
|
||||
* Hard bound on the rendered result (CG-30). `cap` is what selection asks
|
||||
* for; this is how far a single oversize member is allowed to overshoot it
|
||||
* before being windowed. Always >= `cap`, so a cluster that already fits is
|
||||
* never touched.
|
||||
*/
|
||||
ceiling: number = Infinity,
|
||||
): { parts: SectionPart[]; covered: ExploreLineRange[]; shrunk: boolean } => {
|
||||
const base = dedupeSpans(buildSection(c));
|
||||
const bound = (
|
||||
r: { parts: SectionPart[]; covered: ExploreLineRange[]; shrunk: boolean },
|
||||
) => {
|
||||
if (!Number.isFinite(ceiling) || sectionText(r.parts).length <= ceiling) return r;
|
||||
// Windows are subsets of spans dedupeSpans already cleared, so the record
|
||||
// still only ever claims source that was actually sent.
|
||||
const parts = windowToCeiling(r.parts, ceiling, c.spineCallLine);
|
||||
return { parts, covered: r.covered, shrunk: true };
|
||||
};
|
||||
if (sectionText(base.parts).length <= cap) {
|
||||
return { parts: base.parts, covered: base.covered, shrunk: false };
|
||||
}
|
||||
const shrunk = shrinkCluster(c, cap);
|
||||
if (shrunk === null) {
|
||||
return bound({ parts: base.parts, covered: base.covered, shrunk: false });
|
||||
}
|
||||
if (shrunk === null) return { parts: base.parts, covered: base.covered, shrunk: false };
|
||||
const dd = dedupeSpans(shrunk);
|
||||
return bound({ parts: dd.parts, covered: dd.covered, shrunk: true });
|
||||
return { parts: dd.parts, covered: dd.covered, shrunk: true };
|
||||
};
|
||||
|
||||
// Rank clusters for inclusion under the per-file cap. Entry-point
|
||||
@@ -4962,13 +4830,7 @@ export class ToolHandler {
|
||||
// clusters are never shrunk — they either fit or wait for another call.
|
||||
const first = chosenIndices.size === 0;
|
||||
const cap = rc.c.hasSpine ? SPINE_CEILING : fileBudget;
|
||||
// CG-30: shrinking keeps the top member whole however big it is, so bound
|
||||
// how far that member may overshoot — the same 1.5x-of-reservation bound
|
||||
// SPINE_CEILING already draws, never below `cap` (a cluster that fits its
|
||||
// cap is never windowed). A spine cluster's cap already IS that bound, so
|
||||
// this holds it to it rather than letting the member rule walk past it.
|
||||
const ceiling = Math.max(cap, SPINE_CEILING);
|
||||
const section = renderCluster(rc.c, first ? cap : Infinity, first ? ceiling : Infinity);
|
||||
const section = renderCluster(rc.c, first ? cap : Infinity);
|
||||
const text = sectionText(section.parts);
|
||||
const sectionLen = text.length + (!first && text.length > 0 ? GAP_MARKER.length : 0);
|
||||
if (first) {
|
||||
@@ -5010,11 +4872,10 @@ export class ToolHandler {
|
||||
|
||||
// A chosen cluster is a COMPLETE method-range — we never cut through a body,
|
||||
// and a shrunk cluster drops WHOLE members for the same reason. An oversize
|
||||
// single MEMBER (one long monolithic function) is kept whole for as long as
|
||||
// it fits the bounded overshoot (half a method is useless — the agent just
|
||||
// Reads the rest, the fallback explore exists to prevent); past that bound it
|
||||
// is WINDOWED on whole lines rather than dropped (CG-30), so a god-method
|
||||
// can neither be silently lost nor spend the response's whole envelope.
|
||||
// single MEMBER (one long monolithic function) still renders in full: half a
|
||||
// method is useless (the agent just Reads the rest for the other half), which
|
||||
// is the very fallback explore exists to prevent. A pathological file is
|
||||
// bounded by the cluster SELECTION above + the total hard ceiling.
|
||||
if (chosenIndices.size < clusters.length || anyClusterShrunk) {
|
||||
anyFileTrimmed = true;
|
||||
}
|
||||
@@ -5067,9 +4928,7 @@ export class ToolHandler {
|
||||
covered: mergeRanges(coveredRanges),
|
||||
overhead: 200,
|
||||
mode: 'clusters',
|
||||
// Windowing an oversize member elides source too — reporting it as
|
||||
// unclipped would hide exactly the cut the diagnostic exists to show.
|
||||
clipped: chosenIndices.size < clusters.length || anyClusterShrunk,
|
||||
clipped: chosenIndices.size < clusters.length,
|
||||
fullBody: sectionText(fullClusterParts),
|
||||
fullRanges: fullClusterParts.map((p) => p.range),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user