Files
codegraph/scripts/agent-eval/probe-suite-envelope.mjs
T
Colby McHenryandClaude Opus 5 7cbde95ce2 fix(explore): pay every admitted file on every render path (CG-26)
The invariant this closes: every admitted file receives at least its
reservation before any file draws on carry-forward slack. CG-30 bounded an
oversize cluster member and CG-31 gave the cluster path a displacement guard;
three holes were left, and each one starved a file that had been admitted,
reserved and — in the worst case — rendered.

1. The whole-file arms had no displacement guard. BUY's fit test read
   `renderCeiling - totalChars` (everyone's room) while its source-space
   sibling refused the same trade, and GRACE was not fit-tested at all.
   okhttp's CallServerInterceptor.kt shipped 8,499 chars on a 5,964 funded
   ceiling and the rank-6 file below it delivered nothing. Both arms now test
   the render they actually produce against `fundedHeadroom`, and a whole
   render that does not fit falls through to clustering instead of skipping
   the file.

2. Every section was charged a flat 200 chars while a real header runs
   300-500. The loop believed it had room it did not have — okhttp allocated
   26,601 against a 24,400 ceiling — so the final truncation threw a
   fully-rendered section away. Sections are charged their real cost now, the
   owed-below arithmetic uses a per-file overhead estimated from the file's own
   symbols, and a marginal overrun trims the weakest cluster (or windows the
   last one into the room that is left) rather than skipping the file over a
   rounding difference.

3. `owedPayableBelow` held all-or-nothing. When the last admitted file's FULL
   reservation no longer fit, nothing was held for it: on the precise-query
   fixture the rank-5 file took 4,134 chars against a 2,948 reservation while
   rank 6 — admitted, reserved 2,539 — was left 4 chars and skipped. It now
   holds the remainder while that remainder is still worth a section
   (MIN_CHARS).

And the epilogue is budgeted instead of discarded. The flat 600-char margin was
neither the epilogue's size (1,064 gin, 1,788 django, 2,231 excalidraw) nor a
bound on it, so four of six suite repos shipped with no pointer list and no
reminders at all. The loop now reserves the epilogue's FLOOR — the one line
that says an uncovered area exists, plus a pointer for every file whose bytes
were deliberately withheld (CG-12) — and the rest is fitted to the room that
actually remains, in priority order, entry by entry. Sized from the real
strings; no constant was swept against the suite.

Deterministic, same clean-rebuilt indexes, baseline = CG-31 tip:

  repo         base source   new source   files      ceiling
  django            20,791       20,878   6 -> 6     was discarding its epilogue
  tokio             21,521       21,607   5 -> 5     was discarding its epilogue
  okhttp            19,034       18,870   5 -> 6     +1 file delivered
  excalidraw        20,204       19,652   8 -> 8     keeps its pointer list
  gin               10,776       10,776   4 -> 4     byte-identical
  alamofire         11,662       11,662   2 -> 2     byte-identical

No repo truncates any more and none loses a file. okhttp and excalidraw trade
164 and 552 source chars on their LAST-ranked file for the pointer list naming
what the response could not cover — bytes the CG-31 tip only had because it
over-filled a ceiling it mis-measured and then discarded the epilogue whole.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 03:45:28 -05:00

125 lines
5.6 KiB
JavaScript

#!/usr/bin/env node
/**
* Deterministic 6-repo envelope sweep for `codegraph_explore` (CG-26).
*
* The allocation issues (CG-30 / CG-31 / CG-26) are all decided by how the
* render loop divides a fixed byte ceiling, and the agent A/B is far too noisy
* to see a 2K byte shift. This runs the SAME six queries the CG-30 and CG-31
* benchmark tables use, against the same clean-rebuilt corpus indexes, and
* prints the numbers those tables are made of: source chars delivered, files in
* the final output, whether the hard ceiling cut anything, and whether the
* epilogue survived.
*
* Numbers come from the CG-4 diagnostic (`CODEGRAPH_EXPLORE_DEBUG`), so this
* measures the shipping allocator rather than re-deriving shares from markdown.
*
* Usage (needs a current `npm run build`, and full-REBUILT indexes — CG-33):
* node scripts/agent-eval/probe-suite-envelope.mjs
* node scripts/agent-eval/probe-suite-envelope.mjs --json > /tmp/new.json
* node scripts/agent-eval/probe-suite-envelope.mjs --baseline /tmp/base.json
* CORPUS=/tmp/codegraph-corpus node scripts/agent-eval/probe-suite-envelope.mjs
*/
import { mkdtempSync, readFileSync, rmSync, existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
const CORPUS = process.env.CORPUS ?? '/tmp/codegraph-corpus';
/** The six suite repos + the exact queries the CG-30/CG-31 tables were measured on. */
const SUITE = [
{ id: 'django', q: 'How does a QuerySet turn into SQL and fetch rows from the database?' },
{ id: 'excalidraw', q: 'How does updating an element re-render the canvas on screen?' },
{ id: 'okhttp', q: 'How does a call go through the interceptor chain to the network?' },
{ id: 'tokio', q: 'How does a spawned task get scheduled and run by a worker?' },
{ id: 'gin', q: 'How does a registered route handler get invoked for an incoming HTTP request?' },
{ id: 'alamofire', q: 'How does a request get built and sent through the session?' },
];
const argv = process.argv.slice(2);
const asJson = argv.includes('--json');
const baselineAt = argv.includes('--baseline') ? argv[argv.indexOf('--baseline') + 1] : null;
const only = argv.filter((a) => !a.startsWith('--') && a !== baselineAt);
const say = (s = '') => { if (!asJson) console.log(s); };
const num = (n) => Math.round(n).toLocaleString('en-US');
const load = (rel) => import(pathToFileURL(resolve(rel)).href);
const idx = await load('dist/index.js');
const toolsMod = await load('dist/mcp/tools.js');
const CodeGraph = idx.default?.default ?? idx.default ?? idx.CodeGraph;
const ToolHandler = toolsMod.ToolHandler ?? toolsMod.default?.ToolHandler;
if (typeof CodeGraph?.openSync !== 'function' || typeof ToolHandler !== 'function') {
console.error('could not resolve CodeGraph/ToolHandler from dist/ — run `npm run build`');
process.exit(2);
}
const tmp = mkdtempSync(join(tmpdir(), 'cg-suite-'));
const results = [];
try {
for (const { id, q } of SUITE) {
if (only.length > 0 && !only.includes(id)) continue;
const repo = join(CORPUS, id);
if (!existsSync(join(repo, '.codegraph', 'codegraph.db'))) {
say(`${id}: no index at ${repo} — skipped`);
continue;
}
const sidecar = join(tmp, `${id}.jsonl`);
process.env.CODEGRAPH_EXPLORE_DEBUG = sidecar;
const cg = CodeGraph.openSync(repo);
const h = new ToolHandler(cg);
const res = await h.execute('codegraph_explore', { query: q });
const text = res.content?.[0]?.text ?? '';
try { cg.close?.(); } catch { /* best effort */ }
const report = JSON.parse(readFileSync(sidecar, 'utf8').trim().split('\n').pop());
results.push({
repo: id,
sourceChars: report.envelope.sourceChars,
envelopeChars: report.envelope.chars,
allocatedChars: report.envelope.allocatedChars,
hardCeiling: report.budget.hardCeiling,
truncated: report.envelope.truncated,
files: report.selection.filesInFinalOutput,
// Did the response keep its trailing pointer list / notes, or did the
// hard ceiling spend them? This is CG-26's residual 1.
epilogueCut: text.includes('omitted for size'),
sectionCut: text.includes('output truncated to budget'),
notShown: text.includes('Not shown above'),
budgetNote: text.includes('**Explore budget:'),
});
}
} finally {
rmSync(tmp, { recursive: true, force: true });
}
if (asJson) {
console.log(JSON.stringify(results, null, 2));
} else {
const base = baselineAt ? JSON.parse(readFileSync(baselineAt, 'utf8')) : null;
const byRepo = new Map((base ?? []).map((r) => [r.repo, r]));
say('repo source Δ env files cut epilogue');
say('-'.repeat(74));
for (const r of results) {
const b = byRepo.get(r.repo);
const delta = b ? (r.sourceChars - b.sourceChars) : null;
const dStr = delta === null ? '' : (delta > 0 ? `+${num(delta)}` : num(delta));
const cut = r.sectionCut ? 'section' : r.epilogueCut ? 'epilogue' : '—';
const epi = [r.notShown ? 'not-shown' : null, r.budgetNote ? 'budget-note' : null]
.filter(Boolean).join('+') || 'none';
say(
`${r.repo.padEnd(12)} ${num(r.sourceChars).padStart(7)} ${dStr.padStart(8)} `
+ `${num(r.envelopeChars).padStart(7)} ${String(r.files).padStart(5)} ${cut.padEnd(12)} ${epi}`,
);
}
if (base) {
const lost = results.filter((r) => {
const b = byRepo.get(r.repo);
return b && (r.sourceChars < b.sourceChars || r.files < b.files);
});
say('');
say(lost.length === 0
? 'No repo delivers less source or fewer files than the baseline.'
: `REGRESSION: ${lost.map((r) => r.repo).join(', ')} deliver less than baseline.`);
}
}