Files
callstack__agent-device/scripts/ripwire-eval/retrieval-bench.mjs
T
Claude f868f0c0c7 fix(eval): narrow the conclusions to the invocations actually tested
Review follow-up on #2400. Three claims went further than the evidence.

- The report recommended waiting for a terse output mode. --legend=compact is
  already documented at the tested revision. Measured: it costs no recall and
  saves 55% on --callers, 10% on --affected, 1% on --for. The arms ran on
  default output, and their calls were mostly --for-shaped, so compact narrows
  the +10% token gap rather than closing it — stated as the open question it is
  rather than a reason to defer. retrieval-bench gains a for-compact variant so
  the comparison is reproducible.
- The CLI-flag head-to-head tested --for and --pack-task, which reach 1-3 of the
  five documented declaration sites, and concluded a call graph cannot recover a
  convention. --recall, the verb built for document questions, finds all five.
  That conclusion was an artifact of the verb chosen and is withdrawn.
- --affected scored a precision figure against the historical commit's file
  list. It answers transitive reach, so a test it names that the commit left
  alone is not a false positive and that list is not a precision oracle. The
  field is removed; recall stands, and selection breadth is reported as read
  cost rather than a defect.

Recall and every A/B number are unchanged; the corrections are to framing, one
withdrawn conclusion, and one removed metric.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F7vMn8ehPq3NTPYMfxs7ro
2026-09-09 06:25:49 +00:00

102 lines
4.0 KiB
JavaScript

#!/usr/bin/env node
// Deterministic half of the ripwire evaluation: no model in the loop.
//
// For every task in tasks.json it runs one ripwire verb against the worktree pinned at the
// task's parent commit and asks a single question — does this one call surface the files the
// real change touched, and what does the answer cost? Ranks come from the order paths first
// appear in ripwire's output, which is its own ranking order.
//
// Recall here is over the change's EXISTING files only (`ground_truth`), not the whole change set:
// a retrieval verb ranks what the tree contains, so a file the commit created is not a hit it
// could have scored. The agent A/B scores the whole set, added files included, and the two
// denominators are therefore different on purpose.
//
// Usage: node scripts/ripwire-eval/retrieval-bench.mjs --ripwire=<bin> --worktrees=<dir> [--out=<file>]
import { writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { harnessDir, loadTasks, readArgs, runRipwire } from './bench-cli.mjs';
const { ripwire, worktrees, out } = readArgs({
usage: 'retrieval-bench.mjs --ripwire=<bin> --worktrees=<dir> [--out=<file>]',
required: ['ripwire', 'worktrees'],
optional: { out: join(harnessDir, 'retrieval-results.json') },
});
const VERBS = [
{ id: 'for', args: (task) => ['.', `--for=${task.prompt}`] },
{ id: 'pack-task', args: (task) => ['.', `--pack-task=${task.prompt}`] },
{
id: 'pack-task-4k',
args: (task) => ['.', `--pack-task=${task.prompt}`, '--token-budget=4000'],
},
// Same verb, but fed only the identifiers the task text itself puts in backticks — a mechanical
// distillation, not a hand-tuned query. Isolates how much of --for's result is phrasing.
// Same query, with the documented compact legend. Isolates how much of the default output is
// the self-documenting preamble — the answer differs sharply by verb, so it is measured, not
// assumed (docs/COMMANDS.md `--legend=full|compact`).
{ id: 'for-compact', args: (task) => ['.', `--for=${task.prompt}`, '--legend=compact'] },
{
id: 'for-idents',
args: (task) => ['.', `--for=${backtickedTerms(task.prompt)}`],
skipWhen: (task) => backtickedTerms(task.prompt) === '',
},
];
function backtickedTerms(prompt) {
return [...prompt.matchAll(/`([^`]+)`/g)]
.map((match) => match[1])
.join(' ')
.trim();
}
// Rank of a path in an output blob: 1-based index of its first appearance among all distinct
// repo-relative paths the output mentions, in output order.
function rankPaths(output) {
const seen = new Map();
const re =
/(?:^|["'\s=(])((?:src|packages|scripts|test|android|apple|contracts)\/[\w./@+-]+\.[\w]+)/g;
let match;
let next = 1;
while ((match = re.exec(output)) !== null) {
if (!seen.has(match[1])) seen.set(match[1], next++);
}
return seen;
}
const results = [];
for (const task of loadTasks()) {
for (const verb of VERBS) {
if (verb.skipWhen?.(task)) continue;
const call = await runRipwire(ripwire, verb.args(task), join(worktrees, task.id));
const ranks = rankPaths(call.stdout);
const hits = task.ground_truth.map((path) => ({ path, rank: ranks.get(path) ?? null }));
const found = hits.filter((hit) => hit.rank !== null);
results.push({
task: task.id,
verb: verb.id,
failed: call.failed,
ms: call.ms,
bytes: call.bytes,
est_tokens: Math.round(call.bytes / 4),
paths_mentioned: ranks.size,
ground_truth: task.ground_truth.length,
ground_truth_basis: 'existing-files-only',
hits: found.length,
recall: Number((found.length / task.ground_truth.length).toFixed(3)),
best_rank: found.length ? Math.min(...found.map((hit) => hit.rank)) : null,
per_file: hits,
});
process.stderr.write(
`${task.id}/${verb.id}: ${found.length}/${task.ground_truth.length} in ${call.bytes} B\n`,
);
}
}
writeFileSync(
out,
`${JSON.stringify({ generated: new Date().toISOString(), results }, null, 2)}\n`,
);
console.log(out);