fix(explore): bound how far an oversize cluster member may overshoot (CG-30)
shrinkCluster keeps an oversize cluster's highest-importance member WHOLE on purpose — an empty file section sends the agent to Read, the outcome explore exists to prevent. What it lacked was a bound, and "never empty" quietly meant "never bounded": on the reporting repo one file emitted 22,376 chars against a 9,181-char reservation (2.44x), past both the per-file budget and the spine ceiling. That overshoot is what collapses `headroom` for every file below it. The same rule has a second face. When the top member is bigger than the whole response ceiling, the file does not overshoot — it is dropped entirely at the renderCeiling check, so the agent gets nothing for a file it named. renderCluster now takes a ceiling (1.5x what the file may spend — the same multiple SPINE_CEILING already draws, and never below the cap, so a cluster that fits is untouched). Past it the member is WINDOWED on whole lines rather than emitted whole or dropped: leading window plus, on a flow cluster, a window on the spine's call site. A partial window shorter than 12 lines is dropped instead — a sliver in the session record forces the next call's dedup to shred the block around it or re-send it — unless nothing else was emitted, where the never-empty floor wins. Measured on the new fixture, pre-fix vs post-fix: monthly.ts 12,391 chars on a 3,334 budget (3.7x) → 4,941 (1.48x) quarterly.ts dropped, no headroom left → 4,004 delivered Also: the diagnostic now reports `spendable` (reservation + inherited slack) alongside `reserved`. Every render bound reads the former, so reporting only the latter makes an ordinary carry-forward read as a file spending over budget — and it made the overshoot this issue is about unmeasurable. A windowed file is now flagged `clipped` too, instead of presenting a window as the whole file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
d6d17288be
commit
765c06aa40
@@ -92,6 +92,15 @@ interface FileRecord extends ExploreCandidateMeta {
|
||||
* means an oversize first cluster or the whole-file grace overshot.
|
||||
*/
|
||||
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
|
||||
@@ -139,6 +148,8 @@ 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;
|
||||
@@ -362,7 +373,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,
|
||||
path, ...meta, allowance: null, spendable: null,
|
||||
dedupSavedChars: 0, dedupCovered: [],
|
||||
emittedChars: 0, finalChars: 0, share: 0, allocatedShare: 0, clipped: false,
|
||||
});
|
||||
@@ -391,6 +402,15 @@ 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);
|
||||
@@ -538,6 +558,7 @@ 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,
|
||||
@@ -704,6 +725,11 @@ 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}` : '';
|
||||
|
||||
Reference in New Issue
Block a user