@dailephd/my-dev-kit-lab 0.7.2 → 0.8.0
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.
- package/CHANGELOG.md +15 -1
- package/README.md +7 -3
- package/benchmarks/contracts/warm-index-benchmark-cases.json +330 -0
- package/dist/src/cli/help.js +12 -0
- package/dist/src/commands/runExperimentDescribeCommand.js +9 -1
- package/dist/src/commands/runExperimentRunCommand.js +95 -4
- package/dist/src/evaluation/benchmarkMetadata.js +13 -0
- package/dist/src/evaluation/retrievalQuality/buildRetrievalEvidence.js +292 -0
- package/dist/src/evaluation/retrievalQuality/corpusCompleteness.js +122 -0
- package/dist/src/evaluation/retrievalQuality/factContextTargets.js +44 -0
- package/dist/src/evaluation/retrievalQuality/index.js +5 -0
- package/dist/src/evaluation/retrievalQuality/metrics.js +233 -0
- package/dist/src/evaluation/retrievalQuality/types.js +9 -0
- package/dist/src/evaluation/runMyDevKitRetrieval.js +3 -0
- package/dist/src/experiments/defaultRegistry.js +2 -0
- package/dist/src/experiments/plugins/index.js +1 -0
- package/dist/src/experiments/plugins/retrievalPrecisionRecall/config.js +39 -0
- package/dist/src/experiments/plugins/retrievalPrecisionRecall/execution.js +152 -0
- package/dist/src/experiments/plugins/retrievalPrecisionRecall/executionArtifact.js +15 -0
- package/dist/src/experiments/plugins/retrievalPrecisionRecall/index.js +9 -0
- package/dist/src/experiments/plugins/retrievalPrecisionRecall/localSubjectExecution.js +139 -0
- package/dist/src/experiments/plugins/retrievalPrecisionRecall/localSubjectPrivacy.js +119 -0
- package/dist/src/experiments/plugins/retrievalPrecisionRecall/metadata.js +21 -0
- package/dist/src/experiments/plugins/retrievalPrecisionRecall/metrics.js +158 -0
- package/dist/src/experiments/plugins/retrievalPrecisionRecall/plugin.js +253 -0
- package/dist/src/experiments/plugins/retrievalPrecisionRecall/types.js +8 -0
- package/dist/src/report/experiments/buildPluginExperimentReport.js +21 -2
- package/dist/src/report/experiments/buildRetrievalPrecisionRecallReport.js +83 -0
- package/dist/src/report/experiments/index.js +4 -0
- package/dist/src/report/experiments/renderPluginExperimentReportHtml.js +3 -0
- package/dist/src/report/experiments/renderPluginExperimentReportText.js +9 -0
- package/dist/src/report/experiments/renderRetrievalPrecisionRecallHtml.js +96 -0
- package/dist/src/report/experiments/renderRetrievalPrecisionRecallText.js +121 -0
- package/dist/src/report/experiments/retrievalPrecisionRecallReportModel.js +3 -0
- package/docs/METRICS.md +32 -2
- 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, 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,13 @@ 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"];
|
|
54
62
|
export async function runExperimentRunCommandFromArgs(argv, options = {}) {
|
|
55
63
|
try {
|
|
56
64
|
const args = parseRunExperimentArgs(argv);
|
|
@@ -300,16 +308,43 @@ export function parseRunExperimentArgs(argv) {
|
|
|
300
308
|
const KIT_COMMAND_PLUGIN_IDS = [
|
|
301
309
|
warmIndexReusePlugin.metadata.id,
|
|
302
310
|
incrementalChangeStalenessPlugin.metadata.id,
|
|
303
|
-
contextWindowScalingPlugin.metadata.id
|
|
311
|
+
contextWindowScalingPlugin.metadata.id,
|
|
312
|
+
retrievalPrecisionRecallPlugin.metadata.id
|
|
304
313
|
];
|
|
314
|
+
if (experimentId === retrievalPrecisionRecallPlugin.metadata.id) {
|
|
315
|
+
// Mode matrix, no precedence rules: neither flag = bundled; both = external-local; exactly one is rejected.
|
|
316
|
+
if (targetPath !== undefined && localSubjectConfigPath === undefined) {
|
|
317
|
+
throw new Error(`External ${experimentId} targets require --local-subject-config.`);
|
|
318
|
+
}
|
|
319
|
+
if (localSubjectConfigPath !== undefined && targetPath === undefined) {
|
|
320
|
+
throw new Error(`--local-subject-config requires an external --target for ${experimentId}.`);
|
|
321
|
+
}
|
|
322
|
+
const externalMode = targetPath !== undefined;
|
|
323
|
+
if (externalMode && (seenFlags.includes("--case") || seenFlags.includes("--benchmark-project"))) {
|
|
324
|
+
throw new Error("--case and --benchmark-project cannot be combined with --local-subject-config; the local subject config owns the case set.");
|
|
325
|
+
}
|
|
326
|
+
const allowedFlags = externalMode ? RETRIEVAL_PRECISION_RECALL_EXTERNAL_ALLOWED_FLAGS : RETRIEVAL_PRECISION_RECALL_ALLOWED_FLAGS;
|
|
327
|
+
const unsupported = [...new Set(seenFlags.filter((flag) => !allowedFlags.includes(flag)))];
|
|
328
|
+
if (unsupported.length > 0) {
|
|
329
|
+
throw new Error(`${unsupported.join(", ")} ${unsupported.length === 1 ? "is" : "are"} not supported for --experiment ${experimentId}${externalMode ? " in external-local mode" : ""}; supported options: ${allowedFlags.join(", ")}.`);
|
|
330
|
+
}
|
|
331
|
+
if (seenFlags.includes("--case") && caseIds.length === 0) {
|
|
332
|
+
throw new Error("--case must list at least one case id.");
|
|
333
|
+
}
|
|
334
|
+
if (seenFlags.includes("--benchmark-project") && benchmarkProjects.length === 0) {
|
|
335
|
+
throw new Error("--benchmark-project must list at least one benchmark project id.");
|
|
336
|
+
}
|
|
337
|
+
}
|
|
305
338
|
if (contextBudgets !== undefined && experimentId !== contextWindowScalingPlugin.metadata.id) {
|
|
306
339
|
throw new Error(`--context-budgets is only supported for --experiment ${contextWindowScalingPlugin.metadata.id}.`);
|
|
307
340
|
}
|
|
308
341
|
if (syntheticConfigPath !== undefined && experimentId !== contextWindowScalingPlugin.metadata.id) {
|
|
309
342
|
throw new Error(`--synthetic-config is only supported for --experiment ${contextWindowScalingPlugin.metadata.id}.`);
|
|
310
343
|
}
|
|
311
|
-
if (localSubjectConfigPath !== undefined &&
|
|
312
|
-
|
|
344
|
+
if (localSubjectConfigPath !== undefined &&
|
|
345
|
+
experimentId !== contextWindowScalingPlugin.metadata.id &&
|
|
346
|
+
experimentId !== retrievalPrecisionRecallPlugin.metadata.id) {
|
|
347
|
+
throw new Error(`--local-subject-config is only supported for --experiment ${contextWindowScalingPlugin.metadata.id} or ${retrievalPrecisionRecallPlugin.metadata.id}.`);
|
|
313
348
|
}
|
|
314
349
|
if (experimentId === contextWindowScalingPlugin.metadata.id) {
|
|
315
350
|
const unsupported = [...new Set(seenFlags.filter((flag) => !CONTEXT_WINDOW_SCALING_ALLOWED_FLAGS.includes(flag)))];
|
|
@@ -426,8 +461,64 @@ async function loadPluginInputs(args, toolRoot, context, outputRoot) {
|
|
|
426
461
|
if (args.experimentId === contextWindowScalingPlugin.metadata.id) {
|
|
427
462
|
return loadContextWindowScalingInputs(args, toolRoot, context, outputRoot);
|
|
428
463
|
}
|
|
464
|
+
if (args.experimentId === retrievalPrecisionRecallPlugin.metadata.id) {
|
|
465
|
+
return args.localSubjectConfigPath !== undefined
|
|
466
|
+
? loadRetrievalPrecisionRecallLocalSubjectInputs(args, toolRoot, context, outputRoot)
|
|
467
|
+
: loadRetrievalPrecisionRecallInputs(args, toolRoot, context);
|
|
468
|
+
}
|
|
429
469
|
return undefined;
|
|
430
470
|
}
|
|
471
|
+
// External-local retrieval-precision-recall: the existing local-subject loader owns the repository, inventory and case
|
|
472
|
+
// adapter. The only additions are the physical output-root check before anything is created and a ground-truth gate
|
|
473
|
+
// whose message carries logical case ids and counts only (an external subject may have private fact ids and names).
|
|
474
|
+
async function loadRetrievalPrecisionRecallLocalSubjectInputs(args, toolRoot, context, outputRoot) {
|
|
475
|
+
const validation = retrievalPrecisionRecallPlugin.validateConfig(args.config);
|
|
476
|
+
if (!validation.valid || !validation.config) {
|
|
477
|
+
throw new Error(`Invalid retrieval precision/recall config: ${validation.errors.join("; ")}`);
|
|
478
|
+
}
|
|
479
|
+
if (outputRoot === undefined || args.localSubjectConfigPath === undefined) {
|
|
480
|
+
throw new Error("Internal error: external-local runs require a resolved experiment output root and config path.");
|
|
481
|
+
}
|
|
482
|
+
const target = resolveExperimentTarget(args.targetPath, toolRoot);
|
|
483
|
+
if (target.isSelf || target.kind !== "external-local") {
|
|
484
|
+
throw new Error("Local-repository subject mode requires an external-local target; --target must not be the Lab repository.");
|
|
485
|
+
}
|
|
486
|
+
// Fail closed before anything is created: the output root (and the private scratch beneath it) must be physically outside the target.
|
|
487
|
+
try {
|
|
488
|
+
await assertWorkRootOutsideTarget(outputRoot, target.targetRoot);
|
|
489
|
+
}
|
|
490
|
+
catch {
|
|
491
|
+
throw new Error("Experiment output root must not be inside the external target project.");
|
|
492
|
+
}
|
|
493
|
+
const configPath = path.resolve(context.invocationCwd, args.localSubjectConfigPath);
|
|
494
|
+
const localSubjectConfig = await readJsonConfigFile("--local-subject-config", configPath, args.localSubjectConfigPath);
|
|
495
|
+
const localSubject = await loadLocalRepositorySubject({ config: localSubjectConfig, repositoryPath: target.targetRoot });
|
|
496
|
+
const groundTruthIssues = summarizeRetrievalGroundTruthIssuesSafely(localSubject.evaluationCases);
|
|
497
|
+
if (groundTruthIssues.length > 0) {
|
|
498
|
+
throw new Error(`Invalid retrieval-precision-recall ground truth for the local subject (${groundTruthIssues
|
|
499
|
+
.map((issue) => `case ${issue.caseId}: ${issue.issueCount} issue${issue.issueCount === 1 ? "" : "s"}`)
|
|
500
|
+
.join("; ")}); details withheld.`);
|
|
501
|
+
}
|
|
502
|
+
return { cases: localSubject.evaluationCases, localSubject, env: process.env };
|
|
503
|
+
}
|
|
504
|
+
// The frozen bundled corpus is read through the established resource resolver and validation path, then must satisfy
|
|
505
|
+
// the retrieval-precision-recall ground-truth completeness rules. Selection filters are applied by the plugin.
|
|
506
|
+
async function loadRetrievalPrecisionRecallInputs(args, toolRoot, context) {
|
|
507
|
+
const validation = retrievalPrecisionRecallPlugin.validateConfig(args.config);
|
|
508
|
+
if (!validation.valid || !validation.config) {
|
|
509
|
+
throw new Error(`Invalid retrieval precision/recall config: ${validation.errors.join("; ")}`);
|
|
510
|
+
}
|
|
511
|
+
const projectProfiles = await readBenchmarkProjectProfiles(resolvePackageResource(context, DEFAULT_PROJECT_PROFILES_RESOURCE), toolRoot);
|
|
512
|
+
const cases = await readEvaluationCases(resolvePackageResource(context, RETRIEVAL_PRECISION_RECALL_CASES_RESOURCE), toolRoot, {
|
|
513
|
+
projectProfiles,
|
|
514
|
+
requireProjectProfileRef: true
|
|
515
|
+
});
|
|
516
|
+
const corpusErrors = validateRetrievalPrecisionRecallCorpus(cases);
|
|
517
|
+
if (corpusErrors.length > 0) {
|
|
518
|
+
throw new Error(`Invalid retrieval-precision-recall corpus: ${corpusErrors.join(" ")}`);
|
|
519
|
+
}
|
|
520
|
+
return { cases };
|
|
521
|
+
}
|
|
431
522
|
// --case is a selection over the frozen bundled catalog (catalog order, not argument order);
|
|
432
523
|
// the rest of the CLI config is the plugin's own ContextWindowScalingConfig.
|
|
433
524
|
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
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { normalizeRetrievedRepositoryPath } from "./buildRetrievalEvidence.js";
|
|
2
|
+
/** True when the target participates in fact mapping, i.e. it carries a `factIds` field. */
|
|
3
|
+
export function hasFactMapping(target) {
|
|
4
|
+
return typeof target === "object" && target !== null && !Array.isArray(target) && target.factIds !== undefined;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Single owner of the `ExpectedContextTarget.factIds` contract. Pure. Problems are fixed bounded strings and
|
|
8
|
+
* never echo the offending value. Unsafe file identities are rejected, never repaired.
|
|
9
|
+
*/
|
|
10
|
+
export function interpretFactContextTarget(target, knownFactIds) {
|
|
11
|
+
if (typeof target !== "object" || target === null || Array.isArray(target)) {
|
|
12
|
+
return { ok: false, problem: "target must be an object" };
|
|
13
|
+
}
|
|
14
|
+
const record = target;
|
|
15
|
+
const file = normalizeRetrievedRepositoryPath(record.file);
|
|
16
|
+
if (file === null) {
|
|
17
|
+
return { ok: false, problem: "file must be a safe repository-relative path" };
|
|
18
|
+
}
|
|
19
|
+
const rawFactIds = record.factIds;
|
|
20
|
+
if (!Array.isArray(rawFactIds) || rawFactIds.length === 0 || rawFactIds.some((id) => typeof id !== "string" || id.length === 0)) {
|
|
21
|
+
return { ok: false, problem: "factIds must be a nonempty array of nonempty strings" };
|
|
22
|
+
}
|
|
23
|
+
const factIds = rawFactIds;
|
|
24
|
+
if (new Set(factIds).size !== factIds.length) {
|
|
25
|
+
return { ok: false, problem: "factIds must not contain duplicates" };
|
|
26
|
+
}
|
|
27
|
+
if (factIds.some((id) => !knownFactIds.has(id))) {
|
|
28
|
+
return { ok: false, problem: "factIds must reference existing expectedFacts ids" };
|
|
29
|
+
}
|
|
30
|
+
let symbols = [];
|
|
31
|
+
if (record.symbols !== undefined) {
|
|
32
|
+
if (!Array.isArray(record.symbols) || record.symbols.some((symbol) => typeof symbol !== "string" || symbol.length === 0)) {
|
|
33
|
+
return { ok: false, problem: "symbols must be an array of nonempty strings" };
|
|
34
|
+
}
|
|
35
|
+
symbols = record.symbols;
|
|
36
|
+
if (new Set(symbols).size !== symbols.length) {
|
|
37
|
+
return { ok: false, problem: "symbols must not contain duplicates" };
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if (record.required !== undefined && typeof record.required !== "boolean") {
|
|
41
|
+
return { ok: false, problem: "required must be a boolean when present" };
|
|
42
|
+
}
|
|
43
|
+
return { ok: true, target: { file, symbols: [...symbols], required: record.required !== false, factIds: [...factIds] } };
|
|
44
|
+
}
|