@dailephd/my-dev-kit-lab 0.6.1 → 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 +14 -0
- package/README.md +3 -2
- package/benchmarks/contracts/incremental-change-staleness-scenarios.json +360 -0
- package/dist/src/commands/runExperimentRunCommand.js +4 -3
- package/dist/src/evaluation/affectedNeighborhoodMetrics.js +51 -0
- 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/metrics.js +17 -20
- package/dist/src/report/experiments/buildIncrementalChangeStalenessReport.js +112 -0
- package/dist/src/report/experiments/buildPluginExperimentReport.js +3 -0
- 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 +138 -0
- package/docs/METRICS.md +35 -1
- package/package.json +1 -1
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { invalidExperimentConfig, isPlainObject, mergeConfig, validExperimentConfig } from "../../config.js";
|
|
2
|
+
import { FROZEN_INCREMENTAL_CHANGE_STALENESS_SCENARIO_IDS } from "./scenarioTypes.js";
|
|
3
|
+
/**
|
|
4
|
+
* The frozen v0.6.2 compatibility baseline for the upstream tool. Every index
|
|
5
|
+
* of one matched scenario must come from this one configured command.
|
|
6
|
+
*/
|
|
7
|
+
export const INCREMENTAL_CHANGE_STALENESS_DEFAULT_KIT_COMMAND = "npx @dailephd/my-dev-kit@1.12.4";
|
|
8
|
+
export const defaultIncrementalChangeStalenessConfig = {
|
|
9
|
+
outDir: "lab-output/incremental-change-staleness",
|
|
10
|
+
kitCommand: INCREMENTAL_CHANGE_STALENESS_DEFAULT_KIT_COMMAND
|
|
11
|
+
};
|
|
12
|
+
const SUPPORTED_FIELDS = ["outDir", "kitCommand", "caseIds"];
|
|
13
|
+
export const incrementalChangeStalenessConfigDefinition = {
|
|
14
|
+
fields: [
|
|
15
|
+
{ name: "outDir", type: "string", required: true, description: "Experiment output root." },
|
|
16
|
+
{ name: "kitCommand", type: "string", description: "my-dev-kit command used for every treatment index build." },
|
|
17
|
+
{
|
|
18
|
+
name: "caseIds",
|
|
19
|
+
type: "array",
|
|
20
|
+
description: `Frozen scenario ids to include (${FROZEN_INCREMENTAL_CHANGE_STALENESS_SCENARIO_IDS.join(", ")}); defaults to all. Both stale-index and full-refresh always run for each selected scenario.`
|
|
21
|
+
}
|
|
22
|
+
]
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Closed validation: unknown fields (including any attempt to supply variant
|
|
26
|
+
* filters, catalog paths, or mutation text) are rejected. Scenario ids must be
|
|
27
|
+
* frozen production ids, non-empty, and free of duplicates, so invalid
|
|
28
|
+
* selections fail before any target or index work.
|
|
29
|
+
*/
|
|
30
|
+
export function validateIncrementalChangeStalenessConfig(config) {
|
|
31
|
+
if (config !== undefined && !isPlainObject(config)) {
|
|
32
|
+
return invalidExperimentConfig(["incremental-change-staleness config must be an object."]);
|
|
33
|
+
}
|
|
34
|
+
const errors = [];
|
|
35
|
+
const unsupported = Object.keys(config ?? {}).filter((key) => !SUPPORTED_FIELDS.includes(key));
|
|
36
|
+
if (unsupported.length > 0) {
|
|
37
|
+
errors.push(`Unsupported incremental-change-staleness config field(s): ${unsupported.sort().join(", ")}.`);
|
|
38
|
+
}
|
|
39
|
+
const normalized = mergeConfig(defaultIncrementalChangeStalenessConfig, config);
|
|
40
|
+
for (const field of ["outDir", "kitCommand"]) {
|
|
41
|
+
const value = normalized[field];
|
|
42
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
43
|
+
errors.push(`${field} must be a non-empty string.`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const caseIds = normalized.caseIds;
|
|
47
|
+
if (caseIds !== undefined) {
|
|
48
|
+
if (!Array.isArray(caseIds) || caseIds.length === 0 || !caseIds.every((item) => typeof item === "string" && item.trim())) {
|
|
49
|
+
errors.push("caseIds must be a non-empty array of non-empty scenario id strings when provided.");
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
const duplicates = caseIds.filter((id, index) => caseIds.indexOf(id) !== index);
|
|
53
|
+
if (duplicates.length > 0) {
|
|
54
|
+
errors.push(`caseIds contains duplicate scenario id(s): ${[...new Set(duplicates)].join(", ")}.`);
|
|
55
|
+
}
|
|
56
|
+
const unknown = caseIds.filter((id) => !FROZEN_INCREMENTAL_CHANGE_STALENESS_SCENARIO_IDS.includes(id));
|
|
57
|
+
if (unknown.length > 0) {
|
|
58
|
+
errors.push(`caseIds contains unknown scenario id(s): ${unknown.join(", ")}. Expected one of ${FROZEN_INCREMENTAL_CHANGE_STALENESS_SCENARIO_IDS.join(", ")}.`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (errors.length > 0) {
|
|
63
|
+
return invalidExperimentConfig(errors);
|
|
64
|
+
}
|
|
65
|
+
return validExperimentConfig(normalized);
|
|
66
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { cp, realpath, rm } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { resolveWithinRoot } from "../../../core/pathSafety.js";
|
|
5
|
+
/**
|
|
6
|
+
* The two primary v0.6.2 treatment identities, already frozen by the planned
|
|
7
|
+
* plugin design. Batch 2 uses them only to name independent disposable
|
|
8
|
+
* target copies; it never builds a treatment index or runs retrieval.
|
|
9
|
+
*/
|
|
10
|
+
export const INCREMENTAL_CHANGE_STALENESS_TREATMENT_IDS = ["stale-index", "full-refresh"];
|
|
11
|
+
/**
|
|
12
|
+
* Default project-relative, gitignored runtime location for disposable
|
|
13
|
+
* treatment targets. Callers may pass a different `runRoot`, but it must
|
|
14
|
+
* still resolve inside `repoRoot` (see `createDisposableTreatmentTarget`).
|
|
15
|
+
*/
|
|
16
|
+
export const DEFAULT_INCREMENTAL_CHANGE_STALENESS_RUNTIME_ROOT_RELATIVE = ".my-dev-kit-context/runtime/incremental-change-staleness";
|
|
17
|
+
function assertSafeRuntimeBoundary(repoRoot, runRoot) {
|
|
18
|
+
// runRoot itself must live inside the repository; this also rejects a
|
|
19
|
+
// caller-supplied runRoot that points outside the my-dev-kit-lab worktree.
|
|
20
|
+
return resolveWithinRoot(repoRoot, path.resolve(runRoot));
|
|
21
|
+
}
|
|
22
|
+
function assertDisposableRootDoesNotOverlapCanonical(targetRoot, canonicalProjectRoot) {
|
|
23
|
+
const relativeFromCanonical = path.relative(canonicalProjectRoot, targetRoot);
|
|
24
|
+
const targetIsInsideCanonical = relativeFromCanonical !== "" && !relativeFromCanonical.startsWith("..") && !path.isAbsolute(relativeFromCanonical);
|
|
25
|
+
const relativeFromTarget = path.relative(targetRoot, canonicalProjectRoot);
|
|
26
|
+
const canonicalIsInsideTarget = relativeFromTarget !== "" && !relativeFromTarget.startsWith("..") && !path.isAbsolute(relativeFromTarget);
|
|
27
|
+
if (targetRoot === canonicalProjectRoot || targetIsInsideCanonical || canonicalIsInsideTarget) {
|
|
28
|
+
throw new Error(`Disposable treatment target root ${targetRoot} unsafely overlaps the canonical benchmark project root ${canonicalProjectRoot}.`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Creates one independent disposable copy of a canonical benchmark project
|
|
33
|
+
* for one scenario/treatment pair. Always copies the whole benchmark
|
|
34
|
+
* project (not just the changed file), since later index/retrieval
|
|
35
|
+
* execution requires a coherent project. Never overwrites an existing
|
|
36
|
+
* destination and never allows the destination to overlap the canonical
|
|
37
|
+
* project tree.
|
|
38
|
+
*/
|
|
39
|
+
export async function createDisposableTreatmentTarget(options) {
|
|
40
|
+
const { repoRoot, scenarioId, treatmentId, benchmarkProjectId, canonicalProjectRootRelative } = options;
|
|
41
|
+
if (!INCREMENTAL_CHANGE_STALENESS_TREATMENT_IDS.includes(treatmentId)) {
|
|
42
|
+
throw new Error(`Unknown treatment id: ${treatmentId}. Expected one of ${INCREMENTAL_CHANGE_STALENESS_TREATMENT_IDS.join(", ")}.`);
|
|
43
|
+
}
|
|
44
|
+
if (typeof scenarioId !== "string" || scenarioId.length === 0) {
|
|
45
|
+
throw new Error("scenarioId must be a nonempty string.");
|
|
46
|
+
}
|
|
47
|
+
const resolvedRepoRoot = path.resolve(repoRoot);
|
|
48
|
+
const resolvedRunRoot = assertSafeRuntimeBoundary(resolvedRepoRoot, options.runRoot);
|
|
49
|
+
const canonicalProjectRoot = resolveWithinRoot(resolvedRepoRoot, canonicalProjectRootRelative);
|
|
50
|
+
const targetRoot = resolveWithinRoot(resolvedRunRoot, path.join(scenarioId, treatmentId));
|
|
51
|
+
assertDisposableRootDoesNotOverlapCanonical(targetRoot, canonicalProjectRoot);
|
|
52
|
+
if (!existsSync(canonicalProjectRoot)) {
|
|
53
|
+
throw new Error(`Canonical benchmark project root does not exist: ${canonicalProjectRoot}.`);
|
|
54
|
+
}
|
|
55
|
+
if (existsSync(targetRoot)) {
|
|
56
|
+
throw new Error(`Disposable treatment target already exists; refusing to overwrite: ${targetRoot}.`);
|
|
57
|
+
}
|
|
58
|
+
await cp(canonicalProjectRoot, targetRoot, { recursive: true, errorOnExist: true, force: false });
|
|
59
|
+
return {
|
|
60
|
+
scenarioId,
|
|
61
|
+
treatmentId,
|
|
62
|
+
benchmarkProjectId,
|
|
63
|
+
canonicalProjectRoot,
|
|
64
|
+
targetRoot
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Removes one disposable treatment target directory. Only ever deletes a
|
|
69
|
+
* path that resolves inside the caller-owned `runRoot`; refuses to touch
|
|
70
|
+
* anything else, including a canonical benchmark project. Idempotent: a
|
|
71
|
+
* missing target is a successful no-op.
|
|
72
|
+
*/
|
|
73
|
+
export async function removeDisposableTreatmentTarget(target, repoRoot, runRoot) {
|
|
74
|
+
const resolvedRepoRoot = path.resolve(repoRoot);
|
|
75
|
+
const resolvedRunRoot = assertSafeRuntimeBoundary(resolvedRepoRoot, runRoot);
|
|
76
|
+
// Re-derive containment from the caller-owned root rather than trusting the descriptor blindly.
|
|
77
|
+
const containedTargetRoot = resolveWithinRoot(resolvedRunRoot, path.resolve(target.targetRoot));
|
|
78
|
+
await rm(containedTargetRoot, { recursive: true, force: true });
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Removes the entire caller-owned runtime root (both treatment copies for
|
|
82
|
+
* every scenario created under it). Refuses to operate unless `runRoot`
|
|
83
|
+
* resolves inside `repoRoot`, and never touches anything outside it.
|
|
84
|
+
*/
|
|
85
|
+
export async function removeIncrementalChangeStalenessRuntimeRoot(repoRoot, runRoot) {
|
|
86
|
+
const resolvedRepoRoot = path.resolve(repoRoot);
|
|
87
|
+
const resolvedRunRoot = assertSafeRuntimeBoundary(resolvedRepoRoot, runRoot);
|
|
88
|
+
await rm(resolvedRunRoot, { recursive: true, force: true });
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Resolves a target-relative path inside a disposable treatment target,
|
|
92
|
+
* rejecting both textual path traversal and symlink escape: the resolved
|
|
93
|
+
* real path (once the file exists) must still be contained by the real
|
|
94
|
+
* path of the treatment root.
|
|
95
|
+
*/
|
|
96
|
+
export async function resolveWithinDisposableTarget(target, relativePath) {
|
|
97
|
+
const resolved = resolveWithinRoot(target.targetRoot, relativePath);
|
|
98
|
+
if (!existsSync(resolved)) {
|
|
99
|
+
return resolved;
|
|
100
|
+
}
|
|
101
|
+
const [realResolved, realRoot] = await Promise.all([realpath(resolved), realpath(target.targetRoot)]);
|
|
102
|
+
const relativeFromRoot = path.relative(realRoot, realResolved);
|
|
103
|
+
if (relativeFromRoot === ".." || relativeFromRoot.startsWith(`..${path.sep}`) || path.isAbsolute(relativeFromRoot)) {
|
|
104
|
+
throw new Error(`Resolved path escapes the disposable treatment root via a symbolic link: ${relativePath}`);
|
|
105
|
+
}
|
|
106
|
+
return resolved;
|
|
107
|
+
}
|
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { runAgentPrompt } from "../../../agents/index.js";
|
|
3
|
+
import { resolveWithinRoot } from "../../../core/pathSafety.js";
|
|
4
|
+
import { assessAffectedNeighborhood, mapAffectedNeighborhoodSeeds } from "../../../evaluation/affectedNeighborhood.js";
|
|
5
|
+
import { classifyAgentRunOutcome } from "../../../evaluation/classifyAgentRunOutcome.js";
|
|
6
|
+
import { parseAgentAnswer } from "../../../evaluation/parseAgentAnswer.js";
|
|
7
|
+
import { readEvaluationCases } from "../../../evaluation/readEvaluationCases.js";
|
|
8
|
+
import { runMyDevKitRetrievalFromIndex } from "../../../evaluation/runMyDevKitRetrieval.js";
|
|
9
|
+
import { scoreCorrectness } from "../../../evaluation/scoreCorrectness.js";
|
|
10
|
+
import { generatePromptVariants } from "../../../prompts/index.js";
|
|
11
|
+
import { buildRequiredFileEvidence, classifyStaleRisk, compareCorrectness, compareRequiredFileEvidence } from "./comparison.js";
|
|
12
|
+
import { WARM_INDEX_BENCHMARK_CASES_PATH } from "./scenarioCatalog.js";
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// v0.6.2 Batch 4 -- treatment retrieval, correctness, and required-file
|
|
15
|
+
// evidence. Reuses existing generic owners exactly (mapAffectedNeighborhoodSeeds
|
|
16
|
+
// / assessAffectedNeighborhood, runMyDevKitRetrievalFromIndex, scoreCorrectness,
|
|
17
|
+
// generatePromptVariants + runAgentPrompt("fake-agent") + parseAgentAnswer +
|
|
18
|
+
// classifyAgentRunOutcome). No new correctness/retrieval/fake-agent algorithm
|
|
19
|
+
// is introduced here.
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
const SCOREABLE_STATUSES = new Set(["completed", "invalid-output"]);
|
|
22
|
+
function errorMessage(error) {
|
|
23
|
+
return error instanceof Error ? error.message : String(error);
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Resolves one scenario's query/answer key. `inherit` reuses the referenced base case's existing
|
|
27
|
+
* query/answer key unchanged; `scenario` uses the scenario's own post-mutation query/answer key.
|
|
28
|
+
* The two are never merged. Reads the same canonical benchmark-case contract the lifecycle already
|
|
29
|
+
* validated against; never mutates it.
|
|
30
|
+
*/
|
|
31
|
+
export async function resolveIncrementalChangeStalenessQueryAndAnswer(options) {
|
|
32
|
+
const { scenario, repoRoot } = options;
|
|
33
|
+
if (scenario.answerPolicy === "scenario") {
|
|
34
|
+
if (!scenario.scenarioQuery || !scenario.scenarioAnswerKey) {
|
|
35
|
+
throw new Error(`Scenario ${scenario.id}: answerPolicy is "scenario" but scenarioQuery/scenarioAnswerKey are missing.`);
|
|
36
|
+
}
|
|
37
|
+
return {
|
|
38
|
+
query: scenario.scenarioQuery,
|
|
39
|
+
answerKey: scenario.scenarioAnswerKey,
|
|
40
|
+
expectedFiles: [...scenario.scenarioAnswerKey.expectedFiles],
|
|
41
|
+
expectedSymbols: [...scenario.scenarioAnswerKey.expectedSymbols]
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
const cases = await readEvaluationCases(path.resolve(repoRoot, WARM_INDEX_BENCHMARK_CASES_PATH), repoRoot);
|
|
45
|
+
const baseCase = cases.find((candidate) => candidate.id === scenario.baseCaseId);
|
|
46
|
+
if (!baseCase) {
|
|
47
|
+
throw new Error(`Scenario ${scenario.id}: base case ${scenario.baseCaseId} was not found for query/answer resolution.`);
|
|
48
|
+
}
|
|
49
|
+
if (!baseCase.answerKey) {
|
|
50
|
+
throw new Error(`Scenario ${scenario.id}: base case ${scenario.baseCaseId} declares no answerKey for the "inherit" answer policy.`);
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
query: baseCase.query,
|
|
54
|
+
answerKey: baseCase.answerKey,
|
|
55
|
+
expectedFiles: [...baseCase.answerKey.expectedFiles],
|
|
56
|
+
expectedSymbols: [...baseCase.answerKey.expectedSymbols]
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
// Derived in-memory task descriptor (section 9). Never written to disk and
|
|
61
|
+
// never replaces the canonical benchmark case/answer-key files.
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
/**
|
|
64
|
+
* Builds one in-memory `EvaluationCase`-shaped task descriptor: identity/project/source ownership
|
|
65
|
+
* comes from the canonical base case; query and expected files/symbols come from the resolved
|
|
66
|
+
* query/answer. Used for affected-neighborhood task mapping, treatment retrieval, and fake-agent
|
|
67
|
+
* prompt generation; it is never materialized as a benchmark case file.
|
|
68
|
+
*/
|
|
69
|
+
export function buildIncrementalChangeStalenessDerivedTask(args) {
|
|
70
|
+
const base = args.baseEvaluationCase;
|
|
71
|
+
return {
|
|
72
|
+
...base,
|
|
73
|
+
id: `${args.scenario.id}:${base.id}`,
|
|
74
|
+
title: `${args.scenario.id} (${base.title})`,
|
|
75
|
+
query: args.resolved.query,
|
|
76
|
+
expectedFiles: [...args.resolved.expectedFiles],
|
|
77
|
+
expectedSymbols: [...args.resolved.expectedSymbols],
|
|
78
|
+
answerKey: args.resolved.answerKey,
|
|
79
|
+
projectProfileRef: base.projectProfileRef ?? base.benchmarkProject
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
// Affected-neighborhood execution (sections 10-14, 56).
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
/**
|
|
86
|
+
* Assesses one treatment's affected neighborhood using its BASELINE change authority (never the
|
|
87
|
+
* refreshed graph, even for full-refresh): the baseline index's snapshot/graph plus the treatment's
|
|
88
|
+
* already-established post-mutation baseline freshness.
|
|
89
|
+
*/
|
|
90
|
+
export function assessIncrementalChangeStalenessAffectedNeighborhood(treatment, derivedTask) {
|
|
91
|
+
const changeAuthority = treatment.changeAuthority;
|
|
92
|
+
const seedMapping = mapAffectedNeighborhoodSeeds({
|
|
93
|
+
indexSnapshot: changeAuthority.baselineIndex.snapshot,
|
|
94
|
+
freshness: changeAuthority.postMutationBaselineFreshness,
|
|
95
|
+
graph: changeAuthority.baselineIndex.graph
|
|
96
|
+
});
|
|
97
|
+
return assessAffectedNeighborhood({ graph: changeAuthority.baselineIndex.graph, seedMapping, task: derivedTask });
|
|
98
|
+
}
|
|
99
|
+
const SYMMETRIC_NUMERIC_FIELDS = [
|
|
100
|
+
"changedFileCount",
|
|
101
|
+
"changedSymbolCount",
|
|
102
|
+
"affectedNodeCount",
|
|
103
|
+
"affectedEdgeCount",
|
|
104
|
+
"taskOverlapCount",
|
|
105
|
+
"taskOverlapPercent"
|
|
106
|
+
];
|
|
107
|
+
/**
|
|
108
|
+
* Section 13: requires equality of assessment status, relationship, reindexRecommendation, and the
|
|
109
|
+
* availability/value of all six numeric metrics between the two treatment baselines. Does not
|
|
110
|
+
* require raw graph identity or absolute paths to match.
|
|
111
|
+
*/
|
|
112
|
+
export function checkIncrementalChangeStalenessAffectedNeighborhoodSymmetry(stale, fullRefresh) {
|
|
113
|
+
const mismatches = [];
|
|
114
|
+
if (stale.status !== fullRefresh.status)
|
|
115
|
+
mismatches.push(`status (${stale.status} vs ${fullRefresh.status})`);
|
|
116
|
+
if (stale.relationship !== fullRefresh.relationship)
|
|
117
|
+
mismatches.push(`relationship (${stale.relationship} vs ${fullRefresh.relationship})`);
|
|
118
|
+
if (stale.reindexRecommendation !== fullRefresh.reindexRecommendation) {
|
|
119
|
+
mismatches.push(`reindexRecommendation (${stale.reindexRecommendation} vs ${fullRefresh.reindexRecommendation})`);
|
|
120
|
+
}
|
|
121
|
+
for (const field of SYMMETRIC_NUMERIC_FIELDS) {
|
|
122
|
+
if (stale[field] !== fullRefresh[field]) {
|
|
123
|
+
mismatches.push(`${field} (${String(stale[field])} vs ${String(fullRefresh[field])})`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (mismatches.length === 0)
|
|
127
|
+
return { symmetric: true };
|
|
128
|
+
return { symmetric: false, reason: `Affected-neighborhood assessments are asymmetric: ${mismatches.join("; ")}.` };
|
|
129
|
+
}
|
|
130
|
+
/** Same classification rule as the existing warm-index-reuse execution owner. */
|
|
131
|
+
export function classifyIncrementalChangeStalenessRetrieval(result) {
|
|
132
|
+
if (!result.skipped)
|
|
133
|
+
return "completed";
|
|
134
|
+
return result.commands.some((command) => !command.ok) ? "failed" : "skipped";
|
|
135
|
+
}
|
|
136
|
+
export async function runIncrementalChangeStalenessTreatmentRetrieval(args) {
|
|
137
|
+
return runMyDevKitRetrievalFromIndex({
|
|
138
|
+
evaluationCase: args.derivedTask,
|
|
139
|
+
kitCommand: args.kitCommand,
|
|
140
|
+
indexDir: args.indexDir,
|
|
141
|
+
commandsDir: args.commandsDir,
|
|
142
|
+
requireKit: false
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Section 27: a `failed` retrieval status means the actual-files-read set cannot be trusted as
|
|
147
|
+
* complete (unknown); `completed` and `skipped` (search ran and legitimately found no candidate)
|
|
148
|
+
* both provide a complete, trustworthy files-read set, possibly empty.
|
|
149
|
+
*/
|
|
150
|
+
export function buildIncrementalChangeStalenessRequiredFileEvidence(args) {
|
|
151
|
+
if (!args.retrieval) {
|
|
152
|
+
return buildRequiredFileEvidence({
|
|
153
|
+
requiredFiles: args.requiredFiles,
|
|
154
|
+
observedFiles: [],
|
|
155
|
+
observedFilesComplete: false,
|
|
156
|
+
unknownReason: "No retrieval was attempted for this treatment."
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
const status = classifyIncrementalChangeStalenessRetrieval(args.retrieval);
|
|
160
|
+
return buildRequiredFileEvidence({
|
|
161
|
+
requiredFiles: args.requiredFiles,
|
|
162
|
+
observedFiles: args.retrieval.filesRead,
|
|
163
|
+
observedFilesComplete: status !== "failed",
|
|
164
|
+
unknownReason: status === "failed"
|
|
165
|
+
? `Retrieval failed (${args.retrieval.warnings.join(" ") || "my-dev-kit retrieval command failed."}); the actual-files-read set is incomplete.`
|
|
166
|
+
: undefined
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
export async function runIncrementalChangeStalenessFakeAgent(args) {
|
|
170
|
+
try {
|
|
171
|
+
const [promptVariant] = generatePromptVariants({
|
|
172
|
+
cases: [args.derivedTask],
|
|
173
|
+
projectProfiles: [...args.projectProfiles],
|
|
174
|
+
strategies: ["my-dev-kit-guided"],
|
|
175
|
+
complexityLevels: ["short"]
|
|
176
|
+
});
|
|
177
|
+
if (!promptVariant) {
|
|
178
|
+
throw new Error(`Failed to generate a fake-agent prompt for case ${args.derivedTask.id}.`);
|
|
179
|
+
}
|
|
180
|
+
const agentRunResult = await runAgentPrompt({
|
|
181
|
+
runId: args.runId,
|
|
182
|
+
agentId: "fake-agent",
|
|
183
|
+
promptVariant,
|
|
184
|
+
promptText: promptVariant.promptText,
|
|
185
|
+
cwd: args.cwd,
|
|
186
|
+
outDir: args.outDir,
|
|
187
|
+
env: args.env
|
|
188
|
+
});
|
|
189
|
+
const parsedAnswer = parseAgentAnswer({
|
|
190
|
+
text: agentRunResult.finalAnswerText,
|
|
191
|
+
answerKey: promptVariant.expectedAnswerKey,
|
|
192
|
+
tokenUsage: agentRunResult.tokenUsage
|
|
193
|
+
});
|
|
194
|
+
const classification = classifyAgentRunOutcome({ agentRunResult, parsedAnswer });
|
|
195
|
+
const correctness = scoreCorrectness({
|
|
196
|
+
caseId: args.derivedTask.id,
|
|
197
|
+
answerKey: promptVariant.expectedAnswerKey,
|
|
198
|
+
parsedAnswer,
|
|
199
|
+
status: classification.status
|
|
200
|
+
});
|
|
201
|
+
const scoreable = SCOREABLE_STATUSES.has(classification.status) && Number.isFinite(correctness.correctnessScore);
|
|
202
|
+
return {
|
|
203
|
+
status: classification.status,
|
|
204
|
+
correctness: {
|
|
205
|
+
available: scoreable,
|
|
206
|
+
score: scoreable ? correctness.correctnessScore : null,
|
|
207
|
+
passed: scoreable ? correctness.passed : null,
|
|
208
|
+
failureReasons: [...correctness.failureReasons]
|
|
209
|
+
},
|
|
210
|
+
correctnessDetail: scoreable ? correctness : null,
|
|
211
|
+
durationMs: agentRunResult.durationMs,
|
|
212
|
+
warnings: [...classification.warnings],
|
|
213
|
+
errors: [...classification.errors]
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
catch (error) {
|
|
217
|
+
return {
|
|
218
|
+
status: "failed",
|
|
219
|
+
correctness: { available: false, score: null, passed: null, failureReasons: [] },
|
|
220
|
+
correctnessDetail: null,
|
|
221
|
+
durationMs: null,
|
|
222
|
+
warnings: [],
|
|
223
|
+
errors: [errorMessage(error)]
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
function correctnessComparable(fakeAgent) {
|
|
228
|
+
if (!fakeAgent || !fakeAgent.correctness.available || fakeAgent.correctness.score === null) {
|
|
229
|
+
return { available: false };
|
|
230
|
+
}
|
|
231
|
+
return { available: true, score: fakeAgent.correctness.score };
|
|
232
|
+
}
|
|
233
|
+
const INCONCLUSIVE_NO_EXECUTION = {
|
|
234
|
+
correctnessRelation: "unknown",
|
|
235
|
+
requiredFileEvidenceRelation: "unknown",
|
|
236
|
+
staleRiskClassification: "inconclusive",
|
|
237
|
+
reasonCodes: ["correctness-unavailable", "required-file-evidence-unavailable"]
|
|
238
|
+
};
|
|
239
|
+
export const defaultIncrementalChangeStalenessExecutionDeps = {
|
|
240
|
+
runRetrieval: runIncrementalChangeStalenessTreatmentRetrieval,
|
|
241
|
+
runFakeAgent: runIncrementalChangeStalenessFakeAgent
|
|
242
|
+
};
|
|
243
|
+
async function executeOneTreatment(args) {
|
|
244
|
+
const treatment = args.session.treatments[args.treatmentId];
|
|
245
|
+
const baselineFreshness = treatment.changeAuthority.postMutationBaselineFreshness;
|
|
246
|
+
const activeIndexPhase = treatment.activeRetrieval.role;
|
|
247
|
+
let retrieval = null;
|
|
248
|
+
let retrievalStatus = "not-run";
|
|
249
|
+
let failureReason = null;
|
|
250
|
+
try {
|
|
251
|
+
retrieval = await args.deps.runRetrieval({
|
|
252
|
+
derivedTask: args.derivedTask,
|
|
253
|
+
kitCommand: args.kitCommand,
|
|
254
|
+
indexDir: treatment.activeRetrieval.index.indexDir,
|
|
255
|
+
commandsDir: args.retrievalCommandsDir
|
|
256
|
+
});
|
|
257
|
+
retrievalStatus = classifyIncrementalChangeStalenessRetrieval(retrieval);
|
|
258
|
+
}
|
|
259
|
+
catch (error) {
|
|
260
|
+
failureReason = `Treatment retrieval threw: ${errorMessage(error)}`;
|
|
261
|
+
retrievalStatus = "failed";
|
|
262
|
+
}
|
|
263
|
+
let fakeAgent = null;
|
|
264
|
+
if (retrieval !== null) {
|
|
265
|
+
fakeAgent = await args.deps.runFakeAgent({
|
|
266
|
+
derivedTask: args.derivedTask,
|
|
267
|
+
projectProfiles: args.projectProfiles,
|
|
268
|
+
outDir: args.agentOutDir,
|
|
269
|
+
cwd: args.cwd,
|
|
270
|
+
runId: `${args.derivedTask.id}.${args.treatmentId}`,
|
|
271
|
+
env: args.env
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
const requiredFileEvidence = buildIncrementalChangeStalenessRequiredFileEvidence({
|
|
275
|
+
requiredFiles: args.requiredFiles,
|
|
276
|
+
retrieval
|
|
277
|
+
});
|
|
278
|
+
const status = retrievalStatus === "failed" || fakeAgent?.status === "failed"
|
|
279
|
+
? "partial"
|
|
280
|
+
: retrievalStatus === "completed" && fakeAgent && fakeAgent.correctness.available
|
|
281
|
+
? "completed"
|
|
282
|
+
: "partial";
|
|
283
|
+
return {
|
|
284
|
+
treatmentId: args.treatmentId,
|
|
285
|
+
status,
|
|
286
|
+
failureReason,
|
|
287
|
+
activeIndexPhase,
|
|
288
|
+
baselineFreshness,
|
|
289
|
+
affectedNeighborhood: args.affectedNeighborhood,
|
|
290
|
+
retrieval,
|
|
291
|
+
retrievalStatus,
|
|
292
|
+
fakeAgent,
|
|
293
|
+
requiredFileEvidence
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Executes the frozen Batch 4 order (section 36) for one ready lifecycle session: resolve query and
|
|
298
|
+
* answer, derive the task descriptor, assess both treatments' affected neighborhoods from BASELINE
|
|
299
|
+
* change authority, verify symmetry, then run stale retrieval/fake-agent/correctness/required-file
|
|
300
|
+
* evidence, then full-refresh, then compare and classify. A lifecycle that is not `ready` is
|
|
301
|
+
* reported as a failed scenario execution with no fabricated treatment evidence (section 39).
|
|
302
|
+
*/
|
|
303
|
+
export async function executeIncrementalChangeStalenessScenario(options) {
|
|
304
|
+
const deps = { ...defaultIncrementalChangeStalenessExecutionDeps, ...options.deps };
|
|
305
|
+
const scenario = options.scenario;
|
|
306
|
+
if (options.lifecycle.status !== "ready") {
|
|
307
|
+
// Section 39: scenario-level failure; no treatment execution, comparison stays inconclusive.
|
|
308
|
+
return {
|
|
309
|
+
scenarioId: scenario.id,
|
|
310
|
+
category: scenario.category,
|
|
311
|
+
benchmarkProjectId: options.baseCase.benchmarkProjectId,
|
|
312
|
+
baseCaseId: options.baseCase.caseId,
|
|
313
|
+
answerPolicy: scenario.answerPolicy,
|
|
314
|
+
query: null,
|
|
315
|
+
expectedFiles: [],
|
|
316
|
+
expectedSymbols: [],
|
|
317
|
+
status: "failed",
|
|
318
|
+
failureReason: `${options.lifecycle.failure.code}: ${options.lifecycle.failure.message}`,
|
|
319
|
+
session: null,
|
|
320
|
+
stale: null,
|
|
321
|
+
fullRefresh: null,
|
|
322
|
+
comparison: INCONCLUSIVE_NO_EXECUTION
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
const session = options.lifecycle.session;
|
|
326
|
+
const resolved = await resolveIncrementalChangeStalenessQueryAndAnswer({ scenario, repoRoot: options.repoRoot });
|
|
327
|
+
const cases = await readEvaluationCases(path.resolve(options.repoRoot, WARM_INDEX_BENCHMARK_CASES_PATH), options.repoRoot);
|
|
328
|
+
const baseEvaluationCase = cases.find((candidate) => candidate.id === scenario.baseCaseId);
|
|
329
|
+
if (!baseEvaluationCase) {
|
|
330
|
+
throw new Error(`Scenario ${scenario.id}: base case ${scenario.baseCaseId} was not found for task-descriptor construction.`);
|
|
331
|
+
}
|
|
332
|
+
const derivedTask = buildIncrementalChangeStalenessDerivedTask({ scenario, baseEvaluationCase, resolved });
|
|
333
|
+
const staleAffected = assessIncrementalChangeStalenessAffectedNeighborhood(session.treatments["stale-index"], derivedTask);
|
|
334
|
+
const fullAffected = assessIncrementalChangeStalenessAffectedNeighborhood(session.treatments["full-refresh"], derivedTask);
|
|
335
|
+
const symmetry = checkIncrementalChangeStalenessAffectedNeighborhoodSymmetry(staleAffected, fullAffected);
|
|
336
|
+
const scenarioBase = {
|
|
337
|
+
scenarioId: scenario.id,
|
|
338
|
+
category: scenario.category,
|
|
339
|
+
benchmarkProjectId: scenario.benchmarkProjectId,
|
|
340
|
+
baseCaseId: scenario.baseCaseId,
|
|
341
|
+
answerPolicy: scenario.answerPolicy,
|
|
342
|
+
query: resolved.query,
|
|
343
|
+
expectedFiles: resolved.expectedFiles,
|
|
344
|
+
expectedSymbols: resolved.expectedSymbols,
|
|
345
|
+
session
|
|
346
|
+
};
|
|
347
|
+
if (!symmetry.symmetric) {
|
|
348
|
+
return {
|
|
349
|
+
...scenarioBase,
|
|
350
|
+
status: "failed",
|
|
351
|
+
failureReason: `AFFECTED_NEIGHBORHOOD_ASYMMETRY: ${symmetry.reason}`,
|
|
352
|
+
stale: null,
|
|
353
|
+
fullRefresh: null,
|
|
354
|
+
comparison: INCONCLUSIVE_NO_EXECUTION
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
const retrievalCommandsDir = (treatmentId) => resolveWithinRoot(options.runOwnedRoot, path.join("commands", scenario.id, treatmentId, "retrieval"));
|
|
358
|
+
const agentOutDir = (treatmentId) => resolveWithinRoot(options.runOwnedRoot, path.join("agents", scenario.id, treatmentId));
|
|
359
|
+
// Stale-index runs first, deterministically, before full-refresh (section 37).
|
|
360
|
+
const stale = await executeOneTreatment({
|
|
361
|
+
session,
|
|
362
|
+
treatmentId: "stale-index",
|
|
363
|
+
derivedTask,
|
|
364
|
+
affectedNeighborhood: staleAffected,
|
|
365
|
+
requiredFiles: resolved.expectedFiles,
|
|
366
|
+
kitCommand: session.kitCommand,
|
|
367
|
+
retrievalCommandsDir: retrievalCommandsDir("stale-index"),
|
|
368
|
+
agentOutDir: agentOutDir("stale-index"),
|
|
369
|
+
projectProfiles: options.projectProfiles,
|
|
370
|
+
cwd: options.cwd,
|
|
371
|
+
env: options.env,
|
|
372
|
+
deps
|
|
373
|
+
});
|
|
374
|
+
const fullRefresh = await executeOneTreatment({
|
|
375
|
+
session,
|
|
376
|
+
treatmentId: "full-refresh",
|
|
377
|
+
derivedTask,
|
|
378
|
+
affectedNeighborhood: fullAffected,
|
|
379
|
+
requiredFiles: resolved.expectedFiles,
|
|
380
|
+
kitCommand: session.kitCommand,
|
|
381
|
+
retrievalCommandsDir: retrievalCommandsDir("full-refresh"),
|
|
382
|
+
agentOutDir: agentOutDir("full-refresh"),
|
|
383
|
+
projectProfiles: options.projectProfiles,
|
|
384
|
+
cwd: options.cwd,
|
|
385
|
+
env: options.env,
|
|
386
|
+
deps
|
|
387
|
+
});
|
|
388
|
+
const correctnessRelation = compareCorrectness(correctnessComparable(stale.fakeAgent), correctnessComparable(fullRefresh.fakeAgent));
|
|
389
|
+
const requiredFileEvidenceRelation = compareRequiredFileEvidence(stale.requiredFileEvidence, fullRefresh.requiredFileEvidence);
|
|
390
|
+
const classification = classifyStaleRisk(correctnessRelation, requiredFileEvidenceRelation);
|
|
391
|
+
return {
|
|
392
|
+
...scenarioBase,
|
|
393
|
+
status: "ready",
|
|
394
|
+
failureReason: null,
|
|
395
|
+
stale,
|
|
396
|
+
fullRefresh,
|
|
397
|
+
comparison: {
|
|
398
|
+
correctnessRelation,
|
|
399
|
+
requiredFileEvidenceRelation,
|
|
400
|
+
staleRiskClassification: classification.staleRiskClassification,
|
|
401
|
+
reasonCodes: classification.reasonCodes
|
|
402
|
+
}
|
|
403
|
+
};
|
|
404
|
+
}
|