@dailephd/my-dev-kit-lab 0.7.2 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -1
- package/README.md +7 -3
- package/benchmarks/contracts/warm-index-benchmark-cases.json +330 -0
- package/dist/src/cli/help.js +12 -0
- package/dist/src/commands/runExperimentDescribeCommand.js +9 -1
- package/dist/src/commands/runExperimentRunCommand.js +95 -4
- package/dist/src/evaluation/benchmarkMetadata.js +13 -0
- package/dist/src/evaluation/retrievalQuality/buildRetrievalEvidence.js +292 -0
- package/dist/src/evaluation/retrievalQuality/corpusCompleteness.js +122 -0
- package/dist/src/evaluation/retrievalQuality/factContextTargets.js +44 -0
- package/dist/src/evaluation/retrievalQuality/index.js +5 -0
- package/dist/src/evaluation/retrievalQuality/metrics.js +233 -0
- package/dist/src/evaluation/retrievalQuality/types.js +9 -0
- package/dist/src/evaluation/runMyDevKitRetrieval.js +3 -0
- package/dist/src/experiments/defaultRegistry.js +2 -0
- package/dist/src/experiments/plugins/index.js +1 -0
- package/dist/src/experiments/plugins/retrievalPrecisionRecall/config.js +39 -0
- package/dist/src/experiments/plugins/retrievalPrecisionRecall/execution.js +152 -0
- package/dist/src/experiments/plugins/retrievalPrecisionRecall/executionArtifact.js +15 -0
- package/dist/src/experiments/plugins/retrievalPrecisionRecall/index.js +9 -0
- package/dist/src/experiments/plugins/retrievalPrecisionRecall/localSubjectExecution.js +139 -0
- package/dist/src/experiments/plugins/retrievalPrecisionRecall/localSubjectPrivacy.js +119 -0
- package/dist/src/experiments/plugins/retrievalPrecisionRecall/metadata.js +21 -0
- package/dist/src/experiments/plugins/retrievalPrecisionRecall/metrics.js +158 -0
- package/dist/src/experiments/plugins/retrievalPrecisionRecall/plugin.js +253 -0
- package/dist/src/experiments/plugins/retrievalPrecisionRecall/types.js +8 -0
- package/dist/src/report/experiments/buildPluginExperimentReport.js +21 -2
- package/dist/src/report/experiments/buildRetrievalPrecisionRecallReport.js +83 -0
- package/dist/src/report/experiments/index.js +4 -0
- package/dist/src/report/experiments/renderPluginExperimentReportHtml.js +3 -0
- package/dist/src/report/experiments/renderPluginExperimentReportText.js +9 -0
- package/dist/src/report/experiments/renderRetrievalPrecisionRecallHtml.js +96 -0
- package/dist/src/report/experiments/renderRetrievalPrecisionRecallText.js +121 -0
- package/dist/src/report/experiments/retrievalPrecisionRecallReportModel.js +3 -0
- package/docs/METRICS.md +32 -2
- package/package.json +1 -1
|
@@ -0,0 +1,233 @@
|
|
|
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 { evaluationCase, retrieval } = input;
|
|
138
|
+
const evidence = retrieval.retrievalEvidence;
|
|
139
|
+
const evidenceBlocked = evidenceReason(evidence);
|
|
140
|
+
const answerKey = evaluationCase.answerKey;
|
|
141
|
+
const hasAnswerKey = answerKey !== undefined && answerKey !== null && typeof answerKey === "object";
|
|
142
|
+
const expectedFiles = resolveExpectation(evaluationCase.expectedFiles, hasAnswerKey ? answerKey.expectedFiles : undefined, hasAnswerKey, normalizeExpectedFiles, "expected-files-answer-key-mismatch");
|
|
143
|
+
const expectedSymbols = resolveExpectation(evaluationCase.expectedSymbols, hasAnswerKey ? answerKey.expectedSymbols : undefined, hasAnswerKey, normalizeExpectedSymbols, "expected-symbols-answer-key-mismatch");
|
|
144
|
+
const usable = evidenceBlocked === null && evidence !== undefined ? evidence : undefined;
|
|
145
|
+
const retrievedFiles = usable ? sortedUnique(usable.files.map((file) => file.path)) : undefined;
|
|
146
|
+
const retrievedSymbols = usable ? sortedUnique(usable.symbols.map((symbol) => symbol.name)) : undefined;
|
|
147
|
+
// ---- files
|
|
148
|
+
let file = {
|
|
149
|
+
relevantRetrievedFiles: null,
|
|
150
|
+
irrelevantRetrievedFiles: null,
|
|
151
|
+
missedFiles: null,
|
|
152
|
+
missedFileCount: null,
|
|
153
|
+
precision: unavailableRatio(evidenceBlocked ?? (expectedFiles.ok ? "unavailable" : expectedFiles.reason)),
|
|
154
|
+
recall: unavailableRatio(evidenceBlocked ?? (expectedFiles.ok ? "unavailable" : expectedFiles.reason))
|
|
155
|
+
};
|
|
156
|
+
let irrelevantContextRatio = unavailableRatio(evidenceBlocked ?? (expectedFiles.ok ? "unavailable" : expectedFiles.reason));
|
|
157
|
+
if (retrievedFiles !== undefined && expectedFiles.ok) {
|
|
158
|
+
const expected = new Set(expectedFiles.values);
|
|
159
|
+
const retrieved = new Set(retrievedFiles);
|
|
160
|
+
const relevant = retrievedFiles.filter((path) => expected.has(path));
|
|
161
|
+
const irrelevant = retrievedFiles.filter((path) => !expected.has(path));
|
|
162
|
+
const missed = expectedFiles.values.filter((path) => !retrieved.has(path));
|
|
163
|
+
file = {
|
|
164
|
+
relevantRetrievedFiles: relevant,
|
|
165
|
+
irrelevantRetrievedFiles: irrelevant,
|
|
166
|
+
missedFiles: missed,
|
|
167
|
+
missedFileCount: missed.length,
|
|
168
|
+
precision: ratio(relevant.length, retrievedFiles.length, "no-retrieved-files"),
|
|
169
|
+
recall: ratio(relevant.length, expectedFiles.values.length, "no-expected-files")
|
|
170
|
+
};
|
|
171
|
+
irrelevantContextRatio = ratio(irrelevant.length, retrievedFiles.length, "no-retrieved-files");
|
|
172
|
+
}
|
|
173
|
+
// ---- symbols (exact unique names)
|
|
174
|
+
let symbol = {
|
|
175
|
+
relevantRetrievedSymbols: null,
|
|
176
|
+
irrelevantRetrievedSymbols: null,
|
|
177
|
+
missedSymbols: null,
|
|
178
|
+
missedSymbolCount: null,
|
|
179
|
+
precision: unavailableRatio(evidenceBlocked ?? (expectedSymbols.ok ? "unavailable" : expectedSymbols.reason)),
|
|
180
|
+
recall: unavailableRatio(evidenceBlocked ?? (expectedSymbols.ok ? "unavailable" : expectedSymbols.reason))
|
|
181
|
+
};
|
|
182
|
+
if (retrievedSymbols !== undefined && expectedSymbols.ok) {
|
|
183
|
+
const expected = new Set(expectedSymbols.values);
|
|
184
|
+
const retrieved = new Set(retrievedSymbols);
|
|
185
|
+
const relevant = retrievedSymbols.filter((name) => expected.has(name));
|
|
186
|
+
const missed = expectedSymbols.values.filter((name) => !retrieved.has(name));
|
|
187
|
+
symbol = {
|
|
188
|
+
relevantRetrievedSymbols: relevant,
|
|
189
|
+
irrelevantRetrievedSymbols: retrievedSymbols.filter((name) => !expected.has(name)),
|
|
190
|
+
missedSymbols: missed,
|
|
191
|
+
missedSymbolCount: missed.length,
|
|
192
|
+
precision: ratio(relevant.length, retrievedSymbols.length, "no-retrieved-symbols"),
|
|
193
|
+
recall: ratio(relevant.length, expectedSymbols.values.length, "no-expected-symbols")
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
// ---- facts
|
|
197
|
+
let fact = {
|
|
198
|
+
coveredFactIds: null,
|
|
199
|
+
uncoveredFactIds: null,
|
|
200
|
+
uncoveredFactCount: null,
|
|
201
|
+
coverage: unavailableRatio(evidenceBlocked ?? "unavailable")
|
|
202
|
+
};
|
|
203
|
+
if (usable !== undefined && retrievedFiles !== undefined) {
|
|
204
|
+
const outcome = calculateFactCoverage(answerKey, usable, new Set(retrievedFiles));
|
|
205
|
+
if (outcome.kind === "computed") {
|
|
206
|
+
fact = {
|
|
207
|
+
coveredFactIds: outcome.covered,
|
|
208
|
+
uncoveredFactIds: outcome.uncovered,
|
|
209
|
+
uncoveredFactCount: outcome.uncovered.length,
|
|
210
|
+
coverage: ratio(outcome.covered.length, outcome.total, "no-expected-facts")
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
else {
|
|
214
|
+
fact = { ...fact, coverage: outcome.kind === "unavailable" ? unavailableRatio(outcome.reason) : { availability: "not-applicable", numerator: null, denominator: null, value: null, reason: outcome.reason } };
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
const tokensValid = typeof retrieval.totalEstimatedTokens === "number" && Number.isFinite(retrieval.totalEstimatedTokens) && retrieval.totalEstimatedTokens >= 0;
|
|
218
|
+
return {
|
|
219
|
+
schemaVersion: "retrieval-quality-metrics-v1",
|
|
220
|
+
caseId: evaluationCase.id,
|
|
221
|
+
evidence: {
|
|
222
|
+
availability: evidence === undefined ? "missing" : evidence.availability,
|
|
223
|
+
reason: evidence === undefined ? "retrieval-evidence-missing" : (evidence.availabilityReason ?? null)
|
|
224
|
+
},
|
|
225
|
+
expectations: { files: expectationStatus(expectedFiles), symbols: expectationStatus(expectedSymbols) },
|
|
226
|
+
file,
|
|
227
|
+
symbol,
|
|
228
|
+
fact,
|
|
229
|
+
irrelevantContextRatio,
|
|
230
|
+
retrievedTokenCount: tokensValid ? retrieval.totalEstimatedTokens : null,
|
|
231
|
+
tokenCountMethod: tokensValid && typeof retrieval.tokenCountMethod === "string" ? retrieval.tokenCountMethod : null
|
|
232
|
+
};
|
|
233
|
+
}
|
|
@@ -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"];
|
|
@@ -2,6 +2,7 @@ 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";
|
|
5
6
|
const VERSION_PROBE_TIMEOUT_MS = 30_000;
|
|
6
7
|
function parseJsonIfPossible(text) {
|
|
7
8
|
try {
|
|
@@ -52,6 +53,7 @@ function skippedRetrieval(caseId, warnings, commands, durationMs) {
|
|
|
52
53
|
contextText: "",
|
|
53
54
|
filesRead: [],
|
|
54
55
|
commands,
|
|
56
|
+
retrievalEvidence: buildRetrievalEvidenceFromCommands(commands),
|
|
55
57
|
durationMs
|
|
56
58
|
};
|
|
57
59
|
}
|
|
@@ -337,6 +339,7 @@ export async function runMyDevKitRetrievalFromIndex(options) {
|
|
|
337
339
|
selectedNodeId,
|
|
338
340
|
selectedFile,
|
|
339
341
|
selectedSymbol,
|
|
342
|
+
retrievalEvidence: buildRetrievalEvidenceFromCommands(commands, { nodeId: selectedNodeId, file: selectedFile }),
|
|
340
343
|
durationMs: Date.now() - started
|
|
341
344
|
};
|
|
342
345
|
}
|
|
@@ -2,6 +2,7 @@ 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";
|
|
5
6
|
import { warmIndexReusePlugin } from "./plugins/warmIndexReuse/index.js";
|
|
6
7
|
export function createDefaultExperimentPluginRegistry() {
|
|
7
8
|
const registry = new ExperimentPluginRegistry();
|
|
@@ -9,5 +10,6 @@ export function createDefaultExperimentPluginRegistry() {
|
|
|
9
10
|
registry.register(warmIndexReusePlugin);
|
|
10
11
|
registry.register(incrementalChangeStalenessPlugin);
|
|
11
12
|
registry.register(contextWindowScalingPlugin);
|
|
13
|
+
registry.register(retrievalPrecisionRecallPlugin);
|
|
12
14
|
return registry;
|
|
13
15
|
}
|
|
@@ -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";
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { relativeWithinRoot } from "../../../core/pathSafety.js";
|
|
3
|
+
import { normalizeRetrievedRepositoryPath, validateRetrievalPrecisionRecallCase } from "../../../evaluation/retrievalQuality/index.js";
|
|
4
|
+
import { buildMyDevKitIndex, runMyDevKitRetrievalFromIndex } from "../../../evaluation/runMyDevKitRetrieval.js";
|
|
5
|
+
import { captureTargetSnapshot, compareTargetSnapshots } from "../../../evaluation/targetImmutability/index.js";
|
|
6
|
+
import { LocalSubjectExecutionError } from "../contextWindowScaling/localSubjectErrors.js";
|
|
7
|
+
import { deriveGuidedIndexExclusions } from "../contextWindowScaling/localSubjectExclusions.js";
|
|
8
|
+
import { assertWorkRootOutsideTarget, createPrivateScratch, defaultPrivateScratchIo, removePrivateScratch } from "../contextWindowScaling/localSubjectScratch.js";
|
|
9
|
+
import { buildCaseEvidenceFromRetrieval } from "./execution.js";
|
|
10
|
+
function toRepositoryRelative(repositoryRoot, file) {
|
|
11
|
+
if (path.isAbsolute(file)) {
|
|
12
|
+
try {
|
|
13
|
+
return relativeWithinRoot(repositoryRoot, file);
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return file.replace(/\\/g, "/");
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return normalizeRetrievedRepositoryPath(file) ?? file.replace(/\\/g, "/");
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Counts distinct retrieved identities outside the eligible subject universe. It inspects every file identity the
|
|
23
|
+
* retrieval exposes: evidence files, evidence symbol files, the selected file, and the files read. Only the count
|
|
24
|
+
* leaves this function; an offending identity is never named.
|
|
25
|
+
*/
|
|
26
|
+
export function countRetrievedIdentitiesOutsideEligibleUniverse(retrieval, eligibleFiles, repositoryRoot) {
|
|
27
|
+
const identities = new Set();
|
|
28
|
+
for (const file of retrieval.retrievalEvidence?.files ?? [])
|
|
29
|
+
identities.add(toRepositoryRelative(repositoryRoot, file.path));
|
|
30
|
+
for (const symbol of retrieval.retrievalEvidence?.symbols ?? []) {
|
|
31
|
+
if (symbol.file !== undefined)
|
|
32
|
+
identities.add(toRepositoryRelative(repositoryRoot, symbol.file));
|
|
33
|
+
}
|
|
34
|
+
if (retrieval.selectedFile)
|
|
35
|
+
identities.add(toRepositoryRelative(repositoryRoot, retrieval.selectedFile));
|
|
36
|
+
for (const file of retrieval.filesRead)
|
|
37
|
+
identities.add(toRepositoryRelative(repositoryRoot, file));
|
|
38
|
+
return [...identities].filter((identity) => !eligibleFiles.has(identity)).length;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Internal programmatic seam: runs a loaded local subject through the existing index and retrieval owners, one
|
|
42
|
+
* private index per configured case (each case keeps its own exact source roots), with a private scratch outside the
|
|
43
|
+
* target and pre/post target-immutability enforcement. Order: validate work root, derive exclusions, validate
|
|
44
|
+
* ground truth, capture before, create scratch, execute cases, capture after, compare, remove scratch, then decide.
|
|
45
|
+
* Success is returned only when every safety gate passed. A mutated target is never reverted.
|
|
46
|
+
*
|
|
47
|
+
* Every issue message is safe by construction: fixed text plus logical case ids and counts, never a file name,
|
|
48
|
+
* symbol, fact id, path, or caught error text.
|
|
49
|
+
*/
|
|
50
|
+
export async function executeLocalRepositorySubjectRetrievalPrecisionRecall(args) {
|
|
51
|
+
const { subject } = args;
|
|
52
|
+
const buildIndex = args.dependencies?.buildIndex ?? buildMyDevKitIndex;
|
|
53
|
+
const retrieveFromIndex = args.dependencies?.retrieveFromIndex ?? runMyDevKitRetrievalFromIndex;
|
|
54
|
+
await assertWorkRootOutsideTarget(args.workRoot, subject.repositoryRoot);
|
|
55
|
+
const guidedExclusions = deriveGuidedIndexExclusions({
|
|
56
|
+
eligibleFiles: subject.eligibleFiles,
|
|
57
|
+
excludedFiles: [...subject.runtimeSafetyExclusions.gitIgnoredFiles, ...subject.runtimeSafetyExclusions.oversizedFiles]
|
|
58
|
+
});
|
|
59
|
+
const invalidCaseIds = subject.evaluationCases.filter((evaluationCase) => validateRetrievalPrecisionRecallCase(evaluationCase).length > 0).map((evaluationCase) => evaluationCase.id);
|
|
60
|
+
if (invalidCaseIds.length > 0) {
|
|
61
|
+
throw new LocalSubjectExecutionError([
|
|
62
|
+
{ code: "GROUND_TRUTH_INVALID", message: `retrieval ground truth is incomplete for case(s): ${invalidCaseIds.join(", ")}.` }
|
|
63
|
+
]);
|
|
64
|
+
}
|
|
65
|
+
const snapshotConfig = { targetRootPath: subject.repositoryRoot, relativeFilePaths: subject.eligibleFiles };
|
|
66
|
+
const snapshotOptions = { externalLocalSafe: { maxHashedFileBytes: subject.manifest.safetyPolicy.maxFileBytes } };
|
|
67
|
+
const before = await captureTargetSnapshot(snapshotConfig, snapshotOptions);
|
|
68
|
+
if (!before.ok) {
|
|
69
|
+
throw new LocalSubjectExecutionError([{ code: "BEFORE_SNAPSHOT_FAILED", message: `target snapshot before execution failed (${before.code}).` }]);
|
|
70
|
+
}
|
|
71
|
+
const scratch = await createPrivateScratch(args.workRoot, subject.repositoryRoot);
|
|
72
|
+
const issues = [];
|
|
73
|
+
const caseEvidence = [];
|
|
74
|
+
const eligible = new Set(subject.eligibleFiles);
|
|
75
|
+
for (const [position, evaluationCase] of subject.evaluationCases.entries()) {
|
|
76
|
+
// Short, fixed segments: private case or repository names never become directory names.
|
|
77
|
+
const indexDir = path.join(scratch.path, `i${position + 1}`);
|
|
78
|
+
const commandsDir = path.join(scratch.path, `c${position + 1}`);
|
|
79
|
+
try {
|
|
80
|
+
// requireKit: false keeps a failed index as a result we can classify instead of an arbitrary thrown message.
|
|
81
|
+
const build = await buildIndex({
|
|
82
|
+
target: { absoluteTargetRoot: evaluationCase.absoluteTargetRoot, sourceRoots: [...evaluationCase.sourceRoots] },
|
|
83
|
+
kitCommand: args.kitCommand,
|
|
84
|
+
indexDir,
|
|
85
|
+
commandsDir: path.join(commandsDir, "index"),
|
|
86
|
+
requireKit: false,
|
|
87
|
+
excludePaths: guidedExclusions
|
|
88
|
+
});
|
|
89
|
+
if (!build.ok) {
|
|
90
|
+
issues.push({ code: "EXECUTION_FAILED", message: `case ${evaluationCase.id}: the my-dev-kit index could not be prepared.` });
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
const retrieval = await retrieveFromIndex({
|
|
94
|
+
evaluationCase,
|
|
95
|
+
kitCommand: args.kitCommand,
|
|
96
|
+
indexDir,
|
|
97
|
+
commandsDir: path.join(commandsDir, "retrieval"),
|
|
98
|
+
requireKit: false
|
|
99
|
+
});
|
|
100
|
+
const outside = countRetrievedIdentitiesOutsideEligibleUniverse(retrieval, eligible, subject.repositoryRoot);
|
|
101
|
+
if (outside > 0) {
|
|
102
|
+
issues.push({
|
|
103
|
+
code: "RETRIEVAL_OUTSIDE_ELIGIBLE_UNIVERSE",
|
|
104
|
+
message: `case ${evaluationCase.id}: retrieval exposed ${outside} file identit${outside === 1 ? "y" : "ies"} outside the eligible subject universe.`
|
|
105
|
+
});
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
caseEvidence.push(buildCaseEvidenceFromRetrieval(evaluationCase, retrieval));
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
issues.push({ code: "EXECUTION_FAILED", message: `case ${evaluationCase.id}: execution threw before completing (details withheld).` });
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
let comparison = null;
|
|
116
|
+
const after = await captureTargetSnapshot(snapshotConfig, snapshotOptions);
|
|
117
|
+
if (!after.ok) {
|
|
118
|
+
issues.push({ code: "AFTER_SNAPSHOT_FAILED", message: `target snapshot after execution failed (${after.code}).` });
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
comparison = compareTargetSnapshots(before.snapshot, after.snapshot);
|
|
122
|
+
if (comparison.status === "mutated") {
|
|
123
|
+
issues.push({ code: "TARGET_MUTATED", message: "the target changed during execution; it was not restored." });
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
const cleanupFailure = await removePrivateScratch(scratch, args.scratchIo ?? defaultPrivateScratchIo);
|
|
127
|
+
if (cleanupFailure) {
|
|
128
|
+
issues.push({ code: "SCRATCH_CLEANUP_FAILED", message: "the private scratch could not be removed." });
|
|
129
|
+
}
|
|
130
|
+
if (issues.length > 0 || !comparison) {
|
|
131
|
+
throw new LocalSubjectExecutionError(issues.length > 0 ? issues : [{ code: "EXECUTION_FAILED", message: "execution did not complete." }], {
|
|
132
|
+
immutability: comparison
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
return {
|
|
136
|
+
caseEvidence,
|
|
137
|
+
immutability: { status: "unchanged", preExistingGitStatusEntryCount: comparison.preExistingGitStatusEntryCount, newMutationCount: 0 }
|
|
138
|
+
};
|
|
139
|
+
}
|