@tea-agent/loop-agent 0.15.0 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +59 -11
- package/README.md +1 -1
- package/dist/application/evaluation/alias.js +184 -0
- package/dist/application/evaluation/budget.js +192 -0
- package/dist/application/evaluation/campaign-hash.js +47 -0
- package/dist/application/evaluation/campaign-matrix.js +372 -0
- package/dist/application/evaluation/campaign-scorecard.js +135 -0
- package/dist/application/evaluation/campaign.js +370 -0
- package/dist/application/evaluation/candidate.js +23 -6
- package/dist/application/evaluation/corpus-hash.js +38 -0
- package/dist/application/evaluation/corpus.js +56 -0
- package/dist/application/evaluation/experiment.js +294 -0
- package/dist/application/evaluation/ignition.js +198 -0
- package/dist/application/evaluation/integrity-audit.js +162 -0
- package/dist/application/evaluation/outer-loop.js +132 -0
- package/dist/application/evaluation/pi-cell-executor.js +39 -0
- package/dist/application/evaluation/private-verifier.js +46 -0
- package/dist/application/evaluation/promotion-policy.js +151 -0
- package/dist/application/evaluation/proposer.js +98 -0
- package/dist/application/evaluation/types.js +522 -0
- package/dist/cli/command-definitions.js +19 -3
- package/dist/commands/eval.js +1176 -13
- package/dist/commands/init.js +4 -1
- package/dist/infrastructure/evaluation/alias-store.js +199 -0
- package/dist/infrastructure/evaluation/campaign-store.js +154 -0
- package/dist/infrastructure/evaluation/corpus-store.js +181 -0
- package/dist/infrastructure/evaluation/experiment-store.js +124 -0
- package/dist/infrastructure/evaluation/ignition-store.js +82 -0
- package/dist/infrastructure/evaluation/private-verifier-store.js +145 -0
- package/dist/infrastructure/evaluation/proposer-store.js +78 -0
- package/dist/worker/cli.js +6 -3
- package/dist/worker/delivery/final-verification.js +96 -8
- package/dist/worker/delivery/package.js +23 -4
- package/dist/worker/delivery/verification-bundle.js +521 -0
- package/dist/worker/feature/fullstack-validate.js +337 -0
- package/dist/worker/feature/profile-schema.js +44 -0
- package/dist/worker/feature/ready-plan-projection.js +1 -0
- package/dist/worker/feature/reducer.js +2 -0
- package/dist/worker/feature/review.js +106 -11
- package/dist/worker/materialize/harness-task-materializer.js +5 -0
- package/dist/worker/observability/read-model.js +7 -0
- package/dist/worker/observe/static/views/task.js +1 -0
- package/dist/worker/outcomes/adapters.js +144 -0
- package/dist/worker/outcomes/evidence-tokens.js +29 -0
- package/dist/worker/outcomes/gate.js +40 -0
- package/dist/worker/outcomes/projector.js +185 -0
- package/dist/worker/outcomes/registry.js +1 -0
- package/dist/worker/outcomes/store.js +131 -0
- package/dist/worker/outcomes/types.js +79 -0
- package/dist/worker/report/morning-report.js +4 -3
- package/dist/worker/run-task/run-task.js +85 -2
- package/dist/worker/runner/run-ready.js +32 -1
- package/dist/worker/task-graph/acceptance-schema.js +12 -0
- package/dist/worker/task-graph/ready-planner.js +131 -0
- package/dist/worker/task-graph/task-graph-schema.js +31 -0
- package/dist/worker/task-graph/validate.js +44 -4
- package/dist/worker/task-spec/schema.js +9 -0
- package/dist/worker/task-spec/validate.js +39 -0
- package/dist/worker/task-spec/workflow-routing.js +149 -0
- package/dist/workflows/dag/budget-enforcement.js +67 -0
- package/dist/workflows/dag/context-policy.js +137 -0
- package/dist/workflows/dag/knowledge-curator.js +3 -0
- package/dist/workflows/dag/node-execution.js +11 -4
- package/dist/workflows/dag/prompt.js +1 -1
- package/dist/workflows/dag/runner.js +43 -16
- package/dist/workflows/dag/skill-snapshot.js +11 -7
- package/dist/workflows/dag/types.js +18 -0
- package/docs/init-surface.manifest.json +3 -0
- package/docs/templates/evaluation/campaign-budget-v1.json +12 -0
- package/docs/templates/evaluation/campaign-dogfood-v0.json +24 -0
- package/docs/templates/evaluation/campaign-evidence-v1.json +44 -0
- package/docs/templates/evaluation/context-policy-baseline-v1.json +17 -0
- package/docs/templates/evaluation/context-policy-role-specialized-v1.json +28 -0
- package/docs/templates/evaluation/corpus-dogfood-v0.manifest.json +118 -0
- package/docs/templates/evaluation/matrix-dag-dry-run-v1.json +21 -0
- package/docs/templates/evaluation/matrix-fixture-v1.json +10 -0
- package/docs/templates/evaluation/private-verifier-dogfood-v0.json +16 -0
- package/docs/templates/product-line/AGENTS.md +1 -0
- package/docs/templates/product-line/README.md +17 -0
- package/docs/templates/product-line/acceptance.yaml +9 -0
- package/docs/templates/product-line/feature.yaml +11 -0
- package/docs/templates/product-line/task-graph.yaml +8 -0
- package/docs/templates/product-line/task.yaml +4 -0
- package/harness.json +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { experimentProposalRepoPath, listExperimentIds, readExperimentRecord, updateExperimentRecord, writeExperimentRecord, } from "../../infrastructure/evaluation/experiment-store.js";
|
|
5
|
+
import { writeJsonAtomic } from "../../infrastructure/harness/atomic-write.js";
|
|
6
|
+
import { formatContentSha, sha256Hex } from "./candidate-hash.js";
|
|
7
|
+
import { registerCandidate, showCandidate, transitionCandidate, } from "./candidate.js";
|
|
8
|
+
import { readCampaignScorecard } from "../../infrastructure/evaluation/campaign-store.js";
|
|
9
|
+
import { curateKnowledgePatterns } from "../../workflows/dag/knowledge-curator.js";
|
|
10
|
+
const DEFAULT_WHEN_TO_APPLY = "use when the failure class, bounded fix scope, and invariant match a new supervised DAG repair round";
|
|
11
|
+
const DEFAULT_WHEN_NOT_TO_APPLY = "do not apply when the task has different ownership boundaries, missing full verification, or higher-risk public contract changes";
|
|
12
|
+
function toPatternEvidence(patterns) {
|
|
13
|
+
return patterns.map((pattern) => ({
|
|
14
|
+
key: pattern.key,
|
|
15
|
+
failureClass: pattern.failureClass,
|
|
16
|
+
fixScopeShape: pattern.fixScopeShape,
|
|
17
|
+
invariant: pattern.invariant,
|
|
18
|
+
count: pattern.count,
|
|
19
|
+
sourceRunIds: pattern.sourceRunIds,
|
|
20
|
+
whenToApply: DEFAULT_WHEN_TO_APPLY,
|
|
21
|
+
whenNotToApply: DEFAULT_WHEN_NOT_TO_APPLY,
|
|
22
|
+
}));
|
|
23
|
+
}
|
|
24
|
+
function defaultExperimentId(candidateId) {
|
|
25
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
26
|
+
return `exp-${candidateId}-${stamp}`;
|
|
27
|
+
}
|
|
28
|
+
function defaultCandidateId() {
|
|
29
|
+
const stamp = createHash("sha256")
|
|
30
|
+
.update(`${Date.now()}-${Math.random()}`)
|
|
31
|
+
.digest("hex")
|
|
32
|
+
.slice(0, 10);
|
|
33
|
+
return `learned-exp-${stamp}`;
|
|
34
|
+
}
|
|
35
|
+
export async function createExperimentFromCurate(input) {
|
|
36
|
+
const curated = await curateKnowledgePatterns({ repoRoot: input.repoRoot });
|
|
37
|
+
if (!curated.ok) {
|
|
38
|
+
throw new Error(`knowledge curate failed safety audit: ${curated.safetyFindings.map((f) => f.ruleId).join(", ")}`);
|
|
39
|
+
}
|
|
40
|
+
if (!curated.proposalMarkdown || curated.patternCount === 0) {
|
|
41
|
+
throw new Error(`knowledge curate produced no qualifying patterns (${curated.message})`);
|
|
42
|
+
}
|
|
43
|
+
const candidateId = input.candidateId ?? defaultCandidateId();
|
|
44
|
+
const experimentId = input.experimentId ?? defaultExperimentId(candidateId);
|
|
45
|
+
const now = input.now ?? new Date().toISOString();
|
|
46
|
+
const proposalRel = experimentProposalRepoPath(experimentId);
|
|
47
|
+
const proposalAbs = path.join(input.repoRoot, ...proposalRel.split("/"));
|
|
48
|
+
await mkdir(path.dirname(proposalAbs), { recursive: true });
|
|
49
|
+
await writeFile(proposalAbs, curated.proposalMarkdown, "utf-8");
|
|
50
|
+
const proposalSha = formatContentSha(sha256Hex(curated.proposalMarkdown));
|
|
51
|
+
const stagingManifestPath = path.join(input.repoRoot, "ai_workspace/loop-agent/evaluation/experiments", experimentId, "candidate.manifest.json");
|
|
52
|
+
await writeJsonAtomic(stagingManifestPath, {
|
|
53
|
+
schemaVersion: 1,
|
|
54
|
+
candidateId,
|
|
55
|
+
parentCandidateId: null,
|
|
56
|
+
candidateKind: "skill",
|
|
57
|
+
createdAt: now,
|
|
58
|
+
description: "Experimental learned guidance from knowledge curate (M6); not accepted skill default",
|
|
59
|
+
contentRefs: [{ path: proposalRel, sha256: proposalSha }],
|
|
60
|
+
}, { repoRoot: input.repoRoot });
|
|
61
|
+
const registered = await registerCandidate({
|
|
62
|
+
repoRoot: input.repoRoot,
|
|
63
|
+
manifestPath: stagingManifestPath,
|
|
64
|
+
now,
|
|
65
|
+
});
|
|
66
|
+
if (registered.record.status === "proposed") {
|
|
67
|
+
await transitionCandidate({
|
|
68
|
+
repoRoot: input.repoRoot,
|
|
69
|
+
candidateId,
|
|
70
|
+
to: "eligible",
|
|
71
|
+
reason: "M6: curate proposal eligible for experiment",
|
|
72
|
+
now,
|
|
73
|
+
});
|
|
74
|
+
await transitionCandidate({
|
|
75
|
+
repoRoot: input.repoRoot,
|
|
76
|
+
candidateId,
|
|
77
|
+
to: "experimenting",
|
|
78
|
+
reason: "M6: enter experimenting before held-out accept",
|
|
79
|
+
now,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
else if (registered.record.status === "eligible") {
|
|
83
|
+
await transitionCandidate({
|
|
84
|
+
repoRoot: input.repoRoot,
|
|
85
|
+
candidateId,
|
|
86
|
+
to: "experimenting",
|
|
87
|
+
reason: "M6: enter experimenting before held-out accept",
|
|
88
|
+
now,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
else if (registered.record.status !== "experimenting") {
|
|
92
|
+
throw new Error(`unexpected candidate status for experiment bridge: ${registered.record.status}`);
|
|
93
|
+
}
|
|
94
|
+
const candidate = await showCandidate({
|
|
95
|
+
repoRoot: input.repoRoot,
|
|
96
|
+
candidateId,
|
|
97
|
+
});
|
|
98
|
+
const record = {
|
|
99
|
+
schemaVersion: 1,
|
|
100
|
+
experimentId,
|
|
101
|
+
candidateId,
|
|
102
|
+
source: "knowledge-curate",
|
|
103
|
+
createdAt: now,
|
|
104
|
+
proposalPath: proposalRel,
|
|
105
|
+
proposalSha256: proposalSha,
|
|
106
|
+
patterns: toPatternEvidence(curated.patterns),
|
|
107
|
+
status: "experimenting",
|
|
108
|
+
};
|
|
109
|
+
const stored = await writeExperimentRecord({
|
|
110
|
+
repoRoot: input.repoRoot,
|
|
111
|
+
record,
|
|
112
|
+
});
|
|
113
|
+
return {
|
|
114
|
+
record: stored.record,
|
|
115
|
+
candidate,
|
|
116
|
+
idempotent: stored.idempotent && registered.idempotent,
|
|
117
|
+
curateMessage: curated.message,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
export async function showExperiment(input) {
|
|
121
|
+
const record = await readExperimentRecord(input.repoRoot, input.experimentId);
|
|
122
|
+
const candidate = await showCandidate({
|
|
123
|
+
repoRoot: input.repoRoot,
|
|
124
|
+
candidateId: record.candidateId,
|
|
125
|
+
});
|
|
126
|
+
return { record, candidate };
|
|
127
|
+
}
|
|
128
|
+
export async function listExperiments(input) {
|
|
129
|
+
const ids = await listExperimentIds(input.repoRoot);
|
|
130
|
+
const rows = [];
|
|
131
|
+
for (const experimentId of ids) {
|
|
132
|
+
const record = await readExperimentRecord(input.repoRoot, experimentId);
|
|
133
|
+
rows.push({
|
|
134
|
+
experimentId: record.experimentId,
|
|
135
|
+
candidateId: record.candidateId,
|
|
136
|
+
status: record.status,
|
|
137
|
+
patternCount: record.patterns.length,
|
|
138
|
+
createdAt: record.createdAt,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
return rows;
|
|
142
|
+
}
|
|
143
|
+
export async function acceptExperiment(input) {
|
|
144
|
+
const reason = input.reason.trim();
|
|
145
|
+
if (!reason)
|
|
146
|
+
throw new Error("experiment accept requires non-empty --reason");
|
|
147
|
+
const existing = await readExperimentRecord(input.repoRoot, input.experimentId);
|
|
148
|
+
if (existing.status !== "experimenting") {
|
|
149
|
+
throw new Error(`experiment ${input.experimentId} is ${existing.status}; only experimenting may be accepted`);
|
|
150
|
+
}
|
|
151
|
+
const scorecard = await readCampaignScorecard(input.repoRoot, input.campaignId);
|
|
152
|
+
if (!scorecard) {
|
|
153
|
+
throw new Error(`campaign ${input.campaignId} has no scorecard; run eval campaign scorecard first`);
|
|
154
|
+
}
|
|
155
|
+
if (scorecard.policy.challengerCandidateId !== existing.candidateId) {
|
|
156
|
+
throw new Error(`scorecard challenger ${scorecard.policy.challengerCandidateId} does not match experiment candidate ${existing.candidateId}`);
|
|
157
|
+
}
|
|
158
|
+
if (scorecard.policy.gates.heldOutNonRegression !== "pass") {
|
|
159
|
+
throw new Error(`held-out non-regression gate failed: ${scorecard.policy.gates.heldOutNonRegression}`);
|
|
160
|
+
}
|
|
161
|
+
const safetyOk = scorecard.policy.gates.safetyOk;
|
|
162
|
+
if (safetyOk === "fail") {
|
|
163
|
+
throw new Error("safety gate blocks accept: safetyOk=fail");
|
|
164
|
+
}
|
|
165
|
+
if (safetyOk === "review" && !input.acknowledgeReview) {
|
|
166
|
+
throw new Error("safety gate blocks accept: safetyOk=review (use --acknowledge-review after human review)");
|
|
167
|
+
}
|
|
168
|
+
const eligible = scorecard.policy.promotionEligible ||
|
|
169
|
+
(safetyOk === "review" &&
|
|
170
|
+
Boolean(input.acknowledgeReview) &&
|
|
171
|
+
scorecard.policy.gates.privateImprovement === "pass" &&
|
|
172
|
+
scorecard.policy.gates.heldOutNonRegression === "pass" &&
|
|
173
|
+
scorecard.policy.gates.budgetOk === "pass");
|
|
174
|
+
if (!eligible) {
|
|
175
|
+
throw new Error(`campaign promotion policy not eligible: ${scorecard.policy.reasons.join("; ")}`);
|
|
176
|
+
}
|
|
177
|
+
const dryRun = input.dryRun !== false;
|
|
178
|
+
const now = input.now ?? new Date().toISOString();
|
|
179
|
+
if (dryRun) {
|
|
180
|
+
const candidate = await showCandidate({
|
|
181
|
+
repoRoot: input.repoRoot,
|
|
182
|
+
candidateId: existing.candidateId,
|
|
183
|
+
});
|
|
184
|
+
return {
|
|
185
|
+
dryRun: true,
|
|
186
|
+
record: existing,
|
|
187
|
+
candidate,
|
|
188
|
+
changed: false,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
const candidate = await transitionCandidate({
|
|
192
|
+
repoRoot: input.repoRoot,
|
|
193
|
+
candidateId: existing.candidateId,
|
|
194
|
+
to: "accepted",
|
|
195
|
+
reason: `M6 experiment accept: ${reason}`,
|
|
196
|
+
now,
|
|
197
|
+
});
|
|
198
|
+
const record = {
|
|
199
|
+
...existing,
|
|
200
|
+
status: "accepted",
|
|
201
|
+
disposition: {
|
|
202
|
+
status: "accepted",
|
|
203
|
+
at: now,
|
|
204
|
+
reason,
|
|
205
|
+
campaignId: input.campaignId,
|
|
206
|
+
scorecardPlanHash: scorecard.planHash,
|
|
207
|
+
heldOutNonRegression: scorecard.policy.gates.heldOutNonRegression,
|
|
208
|
+
},
|
|
209
|
+
};
|
|
210
|
+
await updateExperimentRecord({ repoRoot: input.repoRoot, record });
|
|
211
|
+
return { dryRun: false, record, candidate, changed: true };
|
|
212
|
+
}
|
|
213
|
+
export async function rejectExperiment(input) {
|
|
214
|
+
const reason = input.reason.trim();
|
|
215
|
+
if (!reason)
|
|
216
|
+
throw new Error("experiment reject requires non-empty --reason");
|
|
217
|
+
const existing = await readExperimentRecord(input.repoRoot, input.experimentId);
|
|
218
|
+
if (existing.status !== "experimenting") {
|
|
219
|
+
throw new Error(`experiment ${input.experimentId} is ${existing.status}; only experimenting may be rejected`);
|
|
220
|
+
}
|
|
221
|
+
const dryRun = input.dryRun !== false;
|
|
222
|
+
const now = input.now ?? new Date().toISOString();
|
|
223
|
+
if (dryRun) {
|
|
224
|
+
const candidate = await showCandidate({
|
|
225
|
+
repoRoot: input.repoRoot,
|
|
226
|
+
candidateId: existing.candidateId,
|
|
227
|
+
});
|
|
228
|
+
return {
|
|
229
|
+
dryRun: true,
|
|
230
|
+
record: existing,
|
|
231
|
+
candidate,
|
|
232
|
+
changed: false,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
const candidate = await transitionCandidate({
|
|
236
|
+
repoRoot: input.repoRoot,
|
|
237
|
+
candidateId: existing.candidateId,
|
|
238
|
+
to: "rejected",
|
|
239
|
+
reason: `M6 experiment reject: ${reason}`,
|
|
240
|
+
now,
|
|
241
|
+
});
|
|
242
|
+
const record = {
|
|
243
|
+
...existing,
|
|
244
|
+
status: "rejected",
|
|
245
|
+
disposition: {
|
|
246
|
+
status: "rejected",
|
|
247
|
+
at: now,
|
|
248
|
+
reason,
|
|
249
|
+
},
|
|
250
|
+
};
|
|
251
|
+
await updateExperimentRecord({ repoRoot: input.repoRoot, record });
|
|
252
|
+
return { dryRun: false, record, candidate, changed: true };
|
|
253
|
+
}
|
|
254
|
+
export function formatExperimentMarkdown(input) {
|
|
255
|
+
const { record } = input;
|
|
256
|
+
const lines = [
|
|
257
|
+
`# Experiment: ${record.experimentId}`,
|
|
258
|
+
"",
|
|
259
|
+
`- status: \`${record.status}\``,
|
|
260
|
+
`- candidateId: \`${record.candidateId}\``,
|
|
261
|
+
`- source: \`${record.source}\``,
|
|
262
|
+
`- createdAt: \`${record.createdAt}\``,
|
|
263
|
+
`- proposal: \`${record.proposalPath}\``,
|
|
264
|
+
`- proposalSha256: \`${record.proposalSha256}\``,
|
|
265
|
+
"",
|
|
266
|
+
"## Pattern evidence",
|
|
267
|
+
"",
|
|
268
|
+
];
|
|
269
|
+
if (record.patterns.length === 0) {
|
|
270
|
+
lines.push("(none)", "");
|
|
271
|
+
}
|
|
272
|
+
else {
|
|
273
|
+
for (const pattern of record.patterns) {
|
|
274
|
+
lines.push(`### ${pattern.failureClass} / ${pattern.fixScopeShape}`, "", `- key: \`${pattern.key}\``, `- count: ${pattern.count}`, `- invariant: ${pattern.invariant}`, `- when to apply: ${pattern.whenToApply}`, `- when not to apply: ${pattern.whenNotToApply}`, `- source runs: ${pattern.sourceRunIds.join(", ") || "(none)"}`, "");
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
if (record.disposition) {
|
|
278
|
+
lines.push("## Disposition", "", `- status: \`${record.disposition.status}\``, `- at: \`${record.disposition.at}\``, `- reason: ${record.disposition.reason}`);
|
|
279
|
+
if (record.disposition.campaignId) {
|
|
280
|
+
lines.push(`- campaignId: \`${record.disposition.campaignId}\``);
|
|
281
|
+
}
|
|
282
|
+
if (record.disposition.scorecardPlanHash) {
|
|
283
|
+
lines.push(`- scorecardPlanHash: \`${record.disposition.scorecardPlanHash}\``);
|
|
284
|
+
}
|
|
285
|
+
if (record.disposition.heldOutNonRegression) {
|
|
286
|
+
lines.push(`- heldOutNonRegression: \`${record.disposition.heldOutNonRegression}\``);
|
|
287
|
+
}
|
|
288
|
+
lines.push("");
|
|
289
|
+
}
|
|
290
|
+
if (input.candidate) {
|
|
291
|
+
lines.push("## Candidate", "", `- status: \`${input.candidate.status}\``, `- bundleHash: \`${input.candidate.manifest.bundleHash}\``, "");
|
|
292
|
+
}
|
|
293
|
+
return `${lines.join("\n")}\n`;
|
|
294
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { listIgnitionIds, readIgnitionReport, writeIgnitionReport, } from "../../infrastructure/evaluation/ignition-store.js";
|
|
2
|
+
import { readCampaignScorecard } from "../../infrastructure/evaluation/campaign-store.js";
|
|
3
|
+
import { readProposerRun } from "../../infrastructure/evaluation/proposer-store.js";
|
|
4
|
+
import { showCandidate } from "./candidate.js";
|
|
5
|
+
export const IGNITION_MIN_GENERATIONS_FOR_SIGNAL = 2;
|
|
6
|
+
export function computeIgnitionMetrics(generations) {
|
|
7
|
+
const generationCount = generations.length;
|
|
8
|
+
const meanProposalsPerGeneration = generationCount === 0
|
|
9
|
+
? 0
|
|
10
|
+
: generations.reduce((sum, row) => sum + row.proposalCount, 0) /
|
|
11
|
+
generationCount;
|
|
12
|
+
const sorted = [...generations].sort((a, b) => a.generation - b.generation);
|
|
13
|
+
const first = sorted[0];
|
|
14
|
+
const last = sorted[sorted.length - 1];
|
|
15
|
+
const proposalYieldDelta = first && last && sorted.length >= 2
|
|
16
|
+
? last.proposalCount - first.proposalCount
|
|
17
|
+
: null;
|
|
18
|
+
const observed = generations.filter((row) => row.promotionEligible !== undefined);
|
|
19
|
+
const scorecardObservedRate = observed.length === 0
|
|
20
|
+
? null
|
|
21
|
+
: observed.filter((row) => row.promotionEligible === true).length /
|
|
22
|
+
observed.length;
|
|
23
|
+
const sampleSufficient = generationCount >= IGNITION_MIN_GENERATIONS_FOR_SIGNAL &&
|
|
24
|
+
generations.every((row) => row.proposalCount >= 0);
|
|
25
|
+
return {
|
|
26
|
+
generationCount,
|
|
27
|
+
meanProposalsPerGeneration,
|
|
28
|
+
proposalYieldDelta,
|
|
29
|
+
scorecardObservedRate,
|
|
30
|
+
minGenerationsForSignal: IGNITION_MIN_GENERATIONS_FOR_SIGNAL,
|
|
31
|
+
sampleSufficient,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
export function decideIgnitionVerdict(input) {
|
|
35
|
+
const reasons = [
|
|
36
|
+
"rsiLevel1ClaimAllowed=false (Ignition is a research gate, not a product RSI Level 1 claim)",
|
|
37
|
+
"autoPromote=false",
|
|
38
|
+
];
|
|
39
|
+
if (!input.metrics.sampleSufficient) {
|
|
40
|
+
reasons.push(`insufficient_evidence: need >= ${input.metrics.minGenerationsForSignal} generations (have ${input.metrics.generationCount})`);
|
|
41
|
+
return { verdict: "insufficient_evidence", reasons };
|
|
42
|
+
}
|
|
43
|
+
const delta = input.metrics.proposalYieldDelta ?? 0;
|
|
44
|
+
const heldOut = input.generations
|
|
45
|
+
.map((row) => row.heldOutNonRegression)
|
|
46
|
+
.filter((value) => value !== undefined);
|
|
47
|
+
const heldOutFail = heldOut.some((value) => value === "fail");
|
|
48
|
+
if (heldOutFail) {
|
|
49
|
+
reasons.push("held-out non-regression failed in at least one generation");
|
|
50
|
+
return { verdict: "trend_negative", reasons };
|
|
51
|
+
}
|
|
52
|
+
if (delta > 0) {
|
|
53
|
+
reasons.push(`proposalYieldDelta=${delta} (later generation proposed more candidates than the first)`);
|
|
54
|
+
return { verdict: "trend_positive", reasons };
|
|
55
|
+
}
|
|
56
|
+
if (delta < 0) {
|
|
57
|
+
reasons.push(`proposalYieldDelta=${delta}`);
|
|
58
|
+
return { verdict: "trend_negative", reasons };
|
|
59
|
+
}
|
|
60
|
+
if (input.metrics.scorecardObservedRate !== null &&
|
|
61
|
+
input.metrics.scorecardObservedRate > 0 &&
|
|
62
|
+
input.metrics.scorecardObservedRate < 1) {
|
|
63
|
+
reasons.push(`scorecardObservedRate=${input.metrics.scorecardObservedRate.toFixed(2)} mixed; inconclusive`);
|
|
64
|
+
return { verdict: "inconclusive", reasons };
|
|
65
|
+
}
|
|
66
|
+
reasons.push("proposalYieldDelta=0 with no decisive scorecard trend");
|
|
67
|
+
return { verdict: "trend_flat", reasons };
|
|
68
|
+
}
|
|
69
|
+
function recomputeReport(base) {
|
|
70
|
+
const metrics = computeIgnitionMetrics(base.generations);
|
|
71
|
+
const { verdict, reasons } = decideIgnitionVerdict({
|
|
72
|
+
metrics,
|
|
73
|
+
generations: base.generations,
|
|
74
|
+
});
|
|
75
|
+
return {
|
|
76
|
+
schemaVersion: 1,
|
|
77
|
+
ignitionId: base.ignitionId,
|
|
78
|
+
createdAt: base.createdAt,
|
|
79
|
+
updatedAt: base.updatedAt,
|
|
80
|
+
generations: [...base.generations].sort((a, b) => a.generation - b.generation),
|
|
81
|
+
metrics,
|
|
82
|
+
verdict,
|
|
83
|
+
reasons,
|
|
84
|
+
rsiLevel1ClaimAllowed: false,
|
|
85
|
+
autoPromote: false,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Record one generation in an Ignition lineage (M8 research gate).
|
|
90
|
+
* Never promotes and never authorizes RSI Level 1 claims.
|
|
91
|
+
*/
|
|
92
|
+
export async function recordIgnitionGeneration(input) {
|
|
93
|
+
if (input.generation < 0) {
|
|
94
|
+
throw new Error("ignition generation must be >= 0");
|
|
95
|
+
}
|
|
96
|
+
if (input.parentCandidateId) {
|
|
97
|
+
await showCandidate({
|
|
98
|
+
repoRoot: input.repoRoot,
|
|
99
|
+
candidateId: input.parentCandidateId,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
const proposer = await readProposerRun(input.repoRoot, input.proposerRunId);
|
|
103
|
+
const now = input.now ?? new Date().toISOString();
|
|
104
|
+
const generationRow = {
|
|
105
|
+
generation: input.generation,
|
|
106
|
+
parentCandidateId: input.parentCandidateId,
|
|
107
|
+
proposerRunId: input.proposerRunId,
|
|
108
|
+
proposedCandidateIds: proposer.proposals.map((row) => row.candidateId),
|
|
109
|
+
proposalCount: proposer.proposals.length,
|
|
110
|
+
recordedAt: now,
|
|
111
|
+
};
|
|
112
|
+
if (input.campaignId) {
|
|
113
|
+
generationRow.campaignId = input.campaignId;
|
|
114
|
+
const scorecard = await readCampaignScorecard(input.repoRoot, input.campaignId);
|
|
115
|
+
if (scorecard) {
|
|
116
|
+
generationRow.promotionEligible = scorecard.policy.promotionEligible;
|
|
117
|
+
generationRow.heldOutNonRegression =
|
|
118
|
+
scorecard.policy.gates.heldOutNonRegression;
|
|
119
|
+
generationRow.privateImprovement =
|
|
120
|
+
scorecard.policy.gates.privateImprovement;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
const existing = await readIgnitionReport(input.repoRoot, input.ignitionId);
|
|
124
|
+
const generations = existing?.generations.filter((row) => row.generation !== input.generation) ?? [];
|
|
125
|
+
generations.push(generationRow);
|
|
126
|
+
const report = recomputeReport({
|
|
127
|
+
ignitionId: input.ignitionId,
|
|
128
|
+
createdAt: existing?.createdAt ?? now,
|
|
129
|
+
updatedAt: now,
|
|
130
|
+
generations,
|
|
131
|
+
});
|
|
132
|
+
await writeIgnitionReport({ repoRoot: input.repoRoot, report });
|
|
133
|
+
return report;
|
|
134
|
+
}
|
|
135
|
+
export async function evaluateIgnition(input) {
|
|
136
|
+
const existing = await readIgnitionReport(input.repoRoot, input.ignitionId);
|
|
137
|
+
if (!existing) {
|
|
138
|
+
throw new Error(`ignition ${input.ignitionId} not found; record generations first`);
|
|
139
|
+
}
|
|
140
|
+
const report = recomputeReport({
|
|
141
|
+
ignitionId: existing.ignitionId,
|
|
142
|
+
createdAt: existing.createdAt,
|
|
143
|
+
updatedAt: input.now ?? new Date().toISOString(),
|
|
144
|
+
generations: existing.generations,
|
|
145
|
+
});
|
|
146
|
+
await writeIgnitionReport({ repoRoot: input.repoRoot, report });
|
|
147
|
+
return report;
|
|
148
|
+
}
|
|
149
|
+
export async function showIgnition(input) {
|
|
150
|
+
const report = await readIgnitionReport(input.repoRoot, input.ignitionId);
|
|
151
|
+
if (!report) {
|
|
152
|
+
throw new Error(`ignition not found: ${input.ignitionId}`);
|
|
153
|
+
}
|
|
154
|
+
return report;
|
|
155
|
+
}
|
|
156
|
+
export async function listIgnitions(input) {
|
|
157
|
+
const ids = await listIgnitionIds(input.repoRoot);
|
|
158
|
+
const rows = [];
|
|
159
|
+
for (const ignitionId of ids) {
|
|
160
|
+
const report = await readIgnitionReport(input.repoRoot, ignitionId);
|
|
161
|
+
if (!report)
|
|
162
|
+
continue;
|
|
163
|
+
rows.push({
|
|
164
|
+
ignitionId: report.ignitionId,
|
|
165
|
+
generationCount: report.metrics.generationCount,
|
|
166
|
+
verdict: report.verdict,
|
|
167
|
+
updatedAt: report.updatedAt,
|
|
168
|
+
rsiLevel1ClaimAllowed: false,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
return rows;
|
|
172
|
+
}
|
|
173
|
+
export function formatIgnitionMarkdown(report) {
|
|
174
|
+
const lines = [
|
|
175
|
+
`# Ignition Research Gate: ${report.ignitionId}`,
|
|
176
|
+
"",
|
|
177
|
+
`- verdict: \`${report.verdict}\``,
|
|
178
|
+
`- rsiLevel1ClaimAllowed: \`${report.rsiLevel1ClaimAllowed}\``,
|
|
179
|
+
`- autoPromote: \`${report.autoPromote}\``,
|
|
180
|
+
`- generations: ${report.metrics.generationCount}`,
|
|
181
|
+
`- meanProposalsPerGeneration: ${report.metrics.meanProposalsPerGeneration.toFixed(2)}`,
|
|
182
|
+
`- proposalYieldDelta: ${report.metrics.proposalYieldDelta ?? "null"}`,
|
|
183
|
+
`- scorecardObservedRate: ${report.metrics.scorecardObservedRate ?? "null"}`,
|
|
184
|
+
`- sampleSufficient: ${report.metrics.sampleSufficient}`,
|
|
185
|
+
"",
|
|
186
|
+
"## Generations",
|
|
187
|
+
"",
|
|
188
|
+
];
|
|
189
|
+
for (const gen of report.generations) {
|
|
190
|
+
lines.push(`- g${gen.generation}: parent=\`${gen.parentCandidateId ?? "null"}\` proposals=${gen.proposalCount} proposer=\`${gen.proposerRunId}\` heldOut=${gen.heldOutNonRegression ?? "-"} eligible=${gen.promotionEligible ?? "-"}`);
|
|
191
|
+
}
|
|
192
|
+
lines.push("", "## Reasons", "");
|
|
193
|
+
for (const reason of report.reasons) {
|
|
194
|
+
lines.push(`- ${reason}`);
|
|
195
|
+
}
|
|
196
|
+
lines.push("", "> M8 Ignition evaluates whether later generations propose more/better candidates under fixed research rules. It does **not** claim RSI Level 1 and never auto-promotes.", "");
|
|
197
|
+
return `${lines.join("\n")}\n`;
|
|
198
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
const WEAKENING_PATTERNS = [
|
|
2
|
+
{ re: /\.only\s*\(/, label: ".only(" },
|
|
3
|
+
{ re: /\.skip\s*\(/, label: ".skip(" },
|
|
4
|
+
{ re: /\bxit\s*\(/, label: "xit(" },
|
|
5
|
+
{ re: /\bxdescribe\s*\(/, label: "xdescribe(" },
|
|
6
|
+
{ re: /\bit\.todo\s*\(/, label: "it.todo(" },
|
|
7
|
+
{ re: /\btest\.todo\s*\(/, label: "test.todo(" },
|
|
8
|
+
{ re: /\bdescribe\.skip\s*\(/, label: "describe.skip(" },
|
|
9
|
+
{ re: /\btest\.skip\s*\(/, label: "test.skip(" },
|
|
10
|
+
{ re: /timeout\s*[:=]\s*\d{5,}/i, label: "timeout increase" },
|
|
11
|
+
];
|
|
12
|
+
function addedLines(unifiedDiff) {
|
|
13
|
+
return unifiedDiff
|
|
14
|
+
.split("\n")
|
|
15
|
+
.filter((line) => line.startsWith("+") && !line.startsWith("+++"))
|
|
16
|
+
.map((line) => line.slice(1));
|
|
17
|
+
}
|
|
18
|
+
function removedLines(unifiedDiff) {
|
|
19
|
+
return unifiedDiff
|
|
20
|
+
.split("\n")
|
|
21
|
+
.filter((line) => line.startsWith("-") && !line.startsWith("---"))
|
|
22
|
+
.map((line) => line.slice(1));
|
|
23
|
+
}
|
|
24
|
+
function countTestDeclarations(lines) {
|
|
25
|
+
let count = 0;
|
|
26
|
+
for (const line of lines) {
|
|
27
|
+
if (/\b(it|test|describe)\s*\(/.test(line))
|
|
28
|
+
count += 1;
|
|
29
|
+
}
|
|
30
|
+
return count;
|
|
31
|
+
}
|
|
32
|
+
/** Detect verification weakening signals in challenger diffs (W4.3). */
|
|
33
|
+
export function auditVerificationWeakening(evidence) {
|
|
34
|
+
const findings = [];
|
|
35
|
+
for (const diff of evidence.diffs) {
|
|
36
|
+
const added = addedLines(diff.unifiedDiff);
|
|
37
|
+
const removed = removedLines(diff.unifiedDiff);
|
|
38
|
+
const text = added.join("\n");
|
|
39
|
+
for (const pattern of WEAKENING_PATTERNS) {
|
|
40
|
+
if (pattern.re.test(text)) {
|
|
41
|
+
findings.push({
|
|
42
|
+
code: "verification_weakening",
|
|
43
|
+
severity: "fail",
|
|
44
|
+
message: `challenger diff introduces ${pattern.label}`,
|
|
45
|
+
path: diff.path,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
const removedTests = countTestDeclarations(removed);
|
|
50
|
+
const addedTests = countTestDeclarations(added);
|
|
51
|
+
if (removedTests > addedTests) {
|
|
52
|
+
findings.push({
|
|
53
|
+
code: "verification_weakening",
|
|
54
|
+
severity: "fail",
|
|
55
|
+
message: `challenger removes more test declarations than it adds (${removedTests} removed, ${addedTests} added)`,
|
|
56
|
+
path: diff.path,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return findings;
|
|
61
|
+
}
|
|
62
|
+
/** Detect verify command set reduction relative to incumbent (W4.3). */
|
|
63
|
+
export function auditCommandReduction(evidence) {
|
|
64
|
+
const incumbent = new Set(evidence.verifyCommands.incumbent);
|
|
65
|
+
const challenger = new Set(evidence.verifyCommands.challenger);
|
|
66
|
+
if (incumbent.size === 0)
|
|
67
|
+
return [];
|
|
68
|
+
const missing = [...incumbent].filter((cmd) => !challenger.has(cmd));
|
|
69
|
+
if (missing.length === 0)
|
|
70
|
+
return [];
|
|
71
|
+
if (challenger.size < incumbent.size) {
|
|
72
|
+
return [
|
|
73
|
+
{
|
|
74
|
+
code: "command_reduction",
|
|
75
|
+
severity: "fail",
|
|
76
|
+
message: `challenger verify command set shrank; missing: ${missing.join(", ")}`,
|
|
77
|
+
},
|
|
78
|
+
];
|
|
79
|
+
}
|
|
80
|
+
return [
|
|
81
|
+
{
|
|
82
|
+
code: "command_reduction",
|
|
83
|
+
severity: "review",
|
|
84
|
+
message: `challenger dropped incumbent verify commands without shrinking set size: ${missing.join(", ")}`,
|
|
85
|
+
},
|
|
86
|
+
];
|
|
87
|
+
}
|
|
88
|
+
function pairKey(cell) {
|
|
89
|
+
return `${cell.split}\u0000${cell.taskRef}\u0000${cell.seed}`;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Flag suspicious single-task outliers (W4.4).
|
|
93
|
+
* Does not fail closed; severity=review so humans must acknowledge.
|
|
94
|
+
*/
|
|
95
|
+
export function auditSuspiciousOutliers(input) {
|
|
96
|
+
const byKey = new Map();
|
|
97
|
+
for (const cell of input.cells) {
|
|
98
|
+
if (cell.split === "public")
|
|
99
|
+
continue;
|
|
100
|
+
const key = pairKey(cell);
|
|
101
|
+
const row = byKey.get(key) ?? {
|
|
102
|
+
taskRef: cell.taskRef,
|
|
103
|
+
split: cell.split,
|
|
104
|
+
};
|
|
105
|
+
if (cell.candidateId === input.incumbentCandidateId) {
|
|
106
|
+
row.incumbent = cell.verifyPassed;
|
|
107
|
+
}
|
|
108
|
+
else if (cell.candidateId === input.challengerCandidateId) {
|
|
109
|
+
row.challenger = cell.verifyPassed;
|
|
110
|
+
}
|
|
111
|
+
byKey.set(key, row);
|
|
112
|
+
}
|
|
113
|
+
const deltas = [];
|
|
114
|
+
for (const row of byKey.values()) {
|
|
115
|
+
if (row.incumbent === undefined || row.challenger === undefined)
|
|
116
|
+
continue;
|
|
117
|
+
const delta = (row.challenger ? 1 : 0) - (row.incumbent ? 1 : 0);
|
|
118
|
+
deltas.push({ taskRef: row.taskRef, split: row.split, delta });
|
|
119
|
+
}
|
|
120
|
+
if (deltas.length < 3)
|
|
121
|
+
return [];
|
|
122
|
+
const mean = deltas.reduce((sum, item) => sum + item.delta, 0) / deltas.length;
|
|
123
|
+
const variance = deltas.reduce((sum, item) => sum + (item.delta - mean) ** 2, 0) /
|
|
124
|
+
deltas.length;
|
|
125
|
+
const stddev = Math.sqrt(variance);
|
|
126
|
+
const soleWins = deltas.filter((item) => item.delta === 1);
|
|
127
|
+
const findings = [];
|
|
128
|
+
// Sole-source gains among mostly flat pairs are review-worthy (not auto-dropped).
|
|
129
|
+
if (soleWins.length === 1 && mean <= 1 / 3) {
|
|
130
|
+
const item = soleWins[0];
|
|
131
|
+
findings.push({
|
|
132
|
+
code: "suspicious_outlier",
|
|
133
|
+
severity: "review",
|
|
134
|
+
message: `suspicious sole gain on ${item.split}/${item.taskRef}: delta=1 among ${deltas.length} pairs (mean=${mean.toFixed(2)}); requires human review, score not auto-dropped`,
|
|
135
|
+
});
|
|
136
|
+
return findings;
|
|
137
|
+
}
|
|
138
|
+
if (stddev === 0)
|
|
139
|
+
return findings;
|
|
140
|
+
for (const item of deltas) {
|
|
141
|
+
const z = (item.delta - mean) / stddev;
|
|
142
|
+
if (item.delta > 0 && z >= 1.5) {
|
|
143
|
+
findings.push({
|
|
144
|
+
code: "suspicious_outlier",
|
|
145
|
+
severity: "review",
|
|
146
|
+
message: `suspicious gain on ${item.split}/${item.taskRef}: delta=${item.delta} z=${z.toFixed(2)} (mean=${mean.toFixed(2)}); requires human review, score not auto-dropped`,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return findings;
|
|
151
|
+
}
|
|
152
|
+
export function runIntegrityAudits(input) {
|
|
153
|
+
return [
|
|
154
|
+
...auditVerificationWeakening(input.evidence),
|
|
155
|
+
...auditCommandReduction(input.evidence),
|
|
156
|
+
...auditSuspiciousOutliers({
|
|
157
|
+
incumbentCandidateId: input.incumbentCandidateId,
|
|
158
|
+
challengerCandidateId: input.evidence.challengerCandidateId,
|
|
159
|
+
cells: input.evidence.cells,
|
|
160
|
+
}),
|
|
161
|
+
];
|
|
162
|
+
}
|