@dailephd/my-dev-kit-lab 0.6.0 → 0.6.2
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 +32 -0
- package/README.md +7 -4
- package/benchmarks/contracts/incremental-change-staleness-scenarios.json +360 -0
- package/dist/src/commands/runExperimentRunCommand.js +4 -3
- package/dist/src/evaluation/affectedNeighborhood.js +712 -0
- package/dist/src/evaluation/affectedNeighborhoodMetrics.js +51 -0
- package/dist/src/evaluation/indexSnapshot.js +1 -1
- package/dist/src/experiments/defaultRegistry.js +2 -0
- package/dist/src/experiments/plugins/incrementalChangeStaleness/comparison.js +101 -0
- package/dist/src/experiments/plugins/incrementalChangeStaleness/config.js +66 -0
- package/dist/src/experiments/plugins/incrementalChangeStaleness/disposableTarget.js +107 -0
- package/dist/src/experiments/plugins/incrementalChangeStaleness/execution.js +404 -0
- package/dist/src/experiments/plugins/incrementalChangeStaleness/executionArtifact.js +160 -0
- package/dist/src/experiments/plugins/incrementalChangeStaleness/index.js +13 -0
- package/dist/src/experiments/plugins/incrementalChangeStaleness/lifecycle.js +276 -0
- package/dist/src/experiments/plugins/incrementalChangeStaleness/lifecyclePolicy.js +152 -0
- package/dist/src/experiments/plugins/incrementalChangeStaleness/mutationExecution.js +144 -0
- package/dist/src/experiments/plugins/incrementalChangeStaleness/plugin.js +274 -0
- package/dist/src/experiments/plugins/incrementalChangeStaleness/scenarioCatalog.js +42 -0
- package/dist/src/experiments/plugins/incrementalChangeStaleness/scenarioMutation.js +116 -0
- package/dist/src/experiments/plugins/incrementalChangeStaleness/scenarioSelection.js +79 -0
- package/dist/src/experiments/plugins/incrementalChangeStaleness/scenarioTypes.js +37 -0
- package/dist/src/experiments/plugins/incrementalChangeStaleness/scenarioValidation.js +168 -0
- package/dist/src/experiments/plugins/incrementalChangeStaleness/sourceState.js +145 -0
- package/dist/src/experiments/plugins/incrementalChangeStaleness/treatmentSession.js +6 -0
- package/dist/src/experiments/plugins/index.js +1 -0
- package/dist/src/experiments/plugins/warmIndexReuse/execution.js +21 -0
- package/dist/src/experiments/plugins/warmIndexReuse/executionArtifact.js +1 -0
- package/dist/src/experiments/plugins/warmIndexReuse/metrics.js +68 -0
- package/dist/src/experiments/plugins/warmIndexReuse/warmIndexSession.js +9 -1
- package/dist/src/report/experiments/buildIncrementalChangeStalenessReport.js +112 -0
- package/dist/src/report/experiments/buildPluginExperimentReport.js +17 -2
- package/dist/src/report/experiments/buildWarmIndexReuseReport.js +80 -3
- package/dist/src/report/experiments/incrementalChangeStalenessReportModel.js +4 -0
- package/dist/src/report/experiments/index.js +3 -0
- package/dist/src/report/experiments/renderIncrementalChangeStalenessHtml.js +239 -0
- package/dist/src/report/experiments/renderPluginExperimentReportHtml.js +3 -0
- package/dist/src/report/experiments/renderPluginExperimentReportText.js +222 -0
- package/dist/src/report/experiments/renderWarmIndexReuseHtml.js +98 -0
- package/dist/src/report/experiments/warmIndexReuseReportModel.js +5 -0
- package/docs/METRICS.md +79 -3
- package/examples/tutorial-browser/prepare.mjs +13 -13
- package/examples/tutorial-browser/server.mjs +13 -13
- package/package.json +1 -1
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { resolveWithinRoot } from "../../../core/pathSafety.js";
|
|
5
|
+
import { readBenchmarkProjectProfiles } from "../../../evaluation/benchmarkMetadata.js";
|
|
6
|
+
import { toAffectedNeighborhoodMetricValues } from "../../../evaluation/affectedNeighborhoodMetrics.js";
|
|
7
|
+
import { sanitizePathSegment } from "../../outputPaths.js";
|
|
8
|
+
import { summarizeExperimentRun } from "../../results.js";
|
|
9
|
+
import { defaultIncrementalChangeStalenessConfig, incrementalChangeStalenessConfigDefinition, validateIncrementalChangeStalenessConfig } from "./config.js";
|
|
10
|
+
import { DEFAULT_INCREMENTAL_CHANGE_STALENESS_RUNTIME_ROOT_RELATIVE, INCREMENTAL_CHANGE_STALENESS_TREATMENT_IDS, removeIncrementalChangeStalenessRuntimeRoot } from "./disposableTarget.js";
|
|
11
|
+
import { executeIncrementalChangeStalenessScenario } from "./execution.js";
|
|
12
|
+
import { buildIncrementalChangeStalenessExecutionArtifact, writeIncrementalChangeStalenessExecutionArtifact } from "./executionArtifact.js";
|
|
13
|
+
import { prepareIncrementalChangeStalenessScenarioLifecycle } from "./lifecycle.js";
|
|
14
|
+
import { BENCHMARK_PROJECT_PROFILES_PATH } from "./scenarioCatalog.js";
|
|
15
|
+
import { resolveIncrementalChangeStalenessScenarios } from "./scenarioSelection.js";
|
|
16
|
+
export const INCREMENTAL_CHANGE_STALENESS_PLUGIN_ID = "incremental-change-staleness";
|
|
17
|
+
export const STALE_INDEX_VARIANT_ID = "stale-index";
|
|
18
|
+
export const FULL_REFRESH_VARIANT_ID = "full-refresh";
|
|
19
|
+
/** Marker proving a runtime directory was created by (and belongs to) one plugin run. */
|
|
20
|
+
export const INCREMENTAL_CHANGE_STALENESS_RUN_OWNER_MARKER = ".incremental-change-staleness-run-owner.json";
|
|
21
|
+
export const incrementalChangeStalenessMetadata = {
|
|
22
|
+
id: INCREMENTAL_CHANGE_STALENESS_PLUGIN_ID,
|
|
23
|
+
name: "Incremental Change Staleness",
|
|
24
|
+
description: "Compares stale-index and full-refresh my-dev-kit retrieval after the same frozen controlled source change: matched correctness, required-file evidence, and a conservative stale-risk classification.",
|
|
25
|
+
schemaVersion: "1.0.0",
|
|
26
|
+
status: "experimental",
|
|
27
|
+
supportedTargets: ["self"],
|
|
28
|
+
supportedOutputs: ["json", "html"]
|
|
29
|
+
};
|
|
30
|
+
const TREATMENT_VARIANTS = [
|
|
31
|
+
{
|
|
32
|
+
id: STALE_INDEX_VARIANT_ID,
|
|
33
|
+
name: "Stale index",
|
|
34
|
+
description: "Keeps the pre-mutation baseline index as its active retrieval index after the controlled change; never reindexes."
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
id: FULL_REFRESH_VARIANT_ID,
|
|
38
|
+
name: "Full refresh",
|
|
39
|
+
description: "Builds a new complete post-mutation index in a separate directory and uses it as its active retrieval index."
|
|
40
|
+
}
|
|
41
|
+
];
|
|
42
|
+
export function resolveIncrementalChangeStalenessRunOwnedRoot(toolRoot, runId) {
|
|
43
|
+
return resolveWithinRoot(path.resolve(toolRoot), path.join(DEFAULT_INCREMENTAL_CHANGE_STALENESS_RUNTIME_ROOT_RELATIVE, sanitizePathSegment(runId)));
|
|
44
|
+
}
|
|
45
|
+
async function claimRunOwnedRoot(runOwnedRoot, runId) {
|
|
46
|
+
if (existsSync(runOwnedRoot)) {
|
|
47
|
+
throw new Error(`Incremental-change-staleness run-owned runtime root already exists; refusing to reuse it: ${runOwnedRoot}`);
|
|
48
|
+
}
|
|
49
|
+
await mkdir(path.dirname(runOwnedRoot), { recursive: true });
|
|
50
|
+
// Non-recursive: fails if another run claimed the same directory in the meantime.
|
|
51
|
+
await mkdir(runOwnedRoot);
|
|
52
|
+
await writeFile(path.join(runOwnedRoot, INCREMENTAL_CHANGE_STALENESS_RUN_OWNER_MARKER), `${JSON.stringify({ pluginId: INCREMENTAL_CHANGE_STALENESS_PLUGIN_ID, runId }, null, 2)}\n`, "utf8");
|
|
53
|
+
}
|
|
54
|
+
async function isOwnedByRun(runOwnedRoot, runId) {
|
|
55
|
+
try {
|
|
56
|
+
const marker = JSON.parse(await readFile(path.join(runOwnedRoot, INCREMENTAL_CHANGE_STALENESS_RUN_OWNER_MARKER), "utf8"));
|
|
57
|
+
return marker.pluginId === INCREMENTAL_CHANGE_STALENESS_PLUGIN_ID && marker.runId === runId;
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Removes the run-owned runtime root (targets, indexes, command logs, agent artifacts) only when
|
|
65
|
+
* it carries this run's ownership marker. Never touches the shared runtime parent, canonical
|
|
66
|
+
* benchmarks, or any other run's directory. Called by the framework's `cleanup` phase, strictly
|
|
67
|
+
* after `run()` has already written the persisted execution artifact to the (separate) output root.
|
|
68
|
+
*/
|
|
69
|
+
export async function cleanupIncrementalChangeStalenessRun(toolRoot, runId) {
|
|
70
|
+
const runOwnedRoot = resolveIncrementalChangeStalenessRunOwnedRoot(toolRoot, runId);
|
|
71
|
+
if (!(await isOwnedByRun(runOwnedRoot, runId))) {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
await removeIncrementalChangeStalenessRuntimeRoot(toolRoot, runOwnedRoot);
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
export const incrementalChangeStalenessPlugin = {
|
|
78
|
+
metadata: incrementalChangeStalenessMetadata,
|
|
79
|
+
defaultConfig: defaultIncrementalChangeStalenessConfig,
|
|
80
|
+
configDefinition: incrementalChangeStalenessConfigDefinition,
|
|
81
|
+
supportedVariants: [...INCREMENTAL_CHANGE_STALENESS_TREATMENT_IDS],
|
|
82
|
+
validateConfig: validateIncrementalChangeStalenessConfig,
|
|
83
|
+
async run(context) {
|
|
84
|
+
if (!context.target.isSelf) {
|
|
85
|
+
throw new Error("incremental-change-staleness runs only against the lab's bundled benchmark projects; an external --target is not supported.");
|
|
86
|
+
}
|
|
87
|
+
const startedAt = context.startedAt.toISOString();
|
|
88
|
+
const runOwnedRoot = resolveIncrementalChangeStalenessRunOwnedRoot(context.toolRoot, context.runId);
|
|
89
|
+
await claimRunOwnedRoot(runOwnedRoot, context.runId);
|
|
90
|
+
const resolvedScenarios = await resolveIncrementalChangeStalenessScenarios({
|
|
91
|
+
repoRoot: context.toolRoot,
|
|
92
|
+
scenarioIds: context.config.caseIds
|
|
93
|
+
});
|
|
94
|
+
const projectProfiles = await readBenchmarkProjectProfiles(path.resolve(context.toolRoot, BENCHMARK_PROJECT_PROFILES_PATH), context.toolRoot);
|
|
95
|
+
const executions = [];
|
|
96
|
+
for (const resolved of resolvedScenarios) {
|
|
97
|
+
const lifecycle = await prepareIncrementalChangeStalenessScenarioLifecycle({
|
|
98
|
+
repoRoot: context.toolRoot,
|
|
99
|
+
runOwnedRoot,
|
|
100
|
+
scenario: resolved.scenario,
|
|
101
|
+
baseCase: resolved.baseCase,
|
|
102
|
+
kitCommand: context.config.kitCommand
|
|
103
|
+
});
|
|
104
|
+
const execution = await executeIncrementalChangeStalenessScenario({
|
|
105
|
+
repoRoot: context.toolRoot,
|
|
106
|
+
scenario: resolved.scenario,
|
|
107
|
+
lifecycle,
|
|
108
|
+
baseCase: resolved.baseCase,
|
|
109
|
+
projectProfiles,
|
|
110
|
+
runOwnedRoot,
|
|
111
|
+
cwd: context.target.targetRoot
|
|
112
|
+
});
|
|
113
|
+
executions.push(execution);
|
|
114
|
+
}
|
|
115
|
+
const outDir = context.outputRoot ?? path.resolve(context.toolRoot, context.config.outDir);
|
|
116
|
+
const artifact = buildIncrementalChangeStalenessExecutionArtifact({
|
|
117
|
+
runId: context.runId,
|
|
118
|
+
pluginId: INCREMENTAL_CHANGE_STALENESS_PLUGIN_ID,
|
|
119
|
+
executions
|
|
120
|
+
});
|
|
121
|
+
// The artifact is written before the framework's separate `cleanup` phase runs, so it is
|
|
122
|
+
// written before the run-owned ephemeral index/target state is ever destroyed.
|
|
123
|
+
const artifactPath = await writeIncrementalChangeStalenessExecutionArtifact(outDir, artifact);
|
|
124
|
+
return mapIncrementalChangeStalenessExecutionsToRun({
|
|
125
|
+
runId: context.runId,
|
|
126
|
+
startedAt,
|
|
127
|
+
completedAt: new Date().toISOString(),
|
|
128
|
+
target: context.target,
|
|
129
|
+
kitCommand: context.config.kitCommand,
|
|
130
|
+
executions,
|
|
131
|
+
artifactPath
|
|
132
|
+
});
|
|
133
|
+
},
|
|
134
|
+
summarize(result) {
|
|
135
|
+
return result.summary ?? summarizeExperimentRun(result);
|
|
136
|
+
},
|
|
137
|
+
async cleanup(context) {
|
|
138
|
+
await cleanupIncrementalChangeStalenessRun(context.toolRoot, context.runId);
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
function metric(id, name, description, unit, value, variantId, caseId) {
|
|
142
|
+
return { id, name, description, unit, value, variantId, caseId };
|
|
143
|
+
}
|
|
144
|
+
function treatmentMetrics(treatment, caseId) {
|
|
145
|
+
const variantId = treatment.treatmentId;
|
|
146
|
+
const metrics = [];
|
|
147
|
+
if (treatment.retrieval) {
|
|
148
|
+
metrics.push(metric("context-character-count", "Context characters", "Characters in the retrieved my-dev-kit context.", "characters", treatment.retrieval.totalChars, variantId, caseId), metric("context-estimated-token-count", "Estimated context tokens", "Character-based estimate of retrieved context tokens; not provider token usage.", "estimated-tokens", treatment.retrieval.totalEstimatedTokens, variantId, caseId), metric("operation-duration-ms", "Operation duration", "Measured retrieval-only duration (search, lookup, slice, source); excludes index build.", "ms", treatment.retrieval.durationMs, variantId, caseId));
|
|
149
|
+
}
|
|
150
|
+
if (treatment.fakeAgent && treatment.fakeAgent.correctness.available && treatment.fakeAgent.correctness.score !== null) {
|
|
151
|
+
metrics.push(metric("agent-correctness-score", "Agent correctness score", "Deterministic benchmark answer-key correctness score for the selected agent output; not semantic LLM judging.", "score", treatment.fakeAgent.correctness.score, variantId, caseId));
|
|
152
|
+
}
|
|
153
|
+
const affectedMetricValues = toAffectedNeighborhoodMetricValues(treatment.affectedNeighborhood, "No affected-neighborhood assessment was recorded for this treatment.");
|
|
154
|
+
const affectedSpecs = [
|
|
155
|
+
{ id: "affected-neighborhood-changed-file-count", name: "Changed indexed files", description: "Unique confirmed modified or missing files that the baseline index snapshot represents.", field: "changedFileCount" },
|
|
156
|
+
{ id: "affected-neighborhood-changed-symbol-count", name: "Changed baseline symbols", description: "Baseline indexed symbol identities belonging to confirmed changed indexed files.", field: "changedSymbolCount" },
|
|
157
|
+
{ id: "affected-neighborhood-node-count", name: "Affected graph nodes", description: "Unique resolved seed nodes plus their direct one-hop baseline graph neighbors.", field: "affectedNodeCount" },
|
|
158
|
+
{ id: "affected-neighborhood-edge-count", name: "Affected graph edges", description: "Unique baseline graph edges incident to at least one resolved seed node.", field: "affectedEdgeCount" },
|
|
159
|
+
{ id: "affected-neighborhood-task-overlap-count", name: "Task-overlap nodes", description: "Unique baseline graph nodes both in the affected neighborhood and resolved from the task's expected files/symbols.", field: "taskOverlapCount" },
|
|
160
|
+
{ id: "affected-neighborhood-task-overlap-percent", name: "Task-overlap percent", description: "Task-overlap nodes as a percentage of resolvable task graph nodes.", field: "taskOverlapPercent" }
|
|
161
|
+
];
|
|
162
|
+
for (const spec of affectedSpecs) {
|
|
163
|
+
const value = affectedMetricValues[spec.field];
|
|
164
|
+
if (value.availability === "available" && value.value !== null) {
|
|
165
|
+
metrics.push(metric(spec.id, spec.name, spec.description, value.unit, value.value, variantId, caseId));
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return metrics;
|
|
169
|
+
}
|
|
170
|
+
function treatmentOutcome(execution, treatmentId) {
|
|
171
|
+
const caseId = execution.scenarioId;
|
|
172
|
+
const base = { id: `${caseId}:${treatmentId}`, caseId, variantId: treatmentId, artifacts: [] };
|
|
173
|
+
if (execution.status === "failed" || !execution.stale || !execution.fullRefresh) {
|
|
174
|
+
return {
|
|
175
|
+
...base,
|
|
176
|
+
status: "failed",
|
|
177
|
+
metrics: [],
|
|
178
|
+
warnings: [],
|
|
179
|
+
failures: [
|
|
180
|
+
{
|
|
181
|
+
code: "incremental-change-staleness-scenario-failed",
|
|
182
|
+
message: execution.failureReason ?? "Scenario execution failed before treatment evidence was produced.",
|
|
183
|
+
variantId: treatmentId,
|
|
184
|
+
caseId,
|
|
185
|
+
recoverable: false
|
|
186
|
+
}
|
|
187
|
+
],
|
|
188
|
+
metadata: { treatmentId, scenarioStatus: execution.status }
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
const treatment = treatmentId === "stale-index" ? execution.stale : execution.fullRefresh;
|
|
192
|
+
const status = treatment.status === "completed" ? "completed" : treatment.status === "failed" ? "failed" : "partial";
|
|
193
|
+
return {
|
|
194
|
+
...base,
|
|
195
|
+
status,
|
|
196
|
+
metrics: treatmentMetrics(treatment, caseId),
|
|
197
|
+
warnings: treatment.retrievalStatus === "failed" || treatment.fakeAgent?.status === "failed"
|
|
198
|
+
? [{ code: "incremental-change-staleness-treatment-partial", message: treatment.failureReason ?? "Treatment retrieval or fake-agent evaluation did not fully complete.", variantId: treatmentId, caseId }]
|
|
199
|
+
: [],
|
|
200
|
+
failures: [],
|
|
201
|
+
metadata: {
|
|
202
|
+
treatmentId,
|
|
203
|
+
activeIndexPhase: treatment.activeIndexPhase,
|
|
204
|
+
retrievalStatus: treatment.retrievalStatus,
|
|
205
|
+
fakeAgentStatus: treatment.fakeAgent?.status ?? "not-run",
|
|
206
|
+
requiredFileEvidenceStatus: treatment.requiredFileEvidence.status,
|
|
207
|
+
affectedNeighborhoodRelationship: treatment.affectedNeighborhood.relationship,
|
|
208
|
+
affectedNeighborhoodReindexRecommendation: treatment.affectedNeighborhood.reindexRecommendation,
|
|
209
|
+
comparisonCorrectnessRelation: execution.comparison.correctnessRelation,
|
|
210
|
+
comparisonRequiredFileEvidenceRelation: execution.comparison.requiredFileEvidenceRelation,
|
|
211
|
+
staleRiskClassification: execution.comparison.staleRiskClassification
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
export function mapIncrementalChangeStalenessExecutionsToRun(args) {
|
|
216
|
+
const cases = args.executions.map((execution) => ({
|
|
217
|
+
id: execution.scenarioId,
|
|
218
|
+
name: `${execution.scenarioId} (${execution.category})`,
|
|
219
|
+
// Exactly two outcomes per scenario, stale-index then full-refresh (never a third "raw" outcome).
|
|
220
|
+
outcomes: [treatmentOutcome(execution, "stale-index"), treatmentOutcome(execution, "full-refresh")],
|
|
221
|
+
metadata: {
|
|
222
|
+
category: execution.category,
|
|
223
|
+
benchmarkProject: execution.benchmarkProjectId,
|
|
224
|
+
baseCaseId: execution.baseCaseId,
|
|
225
|
+
scenarioStatus: execution.status,
|
|
226
|
+
staleRiskClassification: execution.comparison.staleRiskClassification
|
|
227
|
+
}
|
|
228
|
+
}));
|
|
229
|
+
const outcomeStatuses = cases.flatMap((experimentCase) => experimentCase.outcomes.map((outcome) => outcome.status));
|
|
230
|
+
const status = outcomeStatuses.length > 0 && outcomeStatuses.every((value) => value === "failed")
|
|
231
|
+
? "failed"
|
|
232
|
+
: outcomeStatuses.every((value) => value === "completed")
|
|
233
|
+
? "completed"
|
|
234
|
+
: "partial";
|
|
235
|
+
const run = {
|
|
236
|
+
runId: args.runId,
|
|
237
|
+
pluginId: INCREMENTAL_CHANGE_STALENESS_PLUGIN_ID,
|
|
238
|
+
startedAt: args.startedAt,
|
|
239
|
+
completedAt: args.completedAt,
|
|
240
|
+
status,
|
|
241
|
+
target: args.target,
|
|
242
|
+
variants: TREATMENT_VARIANTS.map((variant) => ({ ...variant })),
|
|
243
|
+
cases,
|
|
244
|
+
metrics: [
|
|
245
|
+
{ id: "incremental-change-staleness-scenario-count", name: "Scenario count", value: args.executions.length, unit: "count", description: "Selected scenarios in this run." },
|
|
246
|
+
{
|
|
247
|
+
id: "incremental-change-staleness-observed-regression-count",
|
|
248
|
+
name: "Observed stale regression count",
|
|
249
|
+
value: args.executions.filter((execution) => execution.comparison.staleRiskClassification === "observed-stale-regression").length,
|
|
250
|
+
unit: "count",
|
|
251
|
+
description: "Scenarios classified observed-stale-regression."
|
|
252
|
+
}
|
|
253
|
+
],
|
|
254
|
+
artifacts: [
|
|
255
|
+
{
|
|
256
|
+
id: "incremental-change-staleness-execution",
|
|
257
|
+
label: "Incremental-change-staleness execution evidence",
|
|
258
|
+
path: args.artifactPath,
|
|
259
|
+
kind: "artifact",
|
|
260
|
+
mimeType: "application/json",
|
|
261
|
+
description: "Per-scenario matched stale-index/full-refresh execution and comparison evidence, without raw context bodies."
|
|
262
|
+
}
|
|
263
|
+
],
|
|
264
|
+
warnings: [],
|
|
265
|
+
failures: [],
|
|
266
|
+
metadata: {
|
|
267
|
+
kitCommand: args.kitCommand,
|
|
268
|
+
scenarioIds: args.executions.map((execution) => execution.scenarioId),
|
|
269
|
+
executionArtifactPath: args.artifactPath
|
|
270
|
+
}
|
|
271
|
+
};
|
|
272
|
+
run.summary = summarizeExperimentRun(run);
|
|
273
|
+
return run;
|
|
274
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { readBenchmarkProjectProfiles } from "../../../evaluation/benchmarkMetadata.js";
|
|
4
|
+
import { readEvaluationCases } from "../../../evaluation/readEvaluationCases.js";
|
|
5
|
+
import { validateIncrementalChangeStalenessCatalog } from "./scenarioValidation.js";
|
|
6
|
+
/** Canonical repository-relative locations reused by the production catalog. */
|
|
7
|
+
export const INCREMENTAL_CHANGE_STALENESS_SCENARIO_CATALOG_PATH = "benchmarks/contracts/incremental-change-staleness-scenarios.json";
|
|
8
|
+
export const BENCHMARK_PROJECT_PROFILES_PATH = "benchmarks/contracts/benchmark-project-profiles.json";
|
|
9
|
+
export const WARM_INDEX_BENCHMARK_CASES_PATH = "benchmarks/contracts/warm-index-benchmark-cases.json";
|
|
10
|
+
/**
|
|
11
|
+
* Parses and strictly validates an incremental-change-staleness scenario
|
|
12
|
+
* catalog JSON file. Never mutates the referenced benchmark project/case
|
|
13
|
+
* files or the catalog file itself; mutation validation runs entirely
|
|
14
|
+
* in-memory.
|
|
15
|
+
*/
|
|
16
|
+
export async function readIncrementalChangeStalenessScenarioCatalog(catalogPath, profiles, cases, repoRoot = process.cwd(), options = { requireFrozenProductionScenarios: true }) {
|
|
17
|
+
let parsed;
|
|
18
|
+
try {
|
|
19
|
+
parsed = JSON.parse(await readFile(catalogPath, "utf8"));
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
throw new Error(`Failed to parse incremental-change-staleness scenario catalog: ${error.message}`);
|
|
23
|
+
}
|
|
24
|
+
const errors = await validateIncrementalChangeStalenessCatalog(parsed, profiles, cases, repoRoot, {
|
|
25
|
+
requireFrozenProductionScenarios: options.requireFrozenProductionScenarios ?? true
|
|
26
|
+
});
|
|
27
|
+
if (errors.length > 0) {
|
|
28
|
+
throw new Error(`Invalid incremental-change-staleness scenario catalog:\n${errors.join("\n")}`);
|
|
29
|
+
}
|
|
30
|
+
return parsed;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Convenience loader for the canonical production catalog and its two
|
|
34
|
+
* existing benchmark-contract dependencies, resolved relative to `repoRoot`.
|
|
35
|
+
* This is the loader later batches (and this batch's tests) should use to
|
|
36
|
+
* obtain the validated, immutable production scenario set.
|
|
37
|
+
*/
|
|
38
|
+
export async function readProductionIncrementalChangeStalenessScenarioCatalog(repoRoot = process.cwd()) {
|
|
39
|
+
const profiles = await readBenchmarkProjectProfiles(path.resolve(repoRoot, BENCHMARK_PROJECT_PROFILES_PATH), repoRoot);
|
|
40
|
+
const cases = await readEvaluationCases(path.resolve(repoRoot, WARM_INDEX_BENCHMARK_CASES_PATH), repoRoot);
|
|
41
|
+
return readIncrementalChangeStalenessScenarioCatalog(path.resolve(repoRoot, INCREMENTAL_CHANGE_STALENESS_SCENARIO_CATALOG_PATH), profiles, cases, repoRoot, { requireFrozenProductionScenarios: true });
|
|
42
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { resolveWithinRoot } from "../../../core/pathSafety.js";
|
|
5
|
+
export function sha256Hex(data) {
|
|
6
|
+
return createHash("sha256").update(data).digest("hex");
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* True when `relativePath` (project-root-relative, either separator style) is
|
|
10
|
+
* covered by one of the declared source roots, i.e. equals a root or is
|
|
11
|
+
* nested underneath one. Shared by Batch 1 catalog validation and Batch 2
|
|
12
|
+
* execution-time re-validation so both enforce identical coverage semantics.
|
|
13
|
+
*/
|
|
14
|
+
export function isPathCoveredBySourceRoots(relativePath, sourceRoots) {
|
|
15
|
+
const normalizedFilePath = relativePath.replace(/\\/g, "/");
|
|
16
|
+
return sourceRoots.some((root) => {
|
|
17
|
+
const normalizedRoot = root.replace(/\\/g, "/").replace(/\/+$/, "");
|
|
18
|
+
return normalizedFilePath === normalizedRoot || normalizedFilePath.startsWith(`${normalizedRoot}/`);
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Applies one scenario's ordered, literal replacement operations to in-memory
|
|
23
|
+
* text. Every preimage must match exactly once at the point it is applied;
|
|
24
|
+
* this never touches the filesystem.
|
|
25
|
+
*/
|
|
26
|
+
export function applyMutationOperations(text, operations) {
|
|
27
|
+
let mutated = text;
|
|
28
|
+
for (const [index, operation] of operations.entries()) {
|
|
29
|
+
const occurrences = countOccurrences(mutated, operation.expectedPreimage);
|
|
30
|
+
if (occurrences === 0) {
|
|
31
|
+
throw new Error(`operation ${index}: expectedPreimage not found: ${JSON.stringify(operation.expectedPreimage)}.`);
|
|
32
|
+
}
|
|
33
|
+
if (occurrences > 1) {
|
|
34
|
+
throw new Error(`operation ${index}: expectedPreimage matches ${occurrences} times; it must match exactly once: ${JSON.stringify(operation.expectedPreimage)}.`);
|
|
35
|
+
}
|
|
36
|
+
mutated = mutated.split(operation.expectedPreimage).join(operation.replacement);
|
|
37
|
+
}
|
|
38
|
+
return mutated;
|
|
39
|
+
}
|
|
40
|
+
function countOccurrences(text, needle) {
|
|
41
|
+
if (needle.length === 0) {
|
|
42
|
+
return 0;
|
|
43
|
+
}
|
|
44
|
+
return text.split(needle).length - 1;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Validates one controlled mutation file declaration against the real,
|
|
48
|
+
* on-disk canonical benchmark file:
|
|
49
|
+
* - the resolved path stays within the benchmark project root;
|
|
50
|
+
* - the resolved path is covered by one of the case's configured indexed
|
|
51
|
+
* source roots;
|
|
52
|
+
* - the current canonical SHA-256 equals `expectedPreSha256`;
|
|
53
|
+
* - the ordered operations apply cleanly to in-memory text;
|
|
54
|
+
* - the resulting in-memory SHA-256 equals `expectedPostSha256`.
|
|
55
|
+
*
|
|
56
|
+
* This function only reads the canonical file; it never writes to disk, so
|
|
57
|
+
* canonical benchmark source/test files remain byte-for-byte unchanged.
|
|
58
|
+
*/
|
|
59
|
+
export async function validateMutationFile(label, file, projectAbsoluteRoot, caseSourceRoots) {
|
|
60
|
+
const errors = [];
|
|
61
|
+
if (typeof file.path !== "string" || file.path.length === 0) {
|
|
62
|
+
return { errors: [`${label}: mutation file path must be a nonempty string.`] };
|
|
63
|
+
}
|
|
64
|
+
if (path.isAbsolute(file.path) || file.path.includes("..")) {
|
|
65
|
+
return { errors: [`${label}: mutation file path must be a safe project-relative path: ${file.path}.`] };
|
|
66
|
+
}
|
|
67
|
+
const covered = isPathCoveredBySourceRoots(file.path, caseSourceRoots);
|
|
68
|
+
if (!covered) {
|
|
69
|
+
errors.push(`${label}: mutation file path ${file.path} is not covered by the referenced case's indexed source roots (${caseSourceRoots.join(", ")}).`);
|
|
70
|
+
}
|
|
71
|
+
let absolutePath;
|
|
72
|
+
try {
|
|
73
|
+
absolutePath = resolveWithinRoot(projectAbsoluteRoot, file.path);
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
return { errors: [`${label}: ${error.message}`] };
|
|
77
|
+
}
|
|
78
|
+
if (typeof file.expectedPreSha256 !== "string" || !/^[0-9a-f]{64}$/.test(file.expectedPreSha256)) {
|
|
79
|
+
errors.push(`${label}: expectedPreSha256 must be a lowercase 64-character hex SHA-256 string.`);
|
|
80
|
+
}
|
|
81
|
+
if (typeof file.expectedPostSha256 !== "string" || !/^[0-9a-f]{64}$/.test(file.expectedPostSha256)) {
|
|
82
|
+
errors.push(`${label}: expectedPostSha256 must be a lowercase 64-character hex SHA-256 string.`);
|
|
83
|
+
}
|
|
84
|
+
if (!Array.isArray(file.operations) || file.operations.length === 0) {
|
|
85
|
+
errors.push(`${label}: operations must be a nonempty array.`);
|
|
86
|
+
}
|
|
87
|
+
if (errors.length > 0) {
|
|
88
|
+
return { errors };
|
|
89
|
+
}
|
|
90
|
+
let buffer;
|
|
91
|
+
try {
|
|
92
|
+
buffer = await readFile(absolutePath);
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
return { errors: [`${label}: unable to read mutation file ${file.path}: ${error.message}`] };
|
|
96
|
+
}
|
|
97
|
+
const actualPreSha256 = sha256Hex(buffer);
|
|
98
|
+
if (actualPreSha256 !== file.expectedPreSha256) {
|
|
99
|
+
errors.push(`${label}: expectedPreSha256 ${file.expectedPreSha256} does not match actual canonical file SHA-256 ${actualPreSha256}.`);
|
|
100
|
+
return { errors };
|
|
101
|
+
}
|
|
102
|
+
let mutatedText;
|
|
103
|
+
try {
|
|
104
|
+
mutatedText = applyMutationOperations(buffer.toString("utf8"), file.operations);
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
errors.push(`${label}: ${error.message}`);
|
|
108
|
+
return { errors };
|
|
109
|
+
}
|
|
110
|
+
const actualPostSha256 = sha256Hex(Buffer.from(mutatedText, "utf8"));
|
|
111
|
+
if (actualPostSha256 !== file.expectedPostSha256) {
|
|
112
|
+
errors.push(`${label}: expectedPostSha256 ${file.expectedPostSha256} does not match computed in-memory post-mutation SHA-256 ${actualPostSha256}.`);
|
|
113
|
+
return { errors };
|
|
114
|
+
}
|
|
115
|
+
return { errors: [], mutatedText };
|
|
116
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { readBenchmarkProjectProfiles } from "../../../evaluation/benchmarkMetadata.js";
|
|
3
|
+
import { readEvaluationCases } from "../../../evaluation/readEvaluationCases.js";
|
|
4
|
+
import { normalizeRootForComparison } from "./lifecyclePolicy.js";
|
|
5
|
+
import { BENCHMARK_PROJECT_PROFILES_PATH, INCREMENTAL_CHANGE_STALENESS_SCENARIO_CATALOG_PATH, WARM_INDEX_BENCHMARK_CASES_PATH, readIncrementalChangeStalenessScenarioCatalog } from "./scenarioCatalog.js";
|
|
6
|
+
/**
|
|
7
|
+
* Policy: selects scenarios from the validated catalog. No selection means
|
|
8
|
+
* every catalog scenario. An explicit selection must be non-empty, contain no
|
|
9
|
+
* duplicates, and name only catalog scenarios. The result always follows
|
|
10
|
+
* catalog order, never request order, so run ordering is deterministic.
|
|
11
|
+
*/
|
|
12
|
+
export function selectIncrementalChangeStalenessScenarios(catalog, scenarioIds) {
|
|
13
|
+
if (scenarioIds === undefined) {
|
|
14
|
+
return [...catalog.scenarios];
|
|
15
|
+
}
|
|
16
|
+
if (scenarioIds.length === 0) {
|
|
17
|
+
throw new Error("Scenario selection must name at least one scenario when provided.");
|
|
18
|
+
}
|
|
19
|
+
const duplicates = scenarioIds.filter((id, index) => scenarioIds.indexOf(id) !== index);
|
|
20
|
+
if (duplicates.length > 0) {
|
|
21
|
+
throw new Error(`Scenario selection contains duplicate scenario id(s): ${[...new Set(duplicates)].join(", ")}.`);
|
|
22
|
+
}
|
|
23
|
+
const known = new Set(catalog.scenarios.map((scenario) => scenario.id));
|
|
24
|
+
const unknown = scenarioIds.filter((id) => !known.has(id));
|
|
25
|
+
if (unknown.length > 0) {
|
|
26
|
+
throw new Error(`Unknown incremental-change-staleness scenario id(s): ${unknown.join(", ")}. Known: ${[...known].join(", ")}.`);
|
|
27
|
+
}
|
|
28
|
+
const requested = new Set(scenarioIds);
|
|
29
|
+
return catalog.scenarios.filter((scenario) => requested.has(scenario.id));
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Policy: resolves a scenario's canonical base case identity from the existing
|
|
33
|
+
* benchmark case and project-profile contracts. Source roots always come from
|
|
34
|
+
* the base case; the canonical project root always comes from the project
|
|
35
|
+
* profile, and the two must agree.
|
|
36
|
+
*/
|
|
37
|
+
export function resolveIncrementalChangeStalenessBaseCase(scenario, cases, profiles, repoRoot) {
|
|
38
|
+
const baseCase = cases.find((candidate) => candidate.id === scenario.baseCaseId);
|
|
39
|
+
if (!baseCase) {
|
|
40
|
+
throw new Error(`Scenario ${scenario.id}: base case ${scenario.baseCaseId} was not found.`);
|
|
41
|
+
}
|
|
42
|
+
const profile = profiles.find((candidate) => candidate.projectId === scenario.benchmarkProjectId);
|
|
43
|
+
if (!profile) {
|
|
44
|
+
throw new Error(`Scenario ${scenario.id}: benchmark project ${scenario.benchmarkProjectId} was not found.`);
|
|
45
|
+
}
|
|
46
|
+
if (baseCase.benchmarkProject !== scenario.benchmarkProjectId) {
|
|
47
|
+
throw new Error(`Scenario ${scenario.id}: base case ${baseCase.id} belongs to ${baseCase.benchmarkProject}, not ${scenario.benchmarkProjectId}.`);
|
|
48
|
+
}
|
|
49
|
+
const canonicalProjectRoot = path.resolve(repoRoot, profile.rootPath);
|
|
50
|
+
if (normalizeRootForComparison(canonicalProjectRoot) !== normalizeRootForComparison(baseCase.absoluteTargetRoot)) {
|
|
51
|
+
throw new Error(`Scenario ${scenario.id}: base case target root ${baseCase.absoluteTargetRoot} does not match project profile root ${canonicalProjectRoot}.`);
|
|
52
|
+
}
|
|
53
|
+
if (!Array.isArray(baseCase.sourceRoots) || baseCase.sourceRoots.length === 0) {
|
|
54
|
+
throw new Error(`Scenario ${scenario.id}: base case ${baseCase.id} declares no source roots.`);
|
|
55
|
+
}
|
|
56
|
+
return Object.freeze({
|
|
57
|
+
caseId: baseCase.id,
|
|
58
|
+
benchmarkProjectId: scenario.benchmarkProjectId,
|
|
59
|
+
canonicalProjectRootRelative: profile.rootPath,
|
|
60
|
+
canonicalProjectRoot,
|
|
61
|
+
sourceRoots: Object.freeze([...baseCase.sourceRoots])
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Loads the canonical production scenario catalog (the only mutation
|
|
66
|
+
* authority) and its benchmark case/profile dependencies, then resolves the
|
|
67
|
+
* selected scenarios with their base-case identities in catalog order.
|
|
68
|
+
* Read-only.
|
|
69
|
+
*/
|
|
70
|
+
export async function resolveIncrementalChangeStalenessScenarios(options) {
|
|
71
|
+
const { repoRoot } = options;
|
|
72
|
+
const profiles = await readBenchmarkProjectProfiles(path.resolve(repoRoot, BENCHMARK_PROJECT_PROFILES_PATH), repoRoot);
|
|
73
|
+
const cases = await readEvaluationCases(path.resolve(repoRoot, WARM_INDEX_BENCHMARK_CASES_PATH), repoRoot);
|
|
74
|
+
const catalog = await readIncrementalChangeStalenessScenarioCatalog(path.resolve(repoRoot, INCREMENTAL_CHANGE_STALENESS_SCENARIO_CATALOG_PATH), profiles, cases, repoRoot, { requireFrozenProductionScenarios: true });
|
|
75
|
+
return selectIncrementalChangeStalenessScenarios(catalog, options.scenarioIds).map((scenario) => ({
|
|
76
|
+
scenario,
|
|
77
|
+
baseCase: resolveIncrementalChangeStalenessBaseCase(scenario, cases, profiles, repoRoot)
|
|
78
|
+
}));
|
|
79
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v0.6.2 incremental-change-staleness scenario catalog schema version.
|
|
3
|
+
*
|
|
4
|
+
* This is a separate, versioned contract from the warm-index benchmark case
|
|
5
|
+
* contract (`benchmarks/contracts/warm-index-benchmark-cases.json`), which
|
|
6
|
+
* remains unchanged. Scenario records here reference existing benchmark
|
|
7
|
+
* project/case ids rather than duplicating canonical project/target metadata.
|
|
8
|
+
*/
|
|
9
|
+
export const INCREMENTAL_CHANGE_STALENESS_SCENARIO_SCHEMA_VERSION = "1.0.0";
|
|
10
|
+
/**
|
|
11
|
+
* The experimental dimension a scenario deliberately changes. Categories are
|
|
12
|
+
* not required to be structurally exclusive (for example, an exported
|
|
13
|
+
* function body is still implementation code); the selected category
|
|
14
|
+
* describes the intended experimental contrast, not an exhaustive structural
|
|
15
|
+
* classification.
|
|
16
|
+
*/
|
|
17
|
+
export const INCREMENTAL_CHANGE_STALENESS_SCENARIO_CATEGORIES = [
|
|
18
|
+
"unrelated-file-change",
|
|
19
|
+
"local-implementation-change",
|
|
20
|
+
"exported-symbol-change",
|
|
21
|
+
"public-api-change",
|
|
22
|
+
"import-graph-change",
|
|
23
|
+
"test-only-change"
|
|
24
|
+
];
|
|
25
|
+
/**
|
|
26
|
+
* Answer-key policy for a scenario's follow-up query:
|
|
27
|
+
* - `inherit`: reuse the referenced base case's existing query/answer key
|
|
28
|
+
* because the controlled change does not alter that task's semantic truth.
|
|
29
|
+
* - `scenario`: use a scenario-specific post-mutation query and answer key,
|
|
30
|
+
* expressed with the existing benchmark answer-key structure.
|
|
31
|
+
*
|
|
32
|
+
* Exactly these two policies are supported; no other correctness vocabulary
|
|
33
|
+
* is introduced.
|
|
34
|
+
*/
|
|
35
|
+
export const INCREMENTAL_CHANGE_STALENESS_ANSWER_POLICIES = ["inherit", "scenario"];
|
|
36
|
+
/** The six planner-frozen production scenario ids, in their planned order. */
|
|
37
|
+
export const FROZEN_INCREMENTAL_CHANGE_STALENESS_SCENARIO_IDS = ["U1", "L2", "E1", "P1", "I1", "T1"];
|