@dailephd/my-dev-kit-lab 0.8.0 → 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.
- package/CHANGELOG.md +18 -0
- package/README.md +6 -4
- package/dist/src/cli/help.js +20 -1
- package/dist/src/commands/runExperimentDescribeCommand.js +8 -1
- package/dist/src/commands/runExperimentRunCommand.js +90 -4
- package/dist/src/evaluation/retrievalQuality/metrics.js +27 -2
- package/dist/src/evaluation/retrievalQueryStrategies.js +26 -0
- package/dist/src/evaluation/retrievalQueryStrategyEvidence.js +58 -0
- package/dist/src/evaluation/runMyDevKitRetrieval.js +59 -51
- package/dist/src/evaluation/runSemanticRetrievalStrategy.js +303 -0
- package/dist/src/experiments/defaultRegistry.js +2 -0
- package/dist/src/experiments/plugins/contextWindowScaling/localSubjectPrivacy.js +2 -0
- package/dist/src/experiments/plugins/index.js +1 -0
- package/dist/src/experiments/plugins/retrievalQueryStrategyComparison/analysis.js +172 -0
- package/dist/src/experiments/plugins/retrievalQueryStrategyComparison/analysisArtifact.js +28 -0
- package/dist/src/experiments/plugins/retrievalQueryStrategyComparison/analysisTypes.js +2 -0
- package/dist/src/experiments/plugins/retrievalQueryStrategyComparison/config.js +39 -0
- package/dist/src/experiments/plugins/retrievalQueryStrategyComparison/execution.js +225 -0
- package/dist/src/experiments/plugins/retrievalQueryStrategyComparison/executionArtifact.js +15 -0
- package/dist/src/experiments/plugins/retrievalQueryStrategyComparison/index.js +12 -0
- package/dist/src/experiments/plugins/retrievalQueryStrategyComparison/localSubjectExecution.js +124 -0
- package/dist/src/experiments/plugins/retrievalQueryStrategyComparison/localSubjectPrivacy.js +176 -0
- package/dist/src/experiments/plugins/retrievalQueryStrategyComparison/metadata.js +51 -0
- package/dist/src/experiments/plugins/retrievalQueryStrategyComparison/metrics.js +60 -0
- package/dist/src/experiments/plugins/retrievalQueryStrategyComparison/plugin.js +292 -0
- package/dist/src/experiments/plugins/retrievalQueryStrategyComparison/types.js +1 -0
- package/dist/src/report/experiments/buildPluginExperimentReport.js +25 -2
- package/dist/src/report/experiments/buildRetrievalQueryStrategyComparisonReport.js +91 -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/renderRetrievalQueryStrategyComparisonHtml.js +96 -0
- package/dist/src/report/experiments/renderRetrievalQueryStrategyComparisonText.js +58 -0
- package/dist/src/report/experiments/retrievalQueryStrategyComparisonReportModel.js +1 -0
- package/docs/METRICS.md +122 -2
- 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
|
+
}
|
|
@@ -3,6 +3,7 @@ import { contextStrategyComparisonPlugin } from "./plugins/contextStrategyCompar
|
|
|
3
3
|
import { contextWindowScalingPlugin } from "./plugins/contextWindowScaling/index.js";
|
|
4
4
|
import { incrementalChangeStalenessPlugin } from "./plugins/incrementalChangeStaleness/index.js";
|
|
5
5
|
import { retrievalPrecisionRecallPlugin } from "./plugins/retrievalPrecisionRecall/index.js";
|
|
6
|
+
import { retrievalQueryStrategyComparisonPlugin } from "./plugins/retrievalQueryStrategyComparison/index.js";
|
|
6
7
|
import { warmIndexReusePlugin } from "./plugins/warmIndexReuse/index.js";
|
|
7
8
|
export function createDefaultExperimentPluginRegistry() {
|
|
8
9
|
const registry = new ExperimentPluginRegistry();
|
|
@@ -11,5 +12,6 @@ export function createDefaultExperimentPluginRegistry() {
|
|
|
11
12
|
registry.register(incrementalChangeStalenessPlugin);
|
|
12
13
|
registry.register(contextWindowScalingPlugin);
|
|
13
14
|
registry.register(retrievalPrecisionRecallPlugin);
|
|
15
|
+
registry.register(retrievalQueryStrategyComparisonPlugin);
|
|
14
16
|
return registry;
|
|
15
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 = {
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { RETRIEVAL_QUERY_STRATEGY_IDS } from "../../../evaluation/retrievalQueryStrategies.js";
|
|
2
|
+
import { calculateRetrievalQualityMetricsFromIdentityEvidence } from "../../../evaluation/retrievalQuality/metrics.js";
|
|
3
|
+
import { RETRIEVAL_QUERY_STRATEGY_COMPARISON_SCOPE_IDS } from "./analysisTypes.js";
|
|
4
|
+
function toRetrievalQualityIdentityEvidence(evidence) {
|
|
5
|
+
return {
|
|
6
|
+
availability: evidence.availability,
|
|
7
|
+
...(evidence.availabilityReason !== null ? { availabilityReason: evidence.availabilityReason } : {}),
|
|
8
|
+
files: evidence.files.map((file) => file.path),
|
|
9
|
+
symbols: evidence.symbols.map((symbol) => ({
|
|
10
|
+
name: symbol.name,
|
|
11
|
+
...(symbol.file !== null ? { file: symbol.file } : {})
|
|
12
|
+
}))
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Balanced F1 from identity-set sizes: 2TP / (2TP + FP + FN). Well defined when nothing was retrieved but
|
|
17
|
+
* relevant identities exist (F1 = 0). Never averages precision and recall.
|
|
18
|
+
*/
|
|
19
|
+
export function calculateBalancedIdentityF1(relevant, irrelevant, missed) {
|
|
20
|
+
if (relevant === null || irrelevant === null || missed === null) {
|
|
21
|
+
return { availability: "unavailable", numerator: null, denominator: null, value: null, reason: "quality-identity-sets-unavailable" };
|
|
22
|
+
}
|
|
23
|
+
const numerator = 2 * relevant.length;
|
|
24
|
+
const denominator = numerator + irrelevant.length + missed.length;
|
|
25
|
+
if (denominator === 0) {
|
|
26
|
+
return { availability: "not-applicable", numerator: null, denominator: null, value: null, reason: "no-positive-or-retrieved-identities" };
|
|
27
|
+
}
|
|
28
|
+
return { availability: "available", numerator, denominator, value: numerator / denominator, reason: null };
|
|
29
|
+
}
|
|
30
|
+
const noMeasurementF1 = () => ({
|
|
31
|
+
availability: "unavailable",
|
|
32
|
+
numerator: null,
|
|
33
|
+
denominator: null,
|
|
34
|
+
value: null,
|
|
35
|
+
reason: "no-retrieval-measurement"
|
|
36
|
+
});
|
|
37
|
+
/** Scores already-completed retrieval evidence. It never influences retrieval. */
|
|
38
|
+
export function analyzeRetrievalQueryStrategyTreatment(evaluationCase, treatment) {
|
|
39
|
+
if (treatment.evidence === null || treatment.retrieval === null) {
|
|
40
|
+
return {
|
|
41
|
+
strategyId: treatment.strategyId,
|
|
42
|
+
executionStatus: treatment.status,
|
|
43
|
+
quality: null,
|
|
44
|
+
fileF1: noMeasurementF1(),
|
|
45
|
+
symbolF1: noMeasurementF1()
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
const quality = calculateRetrievalQualityMetricsFromIdentityEvidence({
|
|
49
|
+
evaluationCase,
|
|
50
|
+
retrieval: {
|
|
51
|
+
identityEvidence: toRetrievalQualityIdentityEvidence(treatment.evidence),
|
|
52
|
+
totalEstimatedTokens: treatment.retrieval.totalEstimatedTokens,
|
|
53
|
+
tokenCountMethod: treatment.retrieval.tokenCountMethod
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
return {
|
|
57
|
+
strategyId: treatment.strategyId,
|
|
58
|
+
executionStatus: treatment.status,
|
|
59
|
+
quality,
|
|
60
|
+
fileF1: calculateBalancedIdentityF1(quality.file.relevantRetrievedFiles, quality.file.irrelevantRetrievedFiles, quality.file.missedFiles),
|
|
61
|
+
symbolF1: calculateBalancedIdentityF1(quality.symbol.relevantRetrievedSymbols, quality.symbol.irrelevantRetrievedSymbols, quality.symbol.missedSymbols)
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
export function analyzeRetrievalQueryStrategyCase(evaluationCase, executionCase) {
|
|
65
|
+
if (evaluationCase.id !== executionCase.caseId) {
|
|
66
|
+
throw new Error("Retrieval query strategy analysis case identity mismatch.");
|
|
67
|
+
}
|
|
68
|
+
const order = executionCase.treatments.map((treatment) => treatment.strategyId);
|
|
69
|
+
if (order.length !== RETRIEVAL_QUERY_STRATEGY_IDS.length || order.some((id, index) => id !== RETRIEVAL_QUERY_STRATEGY_IDS[index])) {
|
|
70
|
+
throw new Error("Retrieval query strategy analysis requires the seven canonical treatments in canonical order.");
|
|
71
|
+
}
|
|
72
|
+
return {
|
|
73
|
+
caseId: executionCase.caseId,
|
|
74
|
+
benchmarkProject: executionCase.benchmarkProject,
|
|
75
|
+
taskLocality: executionCase.taskLocality,
|
|
76
|
+
treatments: executionCase.treatments.map((treatment) => analyzeRetrievalQueryStrategyTreatment(evaluationCase, treatment))
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
function isTreatmentComparable(treatment) {
|
|
80
|
+
const quality = treatment.quality;
|
|
81
|
+
return (quality !== null &&
|
|
82
|
+
treatment.fileF1.availability === "available" &&
|
|
83
|
+
treatment.symbolF1.availability === "available" &&
|
|
84
|
+
quality.fact.coverage.availability === "available" &&
|
|
85
|
+
typeof quality.retrievedTokenCount === "number" &&
|
|
86
|
+
Number.isFinite(quality.retrievedTokenCount) &&
|
|
87
|
+
quality.retrievedTokenCount >= 0);
|
|
88
|
+
}
|
|
89
|
+
/** Matched design: a case counts only when all seven strategies expose all four objectives. */
|
|
90
|
+
export function isCaseComparableAcrossAllStrategies(caseAnalysis) {
|
|
91
|
+
return caseAnalysis.treatments.length === RETRIEVAL_QUERY_STRATEGY_IDS.length && caseAnalysis.treatments.every(isTreatmentComparable);
|
|
92
|
+
}
|
|
93
|
+
function casesForScope(cases, scopeId) {
|
|
94
|
+
return scopeId === "overall" ? [...cases] : cases.filter((entry) => entry.taskLocality === scopeId);
|
|
95
|
+
}
|
|
96
|
+
/** No epsilon, tolerance, rounding or weighting. */
|
|
97
|
+
export function paretoDominatesRetrievalStrategy(left, right) {
|
|
98
|
+
const noWorse = left.meanFileF1 >= right.meanFileF1 &&
|
|
99
|
+
left.meanSymbolF1 >= right.meanSymbolF1 &&
|
|
100
|
+
left.meanFactCoverage >= right.meanFactCoverage &&
|
|
101
|
+
left.meanRetrievedTokenCount <= right.meanRetrievedTokenCount;
|
|
102
|
+
const strictlyBetter = left.meanFileF1 > right.meanFileF1 ||
|
|
103
|
+
left.meanSymbolF1 > right.meanSymbolF1 ||
|
|
104
|
+
left.meanFactCoverage > right.meanFactCoverage ||
|
|
105
|
+
left.meanRetrievedTokenCount < right.meanRetrievedTokenCount;
|
|
106
|
+
return noWorse && strictlyBetter;
|
|
107
|
+
}
|
|
108
|
+
/** Every nondominated strategy in the order given (canonical strategy order); never a performance ranking. */
|
|
109
|
+
export function findRetrievalStrategyParetoFront(summaries) {
|
|
110
|
+
const candidates = summaries.filter((summary) => summary.objectives !== null);
|
|
111
|
+
return candidates
|
|
112
|
+
.filter((candidate) => !candidates.some((other) => other !== candidate && paretoDominatesRetrievalStrategy(other.objectives, candidate.objectives)))
|
|
113
|
+
.map((candidate) => candidate.strategyId);
|
|
114
|
+
}
|
|
115
|
+
export function aggregateRetrievalQueryStrategyScope(scopeId, cases) {
|
|
116
|
+
const scopeCases = casesForScope(cases, scopeId);
|
|
117
|
+
const comparisonCases = scopeCases.filter(isCaseComparableAcrossAllStrategies);
|
|
118
|
+
const strategySummaries = RETRIEVAL_QUERY_STRATEGY_IDS.map((strategyId, index) => {
|
|
119
|
+
const treatmentsOfScope = scopeCases.map((entry) => entry.treatments[index]);
|
|
120
|
+
const countStatus = (status) => treatmentsOfScope.filter((treatment) => treatment.executionStatus === status).length;
|
|
121
|
+
let objectives = null;
|
|
122
|
+
if (comparisonCases.length > 0) {
|
|
123
|
+
const values = comparisonCases.map((entry) => {
|
|
124
|
+
const treatment = entry.treatments[index];
|
|
125
|
+
return {
|
|
126
|
+
fileF1: treatment.fileF1.value,
|
|
127
|
+
symbolF1: treatment.symbolF1.value,
|
|
128
|
+
factCoverage: treatment.quality.fact.coverage.value,
|
|
129
|
+
retrievedTokens: treatment.quality.retrievedTokenCount
|
|
130
|
+
};
|
|
131
|
+
});
|
|
132
|
+
const mean = (select) => values.reduce((sum, value) => sum + select(value), 0) / values.length;
|
|
133
|
+
objectives = {
|
|
134
|
+
meanFileF1: mean((value) => value.fileF1),
|
|
135
|
+
meanSymbolF1: mean((value) => value.symbolF1),
|
|
136
|
+
meanFactCoverage: mean((value) => value.factCoverage),
|
|
137
|
+
meanRetrievedTokenCount: mean((value) => value.retrievedTokens)
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
return {
|
|
141
|
+
strategyId,
|
|
142
|
+
completedCaseCount: countStatus("completed"),
|
|
143
|
+
partialCaseCount: countStatus("partial"),
|
|
144
|
+
failedCaseCount: countStatus("failed"),
|
|
145
|
+
objectives
|
|
146
|
+
};
|
|
147
|
+
});
|
|
148
|
+
const paretoFrontStrategyIds = comparisonCases.length === 0 ? [] : findRetrievalStrategyParetoFront(strategySummaries);
|
|
149
|
+
const unique = paretoFrontStrategyIds.length === 1;
|
|
150
|
+
return {
|
|
151
|
+
scopeId,
|
|
152
|
+
caseCount: scopeCases.length,
|
|
153
|
+
comparisonCaseCount: comparisonCases.length,
|
|
154
|
+
excludedCaseCount: scopeCases.length - comparisonCases.length,
|
|
155
|
+
comparisonCaseIds: comparisonCases.map((entry) => entry.caseId),
|
|
156
|
+
strategySummaries,
|
|
157
|
+
paretoFrontStrategyIds,
|
|
158
|
+
bestStrategyId: unique ? paretoFrontStrategyIds[0] : null,
|
|
159
|
+
interpretation: comparisonCases.length === 0 ? "unavailable" : unique ? "unique-best" : "tradeoff"
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
export function analyzeRetrievalQueryStrategyComparison(evaluationCases, executionCases) {
|
|
163
|
+
if (evaluationCases.length !== executionCases.length ||
|
|
164
|
+
evaluationCases.some((evaluationCase, index) => evaluationCase.id !== executionCases[index].caseId)) {
|
|
165
|
+
throw new Error("Retrieval query strategy analysis requires matching selected case IDs in the same order.");
|
|
166
|
+
}
|
|
167
|
+
const cases = evaluationCases.map((evaluationCase, index) => analyzeRetrievalQueryStrategyCase(evaluationCase, executionCases[index]));
|
|
168
|
+
return {
|
|
169
|
+
cases,
|
|
170
|
+
scopes: RETRIEVAL_QUERY_STRATEGY_COMPARISON_SCOPE_IDS.map((scopeId) => aggregateRetrievalQueryStrategyScope(scopeId, cases))
|
|
171
|
+
};
|
|
172
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export const RETRIEVAL_QUERY_STRATEGY_COMPARISON_ANALYSIS_ARTIFACT_FILE = "retrieval-query-strategy-comparison-analysis.json";
|
|
2
|
+
export const RETRIEVAL_QUERY_STRATEGY_COMPARISON_ANALYSIS_SCHEMA_VERSION = "my-dev-kit-lab-retrieval-query-strategy-comparison-analysis-v1";
|
|
3
|
+
/** Single owner of the frozen methodology; the artifact and the report both use it. */
|
|
4
|
+
export const RETRIEVAL_QUERY_STRATEGY_COMPARISON_METHODOLOGY = {
|
|
5
|
+
fileF1: "balanced-f1",
|
|
6
|
+
symbolF1: "balanced-f1",
|
|
7
|
+
aggregation: "matched-complete-case-macro-mean",
|
|
8
|
+
multiObjectiveComparison: "pareto-dominance",
|
|
9
|
+
uniqueBestRule: "single-member-pareto-front",
|
|
10
|
+
objectiveDirections: {
|
|
11
|
+
meanFileF1: "maximize",
|
|
12
|
+
meanSymbolF1: "maximize",
|
|
13
|
+
meanFactCoverage: "maximize",
|
|
14
|
+
meanRetrievedTokenCount: "minimize"
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
export function buildRetrievalQueryStrategyComparisonAnalysisArtifact(args) {
|
|
18
|
+
return {
|
|
19
|
+
schemaVersion: RETRIEVAL_QUERY_STRATEGY_COMPARISON_ANALYSIS_SCHEMA_VERSION,
|
|
20
|
+
runId: args.runId,
|
|
21
|
+
pluginId: args.pluginId,
|
|
22
|
+
pluginSchemaVersion: args.pluginSchemaVersion,
|
|
23
|
+
startedAt: args.startedAt,
|
|
24
|
+
completedAt: args.completedAt,
|
|
25
|
+
methodology: structuredClone(RETRIEVAL_QUERY_STRATEGY_COMPARISON_METHODOLOGY),
|
|
26
|
+
analysis: structuredClone(args.analysis)
|
|
27
|
+
};
|
|
28
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { invalidExperimentConfig, isPlainObject, mergeConfig, validExperimentConfig } from "../../config.js";
|
|
2
|
+
export const defaultRetrievalQueryStrategyComparisonConfig = {
|
|
3
|
+
outDir: "lab-output/retrieval-query-strategy-comparison",
|
|
4
|
+
kitCommand: "npx @dailephd/my-dev-kit@latest"
|
|
5
|
+
};
|
|
6
|
+
const SUPPORTED_FIELDS = ["outDir", "kitCommand", "caseIds", "benchmarkProjects"];
|
|
7
|
+
export const retrievalQueryStrategyComparisonConfigDefinition = {
|
|
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 validateRetrievalQueryStrategyComparisonConfig(config) {
|
|
16
|
+
if (config !== undefined && !isPlainObject(config)) {
|
|
17
|
+
return invalidExperimentConfig(["retrieval query strategy comparison 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-query-strategy-comparison config field(s): ${unsupported.sort().join(", ")}.`);
|
|
23
|
+
}
|
|
24
|
+
const normalized = mergeConfig(defaultRetrievalQueryStrategyComparisonConfig, 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
|
+
}
|