@dailephd/my-dev-kit-lab 0.4.8 → 0.5.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.
Files changed (36) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +24 -3
  3. package/dist/src/cli/help.js +19 -4
  4. package/dist/src/commands/runExperimentDescribeCommand.js +11 -2
  5. package/dist/src/commands/runExperimentListCommand.js +3 -0
  6. package/dist/src/commands/runExperimentRunCommand.js +27 -7
  7. package/dist/src/evaluation/runMyDevKitRetrieval.js +82 -53
  8. package/dist/src/experiments/defaultRegistry.js +2 -0
  9. package/dist/src/experiments/plugins/index.js +1 -0
  10. package/dist/src/experiments/plugins/warmIndexReuse/config.js +58 -0
  11. package/dist/src/experiments/plugins/warmIndexReuse/execution.js +158 -0
  12. package/dist/src/experiments/plugins/warmIndexReuse/executionArtifact.js +77 -0
  13. package/dist/src/experiments/plugins/warmIndexReuse/fakeAgentEvaluation.js +118 -0
  14. package/dist/src/experiments/plugins/warmIndexReuse/index.js +8 -0
  15. package/dist/src/experiments/plugins/warmIndexReuse/metrics.js +257 -0
  16. package/dist/src/experiments/plugins/warmIndexReuse/plugin.js +212 -0
  17. package/dist/src/experiments/plugins/warmIndexReuse/selection.js +85 -0
  18. package/dist/src/experiments/plugins/warmIndexReuse/warmIndexSession.js +43 -0
  19. package/dist/src/plots/buildWarmIndexPlotData.js +112 -0
  20. package/dist/src/plots/index.js +1 -0
  21. package/dist/src/plots/writePlotArtifacts.js +8 -1
  22. package/dist/src/report/experiments/buildPluginExperimentReport.js +18 -2
  23. package/dist/src/report/experiments/buildWarmIndexReuseReport.js +91 -0
  24. package/dist/src/report/experiments/index.js +3 -0
  25. package/dist/src/report/experiments/renderPluginExperimentReportHtml.js +3 -0
  26. package/dist/src/report/experiments/renderPluginExperimentReportText.js +73 -0
  27. package/dist/src/report/experiments/renderWarmIndexReuseHtml.js +150 -0
  28. package/dist/src/report/experiments/warmIndexReuseReportModel.js +1 -0
  29. package/dist/src/tutorial/scenarioValidation.js +11 -0
  30. package/dist/src/tutorial/tutorialActions.js +14 -0
  31. package/dist/src/tutorial/tutorialSession.js +3 -0
  32. package/dist/src/tutorial/types.js +1 -0
  33. package/docs/METRICS.md +64 -0
  34. package/examples/tutorial-browser/index.html +5 -0
  35. package/examples/tutorial-browser/scenario.json +1 -0
  36. package/package.json +1 -1
@@ -0,0 +1,212 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { resolveWithinRoot } from "../../../core/pathSafety.js";
4
+ import { summarizeExperimentRun } from "../../results.js";
5
+ import { defaultWarmIndexReuseConfig, validateWarmIndexReuseConfig, warmIndexReuseConfigDefinition, } from "./config.js";
6
+ import { aggregateStatus, executeWarmIndexReuse, } from "./execution.js";
7
+ import { buildWarmIndexExecutionArtifact, WARM_INDEX_EXECUTION_ARTIFACT_FILE, } from "./executionArtifact.js";
8
+ import { calculateWarmIndexMetrics, toRawOutcomeMetrics, toRunLevelMetrics, toWarmOutcomeMetrics, } from "./metrics.js";
9
+ import { selectWarmIndexCases } from "./selection.js";
10
+ import { evaluateWarmIndexFakeAgents, } from "./fakeAgentEvaluation.js";
11
+ export const RAW_FULL_FILE_VARIANT_ID = "raw-full-file";
12
+ export const WARM_INDEX_REUSE_VARIANT_ID = "warm-index-reuse";
13
+ export const warmIndexReuseMetadata = {
14
+ id: "warm-index-reuse",
15
+ name: "Warm Index Reuse",
16
+ description: "Measure repeated task execution where one my-dev-kit index is built once per benchmark project and reused across multiple tasks, with a matched raw-full-file baseline per task.",
17
+ schemaVersion: "1.0.0",
18
+ status: "experimental",
19
+ supportedTargets: ["self", "external-local"],
20
+ supportedOutputs: ["json", "html", "text", "plot", "artifact"],
21
+ };
22
+ const WARM_INDEX_VARIANTS = [
23
+ {
24
+ id: RAW_FULL_FILE_VARIANT_ID,
25
+ name: "Raw full file",
26
+ description: "Every file matched by the case's raw include globs, read once per task.",
27
+ },
28
+ {
29
+ id: WARM_INDEX_REUSE_VARIANT_ID,
30
+ name: "Warm index reuse",
31
+ description: "my-dev-kit retrieval against one index prepared once per benchmark project.",
32
+ },
33
+ ];
34
+ export const warmIndexReusePlugin = {
35
+ metadata: warmIndexReuseMetadata,
36
+ defaultConfig: defaultWarmIndexReuseConfig,
37
+ configDefinition: warmIndexReuseConfigDefinition,
38
+ supportedVariants: WARM_INDEX_VARIANTS.map((variant) => variant.id),
39
+ validateConfig: validateWarmIndexReuseConfig,
40
+ async run(context) {
41
+ const startedAt = context.startedAt.toISOString();
42
+ const cases = selectWarmIndexCases(readCasesInput(context.inputs), context.config);
43
+ const projectProfiles = readProjectProfilesInput(context.inputs);
44
+ const outDir = context.outputRoot ?? path.resolve(context.toolRoot, context.config.outDir);
45
+ await mkdir(outDir, { recursive: true });
46
+ const projects = await executeWarmIndexReuse({
47
+ cases,
48
+ kitCommand: context.config.kitCommand,
49
+ outputRoot: outDir,
50
+ });
51
+ // Fake-agent evaluation runs after execution evidence exists and never repeats it.
52
+ const agentEvidence = await evaluateWarmIndexFakeAgents({
53
+ projects,
54
+ cases,
55
+ projectProfiles,
56
+ outputRoot: outDir,
57
+ cwd: context.target.targetRoot,
58
+ env: readEnvInput(context.inputs),
59
+ });
60
+ const artifact = buildWarmIndexExecutionArtifact({
61
+ runId: context.runId,
62
+ pluginId: warmIndexReuseMetadata.id,
63
+ projects,
64
+ });
65
+ const artifactPath = resolveWithinRoot(outDir, WARM_INDEX_EXECUTION_ARTIFACT_FILE);
66
+ await writeFile(artifactPath, `${JSON.stringify(artifact, null, 2)}\n`, "utf8");
67
+ const run = mapWarmIndexExecutionToRun({
68
+ runId: context.runId,
69
+ startedAt,
70
+ completedAt: new Date().toISOString(),
71
+ target: context.target,
72
+ cases,
73
+ projects,
74
+ projectSummaries: artifact.projects,
75
+ agentEvidence,
76
+ artifactPath,
77
+ });
78
+ return run;
79
+ },
80
+ summarize(result) {
81
+ return result.summary ?? summarizeExperimentRun(result);
82
+ },
83
+ };
84
+ export function mapWarmIndexExecutionToRun(args) {
85
+ const titles = new Map(args.cases.map((evaluationCase) => [evaluationCase.id, evaluationCase.title]));
86
+ const warmIndexMetrics = calculateWarmIndexMetrics(args.projectSummaries, args.agentEvidence);
87
+ // projectSummaries (and therefore metrics) preserve project and task order of args.projects.
88
+ const experimentCases = args.projects.flatMap((project, projectIndex) => project.tasks.map((task, taskIndex) => {
89
+ const taskMetrics = warmIndexMetrics.projects[projectIndex].tasks[taskIndex];
90
+ const taskAgents = args.agentEvidence[projectIndex].tasks[taskIndex];
91
+ return {
92
+ id: task.caseId,
93
+ name: titles.get(task.caseId) ?? task.caseId,
94
+ outcomes: [
95
+ buildRawOutcome(project, task, taskMetrics, taskAgents),
96
+ buildWarmOutcome(project, task, taskMetrics, taskAgents),
97
+ ],
98
+ metadata: { benchmarkProject: task.benchmarkProject, sessionKey: project.projectSegment },
99
+ };
100
+ }));
101
+ const warnings = args.projects.flatMap((project) => project.warnings.map((message) => ({
102
+ code: "warm-index-setup-warning",
103
+ message,
104
+ details: { benchmarkProject: project.benchmarkProject },
105
+ })));
106
+ const run = {
107
+ runId: args.runId,
108
+ pluginId: warmIndexReuseMetadata.id,
109
+ startedAt: args.startedAt,
110
+ completedAt: args.completedAt,
111
+ status: aggregateStatus(experimentCases.flatMap((experimentCase) => experimentCase.outcomes.map((outcome) => outcome.status))),
112
+ target: args.target,
113
+ variants: WARM_INDEX_VARIANTS.map((variant) => ({ ...variant })),
114
+ cases: experimentCases,
115
+ metrics: toRunLevelMetrics(warmIndexMetrics),
116
+ artifacts: [
117
+ {
118
+ id: "warm-index-execution",
119
+ label: "Warm index execution evidence",
120
+ path: args.artifactPath,
121
+ kind: "artifact",
122
+ mimeType: "application/json",
123
+ description: "Per-project index setup and per-task raw/warm execution summaries without context text.",
124
+ },
125
+ ],
126
+ warnings,
127
+ failures: [],
128
+ metadata: { executionArtifactPath: args.artifactPath },
129
+ projectExecutions: args.projectSummaries,
130
+ agentEvidence: args.agentEvidence,
131
+ warmIndexMetrics,
132
+ };
133
+ run.summary = summarizeExperimentRun(run);
134
+ return run;
135
+ }
136
+ function outcomeMetadata(project, task, agent) {
137
+ return {
138
+ benchmarkProject: task.benchmarkProject,
139
+ sessionKey: project.projectSegment,
140
+ warmSessionAvailable: project.session !== undefined,
141
+ taskStatus: task.status,
142
+ // Agent evaluation status is reported separately; it does not change the execution status.
143
+ agentStatus: agent ? agent.status : "not-run",
144
+ };
145
+ }
146
+ function agentWarnings(agent, variantId, caseId) {
147
+ if (!agent)
148
+ return [];
149
+ return agent.errors.map((message) => ({
150
+ code: "fake-agent-evaluation-error",
151
+ message,
152
+ variantId,
153
+ caseId,
154
+ }));
155
+ }
156
+ function buildRawOutcome(project, task, taskMetrics, taskAgents) {
157
+ return {
158
+ id: `${task.caseId}:${RAW_FULL_FILE_VARIANT_ID}`,
159
+ caseId: task.caseId,
160
+ variantId: RAW_FULL_FILE_VARIANT_ID,
161
+ status: task.rawStatus,
162
+ metrics: toRawOutcomeMetrics(taskMetrics, RAW_FULL_FILE_VARIANT_ID),
163
+ artifacts: [],
164
+ warnings: agentWarnings(taskAgents.raw, RAW_FULL_FILE_VARIANT_ID, task.caseId),
165
+ failures: toFailures(task, "raw", RAW_FULL_FILE_VARIANT_ID),
166
+ metadata: outcomeMetadata(project, task, taskAgents.raw),
167
+ };
168
+ }
169
+ function buildWarmOutcome(project, task, taskMetrics, taskAgents) {
170
+ return {
171
+ id: `${task.caseId}:${WARM_INDEX_REUSE_VARIANT_ID}`,
172
+ caseId: task.caseId,
173
+ variantId: WARM_INDEX_REUSE_VARIANT_ID,
174
+ status: task.warmStatus,
175
+ metrics: toWarmOutcomeMetrics(taskMetrics, WARM_INDEX_REUSE_VARIANT_ID),
176
+ artifacts: [],
177
+ warnings: [
178
+ ...task.warnings.map((message) => ({
179
+ code: "warm-retrieval-warning",
180
+ message,
181
+ variantId: WARM_INDEX_REUSE_VARIANT_ID,
182
+ caseId: task.caseId,
183
+ })),
184
+ ...agentWarnings(taskAgents.warm, WARM_INDEX_REUSE_VARIANT_ID, task.caseId),
185
+ ],
186
+ failures: toFailures(task, "warm", WARM_INDEX_REUSE_VARIANT_ID),
187
+ metadata: outcomeMetadata(project, task, taskAgents.warm),
188
+ };
189
+ }
190
+ function toFailures(task, side, variantId) {
191
+ return task.errors
192
+ .filter((error) => error.side === side)
193
+ .map((error) => ({ code: error.code, message: error.message, variantId, caseId: task.caseId, recoverable: true }));
194
+ }
195
+ function readProjectProfilesInput(inputs) {
196
+ const value = inputs?.projectProfiles;
197
+ if (!Array.isArray(value)) {
198
+ throw new Error("Warm index reuse requires projectProfiles input for fake-agent evaluation.");
199
+ }
200
+ return value;
201
+ }
202
+ function readEnvInput(inputs) {
203
+ const env = inputs?.env;
204
+ return env && typeof env === "object" && !Array.isArray(env) ? env : undefined;
205
+ }
206
+ function readCasesInput(inputs) {
207
+ const value = inputs?.cases;
208
+ if (!Array.isArray(value)) {
209
+ throw new Error("Warm index reuse requires cases input.");
210
+ }
211
+ return value;
212
+ }
@@ -0,0 +1,85 @@
1
+ import path from "node:path";
2
+ import { sanitizePathSegment } from "../../outputPaths.js";
3
+ /**
4
+ * Keeps source case order, applies case-ID then benchmark-project filters, and fails clearly for
5
+ * unknown requested IDs or an empty selection.
6
+ */
7
+ export function selectWarmIndexCases(cases, filters) {
8
+ const knownCaseIds = new Set(cases.map((evaluationCase) => evaluationCase.id));
9
+ const missingCases = (filters.caseIds ?? []).filter((caseId) => !knownCaseIds.has(caseId));
10
+ if (missingCases.length > 0) {
11
+ throw new Error(`Evaluation case not found: ${missingCases.join(", ")}`);
12
+ }
13
+ const knownProjects = new Set(cases.map((evaluationCase) => evaluationCase.benchmarkProject));
14
+ const missingProjects = (filters.benchmarkProjects ?? []).filter((project) => !knownProjects.has(project));
15
+ if (missingProjects.length > 0) {
16
+ throw new Error(`Benchmark project not found: ${missingProjects.join(", ")}`);
17
+ }
18
+ const selected = cases
19
+ .filter((evaluationCase) => !filters.caseIds?.length || filters.caseIds.includes(evaluationCase.id))
20
+ .filter((evaluationCase) => !filters.benchmarkProjects?.length || filters.benchmarkProjects.includes(evaluationCase.benchmarkProject));
21
+ if (selected.length === 0) {
22
+ throw new Error(`No evaluation cases matched the requested filters (cases: ${(filters.caseIds ?? []).join(", ") || "any"}; ` +
23
+ `benchmark projects: ${(filters.benchmarkProjects ?? []).join(", ") || "any"}).`);
24
+ }
25
+ return selected;
26
+ }
27
+ /**
28
+ * Groups cases by benchmarkProject in first-seen order, keeping task order within a group. Cases
29
+ * that share a project but disagree on target root or ordered source roots make the group
30
+ * structurally invalid rather than silently picking one configuration.
31
+ */
32
+ export function groupWarmIndexCases(cases) {
33
+ const groups = new Map();
34
+ for (const evaluationCase of cases) {
35
+ let group = groups.get(evaluationCase.benchmarkProject);
36
+ if (!group) {
37
+ group = {
38
+ benchmarkProject: evaluationCase.benchmarkProject,
39
+ projectSegment: safeOutputSegment(evaluationCase.benchmarkProject, "benchmark project"),
40
+ targetRoot: evaluationCase.absoluteTargetRoot,
41
+ sourceRoots: Object.freeze([...evaluationCase.sourceRoots]),
42
+ cases: [],
43
+ structuralErrors: [],
44
+ };
45
+ groups.set(evaluationCase.benchmarkProject, group);
46
+ }
47
+ else {
48
+ if (path.resolve(group.targetRoot) !== path.resolve(evaluationCase.absoluteTargetRoot)) {
49
+ group.structuralErrors.push(`Case ${evaluationCase.id} target root ${evaluationCase.absoluteTargetRoot} differs from ${group.targetRoot} for benchmark project ${group.benchmarkProject}.`);
50
+ }
51
+ if (evaluationCase.sourceRoots.join("\u0000") !== group.sourceRoots.join("\u0000")) {
52
+ group.structuralErrors.push(`Case ${evaluationCase.id} source roots [${evaluationCase.sourceRoots.join(", ")}] differ from [${group.sourceRoots.join(", ")}] for benchmark project ${group.benchmarkProject}.`);
53
+ }
54
+ }
55
+ group.cases.push(evaluationCase);
56
+ }
57
+ const result = [...groups.values()];
58
+ assertUniqueSegments(result.map((group) => [group.benchmarkProject, group.projectSegment]), "benchmark project");
59
+ for (const group of result) {
60
+ assertUniqueSegments(group.cases.map((evaluationCase) => [evaluationCase.id, taskOutputSegment(evaluationCase.id)]), "case");
61
+ }
62
+ return result;
63
+ }
64
+ export function taskOutputSegment(caseId) {
65
+ return safeOutputSegment(caseId, "case");
66
+ }
67
+ // Reuses the shared experiment path-segment sanitizer and additionally rejects dot-only segments,
68
+ // which the sanitizer preserves and which would resolve outside the intended directory.
69
+ function safeOutputSegment(value, label) {
70
+ const segment = sanitizePathSegment(value);
71
+ if (/^\.+$/.test(segment)) {
72
+ throw new Error(`Unsafe ${label} ID for an output path segment: ${JSON.stringify(value)}`);
73
+ }
74
+ return segment;
75
+ }
76
+ function assertUniqueSegments(entries, label) {
77
+ const owners = new Map();
78
+ for (const [id, segment] of entries) {
79
+ const owner = owners.get(segment);
80
+ if (owner !== undefined && owner !== id) {
81
+ throw new Error(`The ${label} IDs ${owner} and ${id} map to the same output path segment "${segment}".`);
82
+ }
83
+ owners.set(segment, id);
84
+ }
85
+ }
@@ -0,0 +1,43 @@
1
+ import { existsSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { buildMyDevKitIndex } from "../../../evaluation/runMyDevKitRetrieval.js";
4
+ /**
5
+ * Builds the index once through the shared `buildMyDevKitIndex` owner. A build that reports
6
+ * success but leaves no index directory is not treated as a prepared session.
7
+ */
8
+ export async function prepareWarmIndexSession(options) {
9
+ const build = await buildMyDevKitIndex(options);
10
+ if (!build.ok) {
11
+ return { ok: false, warnings: [...build.warnings], build };
12
+ }
13
+ if (!existsSync(build.indexDir)) {
14
+ const message = `my-dev-kit index reported success but index directory is missing: ${build.indexDir}`;
15
+ if (options.requireKit) {
16
+ throw new Error(message);
17
+ }
18
+ return { ok: false, warnings: [...build.warnings, message], build };
19
+ }
20
+ const session = Object.freeze({
21
+ indexDir: build.indexDir,
22
+ buildDurationMs: build.durationMs,
23
+ targetRoot: options.target.absoluteTargetRoot,
24
+ sourceRoots: Object.freeze([...options.target.sourceRoots]),
25
+ buildCommand: build.command
26
+ });
27
+ return { ok: true, session, build };
28
+ }
29
+ function normalizeRoot(root) {
30
+ const resolved = path.resolve(root);
31
+ return process.platform === "win32" ? resolved.toLowerCase() : resolved;
32
+ }
33
+ /** Rejects reuse of a session against a different target root or source-root configuration. */
34
+ export function assertWarmIndexSessionMatchesTarget(session, target) {
35
+ if (normalizeRoot(session.targetRoot) !== normalizeRoot(target.absoluteTargetRoot)) {
36
+ throw new Error(`Warm index session target root ${session.targetRoot} does not match ${target.absoluteTargetRoot}.`);
37
+ }
38
+ const sameSourceRoots = session.sourceRoots.length === target.sourceRoots.length &&
39
+ session.sourceRoots.every((sourceRoot, index) => sourceRoot === target.sourceRoots[index]);
40
+ if (!sameSourceRoots) {
41
+ throw new Error(`Warm index session source roots [${session.sourceRoots.join(", ")}] do not match [${target.sourceRoots.join(", ")}].`);
42
+ }
43
+ }
@@ -0,0 +1,112 @@
1
+ import { existsSync } from "node:fs";
2
+ import { readFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { WARM_INDEX_REUSE_REPORT_SCHEMA_VERSION, } from "../report/experiments/warmIndexReuseReportModel.js";
5
+ // ---------------------------------------------------------------------------
6
+ // Warm-index plot data. Maps metrics already calculated by the warm-index
7
+ // metric owner (as carried by the plugin report) to plot points; unavailable
8
+ // metrics become skipped points with their reason. No formula is recalculated.
9
+ // ---------------------------------------------------------------------------
10
+ export const WARM_INDEX_PLOT_IDS = [
11
+ "warm-index-amortized-index-cost",
12
+ "warm-index-context-size",
13
+ "warm-index-correctness",
14
+ "warm-index-cumulative-token-usage",
15
+ ];
16
+ const WARM_INDEX_PLUGIN_ID = "warm-index-reuse";
17
+ const VARIANTS = ["raw-full-file", "warm-index-reuse"];
18
+ /**
19
+ * Returns the warm-index report section when `<experimentDir>/report.json` belongs to the
20
+ * warm-index-reuse plugin, or null for any other directory. A warm report that is malformed
21
+ * fails instead of falling back to legacy controlled-experiment parsing.
22
+ */
23
+ export async function readWarmIndexPlotSource(experimentDir) {
24
+ const reportPath = path.join(experimentDir, "report.json");
25
+ if (!existsSync(reportPath))
26
+ return null;
27
+ let parsed;
28
+ try {
29
+ parsed = JSON.parse(await readFile(reportPath, "utf8"));
30
+ }
31
+ catch {
32
+ return null;
33
+ }
34
+ const report = parsed?.report;
35
+ if (report?.plugin?.id !== WARM_INDEX_PLUGIN_ID)
36
+ return null;
37
+ const section = report.warmIndexReuse;
38
+ const invalid = (detail) => new Error(`Invalid warm-index-reuse report for plots (${reportPath}): ${detail}`);
39
+ if (!section || typeof section !== "object")
40
+ throw invalid("missing warmIndexReuse section.");
41
+ if (section.schemaVersion !== WARM_INDEX_REUSE_REPORT_SCHEMA_VERSION) {
42
+ throw invalid(`unsupported schema version ${String(section.schemaVersion)}.`);
43
+ }
44
+ if (!Array.isArray(section.projects))
45
+ throw invalid("projects must be an array.");
46
+ for (const project of section.projects) {
47
+ if (!project || typeof project.benchmarkProject !== "string" || !Array.isArray(project.tasks)) {
48
+ throw invalid("each project needs a benchmarkProject and a tasks array.");
49
+ }
50
+ for (const task of project.tasks) {
51
+ if (!task || typeof task.taskOrdinal !== "number" || !task.raw || !task.warm) {
52
+ throw invalid("each task needs a taskOrdinal and raw/warm metrics.");
53
+ }
54
+ }
55
+ }
56
+ return section;
57
+ }
58
+ export function buildWarmIndexPlotData(args) {
59
+ const skippedPoints = [];
60
+ const series = (id, title, yLabel) => ({
61
+ id,
62
+ title,
63
+ xLabel: "Task ordinal",
64
+ yLabel,
65
+ kind: "scatter",
66
+ points: [],
67
+ warnings: [],
68
+ });
69
+ const amortized = series("warm-index-amortized-index-cost", "Amortized index build cost", "Amortized index build duration (ms)");
70
+ const contextSize = series("warm-index-context-size", "Raw vs retrieved context size", "Estimated context tokens");
71
+ const correctness = series("warm-index-correctness", "Fake-agent correctness by strategy", "Fake-agent correctness score");
72
+ const cumulativeTokens = series("warm-index-cumulative-token-usage", "Cumulative fake-agent token usage", "Cumulative fake-agent total tokens (simulated)");
73
+ const add = (plot, metric, group, label, task) => {
74
+ if (metric.availability === "available" && metric.value !== null && Number.isFinite(metric.value)) {
75
+ const point = { x: task.taskOrdinal, y: metric.value, group, label, metadata: { caseId: task.caseId } };
76
+ plot.points.push(point);
77
+ }
78
+ else {
79
+ skippedPoints.push({ plotId: plot.id, label, reason: metric.reason ?? `Metric is ${metric.availability}.` });
80
+ }
81
+ };
82
+ for (const project of args.section.projects) {
83
+ for (const task of project.tasks) {
84
+ const taskLabel = `${project.benchmarkProject} task ${task.taskOrdinal} (${task.caseId})`;
85
+ add(amortized, task.warm.amortizedIndexBuildDurationMs, project.benchmarkProject, taskLabel, task);
86
+ for (const variantId of VARIANTS) {
87
+ const side = variantId === "raw-full-file" ? task.raw : task.warm;
88
+ const group = `${project.benchmarkProject} / ${variantId}`;
89
+ const label = `${taskLabel} ${variantId}`;
90
+ add(contextSize, side.contextEstimatedTokens, group, label, task);
91
+ add(correctness, side.agentCorrectness, group, label, task);
92
+ // Agent token totals only; estimated context tokens are never used as a fallback.
93
+ add(cumulativeTokens, side.cumulativeAgentTotalTokens, group, label, task);
94
+ }
95
+ }
96
+ }
97
+ const plots = [amortized, contextSize, correctness, cumulativeTokens];
98
+ const warnings = [];
99
+ for (const plot of plots) {
100
+ if (plot.points.length === 0) {
101
+ plot.warnings.push("No comparable data available.");
102
+ warnings.push(`${plot.title}: no comparable data available.`);
103
+ }
104
+ }
105
+ return {
106
+ generatedAt: args.generatedAt ?? new Date().toISOString(),
107
+ sourceExperimentDir: args.experimentDir,
108
+ plots,
109
+ skippedPoints,
110
+ warnings,
111
+ };
112
+ }
@@ -1,4 +1,5 @@
1
1
  export * from "./types.js";
2
2
  export * from "./buildExperimentPlotData.js";
3
+ export * from "./buildWarmIndexPlotData.js";
3
4
  export * from "./renderSvgChart.js";
4
5
  export * from "./writePlotArtifacts.js";
@@ -2,6 +2,7 @@ import { mkdir, writeFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { resolveWithinRoot } from "../core/pathSafety.js";
4
4
  import { buildExperimentPlotData } from "./buildExperimentPlotData.js";
5
+ import { buildWarmIndexPlotData, readWarmIndexPlotSource } from "./buildWarmIndexPlotData.js";
5
6
  import { renderSvgChart } from "./renderSvgChart.js";
6
7
  const chartFiles = {
7
8
  "token-savings-vs-prompt-length": "token-savings-vs-prompt-length.svg",
@@ -12,7 +13,13 @@ const chartFiles = {
12
13
  "run-outcomes-by-agent": "run-outcomes-by-agent.svg"
13
14
  };
14
15
  export async function writePlotArtifacts(options) {
15
- const data = await buildExperimentPlotData({ experimentDir: options.experimentDir, repoRoot: options.repoRoot });
16
+ // A warm-index-reuse plugin output directory is detected by its plugin report; every other
17
+ // directory keeps the legacy controlled-experiment plot path unchanged.
18
+ const experimentDir = path.resolve(options.repoRoot ?? process.cwd(), options.experimentDir);
19
+ const warmSection = await readWarmIndexPlotSource(experimentDir);
20
+ const data = warmSection
21
+ ? buildWarmIndexPlotData({ section: warmSection, experimentDir })
22
+ : await buildExperimentPlotData({ experimentDir: options.experimentDir, repoRoot: options.repoRoot });
16
23
  return writePlotArtifactsFromData({ data, outDir: options.outDir });
17
24
  }
18
25
  export async function writePlotArtifactsFromData(options) {
@@ -1,5 +1,6 @@
1
1
  import path from "node:path";
2
2
  import { buildContextStrategyComparisonV043Report } from "./buildContextStrategyComparisonV043Report.js";
3
+ import { buildWarmIndexReuseReport } from "./buildWarmIndexReuseReport.js";
3
4
  const V043_BULK_ARRAY_KEYS = [
4
5
  "v043StageContextExecutions",
5
6
  "v043StageContextEvaluations",
@@ -9,6 +10,7 @@ export function buildPluginExperimentReport(args) {
9
10
  const outputRoot = args.outputRoot ?? readString(args.run.metadata?.outputRoot) ?? null;
10
11
  const allOutcomes = args.run.cases.flatMap((experimentCase) => experimentCase.outcomes);
11
12
  const contextStrategyComparisonV043 = buildContextStrategyComparisonV043Report(args.run);
13
+ const warmIndexReuse = buildWarmIndexReuseReport(args.run);
12
14
  const rawRun = { ...args.run, artifacts: relativizeArtifacts(args.run.artifacts, outputRoot) };
13
15
  for (const key of V043_BULK_ARRAY_KEYS) {
14
16
  delete rawRun[key];
@@ -36,8 +38,9 @@ export function buildPluginExperimentReport(args) {
36
38
  failures: args.run.failures,
37
39
  skippedOutcomes: allOutcomes.filter((outcome) => outcome.status === "skipped"),
38
40
  findings: buildFindings(args.run),
41
+ warmIndexReuse,
39
42
  contextStrategyComparisonV043,
40
- interpretation: buildInterpretation(args.run, contextStrategyComparisonV043),
43
+ interpretation: buildInterpretation(args.run, contextStrategyComparisonV043, warmIndexReuse),
41
44
  rawRun,
42
45
  };
43
46
  }
@@ -94,7 +97,20 @@ function buildFindings(run) {
94
97
  ...skipped,
95
98
  ];
96
99
  }
97
- function buildInterpretation(run, contextStrategyComparisonV043) {
100
+ function buildInterpretation(run, contextStrategyComparisonV043, warmIndexReuse) {
101
+ if (run.pluginId === "warm-index-reuse" && warmIndexReuse) {
102
+ const summary = warmIndexReuse.summary;
103
+ return {
104
+ summary: `The warm-index run prepared ${summary.preparedSessionProjectCount} of ${summary.projectCount} project indexes and evaluated ${summary.taskCount} tasks. ` +
105
+ "The report separates one-time index-build duration from per-task retrieval duration and shows how the fixed build cost is amortized across repeated tasks. " +
106
+ `Deterministic fake-agent correctness is available for ${summary.agentCorrectnessAvailableCount} of ${summary.agentSideCount} task sides and fake-agent token totals for ${summary.agentTotalTokensAvailableCount}; ` +
107
+ "these are simulated harness evidence, not real-model or provider measurements. " +
108
+ "Estimated context-token values are context-size estimates, not provider token usage.",
109
+ recommendedNextStep: run.status === "completed"
110
+ ? "Review per-task measurements and cumulative component costs for each project; the variants are reported side by side and are not ranked."
111
+ : "Inspect unavailable metrics, warnings, and failures before drawing conclusions from this run.",
112
+ };
113
+ }
98
114
  if (run.pluginId === "context-strategy-comparison" && (contextStrategyComparisonV043?.summary.strategyCount ?? 0) > 0) {
99
115
  return {
100
116
  summary: `Stage-context evidence was recorded for ${contextStrategyComparisonV043.summary.strategyCount} strategy executions. Review each strategy independently; this report does not calculate a composite ranking or winning strategy.`,
@@ -0,0 +1,91 @@
1
+ import { WARM_INDEX_REUSE_REPORT_SCHEMA_VERSION, } from "./warmIndexReuseReportModel.js";
2
+ const WARM_INDEX_REUSE_PLUGIN_ID = "warm-index-reuse";
3
+ const COST_MODEL = [
4
+ "Index construction is a one-time cost per benchmark project; task retrieval is the repeated warm cost.",
5
+ "Cold start: for the first task in a project, the cumulative warm component duration is the one-time index build plus the first retrieval.",
6
+ "Warm reuse: later tasks do not rebuild the index; their warm operation duration is retrieval only.",
7
+ "Amortized index build duration after N tasks is the index build duration divided by N.",
8
+ "Raw cumulative duration sums one raw-context construction per task; warm cumulative component duration includes one index build plus each warm retrieval once.",
9
+ "Cumulative durations are sums of measured components, not wall-clock or end-to-end latency, and are not presented as a speedup.",
10
+ ];
11
+ const LIMITATIONS = [
12
+ "Context estimated-token counts use the existing character-based estimate and are not provider billing telemetry.",
13
+ "Agent evidence comes from the deterministic fake agent only; real agents such as Codex or Claude are not run by warm-index-reuse in v0.5.0.",
14
+ "Fake-agent correctness is deterministic benchmark-harness evidence from the existing answer-key scorer, not a measure of real-model correctness.",
15
+ "Fake-agent token totals are simulated harness telemetry, not provider billing telemetry; estimated context tokens are a separate character-based context-size estimate.",
16
+ "Component-duration sums are not equivalent to provider end-to-end latency.",
17
+ "Failed or missing prefix measurements make cumulative metrics unavailable; missing values are never treated as zero.",
18
+ "No composite score, winner, ranking, or break-even task is calculated.",
19
+ ];
20
+ /**
21
+ * Presents the warm-index metrics already calculated on the run. It performs no amortization,
22
+ * cumulative, or availability calculation of its own.
23
+ */
24
+ export function buildWarmIndexReuseReport(run) {
25
+ if (run.pluginId !== WARM_INDEX_REUSE_PLUGIN_ID)
26
+ return null;
27
+ const candidate = run;
28
+ const executions = candidate.projectExecutions ?? [];
29
+ const metricProjects = candidate.warmIndexMetrics?.projects ?? [];
30
+ const agentProjects = candidate.agentEvidence;
31
+ if (executions.length !== metricProjects.length) {
32
+ throw new Error("Invalid warm-index report source: project executions and metrics are inconsistent.");
33
+ }
34
+ const projects = executions.map((execution, projectIndex) => {
35
+ const metrics = metricProjects[projectIndex];
36
+ if (metrics.benchmarkProject !== execution.benchmarkProject ||
37
+ metrics.tasks.length !== execution.tasks.length ||
38
+ metrics.tasks.some((task, taskIndex) => task.caseId !== execution.tasks[taskIndex].caseId)) {
39
+ throw new Error("Invalid warm-index report source: project executions and metrics are inconsistent.");
40
+ }
41
+ return {
42
+ benchmarkProject: execution.benchmarkProject,
43
+ sessionKey: execution.sessionKey,
44
+ status: execution.status,
45
+ sessionPrepared: metrics.sessionPrepared,
46
+ indexBuildDurationMs: metrics.indexBuildDurationMs,
47
+ taskCount: metrics.tasks.length,
48
+ tasks: metrics.tasks.map((task, taskIndex) => {
49
+ const agents = agentProjects?.[projectIndex]?.tasks[taskIndex];
50
+ return {
51
+ taskOrdinal: task.taskOrdinal,
52
+ caseId: task.caseId,
53
+ rawStatus: execution.tasks[taskIndex].rawStatus,
54
+ warmStatus: execution.tasks[taskIndex].warmStatus,
55
+ raw: task.raw,
56
+ warm: task.warm,
57
+ rawAgent: toReportAgent(agents?.raw ?? null),
58
+ warmAgent: toReportAgent(agents?.warm ?? null),
59
+ };
60
+ }),
61
+ };
62
+ });
63
+ const sides = projects.flatMap((project) => project.tasks.flatMap((task) => [task.raw, task.warm]));
64
+ return {
65
+ schemaVersion: WARM_INDEX_REUSE_REPORT_SCHEMA_VERSION,
66
+ summary: {
67
+ projectCount: projects.length,
68
+ taskCount: projects.reduce((sum, project) => sum + project.taskCount, 0),
69
+ preparedSessionProjectCount: projects.filter((project) => project.sessionPrepared).length,
70
+ incompleteProjectCount: projects.filter((project) => project.status !== "completed").length,
71
+ agentSideCount: sides.length,
72
+ agentCorrectnessAvailableCount: sides.filter((side) => side.agentCorrectness.availability === "available").length,
73
+ agentTotalTokensAvailableCount: sides.filter((side) => side.agentTotalTokens.availability === "available").length,
74
+ },
75
+ costModel: [...COST_MODEL],
76
+ limitations: [...LIMITATIONS],
77
+ projects,
78
+ };
79
+ }
80
+ function toReportAgent(agent) {
81
+ if (!agent)
82
+ return null;
83
+ return {
84
+ status: agent.status,
85
+ passed: agent.correctness.passed,
86
+ tokenUsageSource: agent.tokenUsage.source,
87
+ tokenUsageReliability: agent.tokenUsage.reliability,
88
+ warnings: [...agent.warnings],
89
+ errors: [...agent.errors],
90
+ };
91
+ }
@@ -6,6 +6,9 @@ export * from "./contextStrategyComparisonV043ReportModel.js";
6
6
  export { buildContextStrategyComparisonV043Report } from "./buildContextStrategyComparisonV043Report.js";
7
7
  export { renderContextStrategyComparisonV043Html } from "./renderContextStrategyComparisonV043Html.js";
8
8
  export { renderPluginExperimentReportText } from "./renderPluginExperimentReportText.js";
9
+ export * from "./warmIndexReuseReportModel.js";
10
+ export { buildWarmIndexReuseReport } from "./buildWarmIndexReuseReport.js";
11
+ export { renderWarmIndexReuseHtml } from "./renderWarmIndexReuseHtml.js";
9
12
  export * from "./contextIntegrityReportModel.js";
10
13
  export { buildContextIntegrityReport } from "./buildContextIntegrityReport.js";
11
14
  export { renderContextIntegrityJsonReport } from "./renderContextIntegrityJsonReport.js";
@@ -1,4 +1,5 @@
1
1
  import { renderContextStrategyComparisonV043Html } from "./renderContextStrategyComparisonV043Html.js";
2
+ import { renderWarmIndexReuseHtml } from "./renderWarmIndexReuseHtml.js";
2
3
  export function renderPluginExperimentReportHtml(report) {
3
4
  return `<!DOCTYPE html>
4
5
  <html lang="en">
@@ -96,6 +97,8 @@ export function renderPluginExperimentReportHtml(report) {
96
97
 
97
98
  ${renderContextStrategyComparisonV043Html(report.contextStrategyComparisonV043)}
98
99
 
100
+ ${renderWarmIndexReuseHtml(report.warmIndexReuse)}
101
+
99
102
  <section>
100
103
  <h2>Warnings, Skips, And Failures</h2>
101
104
  ${report.findings.length ? table(["Severity", "Code", "Message", "Variant", "Case"], report.findings.map((finding) => [