@dailephd/my-dev-kit-lab 0.7.2 → 0.8.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.
Files changed (57) hide show
  1. package/CHANGELOG.md +33 -1
  2. package/README.md +9 -3
  3. package/benchmarks/contracts/warm-index-benchmark-cases.json +330 -0
  4. package/dist/src/cli/help.js +32 -1
  5. package/dist/src/commands/runExperimentDescribeCommand.js +16 -1
  6. package/dist/src/commands/runExperimentRunCommand.js +181 -4
  7. package/dist/src/evaluation/benchmarkMetadata.js +13 -0
  8. package/dist/src/evaluation/retrievalQuality/buildRetrievalEvidence.js +292 -0
  9. package/dist/src/evaluation/retrievalQuality/corpusCompleteness.js +122 -0
  10. package/dist/src/evaluation/retrievalQuality/factContextTargets.js +44 -0
  11. package/dist/src/evaluation/retrievalQuality/index.js +5 -0
  12. package/dist/src/evaluation/retrievalQuality/metrics.js +258 -0
  13. package/dist/src/evaluation/retrievalQuality/types.js +9 -0
  14. package/dist/src/evaluation/retrievalQueryStrategies.js +26 -0
  15. package/dist/src/evaluation/retrievalQueryStrategyEvidence.js +58 -0
  16. package/dist/src/evaluation/runMyDevKitRetrieval.js +61 -50
  17. package/dist/src/evaluation/runSemanticRetrievalStrategy.js +303 -0
  18. package/dist/src/experiments/defaultRegistry.js +4 -0
  19. package/dist/src/experiments/plugins/contextWindowScaling/localSubjectPrivacy.js +2 -0
  20. package/dist/src/experiments/plugins/index.js +2 -0
  21. package/dist/src/experiments/plugins/retrievalPrecisionRecall/config.js +39 -0
  22. package/dist/src/experiments/plugins/retrievalPrecisionRecall/execution.js +152 -0
  23. package/dist/src/experiments/plugins/retrievalPrecisionRecall/executionArtifact.js +15 -0
  24. package/dist/src/experiments/plugins/retrievalPrecisionRecall/index.js +9 -0
  25. package/dist/src/experiments/plugins/retrievalPrecisionRecall/localSubjectExecution.js +139 -0
  26. package/dist/src/experiments/plugins/retrievalPrecisionRecall/localSubjectPrivacy.js +119 -0
  27. package/dist/src/experiments/plugins/retrievalPrecisionRecall/metadata.js +21 -0
  28. package/dist/src/experiments/plugins/retrievalPrecisionRecall/metrics.js +158 -0
  29. package/dist/src/experiments/plugins/retrievalPrecisionRecall/plugin.js +253 -0
  30. package/dist/src/experiments/plugins/retrievalPrecisionRecall/types.js +8 -0
  31. package/dist/src/experiments/plugins/retrievalQueryStrategyComparison/analysis.js +172 -0
  32. package/dist/src/experiments/plugins/retrievalQueryStrategyComparison/analysisArtifact.js +28 -0
  33. package/dist/src/experiments/plugins/retrievalQueryStrategyComparison/analysisTypes.js +2 -0
  34. package/dist/src/experiments/plugins/retrievalQueryStrategyComparison/config.js +39 -0
  35. package/dist/src/experiments/plugins/retrievalQueryStrategyComparison/execution.js +225 -0
  36. package/dist/src/experiments/plugins/retrievalQueryStrategyComparison/executionArtifact.js +15 -0
  37. package/dist/src/experiments/plugins/retrievalQueryStrategyComparison/index.js +12 -0
  38. package/dist/src/experiments/plugins/retrievalQueryStrategyComparison/localSubjectExecution.js +124 -0
  39. package/dist/src/experiments/plugins/retrievalQueryStrategyComparison/localSubjectPrivacy.js +176 -0
  40. package/dist/src/experiments/plugins/retrievalQueryStrategyComparison/metadata.js +51 -0
  41. package/dist/src/experiments/plugins/retrievalQueryStrategyComparison/metrics.js +60 -0
  42. package/dist/src/experiments/plugins/retrievalQueryStrategyComparison/plugin.js +292 -0
  43. package/dist/src/experiments/plugins/retrievalQueryStrategyComparison/types.js +1 -0
  44. package/dist/src/report/experiments/buildPluginExperimentReport.js +44 -2
  45. package/dist/src/report/experiments/buildRetrievalPrecisionRecallReport.js +83 -0
  46. package/dist/src/report/experiments/buildRetrievalQueryStrategyComparisonReport.js +91 -0
  47. package/dist/src/report/experiments/index.js +8 -0
  48. package/dist/src/report/experiments/renderPluginExperimentReportHtml.js +6 -0
  49. package/dist/src/report/experiments/renderPluginExperimentReportText.js +18 -0
  50. package/dist/src/report/experiments/renderRetrievalPrecisionRecallHtml.js +96 -0
  51. package/dist/src/report/experiments/renderRetrievalPrecisionRecallText.js +121 -0
  52. package/dist/src/report/experiments/renderRetrievalQueryStrategyComparisonHtml.js +96 -0
  53. package/dist/src/report/experiments/renderRetrievalQueryStrategyComparisonText.js +58 -0
  54. package/dist/src/report/experiments/retrievalPrecisionRecallReportModel.js +3 -0
  55. package/dist/src/report/experiments/retrievalQueryStrategyComparisonReportModel.js +1 -0
  56. package/docs/METRICS.md +152 -2
  57. package/package.json +1 -1
@@ -3,8 +3,9 @@ import path from "node:path";
3
3
  import { parseAgentCommandTemplate } from "../agents/index.js";
4
4
  import { parseAgentId } from "../agents/agentRegistry.js";
5
5
  import { readBenchmarkProjectProfiles, readEvaluationCases } from "../evaluation/index.js";
6
+ import { summarizeRetrievalGroundTruthIssuesSafely, validateRetrievalPrecisionRecallCorpus } from "../evaluation/retrievalQuality/index.js";
6
7
  import { loadLocalRepositorySubject } from "../evaluation/localRepositorySubject/index.js";
7
- import { contextStrategyComparisonPlugin, contextWindowScalingPlugin, createDefaultExperimentPluginRegistry, getWarmIndexCampaignPreset, incrementalChangeStalenessPlugin, parseContextBudgetsCliValue, parseWarmIndexCampaignPresetId, resolveExperimentTarget, runExperiment, warmIndexReusePlugin } from "../experiments/index.js";
8
+ import { contextStrategyComparisonPlugin, contextWindowScalingPlugin, createDefaultExperimentPluginRegistry, getWarmIndexCampaignPreset, incrementalChangeStalenessPlugin, parseContextBudgetsCliValue, parseWarmIndexCampaignPresetId, resolveExperimentTarget, retrievalPrecisionRecallPlugin, retrievalQueryStrategyComparisonPlugin, runExperiment, warmIndexReusePlugin } from "../experiments/index.js";
8
9
  import { prepareSyntheticContextWindowScalingInputs } from "../experiments/plugins/contextWindowScaling/index.js";
9
10
  import { projectExternalLocalTarget, projectRunForExternalLocalPersistence, } from "../experiments/plugins/contextWindowScaling/localSubjectPrivacy.js";
10
11
  import { assertWorkRootOutsideTarget } from "../experiments/plugins/contextWindowScaling/localSubjectScratch.js";
@@ -51,6 +52,18 @@ const CONTEXT_WINDOW_SCALING_ALLOWED_FLAGS = [
51
52
  "--context-budgets",
52
53
  "--kit-command"
53
54
  ];
55
+ // retrieval-precision-recall owns one frozen bundled corpus (the warm-index benchmark cases) and its project profiles,
56
+ // both resolved from the package root, never the cwd. It accepts only these options; every other flag is rejected.
57
+ const RETRIEVAL_PRECISION_RECALL_CASES_RESOURCE = "benchmarks/contracts/warm-index-benchmark-cases.json";
58
+ const RETRIEVAL_PRECISION_RECALL_ALLOWED_FLAGS = ["--experiment", "--out", "--case", "--benchmark-project", "--kit-command"];
59
+ // External-local mode (--target with --local-subject-config) is a different subject source: the local-subject config owns
60
+ // the case set, so the bundled corpus filters are not accepted there.
61
+ const RETRIEVAL_PRECISION_RECALL_EXTERNAL_ALLOWED_FLAGS = ["--experiment", "--out", "--target", "--local-subject-config", "--kit-command"];
62
+ // retrieval-query-strategy-comparison reuses the same frozen bundled corpus. All seven strategies always run, so there
63
+ // is no strategy option. External-local mode again accepts only the local-subject source flags.
64
+ const RETRIEVAL_QUERY_STRATEGY_COMPARISON_CASES_RESOURCE = "benchmarks/contracts/warm-index-benchmark-cases.json";
65
+ const RETRIEVAL_QUERY_STRATEGY_COMPARISON_ALLOWED_FLAGS = ["--experiment", "--out", "--case", "--benchmark-project", "--kit-command"];
66
+ const RETRIEVAL_QUERY_STRATEGY_COMPARISON_EXTERNAL_ALLOWED_FLAGS = ["--experiment", "--out", "--target", "--local-subject-config", "--kit-command"];
54
67
  export async function runExperimentRunCommandFromArgs(argv, options = {}) {
55
68
  try {
56
69
  const args = parseRunExperimentArgs(argv);
@@ -300,16 +313,71 @@ export function parseRunExperimentArgs(argv) {
300
313
  const KIT_COMMAND_PLUGIN_IDS = [
301
314
  warmIndexReusePlugin.metadata.id,
302
315
  incrementalChangeStalenessPlugin.metadata.id,
303
- contextWindowScalingPlugin.metadata.id
316
+ contextWindowScalingPlugin.metadata.id,
317
+ retrievalPrecisionRecallPlugin.metadata.id,
318
+ retrievalQueryStrategyComparisonPlugin.metadata.id
304
319
  ];
320
+ if (experimentId === retrievalPrecisionRecallPlugin.metadata.id) {
321
+ // Mode matrix, no precedence rules: neither flag = bundled; both = external-local; exactly one is rejected.
322
+ if (targetPath !== undefined && localSubjectConfigPath === undefined) {
323
+ throw new Error(`External ${experimentId} targets require --local-subject-config.`);
324
+ }
325
+ if (localSubjectConfigPath !== undefined && targetPath === undefined) {
326
+ throw new Error(`--local-subject-config requires an external --target for ${experimentId}.`);
327
+ }
328
+ const externalMode = targetPath !== undefined;
329
+ if (externalMode && (seenFlags.includes("--case") || seenFlags.includes("--benchmark-project"))) {
330
+ throw new Error("--case and --benchmark-project cannot be combined with --local-subject-config; the local subject config owns the case set.");
331
+ }
332
+ const allowedFlags = externalMode ? RETRIEVAL_PRECISION_RECALL_EXTERNAL_ALLOWED_FLAGS : RETRIEVAL_PRECISION_RECALL_ALLOWED_FLAGS;
333
+ const unsupported = [...new Set(seenFlags.filter((flag) => !allowedFlags.includes(flag)))];
334
+ if (unsupported.length > 0) {
335
+ throw new Error(`${unsupported.join(", ")} ${unsupported.length === 1 ? "is" : "are"} not supported for --experiment ${experimentId}${externalMode ? " in external-local mode" : ""}; supported options: ${allowedFlags.join(", ")}.`);
336
+ }
337
+ if (seenFlags.includes("--case") && caseIds.length === 0) {
338
+ throw new Error("--case must list at least one case id.");
339
+ }
340
+ if (seenFlags.includes("--benchmark-project") && benchmarkProjects.length === 0) {
341
+ throw new Error("--benchmark-project must list at least one benchmark project id.");
342
+ }
343
+ }
344
+ if (experimentId === retrievalQueryStrategyComparisonPlugin.metadata.id) {
345
+ // Mode matrix, no precedence rules: neither flag = bundled; both = external-local; exactly one is rejected.
346
+ if (targetPath !== undefined && localSubjectConfigPath === undefined) {
347
+ throw new Error(`External ${experimentId} targets require --local-subject-config.`);
348
+ }
349
+ if (localSubjectConfigPath !== undefined && targetPath === undefined) {
350
+ throw new Error(`--local-subject-config requires an external --target for ${experimentId}.`);
351
+ }
352
+ const externalMode = targetPath !== undefined;
353
+ if (externalMode && (seenFlags.includes("--case") || seenFlags.includes("--benchmark-project"))) {
354
+ throw new Error("--case and --benchmark-project cannot be combined with --local-subject-config; the local subject config owns the case set.");
355
+ }
356
+ const allowedFlags = externalMode
357
+ ? RETRIEVAL_QUERY_STRATEGY_COMPARISON_EXTERNAL_ALLOWED_FLAGS
358
+ : RETRIEVAL_QUERY_STRATEGY_COMPARISON_ALLOWED_FLAGS;
359
+ const unsupported = [...new Set(seenFlags.filter((flag) => !allowedFlags.includes(flag)))];
360
+ if (unsupported.length > 0) {
361
+ throw new Error(`${unsupported.join(", ")} ${unsupported.length === 1 ? "is" : "are"} not supported for --experiment ${experimentId}${externalMode ? " in external-local mode" : ""}; supported options: ${allowedFlags.join(", ")}.`);
362
+ }
363
+ if (seenFlags.includes("--case") && caseIds.length === 0) {
364
+ throw new Error("--case must list at least one case id.");
365
+ }
366
+ if (seenFlags.includes("--benchmark-project") && benchmarkProjects.length === 0) {
367
+ throw new Error("--benchmark-project must list at least one benchmark project id.");
368
+ }
369
+ }
305
370
  if (contextBudgets !== undefined && experimentId !== contextWindowScalingPlugin.metadata.id) {
306
371
  throw new Error(`--context-budgets is only supported for --experiment ${contextWindowScalingPlugin.metadata.id}.`);
307
372
  }
308
373
  if (syntheticConfigPath !== undefined && experimentId !== contextWindowScalingPlugin.metadata.id) {
309
374
  throw new Error(`--synthetic-config is only supported for --experiment ${contextWindowScalingPlugin.metadata.id}.`);
310
375
  }
311
- if (localSubjectConfigPath !== undefined && experimentId !== contextWindowScalingPlugin.metadata.id) {
312
- throw new Error(`--local-subject-config is only supported for --experiment ${contextWindowScalingPlugin.metadata.id}.`);
376
+ if (localSubjectConfigPath !== undefined &&
377
+ experimentId !== contextWindowScalingPlugin.metadata.id &&
378
+ experimentId !== retrievalPrecisionRecallPlugin.metadata.id &&
379
+ experimentId !== retrievalQueryStrategyComparisonPlugin.metadata.id) {
380
+ throw new Error(`--local-subject-config is only supported for --experiment ${contextWindowScalingPlugin.metadata.id}, ${retrievalPrecisionRecallPlugin.metadata.id}, or ${retrievalQueryStrategyComparisonPlugin.metadata.id}.`);
313
381
  }
314
382
  if (experimentId === contextWindowScalingPlugin.metadata.id) {
315
383
  const unsupported = [...new Set(seenFlags.filter((flag) => !CONTEXT_WINDOW_SCALING_ALLOWED_FLAGS.includes(flag)))];
@@ -426,8 +494,117 @@ async function loadPluginInputs(args, toolRoot, context, outputRoot) {
426
494
  if (args.experimentId === contextWindowScalingPlugin.metadata.id) {
427
495
  return loadContextWindowScalingInputs(args, toolRoot, context, outputRoot);
428
496
  }
497
+ if (args.experimentId === retrievalPrecisionRecallPlugin.metadata.id) {
498
+ return args.localSubjectConfigPath !== undefined
499
+ ? loadRetrievalPrecisionRecallLocalSubjectInputs(args, toolRoot, context, outputRoot)
500
+ : loadRetrievalPrecisionRecallInputs(args, toolRoot, context);
501
+ }
502
+ if (args.experimentId === retrievalQueryStrategyComparisonPlugin.metadata.id) {
503
+ return args.localSubjectConfigPath !== undefined
504
+ ? loadRetrievalQueryStrategyComparisonLocalSubjectInputs(args, toolRoot, context, outputRoot)
505
+ : loadRetrievalQueryStrategyComparisonInputs(args, toolRoot, context);
506
+ }
429
507
  return undefined;
430
508
  }
509
+ // External-local retrieval-query-strategy-comparison: same loader and safety order as retrieval-precision-recall, with
510
+ // the plugin's own config validation. Ground-truth issues carry logical case ids and counts only.
511
+ async function loadRetrievalQueryStrategyComparisonLocalSubjectInputs(args, toolRoot, context, outputRoot) {
512
+ const validation = retrievalQueryStrategyComparisonPlugin.validateConfig(args.config);
513
+ if (!validation.valid || !validation.config) {
514
+ throw new Error(`Invalid retrieval query strategy comparison config: ${validation.errors.join("; ")}`);
515
+ }
516
+ if (outputRoot === undefined || args.localSubjectConfigPath === undefined) {
517
+ throw new Error("Internal error: external-local runs require a resolved experiment output root and config path.");
518
+ }
519
+ const target = resolveExperimentTarget(args.targetPath, toolRoot);
520
+ if (target.isSelf || target.kind !== "external-local") {
521
+ throw new Error("Local-repository subject mode requires an external-local target; --target must not be the Lab repository.");
522
+ }
523
+ try {
524
+ await assertWorkRootOutsideTarget(outputRoot, target.targetRoot);
525
+ }
526
+ catch {
527
+ throw new Error("Experiment output root must not be inside the external target project.");
528
+ }
529
+ const configPath = path.resolve(context.invocationCwd, args.localSubjectConfigPath);
530
+ const localSubjectConfig = await readJsonConfigFile("--local-subject-config", configPath, args.localSubjectConfigPath);
531
+ const localSubject = await loadLocalRepositorySubject({ config: localSubjectConfig, repositoryPath: target.targetRoot });
532
+ const groundTruthIssues = summarizeRetrievalGroundTruthIssuesSafely(localSubject.evaluationCases);
533
+ if (groundTruthIssues.length > 0) {
534
+ throw new Error(`Invalid retrieval-query-strategy-comparison ground truth for the local subject (${groundTruthIssues
535
+ .map((issue) => `case ${issue.caseId}: ${issue.issueCount} issue${issue.issueCount === 1 ? "" : "s"}`)
536
+ .join("; ")}); details withheld.`);
537
+ }
538
+ return { cases: localSubject.evaluationCases, localSubject, env: process.env };
539
+ }
540
+ // The frozen bundled corpus is the same warm-index corpus and must satisfy the same complete ground-truth contract.
541
+ async function loadRetrievalQueryStrategyComparisonInputs(args, toolRoot, context) {
542
+ const validation = retrievalQueryStrategyComparisonPlugin.validateConfig(args.config);
543
+ if (!validation.valid || !validation.config) {
544
+ throw new Error(`Invalid retrieval query strategy comparison config: ${validation.errors.join("; ")}`);
545
+ }
546
+ const projectProfiles = await readBenchmarkProjectProfiles(resolvePackageResource(context, DEFAULT_PROJECT_PROFILES_RESOURCE), toolRoot);
547
+ const cases = await readEvaluationCases(resolvePackageResource(context, RETRIEVAL_QUERY_STRATEGY_COMPARISON_CASES_RESOURCE), toolRoot, {
548
+ projectProfiles,
549
+ requireProjectProfileRef: true
550
+ });
551
+ const corpusErrors = validateRetrievalPrecisionRecallCorpus(cases);
552
+ if (corpusErrors.length > 0) {
553
+ throw new Error(`Invalid retrieval-query-strategy-comparison corpus: ${corpusErrors.join(" ")}`);
554
+ }
555
+ return { cases };
556
+ }
557
+ // External-local retrieval-precision-recall: the existing local-subject loader owns the repository, inventory and case
558
+ // adapter. The only additions are the physical output-root check before anything is created and a ground-truth gate
559
+ // whose message carries logical case ids and counts only (an external subject may have private fact ids and names).
560
+ async function loadRetrievalPrecisionRecallLocalSubjectInputs(args, toolRoot, context, outputRoot) {
561
+ const validation = retrievalPrecisionRecallPlugin.validateConfig(args.config);
562
+ if (!validation.valid || !validation.config) {
563
+ throw new Error(`Invalid retrieval precision/recall config: ${validation.errors.join("; ")}`);
564
+ }
565
+ if (outputRoot === undefined || args.localSubjectConfigPath === undefined) {
566
+ throw new Error("Internal error: external-local runs require a resolved experiment output root and config path.");
567
+ }
568
+ const target = resolveExperimentTarget(args.targetPath, toolRoot);
569
+ if (target.isSelf || target.kind !== "external-local") {
570
+ throw new Error("Local-repository subject mode requires an external-local target; --target must not be the Lab repository.");
571
+ }
572
+ // Fail closed before anything is created: the output root (and the private scratch beneath it) must be physically outside the target.
573
+ try {
574
+ await assertWorkRootOutsideTarget(outputRoot, target.targetRoot);
575
+ }
576
+ catch {
577
+ throw new Error("Experiment output root must not be inside the external target project.");
578
+ }
579
+ const configPath = path.resolve(context.invocationCwd, args.localSubjectConfigPath);
580
+ const localSubjectConfig = await readJsonConfigFile("--local-subject-config", configPath, args.localSubjectConfigPath);
581
+ const localSubject = await loadLocalRepositorySubject({ config: localSubjectConfig, repositoryPath: target.targetRoot });
582
+ const groundTruthIssues = summarizeRetrievalGroundTruthIssuesSafely(localSubject.evaluationCases);
583
+ if (groundTruthIssues.length > 0) {
584
+ throw new Error(`Invalid retrieval-precision-recall ground truth for the local subject (${groundTruthIssues
585
+ .map((issue) => `case ${issue.caseId}: ${issue.issueCount} issue${issue.issueCount === 1 ? "" : "s"}`)
586
+ .join("; ")}); details withheld.`);
587
+ }
588
+ return { cases: localSubject.evaluationCases, localSubject, env: process.env };
589
+ }
590
+ // The frozen bundled corpus is read through the established resource resolver and validation path, then must satisfy
591
+ // the retrieval-precision-recall ground-truth completeness rules. Selection filters are applied by the plugin.
592
+ async function loadRetrievalPrecisionRecallInputs(args, toolRoot, context) {
593
+ const validation = retrievalPrecisionRecallPlugin.validateConfig(args.config);
594
+ if (!validation.valid || !validation.config) {
595
+ throw new Error(`Invalid retrieval precision/recall config: ${validation.errors.join("; ")}`);
596
+ }
597
+ const projectProfiles = await readBenchmarkProjectProfiles(resolvePackageResource(context, DEFAULT_PROJECT_PROFILES_RESOURCE), toolRoot);
598
+ const cases = await readEvaluationCases(resolvePackageResource(context, RETRIEVAL_PRECISION_RECALL_CASES_RESOURCE), toolRoot, {
599
+ projectProfiles,
600
+ requireProjectProfileRef: true
601
+ });
602
+ const corpusErrors = validateRetrievalPrecisionRecallCorpus(cases);
603
+ if (corpusErrors.length > 0) {
604
+ throw new Error(`Invalid retrieval-precision-recall corpus: ${corpusErrors.join(" ")}`);
605
+ }
606
+ return { cases };
607
+ }
431
608
  // --case is a selection over the frozen bundled catalog (catalog order, not argument order);
432
609
  // the rest of the CLI config is the plugin's own ContextWindowScalingConfig.
433
610
  function pluginConfigFor(args) {
@@ -2,6 +2,7 @@ import { existsSync } from "node:fs";
2
2
  import { readFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { PROJECT_COMPLEXITY_FORMULA, calculateProjectComplexityScore, roundToTwo } from "./projectComplexity.js";
5
+ import { hasFactMapping, interpretFactContextTarget } from "./retrievalQuality/factContextTargets.js";
5
6
  import { TASK_LOCALITIES } from "./types.js";
6
7
  export const REQUIRED_BENCHMARK_PROJECT_IDS = [
7
8
  "todo-ts",
@@ -90,6 +91,18 @@ export function validateAnswerKey(answerKey, label) {
90
91
  errors.push(`${label}: answerKey.minimumCorrectFacts is not satisfiable.`);
91
92
  }
92
93
  }
94
+ if (Array.isArray(candidate.expectedContextTargets) && Array.isArray(candidate.expectedFacts)) {
95
+ // Only targets that opt into fact mapping with `factIds` are validated here; legacy targets stay valid as-is.
96
+ const knownFactIds = new Set(candidate.expectedFacts.flatMap((fact) => (fact && typeof fact === "object" && typeof fact.id === "string" && fact.id.length > 0 ? [fact.id] : [])));
97
+ candidate.expectedContextTargets.forEach((target, index) => {
98
+ if (!hasFactMapping(target))
99
+ return;
100
+ const interpretation = interpretFactContextTarget(target, knownFactIds);
101
+ if (!interpretation.ok) {
102
+ errors.push(`${label}: answerKey.expectedContextTargets[${index}]: ${interpretation.problem}.`);
103
+ }
104
+ });
105
+ }
93
106
  if (Array.isArray(candidate.expectedFiles) && candidate.expectedFiles.length === 0) {
94
107
  errors.push(`${label}: answerKey.expectedFiles must not be empty.`);
95
108
  }
@@ -0,0 +1,292 @@
1
+ import { compareCodeUnits } from "../indexSnapshot.js";
2
+ import { RETRIEVAL_COMMAND_FAMILIES } from "./types.js";
3
+ const MAX_PATH_LENGTH = 1024;
4
+ const REASON_COMMAND_FAILED = "command-failed";
5
+ const REASON_NOT_JSON = "output-is-not-json";
6
+ const REASON_UNSUPPORTED = "output-is-not-the-supported-upstream-schema";
7
+ const REASON_REJECTED = "unsafe-or-invalid-identity-rejected";
8
+ const REASON_NO_SELECTED_FILE = "no-selected-file-for-source-attribution";
9
+ const REASON_SELECTION_UNTRUSTED = "selection-not-backed-by-supported-search-output";
10
+ const REASON_DUPLICATE = "repeated-command-family-ignored";
11
+ /**
12
+ * Normalizes a repository-relative path to the single platform-neutral form (forward slashes, no leading
13
+ * "./"). Returns null for anything that is not a safe repository-relative path: absolute, drive-qualified,
14
+ * UNC, traversing, or containing empty segments. It never repairs an unsafe value into a plausible one.
15
+ *
16
+ * Same contract as the context-window-scaling plugin's `normalizeRepositoryRelativePath`; kept local so the
17
+ * evaluation layer does not import from an experiment plugin. The unit tests assert the two agree.
18
+ */
19
+ export function normalizeRetrievedRepositoryPath(value) {
20
+ if (typeof value !== "string" || value.length === 0 || value.length > MAX_PATH_LENGTH || value.includes("\0")) {
21
+ return null;
22
+ }
23
+ let normalized = value.replace(/\\/g, "/");
24
+ while (normalized.startsWith("./")) {
25
+ normalized = normalized.slice(2);
26
+ }
27
+ if (normalized.length === 0 || normalized.startsWith("/") || /^[A-Za-z]:/.test(normalized)) {
28
+ return null;
29
+ }
30
+ if (normalized.split("/").some((segment) => segment === ".." || segment === "")) {
31
+ return null;
32
+ }
33
+ return normalized;
34
+ }
35
+ function isRecord(value) {
36
+ return typeof value === "object" && value !== null && !Array.isArray(value);
37
+ }
38
+ function parseJson(text) {
39
+ try {
40
+ return JSON.parse(text);
41
+ }
42
+ catch {
43
+ return undefined;
44
+ }
45
+ }
46
+ function symbolKey(name, nodeId, file) {
47
+ return nodeId !== undefined ? `id\0${nodeId}` : `name\0${file ?? ""}\0${name}`;
48
+ }
49
+ /**
50
+ * Reads one upstream node-like entry. Only `file` and `symbol` kinds are evidence; edges and every other
51
+ * kind carry no explicit file/symbol identity of their own and are ignored. Fields are read by their
52
+ * documented names only; identifiers embedded inside other strings are never parsed.
53
+ */
54
+ function readNode(entry, idKey) {
55
+ if (!isRecord(entry) || typeof entry.kind !== "string") {
56
+ return { type: "rejected" };
57
+ }
58
+ if (entry.kind === "file") {
59
+ const path = normalizeRetrievedRepositoryPath(entry.path);
60
+ return path === null ? { type: "rejected" } : { type: "file", path };
61
+ }
62
+ if (entry.kind === "symbol") {
63
+ const name = typeof entry.symbolName === "string" && entry.symbolName.length > 0 ? entry.symbolName : entry.label;
64
+ if (typeof name !== "string" || name.length === 0) {
65
+ return { type: "rejected" };
66
+ }
67
+ let file;
68
+ if (entry.path !== undefined) {
69
+ const normalized = normalizeRetrievedRepositoryPath(entry.path);
70
+ if (normalized === null) {
71
+ return { type: "rejected" };
72
+ }
73
+ file = normalized;
74
+ }
75
+ const rawId = entry[idKey];
76
+ if (rawId !== undefined && (typeof rawId !== "string" || rawId.length === 0)) {
77
+ return { type: "rejected" };
78
+ }
79
+ return { type: "symbol", name, nodeId: rawId, file };
80
+ }
81
+ return { type: "ignored" };
82
+ }
83
+ function interpretEntries(family, entries, idKey, files, symbols) {
84
+ const result = { parseState: "parsed", files: new Set(), symbolKeys: new Set(), rejected: 0 };
85
+ for (const entry of entries) {
86
+ const reading = readNode(entry, idKey);
87
+ if (reading.type === "ignored")
88
+ continue;
89
+ if (reading.type === "rejected") {
90
+ result.rejected += 1;
91
+ continue;
92
+ }
93
+ if (reading.type === "file") {
94
+ addFile(files, reading.path, family);
95
+ result.files.add(reading.path);
96
+ continue;
97
+ }
98
+ const key = symbolKey(reading.name, reading.nodeId, reading.file);
99
+ const existing = symbols.get(key);
100
+ if (existing) {
101
+ existing.surfacedBy.add(family);
102
+ }
103
+ else {
104
+ symbols.set(key, { name: reading.name, nodeId: reading.nodeId, file: reading.file, surfacedBy: new Set([family]) });
105
+ }
106
+ result.symbolKeys.add(key);
107
+ if (reading.file !== undefined) {
108
+ // A symbol node carries an explicit file identity, so that file is surfaced by the same command.
109
+ addFile(files, reading.file, family);
110
+ result.files.add(reading.file);
111
+ }
112
+ }
113
+ if (result.rejected > 0) {
114
+ result.reason = REASON_REJECTED;
115
+ }
116
+ return result;
117
+ }
118
+ function addFile(files, path, family) {
119
+ const existing = files.get(path);
120
+ if (existing) {
121
+ existing.add(family);
122
+ }
123
+ else {
124
+ files.set(path, new Set([family]));
125
+ }
126
+ }
127
+ function failedInterpretation(parseState, reason) {
128
+ return { parseState, files: new Set(), symbolKeys: new Set(), rejected: 0, reason };
129
+ }
130
+ function interpretStructured(command, files, symbols) {
131
+ const payload = parseJson(command.stdout);
132
+ if (!isRecord(payload)) {
133
+ return failedInterpretation(payload === undefined ? "malformed" : "unsupported-schema", payload === undefined ? REASON_NOT_JSON : REASON_UNSUPPORTED);
134
+ }
135
+ if (command.family === "search") {
136
+ const supported = payload.artifactKind === "my-dev-kit-v1-search-result" &&
137
+ typeof payload.version === "string" &&
138
+ payload.version.startsWith("1.") &&
139
+ Array.isArray(payload.results);
140
+ return supported
141
+ ? interpretEntries("search", payload.results, "nodeId", files, symbols)
142
+ : failedInterpretation("unsupported-schema", REASON_UNSUPPORTED);
143
+ }
144
+ if (command.family === "lookup") {
145
+ const supported = payload.status === "found" && isRecord(payload.node) && Array.isArray(payload.neighbors);
146
+ return supported
147
+ ? interpretEntries("lookup", [payload.node, ...payload.neighbors], "id", files, symbols)
148
+ : failedInterpretation("unsupported-schema", REASON_UNSUPPORTED);
149
+ }
150
+ const supported = payload.artifactKind === "my-dev-kit-v1-graph-slice" && typeof payload.version === "string" && payload.version.startsWith("1.") && Array.isArray(payload.nodes);
151
+ return supported
152
+ ? interpretEntries("slice", payload.nodes, "id", files, symbols)
153
+ : failedInterpretation("unsupported-schema", REASON_UNSUPPORTED);
154
+ }
155
+ /**
156
+ * Source output is context text. Evidence is attributed only from the trusted selection the lifecycle
157
+ * passed to the source command: its selected file, and the symbol already surfaced by structured output
158
+ * under the same node id. Nothing is read from the source body.
159
+ */
160
+ function attributeSource(selection, searchInterpreted, files, symbols) {
161
+ if (!searchInterpreted) {
162
+ // The selection was derived from search output; without supported search evidence it is not trusted.
163
+ return failedInterpretation("unattributable", REASON_SELECTION_UNTRUSTED);
164
+ }
165
+ if (selection?.file === undefined) {
166
+ return failedInterpretation("unattributable", REASON_NO_SELECTED_FILE);
167
+ }
168
+ const path = normalizeRetrievedRepositoryPath(selection.file);
169
+ if (path === null) {
170
+ return { ...failedInterpretation("unattributable", REASON_REJECTED), rejected: 1 };
171
+ }
172
+ const result = { parseState: "selection-attributed", files: new Set([path]), symbolKeys: new Set(), rejected: 0 };
173
+ addFile(files, path, "source");
174
+ if (selection.nodeId !== undefined) {
175
+ const key = symbolKey("", selection.nodeId, undefined);
176
+ const known = symbols.get(key);
177
+ if (known) {
178
+ known.surfacedBy.add("source");
179
+ result.symbolKeys.add(key);
180
+ }
181
+ }
182
+ return result;
183
+ }
184
+ function orderedProvenance(provenance) {
185
+ return RETRIEVAL_COMMAND_FAMILIES.filter((family) => provenance.has(family));
186
+ }
187
+ function isFullyInterpreted(entry) {
188
+ return (entry.parseState === "parsed" || entry.parseState === "selection-attributed") && entry.rejectedIdentityCount === 0;
189
+ }
190
+ /**
191
+ * Pure policy: builds normalized retrieval evidence from the commands the lifecycle executed.
192
+ * Deterministic: output ordering depends only on code-unit comparison of identities and the fixed
193
+ * search -> lookup -> slice -> source family order, never on input order or object enumeration.
194
+ */
195
+ export function buildRetrievalEvidence(input) {
196
+ const files = new Map();
197
+ const symbols = new Map();
198
+ const interpreted = new Map();
199
+ const duplicates = [];
200
+ const ordered = RETRIEVAL_COMMAND_FAMILIES.flatMap((family) => input.commands.filter((command) => command.family === family));
201
+ // Symbol attribution for source depends on structured evidence, so structured families run first (they do
202
+ // by family order); source is last.
203
+ for (const command of ordered) {
204
+ if (interpreted.has(command.family)) {
205
+ duplicates.push(command.family);
206
+ continue;
207
+ }
208
+ let outcome;
209
+ if (!command.ok) {
210
+ outcome = failedInterpretation("command-failed", REASON_COMMAND_FAILED);
211
+ }
212
+ else if (command.family === "source") {
213
+ outcome = attributeSource(input.selection, interpreted.get("search")?.parseState === "parsed", files, symbols);
214
+ }
215
+ else {
216
+ outcome = interpretStructured(command, files, symbols);
217
+ }
218
+ interpreted.set(command.family, {
219
+ family: command.family,
220
+ succeeded: command.ok,
221
+ parseState: outcome.parseState,
222
+ fileEvidenceCount: outcome.files.size,
223
+ symbolEvidenceCount: outcome.symbolKeys.size,
224
+ rejectedIdentityCount: outcome.rejected,
225
+ ...(outcome.reason !== undefined ? { reason: outcome.reason } : {})
226
+ });
227
+ }
228
+ const commands = RETRIEVAL_COMMAND_FAMILIES.flatMap((family) => {
229
+ const entry = interpreted.get(family);
230
+ return entry ? [entry] : [];
231
+ });
232
+ const fileEvidence = [...files.entries()]
233
+ .map(([path, provenance]) => ({ path, surfacedBy: orderedProvenance(provenance) }))
234
+ .sort((left, right) => compareCodeUnits(left.path, right.path));
235
+ const symbolEvidence = [...symbols.values()]
236
+ .map((symbol) => ({
237
+ name: symbol.name,
238
+ ...(symbol.nodeId !== undefined ? { nodeId: symbol.nodeId } : {}),
239
+ ...(symbol.file !== undefined ? { file: symbol.file } : {}),
240
+ surfacedBy: orderedProvenance(symbol.surfacedBy)
241
+ }))
242
+ .sort((left, right) => compareCodeUnits(left.file ?? "", right.file ?? "") ||
243
+ compareCodeUnits(left.name, right.name) ||
244
+ compareCodeUnits(left.nodeId ?? "", right.nodeId ?? ""));
245
+ const search = interpreted.get("search");
246
+ let availability;
247
+ let availabilityReason;
248
+ if (commands.length === 0) {
249
+ availability = "unavailable";
250
+ availabilityReason = "no-retrieval-commands-executed";
251
+ }
252
+ else if (!search) {
253
+ availability = "unavailable";
254
+ availabilityReason = "search-command-not-executed";
255
+ }
256
+ else if (search.parseState !== "parsed") {
257
+ availability = "unavailable";
258
+ availabilityReason = "search-evidence-unavailable";
259
+ }
260
+ else if (duplicates.length > 0 || !commands.every(isFullyInterpreted)) {
261
+ availability = "partial";
262
+ availabilityReason = "some-executed-commands-were-not-fully-interpreted";
263
+ }
264
+ else {
265
+ availability = "available";
266
+ }
267
+ return {
268
+ schemaVersion: "retrieval-evidence-v1",
269
+ availability,
270
+ ...(availabilityReason !== undefined ? { availabilityReason } : {}),
271
+ files: fileEvidence,
272
+ symbols: symbolEvidence,
273
+ commands: commands.map((entry) => duplicates.includes(entry.family) && entry.reason === undefined ? { ...entry, reason: REASON_DUPLICATE } : entry)
274
+ };
275
+ }
276
+ /**
277
+ * Thin adapter from the lifecycle's measured commands to the pure builder. Only the four retrieval
278
+ * families are considered; `index`, `version`, and any other command are ignored. Source stdout is dropped
279
+ * here on purpose.
280
+ */
281
+ export function buildRetrievalEvidenceFromCommands(commands, selection) {
282
+ const inputs = [];
283
+ for (const command of commands) {
284
+ if (command.commandId === "source") {
285
+ inputs.push({ family: "source", ok: command.ok });
286
+ }
287
+ else if (command.commandId === "search" || command.commandId === "lookup" || command.commandId === "slice") {
288
+ inputs.push({ family: command.commandId, ok: command.ok, stdout: command.stdout });
289
+ }
290
+ }
291
+ return buildRetrievalEvidence({ commands: inputs, selection });
292
+ }
@@ -0,0 +1,122 @@
1
+ import { normalizeRetrievedRepositoryPath } from "./buildRetrievalEvidence.js";
2
+ import { hasFactMapping, interpretFactContextTarget } from "./factContextTargets.js";
3
+ const asRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value) ? value : undefined;
4
+ function normalizedSet(values, normalize) {
5
+ if (!Array.isArray(values))
6
+ return undefined;
7
+ const result = new Set();
8
+ for (const value of values) {
9
+ const normalized = normalize(value);
10
+ if (normalized === null)
11
+ return undefined;
12
+ result.add(normalized);
13
+ }
14
+ return result;
15
+ }
16
+ const plainString = (value) => (typeof value === "string" && value.length > 0 ? value : null);
17
+ /**
18
+ * Ground-truth completeness for one retrieval-precision-recall case. Returns bounded error strings that never
19
+ * echo unsafe values. This rule set applies only to corpora that opt into retrieval-precision-recall; it is not
20
+ * a global answer-key rule.
21
+ */
22
+ export function validateRetrievalPrecisionRecallCase(evaluationCase) {
23
+ const label = `retrieval-precision-recall case ${typeof evaluationCase.id === "string" ? evaluationCase.id : "<unknown>"}`;
24
+ const answerKey = asRecord(evaluationCase.answerKey);
25
+ if (!answerKey)
26
+ return [`${label}: answerKey is required.`];
27
+ const errors = [];
28
+ const expectedFiles = normalizedSet(answerKey.expectedFiles, normalizeRetrievedRepositoryPath);
29
+ const expectedSymbols = normalizedSet(answerKey.expectedSymbols, plainString);
30
+ if (expectedFiles === undefined || expectedFiles.size === 0)
31
+ errors.push(`${label}: answerKey.expectedFiles must be a nonempty array of safe repository-relative paths.`);
32
+ if (expectedSymbols === undefined || expectedSymbols.size === 0)
33
+ errors.push(`${label}: answerKey.expectedSymbols must be a nonempty array of nonempty strings.`);
34
+ const topFiles = normalizedSet(evaluationCase.expectedFiles, normalizeRetrievedRepositoryPath);
35
+ const topSymbols = normalizedSet(evaluationCase.expectedSymbols, plainString);
36
+ const sameSet = (left, right) => left !== undefined && right !== undefined && left.size === right.size && [...left].every((value) => right.has(value));
37
+ if (expectedFiles !== undefined && !sameSet(topFiles, expectedFiles))
38
+ errors.push(`${label}: expectedFiles must agree with answerKey.expectedFiles.`);
39
+ if (expectedSymbols !== undefined && !sameSet(topSymbols, expectedSymbols))
40
+ errors.push(`${label}: expectedSymbols must agree with answerKey.expectedSymbols.`);
41
+ const facts = answerKey.expectedFacts;
42
+ const factIds = [];
43
+ if (!Array.isArray(facts) || facts.length === 0) {
44
+ errors.push(`${label}: answerKey.expectedFacts must be a nonempty array.`);
45
+ }
46
+ else {
47
+ for (const fact of facts) {
48
+ const id = asRecord(fact)?.id;
49
+ if (typeof id !== "string" || id.length === 0 || factIds.includes(id)) {
50
+ errors.push(`${label}: answerKey.expectedFacts must have unique nonempty ids.`);
51
+ break;
52
+ }
53
+ factIds.push(id);
54
+ }
55
+ }
56
+ const targets = answerKey.expectedContextTargets;
57
+ if (!Array.isArray(targets) || targets.length === 0) {
58
+ errors.push(`${label}: answerKey.expectedContextTargets is required for fact coverage.`);
59
+ return errors;
60
+ }
61
+ const known = new Set(factIds);
62
+ const requiredMappedFacts = new Set();
63
+ targets.forEach((target, index) => {
64
+ const targetLabel = `${label}: answerKey.expectedContextTargets[${index}]`;
65
+ const record = asRecord(target);
66
+ if (!record) {
67
+ errors.push(`${targetLabel}: target must be an object.`);
68
+ return;
69
+ }
70
+ if (hasFactMapping(target)) {
71
+ const interpretation = interpretFactContextTarget(target, known);
72
+ if (!interpretation.ok) {
73
+ errors.push(`${targetLabel}: ${interpretation.problem}.`);
74
+ return;
75
+ }
76
+ if (interpretation.target.required)
77
+ interpretation.target.factIds.forEach((id) => requiredMappedFacts.add(id));
78
+ }
79
+ // No hidden expectation outside the answer key, mapped or not.
80
+ const file = normalizeRetrievedRepositoryPath(record.file);
81
+ if (file === null) {
82
+ errors.push(`${targetLabel}: file must be a safe repository-relative path.`);
83
+ }
84
+ else if (expectedFiles !== undefined && !expectedFiles.has(file)) {
85
+ errors.push(`${targetLabel}: file must be listed in answerKey.expectedFiles.`);
86
+ }
87
+ if (Array.isArray(record.symbols) && expectedSymbols !== undefined) {
88
+ for (const symbol of record.symbols) {
89
+ if (typeof symbol !== "string" || !expectedSymbols.has(symbol)) {
90
+ errors.push(`${targetLabel}: every symbol must be listed in answerKey.expectedSymbols.`);
91
+ break;
92
+ }
93
+ }
94
+ }
95
+ });
96
+ for (const id of factIds) {
97
+ if (!requiredMappedFacts.has(id))
98
+ errors.push(`${label}: expected fact ${id} has no required fact-mapped context target.`);
99
+ }
100
+ return errors;
101
+ }
102
+ /** Validates every case of a production retrieval-precision-recall corpus; case ids must be unique. */
103
+ export function validateRetrievalPrecisionRecallCorpus(cases) {
104
+ const errors = [];
105
+ const seen = new Set();
106
+ for (const evaluationCase of cases) {
107
+ if (seen.has(evaluationCase.id))
108
+ errors.push(`retrieval-precision-recall corpus: duplicate case id ${evaluationCase.id}.`);
109
+ seen.add(evaluationCase.id);
110
+ errors.push(...validateRetrievalPrecisionRecallCase(evaluationCase));
111
+ }
112
+ return errors;
113
+ }
114
+ /**
115
+ * Per-case issue counts without any identity text. Use this, never the detailed messages, when reporting about an
116
+ * external private subject: the detailed messages may name expected facts.
117
+ */
118
+ export function summarizeRetrievalGroundTruthIssuesSafely(cases) {
119
+ return cases
120
+ .map((evaluationCase) => ({ caseId: evaluationCase.id, issueCount: validateRetrievalPrecisionRecallCase(evaluationCase).length }))
121
+ .filter((entry) => entry.issueCount > 0);
122
+ }