04c0f8eab5
"Tokens processed" was read off result.usage. That was correct when the README figures were measured; in current Claude Code the field reports the LAST turn only. Nothing here changed — the host did, silently — and the harness kept reporting the smaller number. The error is one-sided, which makes it worse than noise: it under-counts whichever arm takes more turns, and that is always the WITHOUT arm. On the 2026-08-05 campaign it turned a real 62% token saving into 19% and invented a token REGRESSION on tokio (-41%) and alamofire (-25%) that does not exist. Those numbers were one push away from the README. Now summed per assistant request and deduped by message.id, the same rule the occupancy timeline already used — Claude Code emits one event per content block carrying identical usage, so summing per event double-counts (~1.7x measured). CLAUDE.md already warned about this field. The code did not follow; it does now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1402 lines
71 KiB
JavaScript
1402 lines
71 KiB
JavaScript
#!/usr/bin/env node
|
||
// Parse Claude Code stream-json run log(s): tool-call sequence, token usage, and
|
||
// RESIDUAL CONTEXT OCCUPANCY — how many tokens of the context window each tool
|
||
// family's responses still occupy when the run ends.
|
||
//
|
||
// Usage: parse-run.mjs <run.jsonl> [run.t2.jsonl ...] [--brief] [--envelope] [--answer <glob>]...
|
||
// Multiple files = one multi-turn session's segments, IN ORDER (run-all.sh
|
||
// writes run-<label>.jsonl, run-<label>.t2.jsonl, … for a `Q1||Q2||Q3` set).
|
||
// `--resume` does not replay prior messages, so the segments concatenate
|
||
// cleanly and token accounting carries across the boundary.
|
||
//
|
||
// `--brief` drops the numbered tool-call transcript and keeps everything else,
|
||
// for harnesses that print one of these blocks per run (ab-new-vs-baseline.sh
|
||
// at RUNS>=2 is otherwise mostly call listings).
|
||
//
|
||
// Every run also reports EXPLORE SUFFICIENCY — each codegraph_explore call
|
||
// bucketed by what the agent did next (see classifySufficiency) — and EXPLORE
|
||
// ALLOCATION EFFICIENCY, the share of the bytes explore returned that belonged
|
||
// to files the agent's final answer actually cited (see computeAllocation).
|
||
//
|
||
// `--envelope` additionally reports how the codegraph_explore responses were
|
||
// DIVIDED across files — the per-file share of the source envelope (#1500).
|
||
// `--answer <glob>` (repeatable, implies --envelope) marks the files that
|
||
// actually answer the question and reports their combined share: bar 2 of the
|
||
// CG-1/CG-22 allocation gate. See formatEnvelope for why it parses the
|
||
// rendered markdown rather than the CG-4 diagnostic sidecar.
|
||
//
|
||
// ---------------------------------------------------------------------------
|
||
// Why occupancy, and how it's measured
|
||
// ---------------------------------------------------------------------------
|
||
// A single-question A/B reports cost/tokens/time/tool-calls for ONE answer. It
|
||
// cannot see what issue #1500 measured: a tool response stays in the window for
|
||
// everything that follows, so it is charged against every later turn's headroom.
|
||
// That is a per-session cost our single-question runs structurally miss.
|
||
//
|
||
// Tokens are MEASURED, not estimated at bytes/4. For assistant request k,
|
||
// ctx_k = usage.input_tokens + cache_read_input_tokens + cache_creation_input_tokens
|
||
// is the exact token count of that request's whole prompt. So
|
||
// gap_k = ctx_k - ctx_{k-1}
|
||
// is exactly the tokens appended since the previous request: the previous
|
||
// assistant output (thinking + text + tool_use JSON) plus the tool_results and
|
||
// user text that followed it. We split gap_k across those blocks in proportion
|
||
// to their characters, which attributes each tool_result its measured share.
|
||
// (Measured on real runs, explore output lands near 2.3 chars/token — bytes/4
|
||
// under-counts it by ~40%, which is why the estimate isn't good enough.)
|
||
//
|
||
// Two traps this file works around, both verified against real logs:
|
||
// * Claude Code emits ONE assistant event PER CONTENT BLOCK, all carrying the
|
||
// same message.id and the same `usage`. Summing usage per event double-counts
|
||
// every turn that emits both thinking and a tool_use — dedupe by message.id.
|
||
// * The streamed `output_tokens` is a partial snapshot (observed `out=2` on a
|
||
// turn that really generated ~1100). Never trust it; the char-proportional
|
||
// split doesn't need it.
|
||
//
|
||
// Residual ≠ contributed. Content leaves the window two ways, and both are
|
||
// tracked: a `compact_boundary` system event (everything prior is replaced by a
|
||
// summary) and micro-compaction (ctx drops mid-run — oldest tool results are
|
||
// dropped first, so eviction is applied FIFO).
|
||
import { readFileSync } from 'fs';
|
||
import { pathToFileURL } from 'url';
|
||
|
||
// Nominal window for the share-of-window column. Override for a [1m] context.
|
||
const WINDOW_TOKENS = Number(process.env.CG_WINDOW_TOKENS || 200_000);
|
||
const CHARS_PER_TOKEN_FALLBACK = 3.0;
|
||
|
||
/** Which tool family a tool_use belongs to. */
|
||
function familyOf(name) {
|
||
if (/codegraph/.test(name)) return 'codegraph';
|
||
if (name === 'Read' || name === 'NotebookRead') return 'read';
|
||
if (name === 'Grep' || name === 'Glob') return 'search';
|
||
if (name === 'Bash' || name === 'BashOutput') return 'bash';
|
||
return 'other';
|
||
}
|
||
const FAMILIES = ['codegraph', 'read', 'search', 'bash', 'other'];
|
||
// The without-arm's way of getting the same bytes: reading and searching files.
|
||
const FILE_ACCESS = ['read', 'search', 'bash'];
|
||
|
||
// A Bash command that INVOKES the codegraph CLI, in any command position and by
|
||
// any path. Mentions are not invocations: `grep codegraph src/`, `ls .codegraph`
|
||
// and `which codegraph` all pass. Kept in step with run-all.sh's blocking hook.
|
||
const CG_CLI_RE = /(^|[;&|(]|&&|\|\||\$\(|`)\s*(?:[A-Za-z_]\w*=\S*\s+)*[\w./~-]*codegraph(\s|$)/;
|
||
|
||
const textOf = (content) =>
|
||
Array.isArray(content) ? content.map((c) => c.text ?? (typeof c === 'string' ? c : JSON.stringify(c))).join('')
|
||
: typeof content === 'string' ? content
|
||
: content == null ? '' : JSON.stringify(content);
|
||
|
||
/** Characters an assistant content block occupies once it is back in the prompt. */
|
||
function assistantBlockChars(b) {
|
||
if (b.type === 'text') return (b.text || '').length;
|
||
if (b.type === 'thinking') return (b.thinking || '').length;
|
||
if (b.type === 'tool_use') return JSON.stringify(b.input ?? {}).length + (b.name || '').length;
|
||
return JSON.stringify(b).length;
|
||
}
|
||
|
||
/**
|
||
* Parse one session (its segment files, in order) into tool + occupancy stats.
|
||
* Exported so parse-bench-readme.mjs can aggregate without duplicating any of
|
||
* this — deliberately NOT a separate module file: a new scripts/agent-eval/*.mjs
|
||
* scores into the self-query eval fixture's own corpus and moves its numbers.
|
||
*/
|
||
export function parseSession(files) {
|
||
const events = [];
|
||
for (const f of files) {
|
||
for (const line of readFileSync(f, 'utf8').split('\n')) {
|
||
if (!line) continue;
|
||
try { events.push(JSON.parse(line)); } catch { /* partial line */ }
|
||
}
|
||
}
|
||
|
||
const toolCalls = []; // display sequence
|
||
const nameById = new Map(); // tool_use_id -> tool name
|
||
const cliById = new Set(); // tool_use_ids that tried to run the codegraph CLI
|
||
const counts = {}; // tool name -> calls
|
||
// Attempts vs successes: run-all.sh's hook DENIES CLI invocations, and a
|
||
// denied attempt puts no codegraph output in the window. Only a call that
|
||
// actually returned content contaminates the arm.
|
||
let initTools = null, result = null, raced = false, cliCalls = 0, cliContaminated = 0;
|
||
const results = []; // one `result` event per session segment (multi-turn)
|
||
let compactions = 0;
|
||
// Raw codegraph_explore response text, in call order. Feeds the envelope view
|
||
// (see formatEnvelope) — kept here rather than re-parsed from the log later so
|
||
// a multi-segment session's responses stay in one ordered list.
|
||
const exploreTexts = [];
|
||
|
||
// A timeline of everything appended to the context, in order. `req` entries
|
||
// are assistant requests (carrying that request's ctx); `add` entries are
|
||
// characters appended (assistant output blocks, tool results, user text).
|
||
const timeline = [];
|
||
const seenMsgIds = new Set();
|
||
|
||
for (const ev of events) {
|
||
if (ev.type === 'system' && ev.subtype === 'init') {
|
||
initTools = (ev.tools || []).filter((t) => /codegraph/.test(t));
|
||
}
|
||
if (ev.type === 'system' && (ev.subtype === 'compact_boundary' || ev.subtype === 'compaction')) {
|
||
compactions++;
|
||
timeline.push({ kind: 'compact' });
|
||
}
|
||
if (ev.type === 'assistant' && ev.message) {
|
||
const id = ev.message.id;
|
||
// One event per content block, same id + same usage: count usage once,
|
||
// but take the content blocks from every event that carries the id.
|
||
if (id && !seenMsgIds.has(id)) {
|
||
seenMsgIds.add(id);
|
||
const u = ev.message.usage || {};
|
||
const ctx = (u.input_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0);
|
||
timeline.push({ kind: 'req', ctx, out: u.output_tokens || 0 });
|
||
}
|
||
for (const b of ev.message.content || []) {
|
||
timeline.push({ kind: 'add', family: null, chars: assistantBlockChars(b) });
|
||
if (b.type === 'tool_use') {
|
||
nameById.set(b.id, b.name);
|
||
counts[b.name] = (counts[b.name] || 0) + 1;
|
||
let detail = '';
|
||
if (b.name === 'Task') detail = ` [subagent_type=${b.input?.subagent_type ?? '?'}] ${(b.input?.description ?? '').slice(0, 40)}`;
|
||
else if (/codegraph/.test(b.name)) detail = ` ${JSON.stringify(b.input?.query ?? b.input?.task ?? b.input?.symbol ?? '').slice(0, 60)}`;
|
||
else if (b.name === 'Bash') {
|
||
detail = ` ${(b.input?.command ?? '').slice(0, 50)}`;
|
||
// An arm with no codegraph MCP can still shell out to the CLI — the
|
||
// target repo carries the .codegraph/ index and the binary is on
|
||
// PATH. That silently turns a "without" arm into codegraph-over-CLI.
|
||
if (CG_CLI_RE.test(b.input?.command ?? '')) { cliCalls++; cliById.add(b.id); }
|
||
}
|
||
else if (b.name === 'Read') detail = ` ${(b.input?.file_path ?? '').split('/').slice(-1)[0]}`;
|
||
toolCalls.push(`${b.name}${detail}`);
|
||
}
|
||
}
|
||
}
|
||
if (ev.type === 'user' && ev.message) {
|
||
const content = ev.message.content;
|
||
if (Array.isArray(content)) {
|
||
for (const b of content) {
|
||
if (b.type === 'tool_result') {
|
||
const t = textOf(b.content);
|
||
// MCP cold-start race: the agent fired before `serve --mcp` had
|
||
// registered its tools, so it floundered into grep/Read. That
|
||
// measures startup latency, not steady-state value — flag it.
|
||
if (/No such tool available/.test(t)) raced = true;
|
||
// A CLI attempt that came back an error was blocked (by the hook, or
|
||
// by the binary being genuinely absent) and put nothing in context.
|
||
if (cliById.has(b.tool_use_id) && !b.is_error) cliContaminated++;
|
||
const name = nameById.get(b.tool_use_id) || '';
|
||
if (/codegraph_explore/.test(name) && !b.is_error) exploreTexts.push(t);
|
||
timeline.push({ kind: 'add', family: familyOf(name), chars: t.length, tool: name });
|
||
} else {
|
||
timeline.push({ kind: 'add', family: null, chars: textOf([b]).length });
|
||
}
|
||
}
|
||
} else if (typeof content === 'string') {
|
||
timeline.push({ kind: 'add', family: null, chars: content.length });
|
||
}
|
||
}
|
||
if (ev.type === 'result') { result = ev; results.push(ev); }
|
||
}
|
||
|
||
// ---- Pass 1: chars/token, calibrated on tool-result-dominated gaps. ------
|
||
// Splitting a gap in proportion to characters over-attributes to tool results
|
||
// whenever the assistant's own output is under-represented in the transcript
|
||
// (redacted/empty thinking blocks are the common case — a gap whose only
|
||
// visible chars were a 73-char tool_result charged it the whole 830-token
|
||
// delta, 5.5 tok/char). So calibrate the ratio on gaps that are ≥80% tool
|
||
// result by characters, then price every result at that ratio.
|
||
const reqIdx = timeline.map((t, i) => (t.kind === 'req' ? i : -1)).filter((i) => i >= 0);
|
||
const gaps = [];
|
||
for (let k = 1; k < reqIdx.length; k++) {
|
||
const prev = timeline[reqIdx[k - 1]], cur = timeline[reqIdx[k]];
|
||
let chars = 0, toolChars = 0, compacted = false;
|
||
const byFamily = {};
|
||
for (let i = reqIdx[k - 1] + 1; i < reqIdx[k]; i++) {
|
||
const t = timeline[i];
|
||
if (t.kind === 'compact') { compacted = true; continue; }
|
||
if (t.kind !== 'add') continue;
|
||
chars += t.chars;
|
||
if (t.family) { toolChars += t.chars; byFamily[t.family] = (byFamily[t.family] || 0) + t.chars; }
|
||
}
|
||
gaps.push({ delta: cur.ctx - prev.ctx, chars, toolChars, byFamily, compacted });
|
||
}
|
||
const clean = gaps.filter((g) => !g.compacted && g.delta > 0 && g.chars > 500 && g.toolChars / g.chars >= 0.8);
|
||
// A gap where the window also SHED content has a delta far below what was
|
||
// added, which reads as absurdly dense text and would drag the whole run's
|
||
// ratio with it. Shedding can only push a gap's chars/token UP, so take the
|
||
// lower median as the honest centre and drop anything well above it, then
|
||
// pool the survivors. (On runs that never shed, every ratio is within a few
|
||
// percent of the others and this changes nothing.)
|
||
const ratios = clean.map((g) => g.toolChars / g.delta).sort((a, b) => a - b);
|
||
const lowerMedian = ratios.length ? ratios[Math.floor((ratios.length - 1) / 2)] : 0;
|
||
let sumD = 0, sumC = 0;
|
||
for (const g of clean) {
|
||
if (lowerMedian > 0 && g.toolChars / g.delta > lowerMedian * 1.5) continue; // shed
|
||
sumD += g.delta; sumC += g.toolChars;
|
||
}
|
||
if (sumD === 0) { // no clean gap — fall back to every growing gap, all chars
|
||
for (const g of gaps) if (!g.compacted && g.delta > 0 && g.chars > 0) { sumD += g.delta; sumC += g.chars; }
|
||
}
|
||
const charsPerToken = sumD > 0 ? sumC / sumD : CHARS_PER_TOKEN_FALLBACK;
|
||
const calibrated = sumD > 0;
|
||
|
||
// How far a single result's token density strays from the run-level ratio.
|
||
// On a gap that is almost entirely one tool result, `delta` IS that result's
|
||
// token count, so |chars/ratio - delta| / delta is the attribution error for
|
||
// that result. The median over such gaps is the metric's real error bar.
|
||
const errs = [];
|
||
for (const g of gaps) {
|
||
if (g.compacted || g.delta <= 0 || g.chars <= 500) continue;
|
||
if (g.toolChars / g.chars < 0.95) continue;
|
||
errs.push(Math.abs(g.toolChars / charsPerToken - g.delta) / g.delta);
|
||
}
|
||
errs.sort((a, b) => a - b);
|
||
const dispersion = errs.length ? errs[(errs.length - 1) >> 1] : null;
|
||
|
||
// ---- Pass 2: attribute gap tokens, then apply evictions FIFO. ------------
|
||
const contributed = Object.fromEntries(FAMILIES.map((f) => [f, 0]));
|
||
const resultChars = Object.fromEntries(FAMILIES.map((f) => [f, 0]));
|
||
const resultCount = Object.fromEntries(FAMILIES.map((f) => [f, 0]));
|
||
for (const t of timeline) if (t.kind === 'add' && t.family) { resultChars[t.family] += t.chars; resultCount[t.family]++; }
|
||
|
||
let queue = []; // resident contributions, oldest first
|
||
let evicted = 0;
|
||
const evict = (tokens) => {
|
||
let left = tokens;
|
||
while (left > 0 && queue.length) {
|
||
const head = queue[0];
|
||
if (head.tokens <= left) { left -= head.tokens; evicted += head.tokens; queue.shift(); }
|
||
else { head.tokens -= left; evicted += left; left = 0; }
|
||
}
|
||
};
|
||
|
||
for (const g of gaps) {
|
||
if (g.compacted) {
|
||
// Everything before the boundary is gone; the summary replaces it.
|
||
evicted += queue.reduce((s, q) => s + q.tokens, 0);
|
||
queue = [];
|
||
}
|
||
let toolTokens = 0;
|
||
for (const [fam, ch] of Object.entries(g.byFamily)) {
|
||
const tok = ch / charsPerToken;
|
||
toolTokens += tok;
|
||
contributed[fam] += tok;
|
||
queue.push({ family: fam, tokens: tok });
|
||
}
|
||
// The gap grew by `delta`; the tool results account for `toolTokens` of it.
|
||
// A shortfall means the window also shed content — micro-compaction drops
|
||
// the OLDEST tool results first, so evict FIFO. The tolerance keeps
|
||
// attribution noise (a run-level ratio priced against one gap's delta,
|
||
// typically ±2%) from reading as an eviction; real shedding is thousands.
|
||
const shortfall = toolTokens - g.delta;
|
||
if (!g.compacted && shortfall > Math.max(200, toolTokens * 0.05)) evict(shortfall);
|
||
}
|
||
|
||
const residual = Object.fromEntries(FAMILIES.map((f) => [f, 0]));
|
||
for (const q of queue) residual[q.family] += q.tokens;
|
||
|
||
const ctxFinal = reqIdx.length ? timeline[reqIdx[reqIdx.length - 1]].ctx : 0;
|
||
// The FIRST request's prompt is system + tool schemas + the question, before
|
||
// any tool has answered. Differencing the arms' ctxBase prices codegraph's
|
||
// FIXED occupancy — its tool schema and MCP `initialize` instructions — which
|
||
// it pays whether or not the agent ever calls it.
|
||
const ctxBase = reqIdx.length ? timeline[reqIdx[0]].ctx : 0;
|
||
// "Tokens processed" = the SUM of every assistant request's whole prompt. Every
|
||
// request re-counts the prefix, which is what makes this a throughput figure and
|
||
// why it cannot answer the occupancy question (that is `residual`, above).
|
||
//
|
||
// It must be summed PER TURN, not read off `result.usage`. The comment that used
|
||
// to sit here asserted result.usage was "cumulative WITHIN a segment"; that was
|
||
// true when the README figures were measured and is NOT true in current Claude
|
||
// Code, where it reports the LAST turn only. Nothing in this repo changed — the
|
||
// host did, silently, and the harness kept reporting the smaller number.
|
||
//
|
||
// The damage is one-sided and therefore worse than noise: it under-counts
|
||
// whichever arm takes more turns, which is always the WITHOUT arm. Measured on
|
||
// the 2026-08-05 campaign it turned a real 62% token saving into 19%, and
|
||
// manufactured a token REGRESSION on tokio (-41%) and alamofire (-25%) that does
|
||
// not exist. CLAUDE.md already warned about this field; the code did not follow.
|
||
//
|
||
// Dedupe by message.id: Claude Code emits one assistant event per content block,
|
||
// each carrying the same `usage`, so summing per EVENT double-counts (~1.7x on a
|
||
// real run). Same rule the occupancy timeline above uses.
|
||
const seenUsageIds = new Set();
|
||
let processed = 0;
|
||
for (const ev of events) {
|
||
if (ev.type !== 'assistant' || !ev.message?.id) continue;
|
||
if (seenUsageIds.has(ev.message.id)) continue;
|
||
seenUsageIds.add(ev.message.id);
|
||
const u = ev.message.usage || {};
|
||
processed += (u.input_tokens || 0) + (u.cache_read_input_tokens || 0)
|
||
+ (u.cache_creation_input_tokens || 0) + (u.output_tokens || 0);
|
||
}
|
||
|
||
return {
|
||
files, toolCalls, counts, initTools, result, results, raced, cliCalls, cliContaminated,
|
||
exploreTexts,
|
||
// What the agent did after each explore — the free sufficiency signal (CG-8).
|
||
sufficiency: classifySufficiency(events),
|
||
// How much of what explore returned the answer actually drew on (CG-9).
|
||
allocation: computeAllocation(exploreTexts, finalAnswerText(events)),
|
||
ok: results.length > 0 && results.every((r) => r.subtype === 'success'),
|
||
turns: reqIdx.length,
|
||
tools: toolCalls.filter((t) => !t.startsWith('ToolSearch')).length,
|
||
reads: counts.Read || 0,
|
||
grep: (counts.Grep || 0) + (counts.Glob || 0),
|
||
cg: Object.entries(counts).filter(([n]) => /codegraph/.test(n)).reduce((s, [, v]) => s + v, 0),
|
||
dur: results.reduce((s, r) => s + (r.duration_ms || 0), 0) / 1000,
|
||
cost: results.reduce((s, r) => s + (r.total_cost_usd || 0), 0),
|
||
processed,
|
||
occupancy: {
|
||
ctxFinal, ctxBase, windowTokens: WINDOW_TOKENS,
|
||
charsPerToken, calibrated, compactions, dispersion, evicted: Math.round(evicted),
|
||
residual: Object.fromEntries(FAMILIES.map((f) => [f, Math.round(residual[f])])),
|
||
contributed: Object.fromEntries(FAMILIES.map((f) => [f, Math.round(contributed[f])])),
|
||
chars: resultChars, results: resultCount,
|
||
residualFileAccess: Math.round(FILE_ACCESS.reduce((s, f) => s + residual[f], 0)),
|
||
contributedFileAccess: Math.round(FILE_ACCESS.reduce((s, f) => s + contributed[f], 0)),
|
||
charsFileAccess: FILE_ACCESS.reduce((s, f) => s + resultChars[f], 0),
|
||
},
|
||
};
|
||
}
|
||
|
||
/** The occupancy block, as printed under a run and reused by the aggregator. */
|
||
export function formatOccupancy(s, indent = ' ') {
|
||
const o = s.occupancy;
|
||
const n = (x) => x.toLocaleString('en-US');
|
||
const pctCtx = (t) => (o.ctxFinal > 0 ? ((t / o.ctxFinal) * 100).toFixed(1) : '0.0');
|
||
const pctWin = (t) => ((t / o.windowTokens) * 100).toFixed(1);
|
||
const rows = [];
|
||
const row = (label, tok, chars, results) => rows.push(
|
||
`${indent} ${label.padEnd(18)}${(n(tok) + ' tok').padStart(12)} ${(pctCtx(tok) + '%').padStart(6)} of ctx ` +
|
||
`${(pctWin(tok) + '%').padStart(6)} of ${Math.round(o.windowTokens / 1000)}k win` +
|
||
(chars !== undefined ? ` (${n(chars)} chars, ${results} result${results === 1 ? '' : 's'})` : '')
|
||
);
|
||
const out = [`${indent}Residual context occupancy at end of run:`];
|
||
out.push(`${indent} ${'final context'.padEnd(18)}${(n(o.ctxFinal) + ' tok').padStart(12)} ${(pctWin(o.ctxFinal) + '%').padStart(6)} of ${Math.round(o.windowTokens / 1000)}k window`);
|
||
row('codegraph', o.residual.codegraph, o.chars.codegraph, o.results.codegraph);
|
||
row('Read', o.residual.read, o.chars.read, o.results.read);
|
||
row('Grep/Glob', o.residual.search, o.chars.search, o.results.search);
|
||
row('Bash', o.residual.bash, o.chars.bash, o.results.bash);
|
||
row('→ file-access', o.residualFileAccess, o.charsFileAccess,
|
||
o.results.read + o.results.search + o.results.bash);
|
||
row('other tools', o.residual.other, o.chars.other, o.results.other);
|
||
const toolTotal = Object.values(o.residual).reduce((a, b) => a + b, 0);
|
||
row('base (prompt+prose)', Math.max(0, o.ctxFinal - toolTotal));
|
||
out.push(`${indent} ${' of which fixed'.padEnd(18)}${(n(o.ctxBase) + ' tok').padStart(12)} system + tool schemas + question, before any tool answered`);
|
||
out.push(...rows);
|
||
const dropped = o.contributed.codegraph + o.contributedFileAccess + o.contributed.other
|
||
- (o.residual.codegraph + o.residualFileAccess + o.residual.other);
|
||
out.push(
|
||
`${indent} measure: ${o.charsPerToken.toFixed(2)} chars/tok ${o.calibrated ? 'measured' : '(FALLBACK — no clean gap to calibrate on)'}` +
|
||
(o.dispersion !== null ? ` ±${(o.dispersion * 100).toFixed(1)}%` : '') +
|
||
` · turns ${s.turns} · compactions ${o.compactions}` +
|
||
(o.evicted > 0 || dropped > 1 ? ` · evicted ${n(o.evicted)} tok` : '')
|
||
);
|
||
return out.join('\n');
|
||
}
|
||
|
||
/**
|
||
* One codegraph_explore response, split into the per-file source sections the
|
||
* allocator divided its budget across.
|
||
*
|
||
* Parsed out of the RENDERED MARKDOWN, not the CG-4 diagnostic sidecar: the
|
||
* sidecar only exists on a post-CG-4 build, so it cannot measure a baseline arm.
|
||
* The markdown parse is the only instrument that measures both arms of a
|
||
* new-vs-baseline A/B the same way. Both the envelope view and the allocation
|
||
* metric key off this one parse.
|
||
*
|
||
* Returns `{ envelope, files: [{ path, chars, symbols }] }`, where `symbols` are
|
||
* the names the section header lists — filtered to the ones the file DEFINES.
|
||
* The header renders `name(kind)` for every node in the shipped clusters, and
|
||
* that includes edge-kind pseudo-entries for call sites (`mutateElement(calls)`
|
||
* on a file that merely calls it). Attributing a file from those would credit
|
||
* every caller of a cited symbol — measured on a real excalidraw run, it marked
|
||
* `dragElements.ts` used because the answer named `mutateElement`.
|
||
*/
|
||
/** NodeKind values that mean "this file defines it" (src/types.ts, less file/import/export). */
|
||
const DEFINING_KINDS = new Set([
|
||
'module', 'class', 'struct', 'interface', 'trait', 'protocol', 'function', 'method',
|
||
'property', 'field', 'variable', 'constant', 'enum', 'enum_member', 'type_alias',
|
||
'namespace', 'route', 'component',
|
||
]);
|
||
export function parseExploreCall(text) {
|
||
const src = String(text ?? '');
|
||
const re = /^\*\*`([^`]+)`\*\*(.*)$/gm;
|
||
const marks = [];
|
||
let m;
|
||
while ((m = re.exec(src)) !== null) marks.push({ path: m[1], header: m[2] || '', at: m.index });
|
||
if (!marks.length) return { envelope: 0, files: [] };
|
||
// Sections run to the next header, or to the trailing guidance quote.
|
||
const tail = src.indexOf('\n> ', marks[marks.length - 1].at);
|
||
const end = tail === -1 ? src.length : tail;
|
||
const files = marks.map((mark, i) => ({
|
||
path: mark.path,
|
||
chars: (i + 1 < marks.length ? marks[i + 1].at : end) - mark.at,
|
||
// `mutateElement(calls), onFinished(variable), +32 more` → the defined ones.
|
||
symbols: [...mark.header.matchAll(/([A-Za-z_$][\w$.]*)\(([a-z_]+)\)/g)]
|
||
.filter((s) => DEFINING_KINDS.has(s[2]))
|
||
.map((s) => ({ name: s[1], kind: s[2] })),
|
||
}));
|
||
return { envelope: files.reduce((s, f) => s + f.chars, 0), files };
|
||
}
|
||
|
||
/**
|
||
* How the codegraph_explore responses the agent received were DIVIDED across
|
||
* files — the per-file share of the source envelope (#1500 / epic CG-1).
|
||
*
|
||
* `answerGlobs` marks the files that actually answer the question; the summary
|
||
* reports their combined share, which is bar 2 of the CG-1/CG-22 gate. That is
|
||
* HAND-SPECIFIED ground truth; the allocation metric below infers the same
|
||
* intersection from the agent's own answer instead.
|
||
*/
|
||
export function formatEnvelope(exploreTexts, answerGlobs = [], indent = ' ') {
|
||
// `tools/cache/**` -> /^tools\/cache\/.*$/ . Same semantics as probe-allocation.
|
||
// The `**` sentinel is written as an escape, never a literal NUL byte — a raw
|
||
// one makes git treat this whole script as binary and costs every future diff.
|
||
const glob2re = (glob) => {
|
||
const S = '\\u0000';
|
||
const body = glob.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
||
.replace(/\*\*/g, S).replace(/\*/g, '[^/]*').replaceAll(S, '.*');
|
||
return new RegExp(`^${body}$`);
|
||
};
|
||
const answerRes = answerGlobs.map(glob2re);
|
||
const isAnswer = (p) => answerRes.some((re) => re.test(p));
|
||
|
||
// Share is over the sum of the per-file sections, i.e. of the source envelope
|
||
// the allocator divides.
|
||
const pooled = new Map();
|
||
let envelope = 0;
|
||
for (const text of exploreTexts) {
|
||
for (const f of parseExploreCall(text).files) {
|
||
pooled.set(f.path, (pooled.get(f.path) ?? 0) + f.chars);
|
||
envelope += f.chars;
|
||
}
|
||
}
|
||
const ranked = [...pooled.entries()]
|
||
.map(([path, chars]) => ({ path, chars, share: envelope ? chars / envelope : 0, answer: isAnswer(path) }))
|
||
.sort((a, b) => b.chars - a.chars);
|
||
const answerChars = ranked.filter((r) => r.answer).reduce((s, r) => s + r.chars, 0);
|
||
const pct = (f) => `${(f * 100).toFixed(1)}%`;
|
||
|
||
const out = [];
|
||
out.push(`${indent}Explore envelope: ${envelope.toLocaleString('en-US')} chars over ${exploreTexts.length} response(s)`);
|
||
if (answerGlobs.length) {
|
||
out.push(`${indent} answer-set share: ${pct(envelope ? answerChars / envelope : 0)} | top file answers: ${ranked[0]?.answer ?? false}`);
|
||
}
|
||
for (const f of ranked.slice(0, 12)) {
|
||
out.push(`${indent} ${f.answer ? '*' : ' '} ${pct(f.share).padStart(6)} ${String(f.chars).padStart(6)} ${f.path}`);
|
||
}
|
||
if (ranked.length > 12) out.push(`${indent} … ${ranked.length - 12} more files`);
|
||
return out.join('\n');
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Explore allocation efficiency (CG-9)
|
||
// ---------------------------------------------------------------------------
|
||
// The envelope view above needs a human to say which files answer the question
|
||
// (`--answer <glob>`). This one reads that off the AGENT'S OWN FINAL ANSWER and
|
||
// reports a single number every run:
|
||
//
|
||
// allocation efficiency = bytes explore returned for files the answer drew on
|
||
// ─────────────────────────────────────────────────
|
||
// all bytes explore returned
|
||
//
|
||
// That is the #1500 defect as a number. On the CG-1 baseline self-query it sat
|
||
// around 16–30%: one correct file, one marginal, three pure noise.
|
||
//
|
||
// ATTRIBUTION IS IMPERFECT AND THE ERROR IS ONE-SIDED. An agent can use a file's
|
||
// source to rule it out, or to build a mental model, and never cite it — that
|
||
// reads as waste. So this is a RELATIVE metric: valid for comparing two builds
|
||
// on the SAME question, not as an absolute claim about how much of an envelope
|
||
// earned its place. Quoting it as an absolute efficiency figure is a misuse.
|
||
//
|
||
// Two citation channels, deliberately ranked so the weaker one is separable:
|
||
// * PATH — the answer names the file (`lib/response.js:126-220`, or the bare
|
||
// `utils.js:225` agents drop into prose). Strong, and reported on its own as
|
||
// the conservative floor.
|
||
// * SYMBOL — the answer cites a symbol in a code span and only that file's
|
||
// section header lists it. Catches answers written entirely in symbol names.
|
||
// Guarded: a name carried by ≥3 of the returned files is too generic to
|
||
// attribute and is dropped, or `send`/`get` would mark half the envelope
|
||
// used and bias the metric optimistic — the one direction it must not lean.
|
||
|
||
/** Symbol tokens too common in prose/code spans to attribute a file from. */
|
||
const SYMBOL_STOPWORDS = new Set([
|
||
'function', 'return', 'const', 'this', 'true', 'false', 'null', 'void', 'undefined',
|
||
'string', 'object', 'number', 'boolean', 'array', 'class', 'import', 'export',
|
||
'async', 'await', 'default', 'type', 'value', 'name', 'data', 'self', 'super',
|
||
'else', 'then', 'case', 'from', 'with', 'when', 'where', 'that', 'they', 'this',
|
||
]);
|
||
/** A name on this many of the returned files stops identifying any one of them. */
|
||
const SYMBOL_AMBIGUITY_LIMIT = 3;
|
||
/** Below this length a token is far more likely prose than a symbol citation. */
|
||
const MIN_SYMBOL_LEN = 4;
|
||
|
||
/**
|
||
* What the agent's final answer CITES: file paths, and the identifiers it puts
|
||
* in code spans. `extensions` are the file extensions actually present in the
|
||
* envelope — the gate that lets `utils.js:225` through as a file citation while
|
||
* rejecting `res.send` and `mime.contentType`, which are the same token shape.
|
||
*/
|
||
export function answerCitations(text, extensions = new Set()) {
|
||
const src = String(text ?? '');
|
||
const paths = new Set();
|
||
// Dotted path with at least one directory: `lib/response.js:126-220`.
|
||
for (const m of src.matchAll(/(?:[\w@.+-]+\/)+[\w@.+-]+\.[A-Za-z]\w*/g)) paths.add(m[0]);
|
||
// Bare basename, only when its extension is one the envelope actually shipped.
|
||
for (const m of src.matchAll(/\b[\w@+-]+\.[A-Za-z]\w*/g)) {
|
||
const ext = m[0].slice(m[0].lastIndexOf('.') + 1).toLowerCase();
|
||
if (extensions.has(ext)) paths.add(m[0]);
|
||
}
|
||
// Identifiers inside code spans. Prose mentions are excluded on purpose: a
|
||
// backtick is the agent marking the token as code, which is the whole signal.
|
||
const symbols = new Set();
|
||
for (const span of src.matchAll(/`([^`\n]+)`/g)) {
|
||
for (const t of span[1].matchAll(/[A-Za-z_$][\w$]*/g)) {
|
||
const tok = t[0];
|
||
if (tok.length >= MIN_SYMBOL_LEN && !SYMBOL_STOPWORDS.has(tok.toLowerCase())) symbols.add(tok);
|
||
}
|
||
}
|
||
return { paths: [...paths], symbols };
|
||
}
|
||
|
||
/**
|
||
* Allocation efficiency over one session's explore responses and its final
|
||
* answer(s). Per call AND pooled over the run — a run-level number alone hides
|
||
* the common shape where call 1 is on target and call 3 is pure noise.
|
||
*/
|
||
export function computeAllocation(exploreTexts, answerText) {
|
||
const calls = exploreTexts.map(parseExploreCall).filter((c) => c.files.length);
|
||
const extensions = new Set();
|
||
for (const c of calls) {
|
||
for (const f of c.files) {
|
||
const dot = f.path.lastIndexOf('.');
|
||
if (dot > 0) extensions.add(f.path.slice(dot + 1).toLowerCase());
|
||
}
|
||
}
|
||
const cited = answerCitations(answerText, extensions);
|
||
|
||
// symbol -> the returned files whose header lists it as DEFINED. A `variable`
|
||
// is usually an import binding (`var compileETag = require('./utils')…`), so
|
||
// when the same name is also a real definition somewhere in the envelope, the
|
||
// definition wins and the aliasing file is not credited.
|
||
const symbolFiles = new Map();
|
||
for (const c of calls) {
|
||
for (const f of c.files) {
|
||
for (const s of f.symbols) {
|
||
if (!symbolFiles.has(s.name)) symbolFiles.set(s.name, { strong: new Set(), weak: new Set() });
|
||
const owners = symbolFiles.get(s.name);
|
||
(s.kind === 'variable' || s.kind === 'constant' ? owners.weak : owners.strong).add(f.path);
|
||
}
|
||
}
|
||
}
|
||
const ownersOf = (name) => {
|
||
const o = symbolFiles.get(name);
|
||
return o ? (o.strong.size ? o.strong : o.weak) : null;
|
||
};
|
||
|
||
// `lib/response.js` is cited by `lib/response.js`, by `response.js`, and by an
|
||
// absolute path ending in it — samePath already encodes exactly that.
|
||
const viaPath = (path) => cited.paths.some((p) => samePath(path, p));
|
||
const viaSymbol = (path) => {
|
||
for (const s of cited.symbols) {
|
||
const owners = ownersOf(s);
|
||
if (owners && owners.size < SYMBOL_AMBIGUITY_LIMIT && owners.has(path)) return s;
|
||
}
|
||
return null;
|
||
};
|
||
|
||
const verdict = new Map(); // path -> { via, symbol }
|
||
const judge = (path) => {
|
||
if (!verdict.has(path)) {
|
||
if (viaPath(path)) verdict.set(path, { via: 'path' });
|
||
else {
|
||
const s = viaSymbol(path);
|
||
verdict.set(path, s ? { via: 'symbol', symbol: s } : { via: null });
|
||
}
|
||
}
|
||
return verdict.get(path);
|
||
};
|
||
|
||
const perCall = calls.map((c, i) => {
|
||
const files = c.files.map((f) => ({ ...f, ...judge(f.path) }));
|
||
const used = files.filter((f) => f.via).reduce((s, f) => s + f.chars, 0);
|
||
const usedPath = files.filter((f) => f.via === 'path').reduce((s, f) => s + f.chars, 0);
|
||
return {
|
||
call: i + 1, envelope: c.envelope, used, usedPath, files,
|
||
efficiency: c.envelope ? used / c.envelope : 0,
|
||
};
|
||
});
|
||
|
||
// Pooled: a file returned twice is charged twice, because it occupied the
|
||
// window twice. Same accounting as formatEnvelope.
|
||
const pooled = new Map();
|
||
let envelope = 0;
|
||
for (const c of perCall) {
|
||
for (const f of c.files) {
|
||
const prev = pooled.get(f.path) ?? { path: f.path, chars: 0, calls: 0, via: f.via, symbol: f.symbol };
|
||
prev.chars += f.chars; prev.calls++;
|
||
pooled.set(f.path, prev);
|
||
envelope += f.chars;
|
||
}
|
||
}
|
||
const files = [...pooled.values()].sort((a, b) => b.chars - a.chars);
|
||
const used = files.filter((f) => f.via).reduce((s, f) => s + f.chars, 0);
|
||
const usedPath = files.filter((f) => f.via === 'path').reduce((s, f) => s + f.chars, 0);
|
||
return {
|
||
calls: perCall, files, envelope, used, usedPath,
|
||
efficiency: envelope ? used / envelope : 0,
|
||
efficiencyPath: envelope ? usedPath / envelope : 0,
|
||
filesReturned: files.length,
|
||
filesUsed: files.filter((f) => f.via).length,
|
||
hasAnswer: String(answerText ?? '').trim().length > 0,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Every answered codegraph_explore response in a transcript, in call order.
|
||
* parseSession collects these as it walks the timeline; this is the same list
|
||
* for callers that only have the raw events (parse-session.mjs).
|
||
*/
|
||
export function collectExploreTexts(events) {
|
||
const nameById = new Map();
|
||
const texts = [];
|
||
for (const ev of events) {
|
||
const content = ev?.message?.content;
|
||
if (!Array.isArray(content)) continue;
|
||
for (const b of content) {
|
||
if (b.type === 'tool_use') nameById.set(b.id, b.name);
|
||
else if (b.type === 'tool_result' && !b.is_error
|
||
&& /codegraph_explore/.test(nameById.get(b.tool_use_id) || '')) texts.push(textOf(b.content));
|
||
}
|
||
}
|
||
return texts;
|
||
}
|
||
|
||
/**
|
||
* The final answer a session produced. Headless stream-json carries it on the
|
||
* `result` event (one per resumed segment, all of which are answers); an
|
||
* interactive transcript has none, so fall back to the LAST assistant text of
|
||
* the main thread — intermediate narration would flood the citation set.
|
||
*/
|
||
export function finalAnswerText(events) {
|
||
const answers = events.filter((e) => e.type === 'result' && typeof e.result === 'string').map((e) => e.result);
|
||
if (answers.length) return answers.join('\n\n');
|
||
let last = '';
|
||
for (const ev of events) {
|
||
if (ev.type !== 'assistant' || (ev.parent_tool_use_id ?? null) !== null) continue;
|
||
const text = (ev.message?.content || []).filter((b) => b.type === 'text').map((b) => b.text || '').join('');
|
||
if (text.trim()) last = text;
|
||
}
|
||
return last;
|
||
}
|
||
|
||
/** The allocation block, as printed under a run and reused by aggregators. */
|
||
export function formatAllocation(s, indent = ' ') {
|
||
const a = s.allocation;
|
||
if (!a || !a.calls.length) return `${indent}Explore allocation: no codegraph_explore responses with source sections`;
|
||
const pct = (f) => `${(f * 100).toFixed(1)}%`;
|
||
const n = (x) => x.toLocaleString('en-US');
|
||
const out = [
|
||
`${indent}Explore allocation — share of returned bytes the answer used ` +
|
||
`(${a.calls.length} call${a.calls.length === 1 ? '' : 's'}, ${a.filesReturned} file${a.filesReturned === 1 ? '' : 's'}):`,
|
||
`${indent} efficiency ${pct(a.efficiency).padStart(6)} ${n(a.used)} of ${n(a.envelope)} chars` +
|
||
` (${a.filesUsed}/${a.filesReturned} files cited; path-cited alone ${pct(a.efficiencyPath)})`,
|
||
];
|
||
if (!a.hasAnswer) out.push(`${indent} !! no final answer text found — efficiency is not meaningful for this run`);
|
||
for (const c of a.calls) {
|
||
out.push(`${indent} call ${c.call}: ${pct(c.efficiency).padStart(6)} ${n(c.used)}/${n(c.envelope)} chars` +
|
||
` ${c.files.filter((f) => f.via).length}/${c.files.length} files`);
|
||
}
|
||
for (const f of a.files.slice(0, 12)) {
|
||
const share = a.envelope ? f.chars / a.envelope : 0;
|
||
const why = f.via === 'symbol' ? `symbol \`${f.symbol}\`` : f.via === 'path' ? 'path' : '—';
|
||
out.push(`${indent} ${f.via ? '*' : ' '} ${pct(share).padStart(6)} ${String(f.chars).padStart(6)} ${f.path} ${why}`);
|
||
}
|
||
if (a.files.length > 12) out.push(`${indent} … ${a.files.length - 12} more files`);
|
||
out.push(`${indent} note: relative metric — attribution is by citation, so compare builds on the same question, not absolutes.`);
|
||
return out.join('\n');
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Explore sufficiency (CG-8)
|
||
// ---------------------------------------------------------------------------
|
||
// The agent's NEXT action after a codegraph_explore is free ground truth about
|
||
// whether that response was enough. The buckets are chosen so each one maps to
|
||
// a distinct fix:
|
||
//
|
||
// another codegraph call insufficient — the response did not answer
|
||
// Read of a file we RETURNED allocation bug — right file, wrong bytes
|
||
// Read of a file we did NOT recall bug — the file never surfaced
|
||
// Grep/Glob recall bug (weaker: the agent is still hunting)
|
||
// anything else / no tool sufficient — the agent moved on
|
||
//
|
||
// Three rules keep this honest:
|
||
// * Only a call issued in a LATER assistant message counts as a reaction. A
|
||
// Read fired in the same message as the explore was issued before its
|
||
// response existed, so it cannot be a verdict on it (those are counted
|
||
// separately as `concurrent`).
|
||
// * ToolSearch/TodoWrite are stepped over: loading a deferred tool schema or
|
||
// ticking a checklist says nothing about the response.
|
||
// * SUBAGENT CALLS ARE A SEPARATE THREAD. Claude Code interleaves a subagent's
|
||
// tool calls into the same stream, tagged `parent_tool_use_id` — verified on
|
||
// a real run where a delegated search's greps landed between the parent's
|
||
// own calls. Reactions are matched within one thread, or the subagent's
|
||
// first grep would be scored as the parent's verdict on an explore it never
|
||
// saw.
|
||
//
|
||
// A delegation (`Agent`/`Task`) is judged by what the SUBAGENT did first, since
|
||
// that thread is right there in the transcript. Scoring the delegation itself as
|
||
// "moved on" would have called this run sufficient while the subagent was off
|
||
// grepping for the file — the one direction of error a tuning metric must not
|
||
// have. A delegation that never runs a tool stays "moved on".
|
||
|
||
/** Tools that carry no signal about whether the previous response was enough. */
|
||
const TRANSPARENT_TOOLS = new Set(['ToolSearch', 'TodoWrite']);
|
||
/** Tools that hand the work to a subagent whose thread we then judge instead. */
|
||
const DELEGATION_TOOLS = new Set(['Agent', 'Task']);
|
||
|
||
/** Buckets, worst → best. Labels double as the summary rows. */
|
||
export const SUFFICIENCY = [
|
||
['explore_again', 'explore again', 'insufficient: did not answer'],
|
||
['read_returned', 'Read a file we returned', 'allocation: right file, wrong bytes'],
|
||
['read_missed', 'Read a file we did not return', 'recall: file never surfaced'],
|
||
['search', 'Grep/Glob', 'recall (weak): still hunting for the file'],
|
||
['sufficient', 'moved on / answered', 'sufficient'],
|
||
];
|
||
const SUFFICIENCY_KEYS = SUFFICIENCY.map(([k]) => k);
|
||
|
||
// Shell equivalents of Read and of Grep. Both arms have Bash, and on small
|
||
// repos an agent reaches for `sed -n 100,200p file` as readily as for Read —
|
||
// counting only the Read tool would score those explores as sufficient.
|
||
const BASH_READ_RE = /(?:^|[;&|]|\$\(|`)\s*(?:sudo\s+)?(?:cat|bat|head|tail|less|more|nl|sed|awk)\s+([^\n|;&]*)/;
|
||
const BASH_SEARCH_RE = /(?:^|[;&|]|\$\(|`)\s*(?:sudo\s+)?(?:grep|egrep|fgrep|rg|ag|ack|find|fd|ls|tree)\b/;
|
||
|
||
/** What a Bash command is really doing, as far as retrieval is concerned. */
|
||
function bashIntent(cmd) {
|
||
const c = String(cmd || '');
|
||
// A heredoc or a redirect is WRITING a file — `cat <<EOF > x` must not read
|
||
// as a Read.
|
||
if (!/<</.test(c) && !/>\s*\S/.test(c)) {
|
||
const m = BASH_READ_RE.exec(c);
|
||
if (m) {
|
||
// Drop flags and numeric arguments (`sed -n '100,200p' lib/x.js`), then
|
||
// take the last path-shaped token.
|
||
const args = m[1].split(/\s+/).filter((a) => a && !a.startsWith('-') && !/^['"]?\d/.test(a));
|
||
const path = args.reverse().find((a) => /[/.]/.test(a));
|
||
if (path) return { kind: 'read', path: path.replace(/^['"]|['"]$/g, '') };
|
||
}
|
||
}
|
||
if (BASH_SEARCH_RE.test(c)) return { kind: 'search' };
|
||
return null;
|
||
}
|
||
|
||
const normPath = (p) => String(p ?? '').replace(/\\/g, '/').replace(/^\.\//, '');
|
||
/** Same file, with either side repo-relative and the other absolute. */
|
||
function samePath(a, b) {
|
||
const x = normPath(a), y = normPath(b);
|
||
if (!x || !y) return false;
|
||
return x === y || x.endsWith('/' + y) || y.endsWith('/' + x);
|
||
}
|
||
|
||
/**
|
||
* The files whose SOURCE an explore response returned — its per-file sections,
|
||
* which start with the unique ``**` `` marker (FILE_SECTION_PREFIX in
|
||
* src/mcp/tools.ts). formatEnvelope keys off the same marker; it needs the byte
|
||
* offsets too, which is why it re-scans rather than calling this.
|
||
*/
|
||
export function exploreReturnedFiles(text) {
|
||
return [...String(text ?? '').matchAll(/^\*\*`([^`]+)`\*\*/gm)].map((m) => m[1]);
|
||
}
|
||
|
||
// Every path-shaped token anywhere in a response — flow steps, blast radius,
|
||
// symbol lists. A file in here but NOT in the returned set was POINTED AT and
|
||
// not delivered, which is a different (and more damning) miss than one the
|
||
// response never mentioned at all.
|
||
const PATH_TOKEN_RE = /(?:[\w@.+-]+\/)+[\w@.+-]+\.[A-Za-z]\w*/g;
|
||
|
||
/**
|
||
* The reaction one action represents, given what the explore had returned.
|
||
* `earlier` is what PREVIOUS explores in the same thread returned: a re-read of
|
||
* a file we already shipped is an allocation miss wherever it was shipped, and
|
||
* filing it as recall would point the fix at the wrong end of the pipeline.
|
||
*/
|
||
function reactionOf(action, returned, mentioned, earlier = []) {
|
||
const { name, input } = action;
|
||
const readOf = (path, prefix) => {
|
||
const base = normPath(path).split('/').pop() || String(path ?? '');
|
||
if (returned.some((r) => samePath(path, r))) return { bucket: 'read_returned', next: `${prefix}Read ${base}` };
|
||
if (earlier.some((r) => samePath(path, r))) return { bucket: 'read_returned', next: `${prefix}Read ${base} (returned by an earlier explore)` };
|
||
const named = mentioned.some((m) => samePath(path, m));
|
||
return { bucket: 'read_missed', next: `${prefix}Read ${base}${named ? ' (named, not returned)' : ''}`, named };
|
||
};
|
||
if (/codegraph/.test(name)) return { bucket: 'explore_again', next: name.replace(/^mcp__[^_]*__/, '') };
|
||
if (name === 'Read' || name === 'NotebookRead') return readOf(input.file_path ?? input.notebook_path, '');
|
||
if (name === 'Grep' || name === 'Glob') return { bucket: 'search', next: name };
|
||
if (name === 'Bash') {
|
||
const intent = bashIntent(input.command);
|
||
if (intent?.kind === 'read') return readOf(intent.path, 'Bash ');
|
||
if (intent?.kind === 'search') return { bucket: 'search', next: 'Bash search' };
|
||
}
|
||
return { bucket: 'sufficient', next: name };
|
||
}
|
||
|
||
/** Is this action one of the ways an agent gets file bytes into its head? */
|
||
const isFileAccess = (a) =>
|
||
a.name === 'Read' || a.name === 'NotebookRead' || a.name === 'Grep' || a.name === 'Glob'
|
||
|| (a.name === 'Bash' && bashIntent(a.input?.command) !== null);
|
||
|
||
/**
|
||
* Bucket every answered codegraph_explore call in a transcript by what the
|
||
* agent did next. Takes the raw JSONL events so it serves both transcript
|
||
* shapes: stream-json runs (parse-run.mjs) and interactive session logs
|
||
* (parse-session.mjs) — both emit one assistant event per content block with
|
||
* `message.id`, and tool results as `tool_result` blocks in user messages.
|
||
*/
|
||
export function classifySufficiency(events) {
|
||
// One action list PER THREAD: 'main', plus one per subagent (keyed by the
|
||
// delegating tool_use id, which is what `parent_tool_use_id` carries).
|
||
const threads = new Map();
|
||
const nameById = new Map();
|
||
const textById = new Map(); // explore tool_use_id -> response text
|
||
for (const ev of events) {
|
||
const content = ev?.message?.content;
|
||
if (!Array.isArray(content)) continue;
|
||
const thread = ev.parent_tool_use_id ?? 'main';
|
||
if (ev.type === 'assistant') {
|
||
if (!threads.has(thread)) threads.set(thread, []);
|
||
const list = threads.get(thread);
|
||
for (const b of content) {
|
||
if (b.type !== 'tool_use') continue;
|
||
nameById.set(b.id, b.name);
|
||
// No message.id (never seen on a real log) degrades to "every call is
|
||
// its own message", i.e. same-message calls read as reactions.
|
||
list.push({ msgId: ev.message.id || `#${thread}-${list.length}`, id: b.id, name: b.name, input: b.input || {} });
|
||
}
|
||
} else if (ev.type === 'user') {
|
||
for (const b of content) {
|
||
if (b.type !== 'tool_result') continue;
|
||
const name = nameById.get(b.tool_use_id) || '';
|
||
if (/codegraph_explore/.test(name) && !b.is_error) textById.set(b.tool_use_id, textOf(b.content));
|
||
}
|
||
}
|
||
}
|
||
|
||
const calls = [];
|
||
let errors = 0, concurrent = 0;
|
||
// What a delegation really did: the subagent's first substantive call. A
|
||
// nested delegation is skipped rather than followed, so a subagent that only
|
||
// spawns another subagent leaves the call as "moved on".
|
||
const throughDelegation = (action, returned, mentioned, earlier) => {
|
||
const first = (threads.get(action.id) || []).find((x) => !TRANSPARENT_TOOLS.has(x.name) && !DELEGATION_TOOLS.has(x.name));
|
||
if (!first) return { bucket: 'sufficient', next: action.name };
|
||
const r = reactionOf(first, returned, mentioned, earlier);
|
||
return { ...r, next: `${action.name} → ${r.next}` };
|
||
};
|
||
for (const [thread, actions] of threads) {
|
||
const earlier = []; // files previous explores in THIS thread already shipped
|
||
for (let i = 0; i < actions.length; i++) {
|
||
const a = actions[i];
|
||
if (!/codegraph_explore/.test(a.name)) continue;
|
||
const text = textById.get(a.id);
|
||
// No response text = the call errored, or the run ended before it
|
||
// returned. Nothing to judge the sufficiency of; count it and move on.
|
||
if (text === undefined) { errors++; continue; }
|
||
const returned = exploreReturnedFiles(text);
|
||
const mentioned = text.match(PATH_TOKEN_RE) || [];
|
||
let reaction = { bucket: 'sufficient', next: '(final answer)' };
|
||
for (let j = i + 1; j < actions.length; j++) {
|
||
const b = actions[j];
|
||
if (b.msgId === a.msgId) { if (isFileAccess(b)) concurrent++; continue; }
|
||
if (TRANSPARENT_TOOLS.has(b.name)) continue;
|
||
reaction = DELEGATION_TOOLS.has(b.name)
|
||
? throughDelegation(b, returned, mentioned, earlier)
|
||
: reactionOf(b, returned, mentioned, earlier);
|
||
break;
|
||
}
|
||
earlier.push(...returned);
|
||
calls.push({ thread, query: String(a.input.query ?? ''), files: returned.length, chars: text.length, ...reaction });
|
||
}
|
||
}
|
||
|
||
const counts = Object.fromEntries(SUFFICIENCY_KEYS.map((k) => [k, 0]));
|
||
for (const c of calls) counts[c.bucket]++;
|
||
return { calls, counts, errors, concurrent, answered: calls.length };
|
||
}
|
||
|
||
/** The sufficiency block, as printed under a run and reused by aggregators. */
|
||
export function formatSufficiency(s, indent = ' ') {
|
||
const f = s.sufficiency;
|
||
if (!f.answered) {
|
||
return `${indent}Explore sufficiency: no answered codegraph_explore calls`
|
||
+ (f.errors ? ` (${f.errors} errored or never returned)` : '');
|
||
}
|
||
const pct = (n) => ((n / f.answered) * 100).toFixed(0) + '%';
|
||
const out = [`${indent}Explore sufficiency — what the agent did NEXT (${f.answered} answered call${f.answered === 1 ? '' : 's'}):`];
|
||
for (const [key, label, meaning] of SUFFICIENCY) {
|
||
out.push(`${indent} ${String(f.counts[key]).padStart(3)} ${pct(f.counts[key]).padStart(4)} ${label.padEnd(31)}${meaning}`);
|
||
}
|
||
f.calls.forEach((c, i) => {
|
||
const q = c.query.length > 46 ? c.query.slice(0, 45) + '…' : c.query;
|
||
const where = c.thread && c.thread !== 'main' ? ' [subagent]' : '';
|
||
out.push(`${indent} ${i + 1}.${where} "${q}" [${c.files} file${c.files === 1 ? '' : 's'}] → ${c.next}`);
|
||
});
|
||
const notes = [];
|
||
if (f.errors) notes.push(`${f.errors} errored/unanswered call${f.errors === 1 ? '' : 's'} (not bucketed)`);
|
||
if (f.concurrent) notes.push(`${f.concurrent} file-access call${f.concurrent === 1 ? '' : 's'} in the SAME message as an explore (not a reaction)`);
|
||
if (notes.length) out.push(`${indent} note: ${notes.join(' · ')}`);
|
||
return out.join('\n');
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// `--selftest`: the occupancy math over synthetic transcripts with known
|
||
// answers. It lives here rather than in a test file on purpose — a new
|
||
// scripts/agent-eval/*.mjs scores into the self-query eval fixture's corpus.
|
||
function selftest() {
|
||
const { writeFileSync, mkdtempSync } = require0('fs');
|
||
const { join } = require0('path');
|
||
const { tmpdir } = require0('os');
|
||
const dir = mkdtempSync(join(tmpdir(), 'cg-occ-'));
|
||
let n = 0, failures = 0;
|
||
const check = (name, got, want, tol) => {
|
||
n++;
|
||
const ok = Math.abs(got - want) <= tol;
|
||
if (!ok) failures++;
|
||
console.log(`${ok ? ' ok ' : ' FAIL'} ${name}: got ${Math.round(got)}, want ${want} ±${tol}`);
|
||
};
|
||
// Builders for the event shapes Claude Code actually emits.
|
||
const req = (ctx, id, blocks) => blocks.map((b) => JSON.stringify({
|
||
type: 'assistant',
|
||
message: { id, content: [b], usage: { input_tokens: ctx, cache_read_input_tokens: 0, cache_creation_input_tokens: 0, output_tokens: 2 } },
|
||
}));
|
||
const use = (id, name, input = {}) => ({ type: 'tool_use', id, name, input });
|
||
const res = (id, chars) => JSON.stringify({
|
||
type: 'user', message: { content: [{ type: 'tool_result', tool_use_id: id, content: [{ type: 'text', text: 'x'.repeat(chars) }] }] },
|
||
});
|
||
const done = () => JSON.stringify({ type: 'result', subtype: 'success', duration_ms: 1000, total_cost_usd: 0.1, usage: {} });
|
||
const write = (name, lines) => { const f = join(dir, name); writeFileSync(f, lines.join('\n') + '\n'); return f; };
|
||
|
||
// 1. Attribution: ratio 2.5 chars/tok, two families, no shedding.
|
||
// 10,000 explore chars over a 4,000-tok gap; 5,000 Read chars over 2,000.
|
||
let f = write('basic.jsonl', [
|
||
...req(10000, 'm1', [use('t1', 'mcp__codegraph__codegraph_explore')]),
|
||
res('t1', 10000),
|
||
...req(14000, 'm2', [use('t2', 'Read')]),
|
||
res('t2', 5000),
|
||
...req(16000, 'm3', [{ type: 'text', text: 'done' }]),
|
||
done(),
|
||
]);
|
||
let o = parseSession([f]).occupancy;
|
||
check('chars/token', o.charsPerToken * 1000, 2500, 30);
|
||
check('codegraph residual', o.residual.codegraph, 4000, 60);
|
||
check('Read residual', o.residual.read, 2000, 40);
|
||
check('file-access residual', o.residualFileAccess, 2000, 40);
|
||
check('final context', o.ctxFinal, 16000, 0);
|
||
check('fixed base', o.ctxBase, 10000, 0);
|
||
check('nothing evicted', o.evicted, 0, 1);
|
||
|
||
// 2. Dedupe: thinking + tool_use are two events sharing one id and one usage.
|
||
// Counting usage per event would report 5 requests instead of 3.
|
||
f = write('dupe.jsonl', [
|
||
...req(10000, 'm1', [{ type: 'thinking', thinking: '' }, use('t1', 'mcp__codegraph__codegraph_explore')]),
|
||
res('t1', 10000),
|
||
...req(14000, 'm2', [{ type: 'thinking', thinking: '' }, use('t2', 'Read')]),
|
||
res('t2', 5000),
|
||
...req(16000, 'm3', [{ type: 'text', text: 'done' }]),
|
||
done(),
|
||
]);
|
||
let s = parseSession([f]);
|
||
check('turns deduped by message.id', s.turns, 3, 0);
|
||
check('codegraph residual (deduped)', s.occupancy.residual.codegraph, 4000, 60);
|
||
|
||
// 3. Compaction: the boundary clears everything resident before it.
|
||
f = write('compact.jsonl', [
|
||
...req(10000, 'm1', [use('t1', 'mcp__codegraph__codegraph_explore')]),
|
||
res('t1', 10000),
|
||
...req(14000, 'm2', [use('t2', 'mcp__codegraph__codegraph_explore')]),
|
||
JSON.stringify({ type: 'system', subtype: 'compact_boundary' }),
|
||
res('t2', 5000),
|
||
...req(8000, 'm3', [{ type: 'text', text: 'done' }]),
|
||
done(),
|
||
]);
|
||
o = parseSession([f]).occupancy;
|
||
check('post-compaction residual = last result only', o.residual.codegraph, 2000, 40);
|
||
check('contributed still counts both', o.contributed.codegraph, 6000, 80);
|
||
|
||
// 4. Micro-compaction: context grows less than the results added, so the
|
||
// oldest result is shed first (FIFO) — here explore, leaving Read.
|
||
f = write('micro.jsonl', [
|
||
...req(10000, 'm1', [use('t1', 'mcp__codegraph__codegraph_explore')]),
|
||
res('t1', 10000),
|
||
...req(14000, 'm2', [use('t2', 'Read')]),
|
||
res('t2', 10000),
|
||
...req(14500, 'm3', [{ type: 'text', text: 'done' }]), // +500 for 4,000 tok of Read
|
||
done(),
|
||
]);
|
||
o = parseSession([f]).occupancy;
|
||
check('FIFO evicted the older codegraph result', o.residual.codegraph, 500, 60);
|
||
check('newer Read result survives', o.residual.read, 4000, 60);
|
||
check('eviction recorded', o.evicted, 3500, 60);
|
||
|
||
// 5. Multi-turn stitching: a resumed segment continues the same context, and
|
||
// a turn that calls no tool leaves the earlier residual in place.
|
||
const a = write('seg1.jsonl', [
|
||
...req(10000, 'm1', [use('t1', 'mcp__codegraph__codegraph_explore')]),
|
||
res('t1', 10000),
|
||
...req(14000, 'm2', [{ type: 'text', text: 'answer one' }]),
|
||
done(),
|
||
]);
|
||
const b = write('seg2.jsonl', [
|
||
...req(14600, 'm3', [{ type: 'text', text: 'answer two, from what is already here' }]),
|
||
done(),
|
||
]);
|
||
s = parseSession([a, b]);
|
||
check('stitched turns', s.turns, 3, 0);
|
||
check('residual carries into turn 2', s.occupancy.residual.codegraph, 4000, 60);
|
||
check('stitched final context', s.occupancy.ctxFinal, 14600, 0);
|
||
check('stitched cost sums segments', s.cost * 100, 20, 0.1);
|
||
|
||
// ---- 6. Explore sufficiency: the bucket each explore call earns. --------
|
||
const checkIs = (name, got, want) => {
|
||
n++;
|
||
const ok = got === want;
|
||
if (!ok) failures++;
|
||
console.log(`${ok ? ' ok ' : ' FAIL'} ${name}: got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`);
|
||
};
|
||
const EXPLORE = 'mcp__codegraph__codegraph_explore';
|
||
// An explore response's shape that matters here: one `**`path`**` section per
|
||
// file whose source it returned, plus whatever else it named.
|
||
const exploreRes = (id, paths, extra = '', isError = false) => JSON.stringify({
|
||
type: 'user',
|
||
message: {
|
||
content: [{
|
||
type: 'tool_result', tool_use_id: id, ...(isError ? { is_error: true } : {}),
|
||
content: [{ type: 'text', text: paths.map((p) => `**\`${p}\`** — fn(function)\n\n1\tcode here\n`).join('\n') + extra }],
|
||
}],
|
||
},
|
||
});
|
||
const suff = (lines) => classifySufficiency(lines.map((l) => JSON.parse(l)));
|
||
|
||
// explore → explore is insufficient; the second explore → a Read of a file it
|
||
// RETURNED is the allocation bucket (this is the CG-22 express baseline).
|
||
let sf = suff([
|
||
...req(10000, 'm1', [use('e1', EXPLORE, { query: 'res.send Content-Type ETag generation' })]),
|
||
exploreRes('e1', ['lib/response.js', 'lib/utils.js']),
|
||
...req(12000, 'm2', [use('e2', EXPLORE, { query: 'response.js res.send function body' })]),
|
||
exploreRes('e2', ['lib/response.js']),
|
||
...req(14000, 'm3', [use('r1', 'Read', { file_path: '/private/tmp/t-base/lib/response.js' })]),
|
||
res('r1', 3722),
|
||
...req(15000, 'm4', [{ type: 'text', text: 'done' }]),
|
||
done(),
|
||
]);
|
||
check('two answered explore calls', sf.answered, 2, 0);
|
||
checkIs('explore → explore = insufficient', sf.calls[0].bucket, 'explore_again');
|
||
checkIs('explore → Read of a returned file (abs path)', sf.calls[1].bucket, 'read_returned');
|
||
|
||
// A file the response NAMED but did not return is still a recall miss — and
|
||
// is flagged as named, since pointing without delivering is its own failure.
|
||
sf = suff([
|
||
...req(10000, 'm1', [use('e1', EXPLORE, { query: 'q' })]),
|
||
exploreRes('e1', ['lib/response.js'], '\n**Flow**\n1. lib/router/index.js:42 handle\n'),
|
||
...req(12000, 'm2', [use('r1', 'Read', { file_path: '/t/lib/router/index.js' })]),
|
||
res('r1', 100),
|
||
done(),
|
||
]);
|
||
checkIs('explore → Read of a named-but-unreturned file', sf.calls[0].bucket, 'read_missed');
|
||
checkIs(' …flagged as named', sf.calls[0].named, true);
|
||
|
||
sf = suff([
|
||
...req(10000, 'm1', [use('e1', EXPLORE, { query: 'q' })]),
|
||
exploreRes('e1', ['lib/response.js']),
|
||
...req(12000, 'm2', [use('r1', 'Read', { file_path: '/t/lib/never/mentioned.js' })]),
|
||
res('r1', 100),
|
||
done(),
|
||
]);
|
||
checkIs('explore → Read of a file never surfaced', sf.calls[0].bucket, 'read_missed');
|
||
checkIs(' …not flagged as named', sf.calls[0].named, false);
|
||
|
||
// Grep, and the shell equivalents of Read and Grep.
|
||
const oneShot = (next) => suff([
|
||
...req(10000, 'm1', [use('e1', EXPLORE, { query: 'q' })]),
|
||
exploreRes('e1', ['lib/response.js']),
|
||
...req(12000, 'm2', [next]),
|
||
res(next.id, 100),
|
||
done(),
|
||
]).calls[0];
|
||
checkIs('explore → Grep', oneShot(use('g1', 'Grep', { pattern: 'send' })).bucket, 'search');
|
||
checkIs('explore → Glob', oneShot(use('g1', 'Glob', { pattern: '**/*.js' })).bucket, 'search');
|
||
checkIs('explore → Bash sed of a returned file',
|
||
oneShot(use('b1', 'Bash', { command: "sed -n '100,200p' lib/response.js" })).bucket, 'read_returned');
|
||
checkIs('explore → Bash grep', oneShot(use('b1', 'Bash', { command: 'grep -rn send lib/' })).bucket, 'search');
|
||
checkIs('explore → Bash that writes a file is not a read',
|
||
oneShot(use('b1', 'Bash', { command: "cat > /tmp/note.md <<'EOF'\nx\nEOF" })).bucket, 'sufficient');
|
||
checkIs('explore → Bash npm test = moved on',
|
||
oneShot(use('b1', 'Bash', { command: 'npm test' })).bucket, 'sufficient');
|
||
checkIs('explore → Edit = sufficient',
|
||
oneShot(use('x1', 'Edit', { file_path: '/t/lib/response.js' })).bucket, 'sufficient');
|
||
|
||
// No further tool call at all: the agent answered from the response.
|
||
sf = suff([
|
||
...req(10000, 'm1', [use('e1', EXPLORE, { query: 'q' })]),
|
||
exploreRes('e1', ['lib/response.js']),
|
||
...req(12000, 'm2', [{ type: 'text', text: 'here is how it works' }]),
|
||
done(),
|
||
]);
|
||
checkIs('explore → final answer', sf.calls[0].bucket, 'sufficient');
|
||
checkIs(' …labelled as the final answer', sf.calls[0].next, '(final answer)');
|
||
|
||
// A Read issued in the SAME message as the explore predates its response, so
|
||
// it is not a verdict on it — step past it and count it separately.
|
||
sf = suff([
|
||
...req(10000, 'm1', [use('e1', EXPLORE, { query: 'q' }), use('r1', 'Read', { file_path: '/t/lib/response.js' })]),
|
||
exploreRes('e1', ['lib/response.js']),
|
||
res('r1', 100),
|
||
...req(12000, 'm2', [use('x1', 'Edit', { file_path: '/t/lib/response.js' })]),
|
||
res('x1', 20),
|
||
done(),
|
||
]);
|
||
checkIs('same-message Read is not a reaction', sf.calls[0].bucket, 'sufficient');
|
||
check(' …counted as concurrent instead', sf.concurrent, 1, 0);
|
||
|
||
// ToolSearch/TodoWrite carry no signal — the Read behind them is the verdict.
|
||
sf = suff([
|
||
...req(10000, 'm1', [use('e1', EXPLORE, { query: 'q' })]),
|
||
exploreRes('e1', ['lib/response.js']),
|
||
...req(12000, 'm2', [use('t1', 'TodoWrite', {})]),
|
||
res('t1', 20),
|
||
...req(13000, 'm3', [use('r1', 'Read', { file_path: '/t/lib/response.js' })]),
|
||
res('r1', 100),
|
||
done(),
|
||
]);
|
||
checkIs('bookkeeping tools are stepped over', sf.calls[0].bucket, 'read_returned');
|
||
|
||
// A re-read of a file an EARLIER explore shipped is still an allocation miss:
|
||
// we returned it and clipped it wrong, just not on this call.
|
||
sf = suff([
|
||
...req(10000, 'm1', [use('e1', EXPLORE, { query: 'first' })]),
|
||
exploreRes('e1', ['lib/response.js', 'lib/utils.js']),
|
||
...req(11000, 'm2', [use('e2', EXPLORE, { query: 'second' })]),
|
||
exploreRes('e2', ['lib/response.js']),
|
||
...req(12000, 'm3', [use('r1', 'Read', { file_path: '/t/lib/utils.js' })]),
|
||
res('r1', 400),
|
||
done(),
|
||
]);
|
||
checkIs('re-read of an earlier explore’s file is allocation, not recall',
|
||
sf.calls[1].bucket, 'read_returned');
|
||
checkIs(' …and says which explore returned it',
|
||
sf.calls[1].next, 'Read utils.js (returned by an earlier explore)');
|
||
|
||
// A subagent's calls are interleaved into the same stream under
|
||
// `parent_tool_use_id` (verified on a real excalidraw run). They belong to
|
||
// their own thread: the parent's verdict is the delegation, judged by what
|
||
// the subagent actually did first — here, grepping for a file we never
|
||
// returned.
|
||
const sub = (parent, obj) => JSON.stringify({ ...JSON.parse(obj), parent_tool_use_id: parent });
|
||
sf = suff([
|
||
...req(10000, 'm1', [use('e1', EXPLORE, { query: 'q' })]),
|
||
exploreRes('e1', ['lib/response.js']),
|
||
...req(12000, 'm2', [use('a1', 'Agent', { subagent_type: 'Explore' })]),
|
||
...req(0, 'sm1', [use('sb1', 'Bash', { command: 'grep -rn nonce lib/' })]).map((l) => sub('a1', l)),
|
||
sub('a1', res('sb1', 400)),
|
||
...req(14000, 'm3', [{ type: 'text', text: 'done' }]),
|
||
res('a1', 900),
|
||
done(),
|
||
]);
|
||
checkIs('delegation is judged by what the subagent did', sf.calls[0].bucket, 'search');
|
||
checkIs(' …and says so', sf.calls[0].next, 'Agent → Bash search');
|
||
|
||
// A subagent's Read must NOT be read as the parent's reaction to an explore
|
||
// the subagent never saw: the parent moved on, the subagent's own explore is
|
||
// judged inside its own thread.
|
||
sf = suff([
|
||
...req(10000, 'm1', [use('a1', 'Agent', { subagent_type: 'Explore' })]),
|
||
...req(0, 'sm1', [use('e1', EXPLORE, { query: 'sub q' })]).map((l) => sub('a1', l)),
|
||
sub('a1', exploreRes('e1', ['lib/response.js'])),
|
||
...req(11000, 'm2', [use('e2', EXPLORE, { query: 'parent q' })]),
|
||
exploreRes('e2', ['lib/other.js']),
|
||
...req(0, 'sm2', [use('sr1', 'Read', { file_path: '/t/lib/response.js' })]).map((l) => sub('a1', l)),
|
||
sub('a1', res('sr1', 400)),
|
||
...req(13000, 'm3', [{ type: 'text', text: 'done' }]),
|
||
done(),
|
||
]);
|
||
check('both threads bucketed', sf.answered, 2, 0);
|
||
checkIs('parent explore is not blamed for a subagent Read',
|
||
sf.calls.find((c) => c.query === 'parent q').bucket, 'sufficient');
|
||
checkIs('subagent explore is judged in its own thread',
|
||
sf.calls.find((c) => c.query === 'sub q').bucket, 'read_returned');
|
||
|
||
// An errored explore has no response to judge; it is counted, not bucketed.
|
||
sf = suff([
|
||
...req(10000, 'm1', [use('e1', EXPLORE, { query: 'q' })]),
|
||
exploreRes('e1', [], 'not indexed', true),
|
||
...req(12000, 'm2', [use('r1', 'Read', { file_path: '/t/lib/response.js' })]),
|
||
res('r1', 100),
|
||
done(),
|
||
]);
|
||
check('errored explore is not bucketed', sf.answered, 0, 0);
|
||
check(' …but is counted', sf.errors, 1, 0);
|
||
|
||
// ---- 7. Allocation efficiency: which returned bytes the answer used. ----
|
||
// The response shape that matters here: a per-file section whose header lists
|
||
// `name(kind)`, and a body big enough that the shares are readable.
|
||
const alloc = (files, answer) => computeAllocation(
|
||
[files.map(([p, syms, size]) =>
|
||
`**\`${p}\`** — ${syms}\n\n\`\`\`js\n${'x'.repeat(size)}\n\`\`\`\n`).join('\n')
|
||
+ '\n> Treat the code above as already read.\n'],
|
||
answer,
|
||
);
|
||
const round = (f) => Math.round(f * 1000) / 10;
|
||
|
||
// The #1500 shape: one file answers, two are noise. The answer names it by
|
||
// repo-relative path.
|
||
let al = alloc(
|
||
[['lib/response.js', 'send(function)', 4000], ['lib/express.js', 'app(variable)', 3000],
|
||
['lib/view.js', 'View(class)', 3000]],
|
||
'The Content-Type is decided in `lib/response.js:126` by `res.send`.',
|
||
);
|
||
check('allocation: one of three files cited', round(al.efficiency), 40.1, 0.6);
|
||
checkIs(' …cited file is flagged path', al.files.find((f) => /response/.test(f.path)).via, 'path');
|
||
checkIs(' …noise file is not', al.files.find((f) => /view/.test(f.path)).via, null);
|
||
check(' …files cited', al.filesUsed, 1, 0);
|
||
|
||
// A bare basename is how agents actually cite in prose — but only when the
|
||
// extension is one the envelope shipped, or `res.send` reads as a file too.
|
||
al = alloc([['lib/response.js', 'send(function)', 4000], ['lib/view.js', 'View(class)', 4000]],
|
||
'It happens in response.js:134, inside `res.send`.');
|
||
check('bare basename counts as a path citation', round(al.efficiency), 50, 0.6);
|
||
al = alloc([['lib/response.js', 'send(function)', 4000], ['lib/view.js', 'View(class)', 4000]],
|
||
'The `res.send` and `mime.contentType` calls do it.');
|
||
checkIs('a dotted expression is not a file citation', al.files.every((f) => f.via !== 'path'), true);
|
||
|
||
// Symbol citations: a code span naming a symbol the file DEFINES counts; a
|
||
// file that merely CALLS it does not (the excalidraw dragElements.ts case).
|
||
al = alloc([['src/mutateElement.ts', 'mutateElement(function)', 4000],
|
||
['src/dragElements.ts', 'mutateElement(calls), updateCoords(function)', 4000]],
|
||
'Mutation goes through `mutateElement`.');
|
||
checkIs('symbol citation credits the definer', al.files.find((f) => /mutateElement.ts/.test(f.path)).via, 'symbol');
|
||
checkIs(' …and not a caller of it', al.files.find((f) => /dragElements/.test(f.path)).via, null);
|
||
|
||
// An import binding (`variable`) loses to the real definition of the name.
|
||
al = alloc([['lib/utils.js', 'compileETag(function)', 4000],
|
||
['lib/application.js', 'compileETag(variable), set(calls)', 4000]],
|
||
'The generator comes from `compileETag`.');
|
||
checkIs('a definition beats an import alias of the same name',
|
||
al.files.find((f) => /utils/.test(f.path)).via, 'symbol');
|
||
checkIs(' …and the aliasing file is not credited',
|
||
al.files.find((f) => /application/.test(f.path)).via, null);
|
||
|
||
// A name carried by ≥3 returned files identifies none of them: crediting them
|
||
// all would bias the metric optimistic, the one direction it must not lean.
|
||
al = alloc([['a/one.js', 'handle(function)', 3000], ['a/two.js', 'handle(function)', 3000],
|
||
['a/three.js', 'handle(function)', 3000]],
|
||
'It all runs through `handle`.');
|
||
check('a symbol on 3 files attributes none of them', al.efficiency, 0, 0.001);
|
||
|
||
// Prose is not a citation — only a code span is.
|
||
al = alloc([['lib/response.js', 'sendResponse(function)', 4000], ['lib/view.js', 'View(class)', 4000]],
|
||
'The sendResponse path handles it.');
|
||
check('an unquoted symbol in prose does not count', al.efficiency, 0, 0.001);
|
||
|
||
// Per-call: the run number pools, but call 2 being pure noise must still show.
|
||
const twoCalls = computeAllocation([
|
||
'**`lib/response.js`** — send(function)\n\n```js\n' + 'x'.repeat(4000) + '\n```\n\n> guidance\n',
|
||
'**`lib/view.js`** — View(class)\n\n```js\n' + 'x'.repeat(4000) + '\n```\n\n> guidance\n',
|
||
], 'See `lib/response.js`.');
|
||
check('per-call: call 1 fully used', round(twoCalls.calls[0].efficiency), 100, 0.1);
|
||
check('per-call: call 2 fully wasted', round(twoCalls.calls[1].efficiency), 0, 0.1);
|
||
check('run pools both calls', round(twoCalls.efficiency), 50, 0.6);
|
||
|
||
// A file returned twice is charged twice — it occupied the window twice.
|
||
const twice = computeAllocation([
|
||
'**`lib/response.js`** — send(function)\n\n' + 'x'.repeat(4000) + '\n\n> g\n',
|
||
'**`lib/response.js`** — send(function)\n\n' + 'x'.repeat(4000) + '\n\n> g\n',
|
||
], 'See `lib/response.js`.');
|
||
check('a re-returned file is charged both times', twice.envelope, 8078, 120);
|
||
check(' …and credited both times', round(twice.efficiency), 100, 0.1);
|
||
|
||
// The answer text itself: the `result` event, and the interactive fallback.
|
||
const ev = (l) => JSON.parse(l);
|
||
checkIs('final answer comes from the result event',
|
||
finalAnswerText([ev(done()), { type: 'result', result: 'the answer' }]), 'the answer');
|
||
checkIs('…else the last main-thread assistant text', finalAnswerText([
|
||
{ type: 'assistant', message: { id: 'm1', content: [{ type: 'text', text: 'let me look' }] } },
|
||
{ type: 'assistant', parent_tool_use_id: 'a1', message: { id: 's1', content: [{ type: 'text', text: 'subagent report' }] } },
|
||
{ type: 'assistant', message: { id: 'm2', content: [{ type: 'text', text: 'the answer' }] } },
|
||
]), 'the answer');
|
||
|
||
// End to end through parseSession, on the transcript shape a run really has.
|
||
f = write('alloc.jsonl', [
|
||
...req(10000, 'm1', [use('e1', EXPLORE, { query: 'q' })]),
|
||
JSON.stringify({
|
||
type: 'user',
|
||
message: {
|
||
content: [{
|
||
type: 'tool_result', tool_use_id: 'e1',
|
||
content: [{
|
||
type: 'text',
|
||
text: '**`lib/response.js`** — send(function)\n\n' + 'x'.repeat(4000)
|
||
+ '\n\n**`lib/view.js`** — View(class)\n\n' + 'x'.repeat(4000) + '\n\n> guidance\n',
|
||
}],
|
||
}],
|
||
},
|
||
}),
|
||
...req(20000, 'm2', [{ type: 'text', text: 'done' }]),
|
||
JSON.stringify({
|
||
type: 'result', subtype: 'success', duration_ms: 1000, total_cost_usd: 0.1, usage: {},
|
||
result: 'It is decided in `lib/response.js` by `res.send`.',
|
||
}),
|
||
]);
|
||
s = parseSession([f]);
|
||
check('parseSession reports allocation', round(s.allocation.efficiency), 50, 0.6);
|
||
checkIs(' …and the block renders', /efficiency\s+50\.\d%/.test(formatAllocation(s)), true);
|
||
checkIs('a run with no explore says so',
|
||
formatAllocation({ allocation: computeAllocation([], 'answer') }).includes('no codegraph_explore responses'), true);
|
||
|
||
console.log(`\n${n - failures}/${n} checks passed`);
|
||
return failures;
|
||
}
|
||
// `--selftest` needs sync fs helpers the module path doesn't import at top level.
|
||
function require0(m) { return process.getBuiltinModule(m); }
|
||
|
||
const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
||
if (isMain && process.argv.includes('--selftest')) process.exit(selftest() ? 1 : 0);
|
||
if (isMain) {
|
||
// `--answer <glob>` is repeatable and implies `--envelope`. Its VALUE is not a
|
||
// run file, so consume it here rather than letting the positional filter below
|
||
// mistake a glob for a log path.
|
||
const argv = process.argv.slice(2);
|
||
const files = [];
|
||
const answerGlobs = [];
|
||
let wantEnvelope = false;
|
||
let brief = false;
|
||
for (let i = 0; i < argv.length; i++) {
|
||
if (argv[i] === '--envelope') wantEnvelope = true;
|
||
else if (argv[i] === '--brief') brief = true;
|
||
else if (argv[i] === '--answer') { answerGlobs.push(argv[++i]); wantEnvelope = true; }
|
||
else if (!argv[i].startsWith('--')) files.push(argv[i]);
|
||
}
|
||
if (!files.length) { console.error('usage: parse-run.mjs <run.jsonl> [run.t2.jsonl ...] [--brief] [--envelope] [--answer <glob>]... | --selftest'); process.exit(1); }
|
||
const s = parseSession(files);
|
||
|
||
console.log(`\n=== ${files.map((f) => f.split('/').pop()).join(' + ')} ===`);
|
||
console.log(`codegraph tools exposed: ${s.initTools ? s.initTools.length : '?'}${s.raced ? ' [MCP COLD-START RACE — tool call hit "No such tool available"]' : ''}`);
|
||
if (s.cliContaminated) console.log(`!! ${s.cliContaminated} codegraph CLI call${s.cliContaminated === 1 ? '' : 's'} RETURNED OUTPUT via Bash — if this is a without-arm, the run is CONTAMINATED`);
|
||
else if (s.cliCalls) console.log(` (${s.cliCalls} codegraph CLI attempt${s.cliCalls === 1 ? '' : 's'} blocked — no output entered the window)`);
|
||
console.log(`\nTool calls (${s.toolCalls.length}):`);
|
||
console.log(' by type:', JSON.stringify(s.counts));
|
||
if (!brief) s.toolCalls.forEach((tc, i) => console.log(` ${i + 1}. ${tc}`));
|
||
|
||
if (s.result) {
|
||
const seg = s.results.length > 1 ? ` | ${s.results.length} segments (${s.results.map((r) => r.subtype).join(',')})` : '';
|
||
console.log(`\nResult: ${s.result.subtype} | duration ${s.dur.toFixed(0)}s | turns ${s.turns}${seg}`);
|
||
console.log(` tokens processed: ${s.processed.toLocaleString('en-US')} | cost $${s.cost.toFixed(3)}`);
|
||
}
|
||
console.log('');
|
||
console.log(formatOccupancy(s));
|
||
console.log('');
|
||
console.log(formatSufficiency(s));
|
||
console.log('');
|
||
console.log(formatAllocation(s));
|
||
if (wantEnvelope) {
|
||
console.log('');
|
||
console.log(formatEnvelope(s.exploreTexts, answerGlobs));
|
||
}
|
||
}
|