@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,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
+ }
@@ -0,0 +1,5 @@
1
+ export * from "./types.js";
2
+ export * from "./buildRetrievalEvidence.js";
3
+ export * from "./factContextTargets.js";
4
+ export * from "./metrics.js";
5
+ export * from "./corpusCompleteness.js";
@@ -0,0 +1,258 @@
1
+ import { compareCodeUnits } from "../indexSnapshot.js";
2
+ import { normalizeRetrievedRepositoryPath } from "./buildRetrievalEvidence.js";
3
+ import { hasFactMapping, interpretFactContextTarget } from "./factContextTargets.js";
4
+ const sortedUnique = (values) => [...new Set(values)].sort(compareCodeUnits);
5
+ function ratio(numerator, denominator, notApplicableReason) {
6
+ if (denominator === 0) {
7
+ return { availability: "not-applicable", numerator: null, denominator: null, value: null, reason: notApplicableReason };
8
+ }
9
+ return { availability: "available", numerator, denominator, value: numerator / denominator, reason: null };
10
+ }
11
+ function unavailableRatio(reason) {
12
+ return { availability: "unavailable", numerator: null, denominator: null, value: null, reason };
13
+ }
14
+ function normalizeExpectedFiles(values) {
15
+ if (!Array.isArray(values))
16
+ return { ok: false, reason: "expected-files-missing" };
17
+ const normalized = [];
18
+ for (const value of values) {
19
+ const path = normalizeRetrievedRepositoryPath(value);
20
+ if (path === null)
21
+ return { ok: false, reason: "unsafe-expected-file-identity" };
22
+ normalized.push(path);
23
+ }
24
+ return { ok: true, values: sortedUnique(normalized) };
25
+ }
26
+ function normalizeExpectedSymbols(values) {
27
+ if (!Array.isArray(values))
28
+ return { ok: false, reason: "expected-symbols-missing" };
29
+ if (values.some((value) => typeof value !== "string" || value.length === 0))
30
+ return { ok: false, reason: "invalid-expected-symbol" };
31
+ return { ok: true, values: sortedUnique(values) };
32
+ }
33
+ function sameSet(left, right) {
34
+ return left.length === right.length && left.every((value, index) => value === right[index]);
35
+ }
36
+ /**
37
+ * The answer key is the semantic source of relevance. A top-level expectation that is also present must agree
38
+ * with it after normalization; disagreement is never silently reconciled.
39
+ */
40
+ function resolveExpectation(topLevel, answerKeyValues, hasAnswerKey, normalize, mismatchReason) {
41
+ if (!hasAnswerKey)
42
+ return normalize(topLevel);
43
+ const fromKey = normalize(answerKeyValues);
44
+ if (!fromKey.ok)
45
+ return fromKey;
46
+ if (Array.isArray(topLevel)) {
47
+ const fromTop = normalize(topLevel);
48
+ if (!fromTop.ok)
49
+ return fromTop;
50
+ if (!sameSet(fromTop.values, fromKey.values))
51
+ return { ok: false, reason: mismatchReason };
52
+ }
53
+ return fromKey;
54
+ }
55
+ const expectationStatus = (resolved) => resolved.ok ? { availability: "available", reason: null } : { availability: "unavailable", reason: resolved.reason };
56
+ /** Symbol name -> normalized files it was retrieved from. Symbols without file identity cannot satisfy a file-specific target. */
57
+ function symbolFilesByName(evidence) {
58
+ const byName = new Map();
59
+ for (const symbol of evidence.symbols) {
60
+ if (symbol.file === undefined)
61
+ continue;
62
+ const files = byName.get(symbol.name) ?? new Set();
63
+ files.add(symbol.file);
64
+ byName.set(symbol.name, files);
65
+ }
66
+ return byName;
67
+ }
68
+ function calculateFactCoverage(answerKey, evidence, retrievedFiles) {
69
+ if (!answerKey || typeof answerKey !== "object")
70
+ return { kind: "unavailable", reason: "answer-key-missing" };
71
+ const facts = answerKey.expectedFacts;
72
+ if (!Array.isArray(facts))
73
+ return { kind: "unavailable", reason: "expected-facts-invalid" };
74
+ const factIds = [];
75
+ for (const fact of facts) {
76
+ const id = typeof fact === "object" && fact !== null ? fact.id : undefined;
77
+ if (typeof id !== "string" || id.length === 0 || factIds.includes(id)) {
78
+ return { kind: "unavailable", reason: "expected-facts-invalid" };
79
+ }
80
+ factIds.push(id);
81
+ }
82
+ if (factIds.length === 0)
83
+ return { kind: "not-applicable", reason: "no-expected-facts" };
84
+ const targets = answerKey.expectedContextTargets;
85
+ if (!Array.isArray(targets))
86
+ return { kind: "unavailable", reason: "fact-context-mapping-unavailable" };
87
+ const known = new Set(factIds);
88
+ const targetsByFact = new Map(factIds.map((id) => [id, []]));
89
+ for (const target of targets) {
90
+ if (!hasFactMapping(target))
91
+ continue;
92
+ const required = target.required !== false;
93
+ const interpretation = interpretFactContextTarget(target, known);
94
+ if (!interpretation.ok) {
95
+ // An invalid optional target cannot gate coverage; an invalid required one makes the mapping untrustworthy.
96
+ if (required)
97
+ return { kind: "unavailable", reason: "fact-context-mapping-invalid" };
98
+ continue;
99
+ }
100
+ if (!interpretation.target.required)
101
+ continue;
102
+ for (const id of interpretation.target.factIds)
103
+ targetsByFact.get(id)?.push(interpretation.target);
104
+ }
105
+ if (factIds.some((id) => (targetsByFact.get(id) ?? []).length === 0)) {
106
+ return { kind: "unavailable", reason: "fact-context-mapping-unavailable" };
107
+ }
108
+ const symbolFiles = symbolFilesByName(evidence);
109
+ const isCovered = (target) => retrievedFiles.has(target.file) && target.symbols.every((symbol) => symbolFiles.get(symbol)?.has(target.file) === true);
110
+ const covered = factIds.filter((id) => (targetsByFact.get(id) ?? []).every(isCovered));
111
+ const coveredSet = new Set(covered);
112
+ return {
113
+ kind: "computed",
114
+ covered: sortedUnique(covered),
115
+ uncovered: sortedUnique(factIds.filter((id) => !coveredSet.has(id))),
116
+ total: factIds.length
117
+ };
118
+ }
119
+ function evidenceReason(evidence) {
120
+ if (evidence === undefined)
121
+ return "retrieval-evidence-missing";
122
+ if (evidence.availability === "partial")
123
+ return "retrieval-evidence-partial";
124
+ if (evidence.availability === "unavailable")
125
+ return "retrieval-evidence-unavailable";
126
+ return null;
127
+ }
128
+ /**
129
+ * Pure per-case retrieval-quality calculation. Formulas are planner-frozen:
130
+ * file precision = |R_file ∩ E_file| / |R_file|; file recall = |R_file ∩ E_file| / |E_file|;
131
+ * irrelevant context ratio = |R_file - E_file| / |R_file|; symbol precision/recall use the same set formulas
132
+ * over exact unique symbol names; fact coverage = covered facts / expected facts, unweighted and derived only
133
+ * from explicit `factIds` target mappings. No input is mutated and nothing is read from disk or text.
134
+ * Unavailable or not-applicable results are never converted to zero.
135
+ */
136
+ export function calculateRetrievalQualityMetrics(input) {
137
+ const evidence = input.retrieval.retrievalEvidence;
138
+ return calculateRetrievalQualityMetricsFromIdentityEvidence({
139
+ evaluationCase: input.evaluationCase,
140
+ retrieval: {
141
+ identityEvidence: evidence === undefined
142
+ ? undefined
143
+ : {
144
+ availability: evidence.availability,
145
+ ...(evidence.availabilityReason !== undefined ? { availabilityReason: evidence.availabilityReason } : {}),
146
+ files: evidence.files.map((file) => file.path),
147
+ symbols: evidence.symbols.map((symbol) => ({
148
+ name: symbol.name,
149
+ ...(symbol.file !== undefined ? { file: symbol.file } : {})
150
+ }))
151
+ },
152
+ totalEstimatedTokens: input.retrieval.totalEstimatedTokens,
153
+ tokenCountMethod: input.retrieval.tokenCountMethod
154
+ }
155
+ });
156
+ }
157
+ /**
158
+ * Single mathematical owner for retrieval-quality calculation. Applies the v0.8.0 formulas and availability
159
+ * semantics to already-normalized identity evidence, so any evidence contract can be scored identically.
160
+ */
161
+ export function calculateRetrievalQualityMetricsFromIdentityEvidence(input) {
162
+ const { evaluationCase, retrieval } = input;
163
+ const evidence = retrieval.identityEvidence;
164
+ const evidenceBlocked = evidenceReason(evidence);
165
+ const answerKey = evaluationCase.answerKey;
166
+ const hasAnswerKey = answerKey !== undefined && answerKey !== null && typeof answerKey === "object";
167
+ const expectedFiles = resolveExpectation(evaluationCase.expectedFiles, hasAnswerKey ? answerKey.expectedFiles : undefined, hasAnswerKey, normalizeExpectedFiles, "expected-files-answer-key-mismatch");
168
+ const expectedSymbols = resolveExpectation(evaluationCase.expectedSymbols, hasAnswerKey ? answerKey.expectedSymbols : undefined, hasAnswerKey, normalizeExpectedSymbols, "expected-symbols-answer-key-mismatch");
169
+ const usable = evidenceBlocked === null && evidence !== undefined ? evidence : undefined;
170
+ const retrievedFiles = usable ? sortedUnique(usable.files) : undefined;
171
+ const retrievedSymbols = usable ? sortedUnique(usable.symbols.map((symbol) => symbol.name)) : undefined;
172
+ // ---- files
173
+ let file = {
174
+ relevantRetrievedFiles: null,
175
+ irrelevantRetrievedFiles: null,
176
+ missedFiles: null,
177
+ missedFileCount: null,
178
+ precision: unavailableRatio(evidenceBlocked ?? (expectedFiles.ok ? "unavailable" : expectedFiles.reason)),
179
+ recall: unavailableRatio(evidenceBlocked ?? (expectedFiles.ok ? "unavailable" : expectedFiles.reason))
180
+ };
181
+ let irrelevantContextRatio = unavailableRatio(evidenceBlocked ?? (expectedFiles.ok ? "unavailable" : expectedFiles.reason));
182
+ if (retrievedFiles !== undefined && expectedFiles.ok) {
183
+ const expected = new Set(expectedFiles.values);
184
+ const retrieved = new Set(retrievedFiles);
185
+ const relevant = retrievedFiles.filter((path) => expected.has(path));
186
+ const irrelevant = retrievedFiles.filter((path) => !expected.has(path));
187
+ const missed = expectedFiles.values.filter((path) => !retrieved.has(path));
188
+ file = {
189
+ relevantRetrievedFiles: relevant,
190
+ irrelevantRetrievedFiles: irrelevant,
191
+ missedFiles: missed,
192
+ missedFileCount: missed.length,
193
+ precision: ratio(relevant.length, retrievedFiles.length, "no-retrieved-files"),
194
+ recall: ratio(relevant.length, expectedFiles.values.length, "no-expected-files")
195
+ };
196
+ irrelevantContextRatio = ratio(irrelevant.length, retrievedFiles.length, "no-retrieved-files");
197
+ }
198
+ // ---- symbols (exact unique names)
199
+ let symbol = {
200
+ relevantRetrievedSymbols: null,
201
+ irrelevantRetrievedSymbols: null,
202
+ missedSymbols: null,
203
+ missedSymbolCount: null,
204
+ precision: unavailableRatio(evidenceBlocked ?? (expectedSymbols.ok ? "unavailable" : expectedSymbols.reason)),
205
+ recall: unavailableRatio(evidenceBlocked ?? (expectedSymbols.ok ? "unavailable" : expectedSymbols.reason))
206
+ };
207
+ if (retrievedSymbols !== undefined && expectedSymbols.ok) {
208
+ const expected = new Set(expectedSymbols.values);
209
+ const retrieved = new Set(retrievedSymbols);
210
+ const relevant = retrievedSymbols.filter((name) => expected.has(name));
211
+ const missed = expectedSymbols.values.filter((name) => !retrieved.has(name));
212
+ symbol = {
213
+ relevantRetrievedSymbols: relevant,
214
+ irrelevantRetrievedSymbols: retrievedSymbols.filter((name) => !expected.has(name)),
215
+ missedSymbols: missed,
216
+ missedSymbolCount: missed.length,
217
+ precision: ratio(relevant.length, retrievedSymbols.length, "no-retrieved-symbols"),
218
+ recall: ratio(relevant.length, expectedSymbols.values.length, "no-expected-symbols")
219
+ };
220
+ }
221
+ // ---- facts
222
+ let fact = {
223
+ coveredFactIds: null,
224
+ uncoveredFactIds: null,
225
+ uncoveredFactCount: null,
226
+ coverage: unavailableRatio(evidenceBlocked ?? "unavailable")
227
+ };
228
+ if (usable !== undefined && retrievedFiles !== undefined) {
229
+ const outcome = calculateFactCoverage(answerKey, usable, new Set(retrievedFiles));
230
+ if (outcome.kind === "computed") {
231
+ fact = {
232
+ coveredFactIds: outcome.covered,
233
+ uncoveredFactIds: outcome.uncovered,
234
+ uncoveredFactCount: outcome.uncovered.length,
235
+ coverage: ratio(outcome.covered.length, outcome.total, "no-expected-facts")
236
+ };
237
+ }
238
+ else {
239
+ fact = { ...fact, coverage: outcome.kind === "unavailable" ? unavailableRatio(outcome.reason) : { availability: "not-applicable", numerator: null, denominator: null, value: null, reason: outcome.reason } };
240
+ }
241
+ }
242
+ const tokensValid = typeof retrieval.totalEstimatedTokens === "number" && Number.isFinite(retrieval.totalEstimatedTokens) && retrieval.totalEstimatedTokens >= 0;
243
+ return {
244
+ schemaVersion: "retrieval-quality-metrics-v1",
245
+ caseId: evaluationCase.id,
246
+ evidence: {
247
+ availability: evidence === undefined ? "missing" : evidence.availability,
248
+ reason: evidence === undefined ? "retrieval-evidence-missing" : (evidence.availabilityReason ?? null)
249
+ },
250
+ expectations: { files: expectationStatus(expectedFiles), symbols: expectationStatus(expectedSymbols) },
251
+ file,
252
+ symbol,
253
+ fact,
254
+ irrelevantContextRatio,
255
+ retrievedTokenCount: tokensValid ? retrieval.totalEstimatedTokens : null,
256
+ tokenCountMethod: tokensValid && typeof retrieval.tokenCountMethod === "string" ? retrieval.tokenCountMethod : null
257
+ };
258
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Normalized retrieval-evidence contract (V1).
3
+ *
4
+ * Describes which files and symbols the existing my-dev-kit search -> lookup -> slice -> source
5
+ * lifecycle actually surfaced. It is observation only: it carries no relevance judgement and no
6
+ * score. It deliberately contains no source text, stdout/stderr bodies, or machine-local paths.
7
+ */
8
+ /** Retrieval command families in fixed lifecycle order. */
9
+ export const RETRIEVAL_COMMAND_FAMILIES = ["search", "lookup", "slice", "source"];
@@ -0,0 +1,26 @@
1
+ export const RETRIEVAL_QUERY_STRATEGY_IDS = [
2
+ "keyword-search",
3
+ "symbol-lookup",
4
+ "graph-neighborhood",
5
+ "source-slice",
6
+ "data-model-graph",
7
+ "model-view-lineage",
8
+ "combined-graph-guided"
9
+ ];
10
+ export const CORE_GRAPH_RETRIEVAL_QUERY_STRATEGY_IDS = [
11
+ "keyword-search",
12
+ "symbol-lookup",
13
+ "graph-neighborhood",
14
+ "source-slice",
15
+ "combined-graph-guided"
16
+ ];
17
+ export function isCoreGraphRetrievalQueryStrategyId(value) {
18
+ return CORE_GRAPH_RETRIEVAL_QUERY_STRATEGY_IDS.includes(value);
19
+ }
20
+ export const SEMANTIC_RETRIEVAL_QUERY_STRATEGY_IDS = [
21
+ "data-model-graph",
22
+ "model-view-lineage"
23
+ ];
24
+ export function isSemanticRetrievalQueryStrategyId(value) {
25
+ return SEMANTIC_RETRIEVAL_QUERY_STRATEGY_IDS.includes(value);
26
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Strategy-neutral retrieval identity evidence (V1).
3
+ *
4
+ * Additive to `retrieval-evidence-v1`, which is frozen to the search/lookup/slice/source command
5
+ * families. This contract records only which file and symbol identities a query strategy exposed and
6
+ * which steps ran. It carries no relevance judgement, score, answer-key data, source text,
7
+ * stdout/stderr, or machine-local paths.
8
+ */
9
+ import { compareCodeUnits } from "./indexSnapshot.js";
10
+ /**
11
+ * Deduplicates and canonically orders identities. Files are keyed by path; symbols by `nodeId` when
12
+ * present, otherwise by `file + NUL + name`. Ordering is code-unit order, never locale-sensitive.
13
+ */
14
+ export function canonicalizeQueryStrategyIdentities(files, symbols) {
15
+ const fileByPath = new Map();
16
+ for (const file of files) {
17
+ if (!fileByPath.has(file.path))
18
+ fileByPath.set(file.path, { path: file.path });
19
+ }
20
+ const symbolByKey = new Map();
21
+ for (const symbol of symbols) {
22
+ const key = symbol.nodeId !== null ? symbol.nodeId : `${symbol.file ?? ""}\0${symbol.name}`;
23
+ if (!symbolByKey.has(key)) {
24
+ symbolByKey.set(key, { name: symbol.name, nodeId: symbol.nodeId, file: symbol.file });
25
+ }
26
+ }
27
+ return {
28
+ files: [...fileByPath.values()].sort((a, b) => compareCodeUnits(a.path, b.path)),
29
+ symbols: [...symbolByKey.values()].sort((a, b) => compareCodeUnits(a.file ?? "", b.file ?? "") ||
30
+ compareCodeUnits(a.name, b.name) ||
31
+ compareCodeUnits(a.nodeId ?? "", b.nodeId ?? ""))
32
+ };
33
+ }
34
+ /**
35
+ * Converts an already-normalized `retrieval-evidence-v1` observation into the strategy-neutral
36
+ * contract. It recalculates no identity, parses no stdout, and does not reinterpret availability.
37
+ */
38
+ export function buildQueryStrategyEvidenceFromRetrievalEvidence(strategyId, evidence) {
39
+ return {
40
+ schemaVersion: "retrieval-query-strategy-evidence-v1",
41
+ strategyId,
42
+ availability: evidence.availability,
43
+ availabilityReason: evidence.availabilityReason ?? null,
44
+ files: evidence.files.map(({ path }) => ({ path })),
45
+ symbols: evidence.symbols.map((symbol) => ({
46
+ name: symbol.name,
47
+ nodeId: symbol.nodeId ?? null,
48
+ file: symbol.file ?? null
49
+ })),
50
+ steps: evidence.commands.map((command) => ({
51
+ kind: command.family,
52
+ succeeded: command.succeeded,
53
+ evidenceAvailable: (command.parseState === "parsed" || command.parseState === "selection-attributed") &&
54
+ command.rejectedIdentityCount === 0,
55
+ reason: command.reason ?? null
56
+ }))
57
+ };
58
+ }
@@ -2,6 +2,8 @@ import path from "node:path";
2
2
  import { countEstimatedTokens, countTextChars, tokenCountMethod } from "../core/countTokens.js";
3
3
  import { runMeasuredCommand } from "../core/runMeasuredCommand.js";
4
4
  import { interpretToolVersionOutput } from "./indexSnapshot.js";
5
+ import { buildRetrievalEvidenceFromCommands } from "./retrievalQuality/buildRetrievalEvidence.js";
6
+ import { buildQueryStrategyEvidenceFromRetrievalEvidence } from "./retrievalQueryStrategyEvidence.js";
5
7
  const VERSION_PROBE_TIMEOUT_MS = 30_000;
6
8
  function parseJsonIfPossible(text) {
7
9
  try {
@@ -52,6 +54,7 @@ function skippedRetrieval(caseId, warnings, commands, durationMs) {
52
54
  contextText: "",
53
55
  filesRead: [],
54
56
  commands,
57
+ retrievalEvidence: buildRetrievalEvidenceFromCommands(commands),
55
58
  durationMs
56
59
  };
57
60
  }
@@ -237,11 +240,31 @@ export async function probeMyDevKitVersion(options) {
237
240
  });
238
241
  }
239
242
  }
243
+ function selectRetrievalStrategyContextText(strategyId, outputs) {
244
+ const ordered = strategyId === "keyword-search"
245
+ ? [outputs.search]
246
+ : strategyId === "symbol-lookup"
247
+ ? [outputs.lookup, outputs.search]
248
+ : strategyId === "graph-neighborhood"
249
+ ? [outputs.slice, outputs.search]
250
+ : strategyId === "source-slice"
251
+ ? [outputs.source, outputs.search]
252
+ : [outputs.source, outputs.slice, outputs.lookup, outputs.search];
253
+ return ordered.find((text) => text && text.trim().length > 0) ?? "";
254
+ }
255
+ const STRATEGY_DOWNSTREAM_COMMANDS = {
256
+ "keyword-search": [],
257
+ "symbol-lookup": ["lookup"],
258
+ "graph-neighborhood": ["slice"],
259
+ "source-slice": ["source"],
260
+ "combined-graph-guided": ["lookup", "slice", "source"]
261
+ };
240
262
  /**
241
- * Runs search -> lookup -> slice -> source against an already prepared index. Never invokes
242
- * `index`; `durationMs` covers retrieval only.
263
+ * Executes exactly one core retrieval query strategy against an already prepared index. Every
264
+ * strategy starts with `search` and expands at most the first candidate. Never invokes `index`;
265
+ * `durationMs` covers retrieval only.
243
266
  */
244
- export async function runMyDevKitRetrievalFromIndex(options) {
267
+ export async function runMyDevKitRetrievalStrategyFromIndex(options) {
245
268
  const started = Date.now();
246
269
  const warnings = [];
247
270
  const commands = [];
@@ -272,58 +295,37 @@ export async function runMyDevKitRetrievalFromIndex(options) {
272
295
  const selectedNodeId = candidate.nodeId;
273
296
  const selectedFile = candidate.file;
274
297
  const selectedSymbol = candidate.symbol;
275
- let lookupOutput = "";
276
- let sliceOutput = "";
277
- let sourceOutput = "";
278
- if (selectedNodeId) {
279
- const lookupCommand = await runMeasuredCommand({
280
- commandId: "lookup",
281
- commandString: options.kitCommand,
282
- cwd: process.cwd(),
283
- outDir: commandsDir,
284
- extraArgs: ["lookup", "--index", indexDir, "--node", selectedNodeId, "--json"]
285
- });
286
- commands.push(lookupCommand);
287
- if (lookupCommand.ok) {
288
- lookupOutput = lookupCommand.stdout;
289
- }
290
- else {
291
- warnings.push("my-dev-kit lookup command failed.");
292
- }
293
- const sliceCommand = await runMeasuredCommand({
294
- commandId: "slice",
295
- commandString: options.kitCommand,
296
- cwd: process.cwd(),
297
- outDir: commandsDir,
298
- extraArgs: ["slice", "--index", indexDir, "--node", selectedNodeId, "--json"]
299
- });
300
- commands.push(sliceCommand);
301
- if (sliceCommand.ok) {
302
- sliceOutput = sliceCommand.stdout;
303
- }
304
- else {
305
- warnings.push("my-dev-kit slice command failed.");
306
- }
307
- const sourceCommand = await runMeasuredCommand({
308
- commandId: "source",
309
- commandString: options.kitCommand,
310
- cwd: process.cwd(),
311
- outDir: commandsDir,
312
- extraArgs: ["source", "--index", indexDir, "--node", selectedNodeId, "--max-lines", "160", "--format", "numbered"]
313
- });
314
- commands.push(sourceCommand);
315
- if (sourceCommand.ok) {
316
- sourceOutput = sourceCommand.stdout;
298
+ const outputs = { search: searchCommand.stdout, lookup: "", slice: "", source: "" };
299
+ const downstream = STRATEGY_DOWNSTREAM_COMMANDS[options.strategyId];
300
+ if (downstream.length > 0) {
301
+ if (selectedNodeId) {
302
+ for (const commandId of downstream) {
303
+ const extraArgs = commandId === "source"
304
+ ? ["source", "--index", indexDir, "--node", selectedNodeId, "--max-lines", "160", "--format", "numbered"]
305
+ : [commandId, "--index", indexDir, "--node", selectedNodeId, "--json"];
306
+ const command = await runMeasuredCommand({
307
+ commandId,
308
+ commandString: options.kitCommand,
309
+ cwd: process.cwd(),
310
+ outDir: commandsDir,
311
+ extraArgs
312
+ });
313
+ commands.push(command);
314
+ if (command.ok) {
315
+ outputs[commandId] = command.stdout;
316
+ }
317
+ else {
318
+ warnings.push(`my-dev-kit ${commandId} command failed.`);
319
+ }
320
+ }
317
321
  }
318
322
  else {
319
- warnings.push("my-dev-kit source command failed.");
323
+ warnings.push("No my-dev-kit node id was available after search.");
320
324
  }
321
325
  }
322
- else {
323
- warnings.push("No my-dev-kit node id was available after search.");
324
- }
325
- const contextText = [sourceOutput, sliceOutput, lookupOutput, searchCommand.stdout].find((text) => text && text.trim().length > 0) ?? "";
326
+ const contextText = selectRetrievalStrategyContextText(options.strategyId, outputs);
326
327
  const filesRead = selectedFile ? [selectedFile] : [];
328
+ const retrievalEvidence = buildRetrievalEvidenceFromCommands(commands, { nodeId: selectedNodeId, file: selectedFile });
327
329
  return {
328
330
  caseId: options.evaluationCase.id,
329
331
  skipped: contextText.length === 0,
@@ -337,9 +339,18 @@ export async function runMyDevKitRetrievalFromIndex(options) {
337
339
  selectedNodeId,
338
340
  selectedFile,
339
341
  selectedSymbol,
342
+ retrievalEvidence,
343
+ queryStrategyEvidence: buildQueryStrategyEvidenceFromRetrievalEvidence(options.strategyId, retrievalEvidence),
340
344
  durationMs: Date.now() - started
341
345
  };
342
346
  }
347
+ /**
348
+ * v0.8.0 compatibility entrypoint: the historical search -> lookup -> slice -> source lifecycle,
349
+ * delegating to the `combined-graph-guided` strategy. Never invokes `index`.
350
+ */
351
+ export async function runMyDevKitRetrievalFromIndex(options) {
352
+ return runMyDevKitRetrievalStrategyFromIndex({ ...options, strategyId: "combined-graph-guided" });
353
+ }
343
354
  /**
344
355
  * Legacy per-case lifecycle: builds a case-specific index and retrieves from it. `commands`
345
356
  * starts with the index command and `durationMs` covers the whole lifecycle including indexing.