@fodx/codelens 2.5.0 → 2.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,19 +11,23 @@ import { ensureFreshIndex } from "../index/reindex.js";
11
11
  import { scanFiles } from "../index/scanner.js";
12
12
  import { setPendingPaths } from "../index/staleness.js";
13
13
  import { ctxImpact } from "../tools/impact.js";
14
+ import { DEFAULT_WEIGHTS } from "../search/rank.js";
14
15
  import { ctxSearch } from "../tools/search.js";
15
16
  import { isInsideRepo, resolveReal } from "../util/paths.js";
16
17
  import { runRgBaseline } from "./baseline.js";
17
- import { aggregateRuns, artifactPaths, evaluateObservation, writeEvalArtifacts } from "./report.js";
18
+ import { aggregateRuns, artifactPaths, evaluateObservation, graphAggregates, retrievalAggregates, writeEvalArtifacts, } from "./report.js";
19
+ import { loadEvalTaskFile, requiredTaskPaths } from "./task-file.js";
18
20
  import { generateEvalTasks, seededShuffle } from "./tasks.js";
21
+ const RETRIEVAL_ARMS = ["full", "lexical", "fts", "rg"];
22
+ const NON_GRAPH_WEIGHT = 1 - DEFAULT_WEIGHTS.graph;
19
23
  const LEXICAL_WEIGHTS = {
20
- fts: 0.50,
21
- symbol: 0.22,
24
+ fts: DEFAULT_WEIGHTS.fts / NON_GRAPH_WEIGHT,
25
+ symbol: DEFAULT_WEIGHTS.symbol / NON_GRAPH_WEIGHT,
22
26
  graph: 0,
23
- code: 0.05,
24
- pathHit: 0.10,
25
- exact: 0.13,
26
- recency: 0,
27
+ code: DEFAULT_WEIGHTS.code / NON_GRAPH_WEIGHT,
28
+ pathHit: DEFAULT_WEIGHTS.pathHit / NON_GRAPH_WEIGHT,
29
+ exact: DEFAULT_WEIGHTS.exact / NON_GRAPH_WEIGHT,
30
+ recency: DEFAULT_WEIGHTS.recency,
27
31
  };
28
32
  const FTS_ONLY_WEIGHTS = {
29
33
  fts: 1,
@@ -69,9 +73,7 @@ class EvalProgressTracker {
69
73
  try {
70
74
  this.callback?.({ ...event, elapsedMs: performance.now() - this.started });
71
75
  }
72
- catch {
73
- // Progress reporting must never break an evaluation.
74
- }
76
+ catch { /* progress is best-effort */ }
75
77
  }
76
78
  }
77
79
  export function defaultEvalOptions(repoRoot) {
@@ -82,12 +84,13 @@ export function defaultEvalOptions(repoRoot) {
82
84
  repeats: 1,
83
85
  resultLimit: 10,
84
86
  scales: [500, 2000, "all"],
87
+ suites: ["retrieval", "graph", "freshness"],
85
88
  quick: false,
86
- freshness: true,
87
89
  thresholds: {
88
90
  minRecallAtK: 0.6,
89
91
  minMrr: 0.5,
90
92
  minSuccessRate: 0.7,
93
+ minGraphPrecision: 0.5,
91
94
  },
92
95
  };
93
96
  }
@@ -96,7 +99,7 @@ export function quickEvalOptions(repoRoot) {
96
99
  ...defaultEvalOptions(repoRoot),
97
100
  taskLimit: 20,
98
101
  scales: [500],
99
- freshness: false,
102
+ suites: ["retrieval", "graph"],
100
103
  quick: true,
101
104
  };
102
105
  }
@@ -106,6 +109,7 @@ export function runRepositoryEval(input) {
106
109
  const progress = new EvalProgressTracker(input.onProgress, started);
107
110
  let temp;
108
111
  try {
112
+ const suites = normalizeSuites(input.suites);
109
113
  progress.start("repository", `Validating repository ${input.repoRoot}`);
110
114
  const requestedRoot = resolveReal(input.repoRoot);
111
115
  const scope = detectScope(requestedRoot);
@@ -119,21 +123,34 @@ export function runRepositoryEval(input) {
119
123
  if (scanned.length === 0)
120
124
  throw new Error(`no indexable files found: ${repoRoot}`);
121
125
  progress.complete("scan", `Found ${scanned.length} indexable files`, { current: scanned.length, total: scanned.length });
122
- progress.start("setup", "Preparing evaluation scales and output paths");
126
+ progress.start("setup", "Preparing frozen tasks, nested scales, and output paths");
123
127
  const outputDirectory = input.outputDir ? canonicalOutputPath(input.outputDir) : defaultOutputDirectory(repoRoot);
124
128
  if (isInsideRepo(repoRoot, outputDirectory))
125
129
  throw new Error("evaluation output must be outside the target repository");
126
130
  const artifacts = artifactPaths(outputDirectory);
131
+ const loaded = input.taskFile ? loadEvalTaskFile(input.taskFile) : undefined;
132
+ let frozenTasks = (loaded?.tasks ?? []).filter((task) => suites.includes(task.suite));
133
+ const taskSuites = suites.filter((suite) => suite === "retrieval" || suite === "graph");
134
+ const taskSource = taskSuites.length === 0
135
+ ? { kind: "none", independentGroundTruth: true }
136
+ : loaded?.source ?? { kind: "automatic-self-evaluation", independentGroundTruth: false };
137
+ const scaleSpecs = taskSuites.length > 0 ? resolveScaleSpecs(input.scales, scanned.length) : [];
138
+ const required = loaded ? requiredTaskPaths(frozenTasks) : new Set();
139
+ ensureRequiredPaths(scanned, required);
140
+ if (scaleSpecs.length > 0 && required.size > scaleSpecs[0].count) {
141
+ throw new Error(`smallest scale (${scaleSpecs[0].count}) cannot contain ${required.size} required task paths`);
142
+ }
143
+ const orderedFiles = nestedFileOrder(scanned, required, input.seed);
127
144
  temp = mkdtempSync(join(tmpdir(), "codelens-eval-"));
128
145
  const skipped = [];
129
- const scaleSpecs = resolveScaleSpecs(input.scales, scanned.length);
130
146
  const scales = [];
131
- progress.complete("setup", `Prepared ${scaleSpecs.length} scale${scaleSpecs.length === 1 ? "" : "s"}: ${scaleSpecs.map((spec) => spec.label).join(", ")}`);
147
+ progress.complete("setup", `Prepared ${scaleSpecs.length} nested scale${scaleSpecs.length === 1 ? "" : "s"}${loaded ? ` from frozen tasks (${taskSource.source ?? "task file"})` : " from automatic self-evaluation"}`);
132
148
  for (let scaleIndex = 0; scaleIndex < scaleSpecs.length; scaleIndex++) {
133
149
  const spec = scaleSpecs[scaleIndex];
134
150
  const scaleCounts = { scale: spec.label, current: scaleIndex + 1, total: scaleSpecs.length };
135
151
  progress.start("scale", `Starting scale ${spec.label} with ${spec.count} files`, scaleCounts);
136
- const selected = selectFiles(scanned, spec.count, input.seed + scaleIndex * 1009);
152
+ const selected = orderedFiles.slice(0, spec.count).sort((a, b) => a.path.localeCompare(b.path));
153
+ const inventory = selected.map((file) => file.path);
137
154
  const dbPath = join(temp, `eval-${scaleIndex}.db`);
138
155
  const db = openDb(dbPath);
139
156
  try {
@@ -144,28 +161,40 @@ export function runRepositoryEval(input) {
144
161
  });
145
162
  const indexMs = performance.now() - indexStart;
146
163
  progress.complete("index", `Indexed ${build.indexedFiles} files and ${build.totalChunks} chunks for scale ${spec.label} in ${formatDuration(indexMs)} (${build.skipped} skipped)`, { scale: spec.label, current: selected.length, total: selected.length });
147
- const inventory = selected.map((file) => file.path);
148
- const scaleTaskLimit = distributedLimit(input.taskLimit, scaleSpecs.length, scaleIndex);
149
- progress.start("tasks", `Generating up to ${scaleTaskLimit} tasks for scale ${spec.label}`, { scale: spec.label });
150
- const tasks = generateEvalTasks(db, build.indexId, repoRoot, inventory, scaleTaskLimit, input.seed + scaleIndex * 7919);
151
- progress.complete("tasks", `Generated ${tasks.length} tasks for scale ${spec.label}`, { scale: spec.label, current: tasks.length, total: scaleTaskLimit });
152
- if (scaleTaskLimit === 0)
153
- skipped.push(`${spec.label}: no tasks allocated because --tasks is lower than the number of scales.`);
154
- for (const type of ["locate", "callers", "tests", "history"]) {
155
- if (!tasks.some((task) => task.type === type))
156
- skipped.push(`${spec.label}: no ${type} tasks were generated.`);
164
+ if (!loaded && scaleIndex === 0) {
165
+ progress.start("tasks", `Generating up to ${input.taskLimit} automatic self-evaluation tasks`, { scale: spec.label });
166
+ frozenTasks = generateEvalTasks(db, build.indexId, repoRoot, inventory, input.taskLimit, input.seed, suites);
167
+ progress.complete("tasks", `Froze ${frozenTasks.length} tasks for every scale`, { scale: spec.label, current: frozenTasks.length, total: input.taskLimit });
157
168
  }
158
- const expectedRuns = tasks.length * 4 * Math.max(1, input.repeats);
159
- progress.start("retrieval", `Running ${expectedRuns} retrieval checks for scale ${spec.label}`, { scale: spec.label, current: 0, total: expectedRuns });
160
- const runs = runTaskSet(db, repoRoot, tasks, input.repeats, input.resultLimit, (current, total) => {
161
- progress.update("retrieval", `Completed ${current}/${total} retrieval checks for scale ${spec.label}`, { scale: spec.label, current, total });
162
- });
163
- progress.complete("retrieval", `Completed ${runs.length} retrieval checks for scale ${spec.label}`, { scale: spec.label, current: runs.length, total: expectedRuns });
164
- if (runs.some((run) => run.arm === "rg" && run.error === "ripgrep is not installed")) {
165
- skipped.push("rg baseline unavailable because ripgrep is not installed.");
166
- progress.skipped("retrieval", "rg baseline unavailable because ripgrep is not installed", { scale: spec.label });
169
+ const tasks = frozenTasks;
170
+ for (const suite of taskSuites) {
171
+ if (!tasks.some((task) => task.suite === suite))
172
+ skipped.push(`${suite}: no tasks were available.`);
173
+ }
174
+ const retrievalTasks = tasks.filter((task) => task.suite === "retrieval");
175
+ const graphTasks = tasks.filter((task) => task.suite === "graph" && (loaded !== undefined || scaleIndex === 0));
176
+ if (!loaded && scaleIndex > 0 && tasks.some((task) => task.suite === "graph")) {
177
+ skipped.push("automatic graph self-consistency runs only at the label-generating smallest tier; frozen independent graph tasks may run across every tier.");
178
+ }
179
+ const runs = [];
180
+ if (retrievalTasks.length > 0) {
181
+ const total = retrievalTasks.length * RETRIEVAL_ARMS.length * Math.max(1, input.repeats);
182
+ progress.start("retrieval", `Running ${total} counterbalanced retrieval checks for scale ${spec.label}`, { scale: spec.label, current: 0, total });
183
+ runs.push(...runRetrievalTaskSet(db, repoRoot, inventory, retrievalTasks, input.repeats, input.resultLimit, (current) => {
184
+ progress.update("retrieval", `Completed ${current}/${total} retrieval checks for scale ${spec.label}`, { scale: spec.label, current, total });
185
+ }));
186
+ progress.complete("retrieval", `Completed ${total} retrieval checks for scale ${spec.label}`, { scale: spec.label, current: total, total });
167
187
  }
168
- const aggregates = aggregateByArm(runs);
188
+ if (graphTasks.length > 0) {
189
+ const total = graphTasks.length * Math.max(1, input.repeats);
190
+ progress.start("graph", `Running ${total} known-target graph checks for scale ${spec.label}`, { scale: spec.label, current: 0, total });
191
+ runs.push(...runGraphTaskSet(db, graphTasks, input.repeats, input.resultLimit, (current) => {
192
+ progress.update("graph", `Completed ${current}/${total} graph checks for scale ${spec.label}`, { scale: spec.label, current, total });
193
+ }));
194
+ progress.complete("graph", `Completed ${total} graph checks for scale ${spec.label}`, { scale: spec.label, current: total, total });
195
+ }
196
+ if (runs.some((run) => run.arm === "rg" && run.error === "ripgrep is not installed"))
197
+ skipped.push("rg baseline unavailable because ripgrep is not installed.");
169
198
  scales.push({
170
199
  label: spec.label,
171
200
  requestedFiles: spec.requested,
@@ -176,44 +205,72 @@ export function runRepositoryEval(input) {
176
205
  dbBytes: databaseBytes(dbPath),
177
206
  tasks,
178
207
  runs,
179
- aggregates,
208
+ retrieval: retrievalAggregates(tasks, runs, input.seed + scaleIndex * 1009),
209
+ graph: graphAggregates(tasks, runs, input.seed + scaleIndex * 2003),
180
210
  });
181
- progress.complete("scale", `Finished scale ${spec.label}: ${formatAggregateSummary(aggregates.full)}`, scaleCounts);
211
+ progress.complete("scale", `Finished scale ${spec.label}: ${retrievalTasks.length} retrieval and ${graphTasks.length} graph tasks`, scaleCounts);
182
212
  }
183
213
  finally {
184
214
  db.close();
185
215
  }
186
216
  }
187
217
  let freshness;
188
- if (input.freshness) {
218
+ if (suites.includes("freshness")) {
189
219
  progress.start("freshness", "Starting detached-worktree edit/delete freshness probe");
190
220
  freshness = runFreshnessEval(repoRoot, input.seed, progress);
191
- const freshnessMessage = freshness.attempted
221
+ const message = freshness.attempted
192
222
  ? `Freshness probe ${freshness.passed ? "passed" : "failed"} in ${formatDuration(freshness.elapsedMs ?? 0)}`
193
223
  : `Freshness probe skipped: ${freshness.skippedReason ?? "not attempted"}`;
194
224
  if (freshness.attempted && freshness.passed)
195
- progress.complete("freshness", freshnessMessage);
225
+ progress.complete("freshness", message);
196
226
  else if (freshness.attempted)
197
- progress.error("freshness", freshnessMessage);
227
+ progress.error("freshness", message);
198
228
  else
199
- progress.skipped("freshness", freshnessMessage);
229
+ progress.skipped("freshness", message);
200
230
  }
201
231
  else {
202
232
  freshness = disabledFreshness();
203
- progress.skipped("freshness", "Freshness probe disabled");
233
+ progress.skipped("freshness", "Freshness suite disabled");
204
234
  }
205
235
  if (freshness.skippedReason)
206
236
  skipped.push(`freshness: ${freshness.skippedReason}`);
207
- progress.start("thresholds", "Checking configured quality thresholds");
237
+ progress.start("thresholds", "Checking quality thresholds on eligible task types");
238
+ const hasThresholdEligibleRetrieval = frozenTasks.some((task) => task.suite === "retrieval" && task.contributesToThresholds);
239
+ const hasThresholdEligibleGraph = frozenTasks.some((task) => task.suite === "graph" && task.groundTruth.independent && task.contributesToThresholds);
240
+ if (suites.includes("retrieval") && !hasThresholdEligibleRetrieval) {
241
+ skipped.push("no threshold-eligible retrieval tasks; retrieval quality is informational only.");
242
+ }
243
+ if (suites.includes("graph") && !hasThresholdEligibleGraph) {
244
+ skipped.push("no independently labeled threshold-eligible graph tasks; graph self-consistency is informational only.");
245
+ }
208
246
  const thresholdFailures = scales.flatMap((scale) => thresholdFailuresFor(scale, input));
209
- if (input.freshness && freshness.attempted && !freshness.passed)
247
+ for (const scale of scales) {
248
+ const executionErrors = new Set(scale.runs
249
+ .filter((run) => run.error && !(run.arm === "rg" && run.error === "ripgrep is not installed"))
250
+ .map((run) => `${run.arm}: ${run.error}`));
251
+ for (const error of executionErrors)
252
+ thresholdFailures.push(`${scale.label}: evaluation arm error — ${error}`);
253
+ }
254
+ if (suites.includes("freshness") && freshness.attempted && !freshness.passed)
210
255
  thresholdFailures.push("Freshness edit/delete probe failed.");
211
256
  if (initialStatus !== gitStatus(repoRoot))
212
257
  thresholdFailures.push("Target repository worktree changed during evaluation.");
213
- progress.complete("thresholds", thresholdFailures.length === 0 ? "All configured thresholds passed" : `${thresholdFailures.length} configured threshold${thresholdFailures.length === 1 ? "" : "s"} failed`);
214
- const completedAt = new Date().toISOString();
258
+ progress.complete("thresholds", thresholdFailures.length === 0 ? "All applicable thresholds passed" : `${thresholdFailures.length} threshold${thresholdFailures.length === 1 ? "" : "s"} failed`);
259
+ const graphTasks = frozenTasks.filter((task) => task.suite === "graph");
260
+ const graphIndependentGroundTruth = graphTasks.length > 0 && graphTasks.every((task) => task.groundTruth.independent);
261
+ const hasTaskEvidence = taskSuites.length === 0 || scales.some((scale) => scale.runs.length > 0);
262
+ const hasFreshnessEvidence = !suites.includes("freshness") || freshness.attempted || !!freshness.skippedReason;
215
263
  const result = {
216
- version: 1,
264
+ version: 2,
265
+ methodology: {
266
+ taskSource,
267
+ taskSetDigest: taskSetDigest(frozenTasks),
268
+ retrievalComparable: true,
269
+ graphIndependentGroundTruth,
270
+ repeatsAreTimingSamples: true,
271
+ scaleTasksFrozen: true,
272
+ notes: methodologyNotes(taskSource, suites, graphTasks.length > 0, graphIndependentGroundTruth),
273
+ },
217
274
  repository: {
218
275
  root: repoRoot,
219
276
  branch: scope.branch,
@@ -227,14 +284,14 @@ export function runRepositoryEval(input) {
227
284
  repeats: input.repeats,
228
285
  resultLimit: input.resultLimit,
229
286
  scales: input.scales,
287
+ suites,
230
288
  quick: input.quick,
231
- freshness: input.freshness,
232
289
  thresholds: input.thresholds,
233
290
  },
234
291
  startedAt,
235
- completedAt,
292
+ completedAt: new Date().toISOString(),
236
293
  durationMs: performance.now() - started,
237
- pass: scales.some((scale) => scale.tasks.length > 0) && thresholdFailures.length === 0,
294
+ pass: hasTaskEvidence && hasFreshnessEvidence && thresholdFailures.length === 0,
238
295
  thresholdFailures,
239
296
  skipped: [...new Set(skipped)],
240
297
  scales,
@@ -291,64 +348,113 @@ function buildSubsetIndex(db, scope, files, onProgress) {
291
348
  setPendingPaths(indexId, []);
292
349
  return { indexId, indexedFiles, totalChunks, skipped };
293
350
  }
294
- function runTaskSet(db, repoRoot, tasks, repeats, limit, onProgress) {
351
+ function runRetrievalTaskSet(db, repoRoot, inventory, tasks, repeats, limit, onProgress) {
352
+ const repeatCount = Math.max(1, repeats);
353
+ if (tasks[0]) {
354
+ for (const arm of RETRIEVAL_ARMS)
355
+ runRetrievalArm(db, repoRoot, inventory, tasks[0], limit, arm);
356
+ }
357
+ const runs = [];
358
+ const nextTypeOffset = new Map();
359
+ const typeOffsets = tasks.map((task) => {
360
+ const offset = nextTypeOffset.get(task.type) ?? 0;
361
+ nextTypeOffset.set(task.type, offset + 1);
362
+ return offset;
363
+ });
364
+ for (let repeat = 0; repeat < repeatCount; repeat++) {
365
+ for (let taskIndex = 0; taskIndex < tasks.length; taskIndex++) {
366
+ const task = tasks[taskIndex];
367
+ const offset = (typeOffsets[taskIndex] + repeat) % RETRIEVAL_ARMS.length;
368
+ const orderedArms = [...RETRIEVAL_ARMS.slice(offset), ...RETRIEVAL_ARMS.slice(0, offset)];
369
+ for (let order = 0; order < orderedArms.length; order++) {
370
+ const arm = orderedArms[order];
371
+ const observation = runRetrievalArm(db, repoRoot, inventory, task, limit, arm);
372
+ runs.push(evaluateObservation(task, arm, observation, limit, repeat, order));
373
+ onProgress?.(runs.length);
374
+ }
375
+ }
376
+ }
377
+ return runs;
378
+ }
379
+ function runGraphTaskSet(db, tasks, repeats, limit, onProgress) {
380
+ if (tasks[0])
381
+ runGraphArm(db, tasks[0], limit);
295
382
  const runs = [];
296
- const arms = ["full", "lexical", "fts", "rg"];
297
- const total = tasks.length * arms.length * Math.max(1, repeats);
298
- const progressInterval = Math.max(1, Math.ceil(total / 20));
299
383
  for (let repeat = 0; repeat < Math.max(1, repeats); repeat++) {
300
384
  for (const task of tasks) {
301
- for (const arm of arms) {
302
- const observation = arm === "rg"
303
- ? runRgBaseline(repoRoot, task, limit)
304
- : runCodeLens(db, task, limit, arm);
305
- runs.push(evaluateObservation(task, arm, observation, limit));
306
- if (runs.length === total || runs.length % progressInterval === 0)
307
- onProgress?.(runs.length, total);
308
- }
385
+ const observation = runGraphArm(db, task, limit);
386
+ runs.push(evaluateObservation(task, "graph", observation, limit, repeat, 0));
387
+ onProgress?.(runs.length);
309
388
  }
310
389
  }
311
390
  return runs;
312
391
  }
313
- function runCodeLens(db, task, limit, arm) {
392
+ function runRetrievalArm(db, repoRoot, inventory, task, limit, arm) {
393
+ if (arm === "rg")
394
+ return runRgBaseline(repoRoot, task, limit, inventory);
314
395
  const start = performance.now();
315
396
  try {
316
- let result;
317
- let foundPaths;
318
- if (arm === "full" && (task.type === "callers" || task.type === "tests") && task.sourcePath) {
319
- result = ctxImpact(db, { path: task.sourcePath, depth: 2 });
320
- const impact = result;
321
- foundPaths = [
322
- ...(task.type === "tests" ? (impact.affectedTests ?? []).map((item) => item.path) : []),
323
- ...(impact.callers ?? []).map((item) => item.path),
324
- ...(impact.affectedFiles ?? []).map((item) => item.path),
325
- impact.target?.path,
326
- ].filter((path) => !!path);
327
- }
328
- else {
329
- const weights = arm === "lexical" ? LEXICAL_WEIGHTS : arm === "fts" ? FTS_ONLY_WEIGHTS : undefined;
330
- result = ctxSearch(db, task.query, { limit, snippet: "none", weights });
331
- foundPaths = result.results.map((item) => item.path);
397
+ const weights = arm === "lexical" ? LEXICAL_WEIGHTS : arm === "fts" ? FTS_ONLY_WEIGHTS : undefined;
398
+ const foundPaths = [];
399
+ const seen = new Set();
400
+ let cursor;
401
+ let toolCalls = 0;
402
+ let bytesServed = 0;
403
+ // ctxSearch ranks chunks. Page through its bounded candidate set so every
404
+ // retrieval arm is scored on up to K unique files, matching rg's unit.
405
+ for (let page = 0; page < 4 && foundPaths.length < limit; page++) {
406
+ const result = ctxSearch(db, task.query, { limit, cursor, snippet: "none", weights });
407
+ toolCalls++;
408
+ bytesServed += Buffer.byteLength(JSON.stringify(result));
409
+ for (const item of result.results) {
410
+ if (seen.has(item.path))
411
+ continue;
412
+ seen.add(item.path);
413
+ foundPaths.push(item.path);
414
+ if (foundPaths.length >= limit)
415
+ break;
416
+ }
417
+ if (!result.nextCursor)
418
+ break;
419
+ cursor = result.nextCursor;
332
420
  }
333
- foundPaths = [...new Set(foundPaths)].slice(0, limit);
334
421
  return {
335
422
  foundPaths,
336
- toolCalls: 1,
337
- bytesServed: Buffer.byteLength(JSON.stringify(result)),
338
- bytesRead: 0,
423
+ toolCalls,
424
+ bytesServed,
339
425
  elapsedMs: performance.now() - start,
340
426
  };
341
427
  }
342
428
  catch (error) {
429
+ return failedObservation(start, error);
430
+ }
431
+ }
432
+ function runGraphArm(db, task, limit) {
433
+ const start = performance.now();
434
+ try {
435
+ if (!task.targetPath || (task.type !== "callers" && task.type !== "tests"))
436
+ throw new Error("graph task requires callers/tests type and targetPath");
437
+ const result = ctxImpact(db, { path: task.targetPath, depth: 1, includeTests: true });
438
+ const paths = task.type === "tests" ? result.affectedTests.map((item) => item.path) : result.callers.map((item) => item.path);
343
439
  return {
344
- foundPaths: [],
440
+ foundPaths: [...new Set(paths)].slice(0, limit),
345
441
  toolCalls: 1,
346
- bytesServed: 0,
347
- bytesRead: 0,
442
+ bytesServed: Buffer.byteLength(JSON.stringify(result)),
348
443
  elapsedMs: performance.now() - start,
349
- error: error instanceof Error ? error.message : String(error),
350
444
  };
351
445
  }
446
+ catch (error) {
447
+ return failedObservation(start, error);
448
+ }
449
+ }
450
+ function failedObservation(start, error) {
451
+ return {
452
+ foundPaths: [],
453
+ toolCalls: 1,
454
+ bytesServed: 0,
455
+ elapsedMs: performance.now() - start,
456
+ error: error instanceof Error ? error.message : String(error),
457
+ };
352
458
  }
353
459
  function runFreshnessEval(repoRoot, seed, progress) {
354
460
  const start = performance.now();
@@ -367,18 +473,14 @@ function runFreshnessEval(repoRoot, seed, progress) {
367
473
  worktreeAdded = true;
368
474
  progress.complete("freshness-worktree", `Created detached worktree at ${worktree}`);
369
475
  const scope = detectScope(worktree);
370
- if (!scope) {
371
- progress.skipped("freshness-worktree", "Temporary worktree scope detection failed");
476
+ if (!scope)
372
477
  return { enabled: true, attempted: false, passed: false, skippedReason: "temporary worktree scope detection failed" };
373
- }
374
478
  progress.start("freshness-scan", "Scanning temporary worktree for a safe probe file");
375
479
  const scanned = scanFiles(worktree);
376
480
  const candidates = seededShuffle(scanned.filter(isFreshnessCandidate), seed);
377
481
  const selected = candidates.map((file) => ({ file, path: safeFreshnessPath(worktree, file) })).find((item) => item.path !== null);
378
- if (!selected?.path) {
379
- progress.skipped("freshness-scan", "No safe regular text file for freshness probe");
482
+ if (!selected?.path)
380
483
  return { enabled: true, attempted: false, passed: false, skippedReason: "no safe regular text file for freshness probe" };
381
- }
382
484
  progress.complete("freshness-scan", `Selected ${selected.file.path} from ${scanned.length} files`);
383
485
  db = openDb(join(temp, "freshness.db"));
384
486
  progress.start("freshness-index", `Building freshness index for ${scanned.length} files`, { current: 0, total: scanned.length });
@@ -397,25 +499,12 @@ function runFreshnessEval(repoRoot, seed, progress) {
397
499
  ensureFreshIndex(db, scope, { budgetMs: 30000 });
398
500
  const deletedRemoved = !ctxSearch(db, token, { snippet: "none" }).results.some((result) => result.path === selected.file.path);
399
501
  progress.complete("freshness-delete", `Deleted content ${deletedRemoved ? "was removed" : "remained searchable"}`);
400
- return {
401
- enabled: true,
402
- attempted: true,
403
- passed: modifiedVisible && deletedRemoved,
404
- modifiedVisible,
405
- deletedRemoved,
406
- elapsedMs: performance.now() - start,
407
- };
502
+ return { enabled: true, attempted: true, passed: modifiedVisible && deletedRemoved, modifiedVisible, deletedRemoved, elapsedMs: performance.now() - start };
408
503
  }
409
504
  catch (error) {
410
505
  const message = error instanceof Error ? error.message : String(error);
411
506
  progress.error("freshness", message);
412
- return {
413
- enabled: true,
414
- attempted: true,
415
- passed: false,
416
- elapsedMs: performance.now() - start,
417
- error: message,
418
- };
507
+ return { enabled: true, attempted: true, passed: false, elapsedMs: performance.now() - start, error: message };
419
508
  }
420
509
  finally {
421
510
  progress.start("freshness-cleanup", "Removing temporary worktree and Git metadata");
@@ -469,9 +558,8 @@ function cleanupTemporaryWorktree(repoRoot, temp, worktree, worktreeAdded) {
469
558
  return;
470
559
  }
471
560
  const pruned = spawnSync("git", ["worktree", "prune", "--expire", "now"], { cwd: repoRoot, encoding: "utf-8" });
472
- if (pruned.error || pruned.status !== 0) {
561
+ if (pruned.error || pruned.status !== 0)
473
562
  throw new Error(`failed to prune temporary worktree metadata: ${pruned.error?.message ?? (pruned.stderr ?? "").trim()}`);
474
- }
475
563
  const listed = spawnSync("git", ["worktree", "list", "--porcelain"], { cwd: repoRoot, encoding: "utf-8" });
476
564
  if (listed.error || listed.status !== 0)
477
565
  throw new Error(`failed to verify temporary worktree cleanup: ${listed.error?.message ?? "git worktree list failed"}`);
@@ -481,53 +569,104 @@ function cleanupTemporaryWorktree(repoRoot, temp, worktree, worktreeAdded) {
481
569
  if (tempFailure)
482
570
  throw new Error(`failed to remove evaluation temp directory: ${tempFailure}`);
483
571
  }
572
+ function normalizeSuites(suites) {
573
+ const normalized = [...new Set(suites)];
574
+ if (normalized.length === 0)
575
+ throw new Error("at least one evaluation suite is required");
576
+ for (const suite of normalized) {
577
+ if (suite !== "retrieval" && suite !== "graph" && suite !== "freshness")
578
+ throw new Error(`unknown evaluation suite: ${suite}`);
579
+ }
580
+ return normalized;
581
+ }
484
582
  function resolveScaleSpecs(scales, total) {
485
- const resolved = [];
486
- const seenCounts = new Set();
487
- const requestedScales = scales.length > 0 ? scales : ["all"];
488
- for (const scale of requestedScales) {
583
+ const byCount = new Map();
584
+ for (const scale of scales.length > 0 ? scales : ["all"]) {
489
585
  const count = scale === "all" ? total : Math.max(1, Math.min(total, Math.floor(scale)));
490
- if (seenCounts.has(count))
491
- continue;
492
- seenCounts.add(count);
493
- resolved.push({ label: count === total ? "all" : String(count), requested: scale, count });
494
- }
495
- if (resolved.length === 0)
496
- resolved.push({ label: "all", requested: "all", count: total });
497
- return resolved;
586
+ if (!byCount.has(count) || scale === "all")
587
+ byCount.set(count, scale);
588
+ }
589
+ return [...byCount.entries()].sort(([a], [b]) => a - b).map(([count, requested]) => ({
590
+ label: count === total ? "all" : String(count),
591
+ requested,
592
+ count,
593
+ }));
498
594
  }
499
- function selectFiles(files, count, seed) {
500
- if (count >= files.length)
501
- return [...files].sort((a, b) => a.path.localeCompare(b.path));
502
- return seededShuffle(files, seed).slice(0, count).sort((a, b) => a.path.localeCompare(b.path));
595
+ function nestedFileOrder(files, requiredPaths, seed) {
596
+ const required = [];
597
+ const remainder = [];
598
+ for (const file of files)
599
+ (requiredPaths.has(file.path) ? required : remainder).push(file);
600
+ required.sort((a, b) => a.path.localeCompare(b.path));
601
+ return [...required, ...seededShuffle(remainder, seed)];
503
602
  }
504
- function aggregateByArm(runs) {
505
- const output = {};
506
- for (const arm of ["full", "lexical", "fts", "rg"]) {
507
- const armRuns = runs.filter((run) => run.arm === arm);
508
- if (armRuns.length > 0)
509
- output[arm] = aggregateRuns(armRuns);
510
- }
511
- return output;
603
+ function ensureRequiredPaths(files, required) {
604
+ const known = new Set(files.map((file) => file.path));
605
+ const missing = [...required].filter((path) => !known.has(path));
606
+ if (missing.length > 0)
607
+ throw new Error(`task paths are missing or not indexable: ${missing.slice(0, 5).join(", ")}${missing.length > 5 ? ` (+${missing.length - 5} more)` : ""}`);
512
608
  }
513
609
  function thresholdFailuresFor(scale, options) {
514
- if (scale.tasks.length === 0)
515
- return [];
516
- const metric = scale.aggregates.full;
517
- if (!metric)
518
- return [`${scale.label}: full CodeLens arm produced no metrics.`];
519
610
  const failures = [];
520
- if (metric.recallAtK < options.thresholds.minRecallAtK)
521
- failures.push(`${scale.label}: full recall@${options.resultLimit} ${metric.recallAtK.toFixed(3)} < ${options.thresholds.minRecallAtK.toFixed(3)}`);
522
- if (metric.mrr < options.thresholds.minMrr)
523
- failures.push(`${scale.label}: full MRR ${metric.mrr.toFixed(3)} < ${options.thresholds.minMrr.toFixed(3)}`);
524
- if (metric.successRate < options.thresholds.minSuccessRate)
525
- failures.push(`${scale.label}: full success rate ${metric.successRate.toFixed(3)} < ${options.thresholds.minSuccessRate.toFixed(3)}`);
611
+ for (const type of ["locate", "history"]) {
612
+ const eligible = new Set(scale.tasks.filter((task) => task.suite === "retrieval" && task.type === type && task.contributesToThresholds).map((task) => task.id));
613
+ if (eligible.size === 0)
614
+ continue;
615
+ const runs = scale.runs.filter((run) => run.arm === "full" && eligible.has(run.taskId));
616
+ const metric = aggregateRuns(runs, options.seed + type.length * 313);
617
+ if (metric.recallAtK < options.thresholds.minRecallAtK)
618
+ failures.push(`${scale.label}/${type}: full recall@${options.resultLimit} ${metric.recallAtK.toFixed(3)} < ${options.thresholds.minRecallAtK.toFixed(3)}`);
619
+ if (metric.mrr < options.thresholds.minMrr)
620
+ failures.push(`${scale.label}/${type}: full MRR ${metric.mrr.toFixed(3)} < ${options.thresholds.minMrr.toFixed(3)}`);
621
+ if (metric.successRate < options.thresholds.minSuccessRate)
622
+ failures.push(`${scale.label}/${type}: full success rate ${metric.successRate.toFixed(3)} < ${options.thresholds.minSuccessRate.toFixed(3)}`);
623
+ }
624
+ for (const type of ["callers", "tests"]) {
625
+ const eligible = new Set(scale.tasks.filter((task) => task.suite === "graph" && task.type === type && task.groundTruth.independent && task.contributesToThresholds).map((task) => task.id));
626
+ if (eligible.size === 0)
627
+ continue;
628
+ const runs = scale.runs.filter((run) => run.arm === "graph" && eligible.has(run.taskId));
629
+ const metric = aggregateRuns(runs, options.seed + type.length * 419);
630
+ if (metric.recallAtK < options.thresholds.minRecallAtK)
631
+ failures.push(`${scale.label}/${type}: graph recall@${options.resultLimit} ${metric.recallAtK.toFixed(3)} < ${options.thresholds.minRecallAtK.toFixed(3)}`);
632
+ if (metric.precisionAtK < options.thresholds.minGraphPrecision)
633
+ failures.push(`${scale.label}/${type}: graph precision@${options.resultLimit} ${metric.precisionAtK.toFixed(3)} < ${options.thresholds.minGraphPrecision.toFixed(3)}`);
634
+ if (metric.successRate < options.thresholds.minSuccessRate)
635
+ failures.push(`${scale.label}/${type}: graph success rate ${metric.successRate.toFixed(3)} < ${options.thresholds.minSuccessRate.toFixed(3)}`);
636
+ }
526
637
  return failures;
527
638
  }
528
- function distributedLimit(total, count, index) {
529
- const base = Math.floor(total / count);
530
- return base + (index < total % count ? 1 : 0);
639
+ function methodologyNotes(taskSource, suites, hasGraph, graphIndependent) {
640
+ const notes = [];
641
+ const hasTaskSuite = suites.includes("retrieval") || suites.includes("graph");
642
+ if (suites.includes("retrieval")) {
643
+ notes.push("Retrieval arms receive identical natural-language queries and the same selected file inventory; chunk-ranked CodeLens pages are deduplicated to the same file-level top-K unit as rg, and the lexical ablation removes only graph weight before proportionally renormalizing the remaining default weights.", "The rg arm is a deterministic OR-term file-match heuristic with path-term tie-breaking; it is not a native ranked-search system.", "Retrieval arm order is deterministically counterbalanced and one warmup per arm is discarded before timing.", "Latency is end-to-end and environment-specific; rg measurements include process startup while CodeLens arms run in-process.");
644
+ }
645
+ if (hasTaskSuite) {
646
+ notes.push("Scale tiers are nested and reuse one fixed task set; larger tiers add distractor files rather than changing labels.", "Repeats are timing samples; unique tasks determine quality metrics and confidence intervals.", "Low-confidence tasks may be reported but only tasks marked contributesToThresholds affect pass/fail.");
647
+ }
648
+ if (taskSource.kind === "automatic-self-evaluation")
649
+ notes.push("Automatic tasks are a self-evaluation and are not independent benchmark ground truth; automatic graph self-consistency runs only at the label-generating smallest tier.");
650
+ if (hasGraph)
651
+ notes.push(graphIndependent
652
+ ? "Graph labels are declared independent by the frozen task file."
653
+ : "Graph labels come from the evaluated CodeLens index and measure self-consistency only.");
654
+ if (suites.includes("freshness"))
655
+ notes.push("Freshness mutations run only in a detached temporary worktree and cleanup is verified.");
656
+ return notes;
657
+ }
658
+ function taskSetDigest(tasks) {
659
+ const canonical = tasks.map((task) => ({
660
+ id: task.id,
661
+ suite: task.suite,
662
+ type: task.type,
663
+ query: task.query,
664
+ targetPath: task.targetPath,
665
+ expectedPaths: task.expectedPaths,
666
+ confidence: task.confidence,
667
+ contributesToThresholds: task.contributesToThresholds,
668
+ }));
669
+ return createHash("sha256").update(JSON.stringify(canonical)).digest("hex");
531
670
  }
532
671
  function isFreshnessCandidate(file) {
533
672
  if (!file.language || file.size > 512 * 1024)
@@ -542,17 +681,13 @@ function freshnessComment(language, token) {
542
681
  return `\n<!-- ${token} -->\n`;
543
682
  return `\n// ${token}\n`;
544
683
  }
545
- function disabledFreshness() {
546
- return { enabled: false, attempted: false, passed: true };
547
- }
548
- function databaseBytes(path) {
549
- try {
550
- return statSync(path).size;
551
- }
552
- catch {
553
- return 0;
554
- }
684
+ function disabledFreshness() { return { enabled: false, attempted: false, passed: true }; }
685
+ function databaseBytes(path) { try {
686
+ return statSync(path).size;
555
687
  }
688
+ catch {
689
+ return 0;
690
+ } }
556
691
  function canonicalOutputPath(path) {
557
692
  let current = resolve(path);
558
693
  const missing = [];
@@ -578,11 +713,6 @@ function formatDuration(elapsedMs) {
578
713
  const minutes = Math.floor(elapsedMs / 60_000);
579
714
  return `${minutes}m ${((elapsedMs % 60_000) / 1000).toFixed(0)}s`;
580
715
  }
581
- function formatAggregateSummary(metric) {
582
- if (!metric)
583
- return "full arm produced no metrics";
584
- return `full recall ${(metric.recallAtK * 100).toFixed(1)}%, MRR ${metric.mrr.toFixed(3)}, success ${(metric.successRate * 100).toFixed(1)}%`;
585
- }
586
716
  function gitStatus(repoRoot) {
587
717
  const result = spawnSync("git", ["status", "--porcelain=v1", "-z"], { cwd: repoRoot, encoding: "utf-8" });
588
718
  return result.status === 0 ? result.stdout ?? "" : "";