@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
@@ -0,0 +1,303 @@
1
+ /**
2
+ * Semantic retrieval strategies (`data-model-graph`, `model-view-lineage`) executed against an
3
+ * already prepared my-dev-kit index through `data-model` queries.
4
+ *
5
+ * The target is chosen from the task query and the upstream data-model graph only; the answer key is
6
+ * never read. Output is normalized into `retrieval-query-strategy-evidence-v1`, never into
7
+ * `retrieval-evidence-v1`, because these commands are not search/lookup/slice/source.
8
+ */
9
+ import { readFile } from "node:fs/promises";
10
+ import path from "node:path";
11
+ import { countEstimatedTokens, countTextChars, tokenCountMethod } from "../core/countTokens.js";
12
+ import { runMeasuredCommand } from "../core/runMeasuredCommand.js";
13
+ import { compareCodeUnits } from "./indexSnapshot.js";
14
+ import { normalizeRetrievedRepositoryPath } from "./retrievalQuality/buildRetrievalEvidence.js";
15
+ import { canonicalizeQueryStrategyIdentities } from "./retrievalQueryStrategyEvidence.js";
16
+ const REJECTED_REASON = "some-semantic-identities-were-rejected";
17
+ function isRecord(value) {
18
+ return !!value && typeof value === "object" && !Array.isArray(value);
19
+ }
20
+ function parseJsonIfPossible(text) {
21
+ try {
22
+ return JSON.parse(text);
23
+ }
24
+ catch {
25
+ return undefined;
26
+ }
27
+ }
28
+ /**
29
+ * Lowercases, splits on every run of non ASCII alphanumerics, drops tokens shorter than two
30
+ * characters, deduplicates, and sorts in code-unit order. No stemming, synonyms, or stop words.
31
+ */
32
+ export function tokenizeSemanticRetrievalQuery(query) {
33
+ const tokens = query
34
+ .toLowerCase()
35
+ .split(/[^A-Za-z0-9]+/)
36
+ .filter((token) => token.length >= 2);
37
+ return [...new Set(tokens)].sort(compareCodeUnits);
38
+ }
39
+ /** Count of unique query tokens that occur exactly as label tokens. */
40
+ export function scoreSemanticLabel(queryTokens, label) {
41
+ const labelTokens = new Set(tokenizeSemanticRetrievalQuery(label));
42
+ let score = 0;
43
+ for (const token of new Set(queryTokens)) {
44
+ if (labelTokens.has(token))
45
+ score += 1;
46
+ }
47
+ return score;
48
+ }
49
+ function step(kind, succeeded, evidenceAvailable, reason) {
50
+ return { kind, succeeded, evidenceAvailable, reason };
51
+ }
52
+ function buildEvidence(strategyId, availability, availabilityReason, identities, steps) {
53
+ const canonical = canonicalizeQueryStrategyIdentities(identities.files.map((file) => ({ path: file })), identities.symbols);
54
+ return {
55
+ schemaVersion: "retrieval-query-strategy-evidence-v1",
56
+ strategyId,
57
+ availability,
58
+ availabilityReason,
59
+ files: canonical.files,
60
+ symbols: canonical.symbols,
61
+ steps
62
+ };
63
+ }
64
+ function unavailableEvidence(strategyId, reason, steps) {
65
+ return buildEvidence(strategyId, "unavailable", reason, { files: [], symbols: [] }, steps);
66
+ }
67
+ async function readDataModelGraph(indexDir) {
68
+ let text;
69
+ try {
70
+ text = await readFile(path.join(indexDir, "data-model-graph.json"), "utf8");
71
+ }
72
+ catch {
73
+ return { ok: false, reason: "data-model-graph-artifact-missing" };
74
+ }
75
+ const payload = parseJsonIfPossible(text);
76
+ if (!isRecord(payload))
77
+ return { ok: false, reason: "data-model-graph-artifact-malformed" };
78
+ if (payload.artifactKind !== "my-dev-kit-v1-data-model-graph" ||
79
+ typeof payload.schemaVersion !== "string" ||
80
+ !payload.schemaVersion.startsWith("1.")) {
81
+ return { ok: false, reason: "data-model-graph-schema-unsupported" };
82
+ }
83
+ if (!Array.isArray(payload.nodes) || !Array.isArray(payload.edges)) {
84
+ return { ok: false, reason: "data-model-graph-artifact-malformed" };
85
+ }
86
+ const nodes = [];
87
+ for (const node of payload.nodes) {
88
+ if (isRecord(node) &&
89
+ typeof node.id === "string" &&
90
+ typeof node.label === "string" &&
91
+ (node.kind === "entity" || node.kind === "field")) {
92
+ nodes.push({ id: node.id, kind: node.kind, label: node.label });
93
+ }
94
+ }
95
+ return { ok: true, nodes };
96
+ }
97
+ /** Highest score wins; ties break on node id in code-unit order. No candidate means no match. */
98
+ function selectSemanticCandidate(queryTokens, nodes) {
99
+ let best;
100
+ for (const node of nodes) {
101
+ const score = scoreSemanticLabel(queryTokens, node.label);
102
+ if (score <= 0)
103
+ continue;
104
+ if (!best || score > best.score || (score === best.score && compareCodeUnits(node.id, best.node.id) < 0)) {
105
+ best = { node, score };
106
+ }
107
+ }
108
+ return best?.node;
109
+ }
110
+ /** Normalizes `filePath` of every ref; unsafe values are counted, never kept. */
111
+ function collectSafeFiles(refs) {
112
+ const files = [];
113
+ let rejected = 0;
114
+ for (const ref of refs) {
115
+ const normalized = isRecord(ref) ? normalizeRetrievedRepositoryPath(ref.filePath) : null;
116
+ if (normalized === null) {
117
+ rejected += 1;
118
+ }
119
+ else {
120
+ files.push(normalized);
121
+ }
122
+ }
123
+ return { files, rejected };
124
+ }
125
+ function uniqueFileOrNull(files) {
126
+ const unique = [...new Set(files)];
127
+ return unique.length === 1 ? unique[0] : null;
128
+ }
129
+ function readEntityOutput(payload, expectedMode) {
130
+ if (!isRecord(payload) || payload.status !== "ok" || typeof payload.mode !== "string")
131
+ return "malformed";
132
+ if (payload.mode !== expectedMode)
133
+ return "mismatched";
134
+ return payload;
135
+ }
136
+ function readIdentity(value) {
137
+ return isRecord(value) && typeof value.id === "string" && typeof value.name === "string"
138
+ ? { id: value.id, name: value.name }
139
+ : undefined;
140
+ }
141
+ function buildDataModelGraphStrategyEvidence(selectedNode, lookupPayload, generationCommand, lookupCommand) {
142
+ const strategyId = "data-model-graph";
143
+ const generationStep = step("data-model", generationCommand.ok, generationCommand.ok, null);
144
+ const failed = (reason) => unavailableEvidence(strategyId, reason, [generationStep, step("data-model", lookupCommand.ok, false, reason)]);
145
+ if (!lookupCommand.ok)
146
+ return failed("data-model-lookup-command-failed");
147
+ const expectedMode = selectedNode.kind === "entity" ? "entity" : "field";
148
+ const output = readEntityOutput(lookupPayload, expectedMode);
149
+ if (output === "malformed")
150
+ return failed("data-model-lookup-output-malformed");
151
+ if (output === "mismatched")
152
+ return failed("data-model-lookup-output-mismatched");
153
+ const selected = readIdentity(expectedMode === "entity" ? output.entity : output.field);
154
+ if (!selected || !Array.isArray(output.sourceRefs))
155
+ return failed("data-model-lookup-output-malformed");
156
+ const { files, rejected } = collectSafeFiles(output.sourceRefs);
157
+ const symbol = {
158
+ name: selected.name,
159
+ nodeId: selected.id,
160
+ file: uniqueFileOrNull(files)
161
+ };
162
+ const partial = rejected > 0;
163
+ return buildEvidence(strategyId, partial ? "partial" : "available", partial ? REJECTED_REASON : null, { files, symbols: [symbol] }, [generationStep, step("data-model", true, !partial, partial ? REJECTED_REASON : null)]);
164
+ }
165
+ function buildModelViewLineageStrategyEvidence(tracePayload, generationCommand, traceCommand, expectedMode) {
166
+ const strategyId = "model-view-lineage";
167
+ const generationStep = step("data-model", generationCommand.ok, generationCommand.ok, null);
168
+ const failed = (reason) => unavailableEvidence(strategyId, reason, [generationStep, step("model-view-lineage", traceCommand.ok, false, reason)]);
169
+ if (!traceCommand.ok)
170
+ return failed("model-view-lineage-command-failed");
171
+ const output = readEntityOutput(tracePayload, expectedMode);
172
+ if (output === "malformed")
173
+ return failed("model-view-lineage-output-malformed");
174
+ if (output === "mismatched")
175
+ return failed("model-view-lineage-output-mismatched");
176
+ const lineage = output.lineage;
177
+ if (!readIdentity(output.entity) || !isRecord(lineage) || !Array.isArray(lineage.nodes) || !Array.isArray(lineage.edges)) {
178
+ return failed("model-view-lineage-output-malformed");
179
+ }
180
+ const files = [];
181
+ const symbols = [];
182
+ let rejected = 0;
183
+ for (const node of lineage.nodes) {
184
+ if (!isRecord(node) || typeof node.id !== "string" || typeof node.label !== "string") {
185
+ rejected += 1;
186
+ continue;
187
+ }
188
+ let nodeFiles = [];
189
+ if (Array.isArray(node.evidenceRefs)) {
190
+ const collected = collectSafeFiles(node.evidenceRefs);
191
+ nodeFiles = collected.files;
192
+ rejected += collected.rejected;
193
+ }
194
+ else if (node.evidenceRefs !== undefined) {
195
+ rejected += 1;
196
+ }
197
+ files.push(...nodeFiles);
198
+ symbols.push({ name: node.label, nodeId: node.id, file: uniqueFileOrNull(nodeFiles) });
199
+ }
200
+ const partial = rejected > 0;
201
+ return buildEvidence(strategyId, partial ? "partial" : "available", partial ? REJECTED_REASON : null, { files, symbols }, [generationStep, step("model-view-lineage", true, !partial, partial ? REJECTED_REASON : null)]);
202
+ }
203
+ async function runDataModelCommand(options, commandId, args, commands) {
204
+ const command = await runMeasuredCommand({
205
+ commandId,
206
+ commandString: options.kitCommand,
207
+ cwd: process.cwd(),
208
+ outDir: options.commandsDir,
209
+ extraArgs: ["data-model", "--index", options.indexDir, ...args, "--json"]
210
+ });
211
+ commands.push(command);
212
+ if (!command.ok && options.requireKit) {
213
+ throw new Error(command.error || `my-dev-kit data-model failed with exit code ${command.exitCode}`);
214
+ }
215
+ return command;
216
+ }
217
+ function buildResult(options, started, commands, evidence, contextText) {
218
+ const warnings = [];
219
+ if (evidence.availability === "unavailable") {
220
+ warnings.push(`my-dev-kit semantic retrieval was unavailable: ${evidence.availabilityReason}.`);
221
+ }
222
+ else if (evidence.availability === "partial") {
223
+ warnings.push(`my-dev-kit semantic retrieval was partially interpreted: ${evidence.availabilityReason}.`);
224
+ }
225
+ const text = evidence.availability === "unavailable" ? "" : contextText;
226
+ return {
227
+ caseId: options.evaluationCase.id,
228
+ strategyId: options.strategyId,
229
+ skipped: text.length === 0,
230
+ warnings,
231
+ totalChars: countTextChars(text),
232
+ totalEstimatedTokens: countEstimatedTokens(text),
233
+ tokenCountMethod,
234
+ contextText: text,
235
+ commands,
236
+ queryStrategyEvidence: evidence,
237
+ durationMs: Date.now() - started
238
+ };
239
+ }
240
+ /** Shared by both strategies: generate artifacts, validate the graph, select one semantic node. */
241
+ async function resolveSemanticTarget(options, commands) {
242
+ const generation = await runDataModelCommand(options, "data-model", [], commands);
243
+ if (!generation.ok) {
244
+ return {
245
+ kind: "done",
246
+ evidence: unavailableEvidence(options.strategyId, "data-model-command-failed", [
247
+ step("data-model", false, false, "data-model-command-failed")
248
+ ])
249
+ };
250
+ }
251
+ const generationStep = step("data-model", true, true, null);
252
+ const graph = await readDataModelGraph(options.indexDir);
253
+ if (!graph.ok) {
254
+ return {
255
+ kind: "done",
256
+ evidence: unavailableEvidence(options.strategyId, graph.reason, [step("data-model", true, false, graph.reason)])
257
+ };
258
+ }
259
+ const node = selectSemanticCandidate(tokenizeSemanticRetrievalQuery(options.evaluationCase.query), graph.nodes);
260
+ if (!node) {
261
+ return {
262
+ kind: "done",
263
+ evidence: buildEvidence(options.strategyId, "available", null, { files: [], symbols: [] }, [generationStep])
264
+ };
265
+ }
266
+ return { kind: "node", node, generation };
267
+ }
268
+ async function executeDataModelGraphStrategy(options, started) {
269
+ const commands = [];
270
+ const resolved = await resolveSemanticTarget(options, commands);
271
+ if (resolved.kind === "done")
272
+ return buildResult(options, started, commands, resolved.evidence, "");
273
+ const selector = resolved.node.kind === "entity" ? ["--entity", resolved.node.label] : ["--field", resolved.node.label];
274
+ const lookup = await runDataModelCommand(options, "data-model-selected", selector, commands);
275
+ const evidence = buildDataModelGraphStrategyEvidence(resolved.node, lookup.ok ? parseJsonIfPossible(lookup.stdout) : undefined, resolved.generation, lookup);
276
+ return buildResult(options, started, commands, evidence, lookup.stdout);
277
+ }
278
+ async function executeModelViewLineageStrategy(options, started) {
279
+ const commands = [];
280
+ const resolved = await resolveSemanticTarget(options, commands);
281
+ if (resolved.kind === "done")
282
+ return buildResult(options, started, commands, resolved.evidence, "");
283
+ const isEntity = resolved.node.kind === "entity";
284
+ const selector = isEntity ? ["--trace-view", resolved.node.label] : ["--field", resolved.node.label, "--trace-view"];
285
+ const trace = await runDataModelCommand(options, "model-view-lineage", selector, commands);
286
+ const evidence = buildModelViewLineageStrategyEvidence(trace.ok ? parseJsonIfPossible(trace.stdout) : undefined, resolved.generation, trace, isEntity ? "trace-entity" : "trace-field");
287
+ return buildResult(options, started, commands, evidence, trace.stdout);
288
+ }
289
+ /**
290
+ * Executes one semantic retrieval strategy against an already prepared index. Never invokes `index`,
291
+ * never reads the answer key, never runs agents.
292
+ */
293
+ export async function runSemanticRetrievalStrategyFromIndex(options) {
294
+ const started = Date.now();
295
+ switch (options.strategyId) {
296
+ case "data-model-graph":
297
+ return executeDataModelGraphStrategy(options, started);
298
+ case "model-view-lineage":
299
+ return executeModelViewLineageStrategy(options, started);
300
+ default:
301
+ throw new Error(`Unsupported semantic retrieval strategy: ${String(options.strategyId)}`);
302
+ }
303
+ }
@@ -2,6 +2,8 @@ import { ExperimentPluginRegistry } from "./registry.js";
2
2
  import { contextStrategyComparisonPlugin } from "./plugins/contextStrategyComparison/index.js";
3
3
  import { contextWindowScalingPlugin } from "./plugins/contextWindowScaling/index.js";
4
4
  import { incrementalChangeStalenessPlugin } from "./plugins/incrementalChangeStaleness/index.js";
5
+ import { retrievalPrecisionRecallPlugin } from "./plugins/retrievalPrecisionRecall/index.js";
6
+ import { retrievalQueryStrategyComparisonPlugin } from "./plugins/retrievalQueryStrategyComparison/index.js";
5
7
  import { warmIndexReusePlugin } from "./plugins/warmIndexReuse/index.js";
6
8
  export function createDefaultExperimentPluginRegistry() {
7
9
  const registry = new ExperimentPluginRegistry();
@@ -9,5 +11,7 @@ export function createDefaultExperimentPluginRegistry() {
9
11
  registry.register(warmIndexReusePlugin);
10
12
  registry.register(incrementalChangeStalenessPlugin);
11
13
  registry.register(contextWindowScalingPlugin);
14
+ registry.register(retrievalPrecisionRecallPlugin);
15
+ registry.register(retrievalQueryStrategyComparisonPlugin);
12
16
  return registry;
13
17
  }
@@ -83,6 +83,8 @@ export function projectRunForExternalLocalPersistence(run, target) {
83
83
  metadata.outputRoot = EXTERNAL_LOCAL_REDACTED_VALUE;
84
84
  if (typeof metadata.executionArtifactPath === "string")
85
85
  metadata.executionArtifactPath = path.basename(metadata.executionArtifactPath);
86
+ if (typeof metadata.analysisArtifactPath === "string")
87
+ metadata.analysisArtifactPath = path.basename(metadata.analysisArtifactPath);
86
88
  return { ...run, target, metadata: metadata };
87
89
  }
88
90
  const GENERIC_ISSUE_TEXT = {
@@ -2,3 +2,5 @@ export * from "./contextStrategyComparison/index.js";
2
2
  export * from "./contextWindowScaling/index.js";
3
3
  export * from "./incrementalChangeStaleness/index.js";
4
4
  export * from "./warmIndexReuse/index.js";
5
+ export * from "./retrievalPrecisionRecall/index.js";
6
+ export * from "./retrievalQueryStrategyComparison/index.js";
@@ -0,0 +1,39 @@
1
+ import { invalidExperimentConfig, isPlainObject, mergeConfig, validExperimentConfig } from "../../config.js";
2
+ export const defaultRetrievalPrecisionRecallConfig = {
3
+ outDir: "lab-output/retrieval-precision-recall",
4
+ kitCommand: "npx @dailephd/my-dev-kit@latest"
5
+ };
6
+ const SUPPORTED_FIELDS = ["outDir", "kitCommand", "caseIds", "benchmarkProjects"];
7
+ export const retrievalPrecisionRecallConfigDefinition = {
8
+ fields: [
9
+ { name: "outDir", type: "string", required: true, description: "Experiment output root." },
10
+ { name: "kitCommand", type: "string", description: "my-dev-kit command used for index and retrieval." },
11
+ { name: "caseIds", type: "array", description: "Bundled evaluation case IDs to include (corpus order is preserved)." },
12
+ { name: "benchmarkProjects", type: "array", description: "Bundled benchmark project IDs to include (corpus order is preserved)." }
13
+ ]
14
+ };
15
+ export function validateRetrievalPrecisionRecallConfig(config) {
16
+ if (config !== undefined && !isPlainObject(config)) {
17
+ return invalidExperimentConfig(["retrieval precision/recall config must be an object."]);
18
+ }
19
+ const errors = [];
20
+ const unsupported = Object.keys(config ?? {}).filter((key) => !SUPPORTED_FIELDS.includes(key));
21
+ if (unsupported.length > 0) {
22
+ errors.push(`Unsupported retrieval-precision-recall config field(s): ${unsupported.sort().join(", ")}.`);
23
+ }
24
+ const normalized = mergeConfig(defaultRetrievalPrecisionRecallConfig, config);
25
+ for (const field of ["outDir", "kitCommand"]) {
26
+ const value = normalized[field];
27
+ if (typeof value !== "string" || !value.trim())
28
+ errors.push(`${field} must be a non-empty string.`);
29
+ }
30
+ for (const field of ["caseIds", "benchmarkProjects"]) {
31
+ const value = normalized[field];
32
+ if (value === undefined)
33
+ continue;
34
+ if (!Array.isArray(value) || value.length === 0 || !value.every((item) => typeof item === "string" && item.trim())) {
35
+ errors.push(`${field} must be a non-empty array of non-empty strings when provided.`);
36
+ }
37
+ }
38
+ return errors.length > 0 ? invalidExperimentConfig(errors) : validExperimentConfig(normalized);
39
+ }
@@ -0,0 +1,152 @@
1
+ import path from "node:path";
2
+ import { resolveWithinRoot } from "../../../core/pathSafety.js";
3
+ import { buildMyDevKitIndex, runMyDevKitRetrievalFromIndex } from "../../../evaluation/runMyDevKitRetrieval.js";
4
+ import { calculateRetrievalQualityMetrics, validateRetrievalPrecisionRecallCase } from "../../../evaluation/retrievalQuality/index.js";
5
+ import { groupWarmIndexCases, taskOutputSegment } from "../warmIndexReuse/selection.js";
6
+ const MAX_MESSAGE_LENGTH = 300;
7
+ const MAX_WARNINGS = 20;
8
+ /** Bounds a message and replaces machine-local paths so persisted errors never carry them. */
9
+ export function boundedRetrievalSafeMessage(message, knownRoots = []) {
10
+ let text = message;
11
+ for (const root of knownRoots) {
12
+ if (!root)
13
+ continue;
14
+ text = text.split(root).join("<path>").split(root.replace(/\\/g, "/")).join("<path>");
15
+ }
16
+ text = text
17
+ .replace(/[A-Za-z]:[\\/][^\s"'`]*/g, "<path>")
18
+ .replace(/(?:^|(?<=[\s"'`(]))\/(?:Users|home|tmp|var|private|opt|mnt)\/[^\s"'`]*/g, "<path>")
19
+ .replace(/[\u0000-\u001f\u007f]/g, " ");
20
+ return text.length > MAX_MESSAGE_LENGTH ? `${text.slice(0, MAX_MESSAGE_LENGTH)}...` : text;
21
+ }
22
+ const errorText = (error) => (error instanceof Error ? error.message : String(error));
23
+ function failedCase(evaluationCase, code, message) {
24
+ return {
25
+ caseId: evaluationCase.id,
26
+ caseName: evaluationCase.title,
27
+ benchmarkProject: evaluationCase.benchmarkProject,
28
+ taskLocality: evaluationCase.taskLocality ?? null,
29
+ status: "failed",
30
+ retrieval: null,
31
+ quality: null,
32
+ errors: [{ code, message }]
33
+ };
34
+ }
35
+ export function summarizeRetrieval(result) {
36
+ const evidence = result.retrievalEvidence;
37
+ return {
38
+ skipped: result.skipped,
39
+ durationMs: result.durationMs,
40
+ totalEstimatedTokens: result.totalEstimatedTokens,
41
+ tokenCountMethod: result.tokenCountMethod,
42
+ warnings: result.warnings.slice(0, MAX_WARNINGS).map((warning) => boundedRetrievalSafeMessage(warning)),
43
+ evidenceAvailability: evidence?.availability ?? "missing",
44
+ evidenceAvailabilityReason: evidence?.availabilityReason ?? (evidence === undefined ? "retrieval-evidence-missing" : null),
45
+ retrievedFileCount: evidence?.files.length ?? null,
46
+ retrievedSymbolCount: evidence?.symbols.length ?? null,
47
+ commands: evidence ? evidence.commands.map((command) => ({ ...command })) : []
48
+ };
49
+ }
50
+ /**
51
+ * One full index per benchmark project, then one retrieval per case from that project's prepared index, using the
52
+ * existing lifecycle owners. A failed project fails only its own cases. No agent runs. Only bounded, context-free
53
+ * evidence is returned: retrieval results (and their contextText) never leave this function.
54
+ */
55
+ export async function executeRetrievalPrecisionRecall(options) {
56
+ const buildIndex = options.dependencies?.buildIndex ?? buildMyDevKitIndex;
57
+ const retrieveFromIndex = options.dependencies?.retrieveFromIndex ?? runMyDevKitRetrievalFromIndex;
58
+ const results = new Map();
59
+ const knownRoots = [options.outputRoot, ...options.cases.map((evaluationCase) => evaluationCase.absoluteTargetRoot)];
60
+ const validCases = [];
61
+ for (const evaluationCase of options.cases) {
62
+ const groundTruthErrors = validateRetrievalPrecisionRecallCase(evaluationCase);
63
+ if (groundTruthErrors.length > 0) {
64
+ results.set(evaluationCase.id, failedCase(evaluationCase, "ground-truth-invalid", boundedRetrievalSafeMessage(groundTruthErrors.join(" "), knownRoots)));
65
+ }
66
+ else {
67
+ validCases.push(evaluationCase);
68
+ }
69
+ }
70
+ for (const group of groupWarmIndexCases(validCases)) {
71
+ if (group.structuralErrors.length > 0) {
72
+ for (const evaluationCase of group.cases) {
73
+ results.set(evaluationCase.id, failedCase(evaluationCase, "project-group-inconsistent", "Cases of one benchmark project disagree on target root or source roots."));
74
+ }
75
+ continue;
76
+ }
77
+ const indexDir = resolveWithinRoot(options.outputRoot, path.join("indexes", group.projectSegment));
78
+ const commandsDir = resolveWithinRoot(options.outputRoot, path.join("commands", group.projectSegment));
79
+ let indexFailure;
80
+ try {
81
+ // requireKit: false keeps a failed index as measurable evidence instead of throwing.
82
+ const build = await buildIndex({
83
+ target: { absoluteTargetRoot: group.targetRoot, sourceRoots: [...group.sourceRoots] },
84
+ kitCommand: options.kitCommand,
85
+ indexDir,
86
+ commandsDir: path.join(commandsDir, "index"),
87
+ requireKit: false
88
+ });
89
+ if (!build.ok)
90
+ indexFailure = build.warnings.join(" ") || "my-dev-kit index setup failed.";
91
+ }
92
+ catch (error) {
93
+ indexFailure = errorText(error);
94
+ }
95
+ if (indexFailure !== undefined) {
96
+ const message = boundedRetrievalSafeMessage(indexFailure, knownRoots);
97
+ for (const evaluationCase of group.cases) {
98
+ results.set(evaluationCase.id, failedCase(evaluationCase, "project-index-failed", message));
99
+ }
100
+ continue;
101
+ }
102
+ for (const evaluationCase of group.cases) {
103
+ results.set(evaluationCase.id, await executeCase({
104
+ evaluationCase,
105
+ kitCommand: options.kitCommand,
106
+ indexDir,
107
+ commandsDir: path.join(commandsDir, taskOutputSegment(evaluationCase.id)),
108
+ retrieveFromIndex,
109
+ knownRoots
110
+ }));
111
+ }
112
+ }
113
+ // Selection order is the corpus order after filtering, regardless of how projects were grouped.
114
+ return options.cases.map((evaluationCase) => results.get(evaluationCase.id));
115
+ }
116
+ async function executeCase(args) {
117
+ const { evaluationCase } = args;
118
+ let retrieval;
119
+ try {
120
+ retrieval = await args.retrieveFromIndex({
121
+ evaluationCase,
122
+ kitCommand: args.kitCommand,
123
+ indexDir: args.indexDir,
124
+ commandsDir: args.commandsDir,
125
+ requireKit: false
126
+ });
127
+ }
128
+ catch (error) {
129
+ return failedCase(evaluationCase, "retrieval-failed", boundedRetrievalSafeMessage(errorText(error), args.knownRoots));
130
+ }
131
+ return buildCaseEvidenceFromRetrieval(evaluationCase, retrieval);
132
+ }
133
+ /**
134
+ * The single place a returned retrieval becomes bounded case evidence: Batch 2 metrics are calculated here and the
135
+ * case is COMPLETED only when the retrieval evidence is available, otherwise PARTIAL. Shared by the bundled and the
136
+ * external-local execution paths so neither duplicates the status rule or the metric call.
137
+ */
138
+ export function buildCaseEvidenceFromRetrieval(evaluationCase, retrieval) {
139
+ const quality = calculateRetrievalQualityMetrics({ evaluationCase, retrieval });
140
+ const status = retrieval.retrievalEvidence?.availability === "available" ? "completed" : "partial";
141
+ const errors = [];
142
+ return {
143
+ caseId: evaluationCase.id,
144
+ caseName: evaluationCase.title,
145
+ benchmarkProject: evaluationCase.benchmarkProject,
146
+ taskLocality: evaluationCase.taskLocality ?? null,
147
+ status,
148
+ retrieval: summarizeRetrieval(retrieval),
149
+ quality,
150
+ errors
151
+ };
152
+ }
@@ -0,0 +1,15 @@
1
+ export const RETRIEVAL_PRECISION_RECALL_EXECUTION_ARTIFACT_FILE = "retrieval-precision-recall-execution.json";
2
+ export const RETRIEVAL_PRECISION_RECALL_EXECUTION_SCHEMA_VERSION = "my-dev-kit-lab-retrieval-precision-recall-execution-v1";
3
+ export function buildRetrievalPrecisionRecallExecutionArtifact(args) {
4
+ return {
5
+ schemaVersion: RETRIEVAL_PRECISION_RECALL_EXECUTION_SCHEMA_VERSION,
6
+ runId: args.runId,
7
+ pluginId: args.pluginId,
8
+ pluginSchemaVersion: args.pluginSchemaVersion,
9
+ startedAt: args.startedAt,
10
+ completedAt: args.completedAt,
11
+ tokenCountMethod: args.cases.find((entry) => entry.retrieval !== null)?.retrieval?.tokenCountMethod ?? null,
12
+ cases: args.cases.map((entry) => structuredClone(entry)),
13
+ aggregate: structuredClone(args.aggregate)
14
+ };
15
+ }
@@ -0,0 +1,9 @@
1
+ export * from "./config.js";
2
+ export * from "./execution.js";
3
+ export * from "./localSubjectExecution.js";
4
+ export * from "./localSubjectPrivacy.js";
5
+ export * from "./executionArtifact.js";
6
+ export * from "./metadata.js";
7
+ export * from "./metrics.js";
8
+ export * from "./plugin.js";
9
+ export * from "./types.js";