@velum-labs/routekit-eval-setup 1.0.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/LICENSE +201 -0
- package/README.md +23 -0
- package/dist/effect-api.d.ts +12 -0
- package/dist/effect-api.js +9 -0
- package/dist/errors.d.ts +91 -0
- package/dist/errors.js +46 -0
- package/dist/host-metadata.d.ts +32 -0
- package/dist/host-metadata.js +46 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.js +13 -0
- package/dist/inspection.d.ts +24 -0
- package/dist/inspection.js +261 -0
- package/dist/model-selection.d.ts +6 -0
- package/dist/model-selection.js +37 -0
- package/dist/ori-authoring.d.ts +16 -0
- package/dist/ori-authoring.js +17 -0
- package/dist/ori-result.d.ts +45 -0
- package/dist/ori-result.js +1 -0
- package/dist/project-artifacts.d.ts +31 -0
- package/dist/project-artifacts.js +353 -0
- package/dist/project-authoring.d.ts +68 -0
- package/dist/project-authoring.js +431 -0
- package/dist/project-contracts.d.ts +1197 -0
- package/dist/project-contracts.js +396 -0
- package/dist/project-store.d.ts +13 -0
- package/dist/project-store.js +53 -0
- package/dist/project-workflow.d.ts +33 -0
- package/dist/project-workflow.js +904 -0
- package/dist/questions.d.ts +7 -0
- package/dist/questions.js +67 -0
- package/dist/runner.d.ts +8 -0
- package/dist/runner.js +16 -0
- package/dist/service.d.ts +24 -0
- package/dist/service.js +279 -0
- package/dist/state-store.d.ts +21 -0
- package/dist/state-store.js +86 -0
- package/dist/test/inspection.test.d.ts +1 -0
- package/dist/test/inspection.test.js +68 -0
- package/dist/test/model-selection.test.d.ts +1 -0
- package/dist/test/model-selection.test.js +15 -0
- package/dist/test/project-authoring.test.d.ts +1 -0
- package/dist/test/project-authoring.test.js +67 -0
- package/dist/test/project-workflow.test.d.ts +1 -0
- package/dist/test/project-workflow.test.js +516 -0
- package/dist/test/questions.test.d.ts +1 -0
- package/dist/test/questions.test.js +48 -0
- package/dist/test/skill.test.d.ts +1 -0
- package/dist/test/skill.test.js +31 -0
- package/dist/test/state-store.test.d.ts +1 -0
- package/dist/test/state-store.test.js +30 -0
- package/dist/test/workflow.test.d.ts +1 -0
- package/dist/test/workflow.test.js +167 -0
- package/dist/types.d.ts +77 -0
- package/dist/types.js +1 -0
- package/package.json +52 -0
- package/skills/setup-eval-routing/SKILL.md +149 -0
|
@@ -0,0 +1,904 @@
|
|
|
1
|
+
import { assertDecompositionResult, assertPublishedRoutingActivation, assertRoutingObjectivePolicy, isForbiddenEvalModel } from "@velum-labs/routekit-eval-contracts";
|
|
2
|
+
import { RoutingActivationConstraints, RoutingObjectivePolicy } from "@velum-labs/routekit-eval-contracts";
|
|
3
|
+
import { Clock, Context, Effect, Layer, Path, Schema } from "effect";
|
|
4
|
+
import { EvalProjectTransitionError } from "./errors.js";
|
|
5
|
+
import { EvalRepositoryInspector } from "./inspection.js";
|
|
6
|
+
import { EvalProjectArtifacts, evaluationProposalDigest, routingBasisDigest } from "./project-artifacts.js";
|
|
7
|
+
import { EVAL_PROJECT_VERSION, summarizeEvalRunLedger } from "./project-contracts.js";
|
|
8
|
+
import { EvalProjectStore } from "./project-store.js";
|
|
9
|
+
const isoNow = Effect.map(Clock.currentTimeMillis, (millis) => new Date(millis).toISOString());
|
|
10
|
+
const questionForProgress = (progress) => {
|
|
11
|
+
switch (progress._tag) {
|
|
12
|
+
case "WorkloadDescriptionRequired":
|
|
13
|
+
return {
|
|
14
|
+
id: "workload-description",
|
|
15
|
+
prompt: "What production workload should RouteKit learn to route?",
|
|
16
|
+
options: []
|
|
17
|
+
};
|
|
18
|
+
case "CandidateModelsRequired":
|
|
19
|
+
return {
|
|
20
|
+
id: "candidate-models",
|
|
21
|
+
prompt: "Which explicit provider/model IDs may RouteKit route to?",
|
|
22
|
+
options: []
|
|
23
|
+
};
|
|
24
|
+
case "ClassifierModelRequired":
|
|
25
|
+
return {
|
|
26
|
+
id: "classifier-model",
|
|
27
|
+
prompt: "Which explicit provider/model ID should decompose requests?",
|
|
28
|
+
options: []
|
|
29
|
+
};
|
|
30
|
+
case "AuthorModelRequired":
|
|
31
|
+
return {
|
|
32
|
+
id: "author-model",
|
|
33
|
+
prompt: "Which explicit provider/model ID should propose dimensions and evaluations?",
|
|
34
|
+
options: []
|
|
35
|
+
};
|
|
36
|
+
case "JudgeModelRequired":
|
|
37
|
+
return {
|
|
38
|
+
id: "judge-model",
|
|
39
|
+
prompt: "Which explicit provider/model ID should judge evaluation results?",
|
|
40
|
+
options: []
|
|
41
|
+
};
|
|
42
|
+
case "RoutingObjectiveRequired":
|
|
43
|
+
return {
|
|
44
|
+
id: "routing-objective",
|
|
45
|
+
prompt: "Provide the complete deterministic routing objective as JSON. Quality thresholds and metric weights are required when the selected objective uses them.",
|
|
46
|
+
options: [
|
|
47
|
+
'{"kind":"highest-quality"}',
|
|
48
|
+
'{"kind":"lowest-cost","minimumQuality":0.8}',
|
|
49
|
+
'{"kind":"lowest-latency","minimumQuality":0.8}',
|
|
50
|
+
'{"kind":"balanced","minimumQuality":0.8,"weights":{"quality":0.6,"cost":0.2,"latency":0.2}}',
|
|
51
|
+
'{"kind":"pareto","minimumQuality":0.8,"preference":"quality"}'
|
|
52
|
+
]
|
|
53
|
+
};
|
|
54
|
+
case "MaximumUnknownWeightRequired":
|
|
55
|
+
return {
|
|
56
|
+
id: "maximum-unknown-weight",
|
|
57
|
+
prompt: "What maximum uncovered request weight may be routed automatically? Provide a number from 0 to 1.",
|
|
58
|
+
options: ["0.1", "0.2", "0.3"]
|
|
59
|
+
};
|
|
60
|
+
case "RoutingConstraintsRequired":
|
|
61
|
+
return {
|
|
62
|
+
id: "routing-constraints",
|
|
63
|
+
prompt: 'Provide routing activation constraints as JSON, or "{}" for no additional quality/failure constraints.',
|
|
64
|
+
options: [
|
|
65
|
+
"{}",
|
|
66
|
+
'{"maximumFailureRate":0.05}',
|
|
67
|
+
'{"minimumDimensionQuality":{"example-dimension":0.8},"maximumFailureRate":0.05}'
|
|
68
|
+
]
|
|
69
|
+
};
|
|
70
|
+
default:
|
|
71
|
+
throw new Error("unsupported eval project setup question");
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
const nextAction = (state, artifacts) => {
|
|
75
|
+
switch (state.stage) {
|
|
76
|
+
case "setup-required":
|
|
77
|
+
return "answer";
|
|
78
|
+
case "dimensions-review":
|
|
79
|
+
return artifacts.basisProposalDigest === undefined
|
|
80
|
+
? "propose-dimensions"
|
|
81
|
+
: "approve-dimensions";
|
|
82
|
+
case "evaluations-review":
|
|
83
|
+
return artifacts.evaluationProposalDigest === undefined
|
|
84
|
+
? "propose-evaluations"
|
|
85
|
+
: "approve-evaluations";
|
|
86
|
+
case "ready":
|
|
87
|
+
return "run";
|
|
88
|
+
case "running":
|
|
89
|
+
return "none";
|
|
90
|
+
case "qualified":
|
|
91
|
+
return "publish";
|
|
92
|
+
case "activated":
|
|
93
|
+
return "none";
|
|
94
|
+
default:
|
|
95
|
+
return "none";
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
const transitionError = (state, detail) => new EvalProjectTransitionError({ state, detail });
|
|
99
|
+
const nonEmptyAnswer = (state, answer) => {
|
|
100
|
+
const value = answer.trim();
|
|
101
|
+
return value.length > 0
|
|
102
|
+
? Effect.succeed(value)
|
|
103
|
+
: Effect.fail(transitionError(state.stage, "answer must not be empty"));
|
|
104
|
+
};
|
|
105
|
+
const validateModel = (state, model, role) => isForbiddenEvalModel(model)
|
|
106
|
+
? Effect.fail(transitionError(state.stage, `${role} model must be an explicit provider/model id, not ${JSON.stringify(model)}`))
|
|
107
|
+
: Effect.succeed(model);
|
|
108
|
+
const parseCandidateModels = (state, answer) => Effect.gen(function* () {
|
|
109
|
+
const value = yield* nonEmptyAnswer(state, answer);
|
|
110
|
+
const candidates = value
|
|
111
|
+
.split(/[\s,]+/u)
|
|
112
|
+
.map((model) => model.trim())
|
|
113
|
+
.filter((model) => model.length > 0);
|
|
114
|
+
const unique = [...new Set(candidates)];
|
|
115
|
+
if (unique.length < 2 || unique.length > 32 || unique.length !== candidates.length) {
|
|
116
|
+
return yield* transitionError(state.stage, "candidate models must contain between 2 and 32 unique explicit provider/model IDs");
|
|
117
|
+
}
|
|
118
|
+
for (const candidate of unique) {
|
|
119
|
+
yield* validateModel(state, candidate, "candidate");
|
|
120
|
+
}
|
|
121
|
+
return unique;
|
|
122
|
+
});
|
|
123
|
+
const parseModel = (state, answer, role) => Effect.gen(function* () {
|
|
124
|
+
const model = yield* nonEmptyAnswer(state, answer);
|
|
125
|
+
return yield* validateModel(state, model, role);
|
|
126
|
+
});
|
|
127
|
+
const sameStrings = (left, right) => left.length === right.length && left.every((value, index) => value === right[index]);
|
|
128
|
+
const sameLedger = (left, right) => left.expectedCalls === right.expectedCalls &&
|
|
129
|
+
left.observedCalls === right.observedCalls &&
|
|
130
|
+
left.observedCandidateRows === right.observedCandidateRows &&
|
|
131
|
+
left.knownInputTokens === right.knownInputTokens &&
|
|
132
|
+
left.knownOutputTokens === right.knownOutputTokens &&
|
|
133
|
+
left.unknownTokenMeasurements === right.unknownTokenMeasurements &&
|
|
134
|
+
left.knownPricedSubtotalUsd === right.knownPricedSubtotalUsd &&
|
|
135
|
+
left.unpricedCalls === right.unpricedCalls;
|
|
136
|
+
const validateQualifiedReport = (state, plan, proposal, report) => {
|
|
137
|
+
assertPublishedRoutingActivation(report.activation);
|
|
138
|
+
if (report.activation.basisDigest !== state.basisDigest ||
|
|
139
|
+
report.activation.classifierModel !== state.configuration.classifierModel ||
|
|
140
|
+
!sameStrings(report.activation.candidateModels, plan.candidateModels)) {
|
|
141
|
+
throw new Error("published activation does not match the immutable execution plan");
|
|
142
|
+
}
|
|
143
|
+
const expectedDimensions = new Map(plan.selectedCaseIds.map((entry) => [entry.dimensionId, new Set(entry.caseIds)]));
|
|
144
|
+
const comparisons = new Map();
|
|
145
|
+
const validateComparison = (comparison, expectedCases) => {
|
|
146
|
+
if (comparison.judgeModel !== plan.judgeModel ||
|
|
147
|
+
comparison.suiteDigest.trim().length === 0) {
|
|
148
|
+
throw new Error("comparison result does not match the planned judge or suite");
|
|
149
|
+
}
|
|
150
|
+
const seenModels = new Set();
|
|
151
|
+
for (const model of comparison.models) {
|
|
152
|
+
if (!plan.candidateModels.includes(model.model) || seenModels.has(model.model)) {
|
|
153
|
+
throw new Error("comparison results contain an unexpected or duplicate candidate");
|
|
154
|
+
}
|
|
155
|
+
seenModels.add(model.model);
|
|
156
|
+
if (model.cases.length !== expectedCases.size) {
|
|
157
|
+
throw new Error("comparison candidate case count does not match the execution plan");
|
|
158
|
+
}
|
|
159
|
+
const seenCases = new Set();
|
|
160
|
+
for (const testCase of model.cases) {
|
|
161
|
+
if (!expectedCases.has(testCase.caseId) ||
|
|
162
|
+
seenCases.has(testCase.caseId) ||
|
|
163
|
+
testCase.outcome === "unknown" ||
|
|
164
|
+
testCase.outcome === "cutoff" ||
|
|
165
|
+
testCase.measurement.judgeScore === undefined) {
|
|
166
|
+
throw new Error("comparison evidence is incomplete, duplicated, or non-terminal");
|
|
167
|
+
}
|
|
168
|
+
seenCases.add(testCase.caseId);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (seenModels.size !== plan.candidateModels.length ||
|
|
172
|
+
plan.candidateModels.some((model) => !seenModels.has(model))) {
|
|
173
|
+
throw new Error("comparison is missing a configured candidate model");
|
|
174
|
+
}
|
|
175
|
+
if (comparison.calls === undefined) {
|
|
176
|
+
throw new Error("comparison is missing sanitized per-call accounting");
|
|
177
|
+
}
|
|
178
|
+
const expectedCallRows = expectedCases.size * plan.candidateModels.length;
|
|
179
|
+
const candidateCalls = comparison.calls.filter((call) => call.role === "candidate");
|
|
180
|
+
const judgeCalls = comparison.calls.filter((call) => call.role === "judge");
|
|
181
|
+
if (candidateCalls.length !== expectedCallRows ||
|
|
182
|
+
judgeCalls.length !== expectedCallRows ||
|
|
183
|
+
comparison.calls.length !== expectedCallRows * 2) {
|
|
184
|
+
throw new Error("comparison call accounting does not match the manifest");
|
|
185
|
+
}
|
|
186
|
+
for (const call of comparison.calls) {
|
|
187
|
+
if (!expectedCases.has(call.caseId) ||
|
|
188
|
+
(call.role === "candidate"
|
|
189
|
+
? !plan.candidateModels.includes(call.model)
|
|
190
|
+
: call.model !== plan.judgeModel)) {
|
|
191
|
+
throw new Error("comparison call accounting contains an unexpected case or model");
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
for (const comparison of report.comparisons) {
|
|
196
|
+
if (comparison.profileId === "composition")
|
|
197
|
+
continue;
|
|
198
|
+
const expectedCases = expectedDimensions.get(comparison.profileId);
|
|
199
|
+
if (expectedCases === undefined || comparisons.has(comparison.profileId)) {
|
|
200
|
+
throw new Error("comparison results do not cover the planned dimensions exactly once");
|
|
201
|
+
}
|
|
202
|
+
validateComparison(comparison, expectedCases);
|
|
203
|
+
comparisons.set(comparison.profileId, comparison);
|
|
204
|
+
}
|
|
205
|
+
if (comparisons.size !== expectedDimensions.size ||
|
|
206
|
+
[...expectedDimensions.keys()].some((dimensionId) => !comparisons.has(dimensionId))) {
|
|
207
|
+
throw new Error("comparison results are missing a planned dimension");
|
|
208
|
+
}
|
|
209
|
+
const compositionComparisons = report.comparisons.filter((comparison) => comparison.profileId === "composition");
|
|
210
|
+
if (compositionComparisons.length !== 1 || compositionComparisons[0] === undefined) {
|
|
211
|
+
throw new Error("qualification requires exactly one composition comparison");
|
|
212
|
+
}
|
|
213
|
+
validateComparison(compositionComparisons[0], new Set(plan.selectedCompositionCaseIds));
|
|
214
|
+
const qualification = report.qualification;
|
|
215
|
+
if (qualification === undefined) {
|
|
216
|
+
throw new Error("qualification report is missing decomposition and composition results");
|
|
217
|
+
}
|
|
218
|
+
const expectedDecompositionCases = new Set(plan.selectedDecompositionCaseIds);
|
|
219
|
+
const observedDecompositionCases = new Set();
|
|
220
|
+
for (const observation of qualification.decomposition.observations) {
|
|
221
|
+
if (!expectedDecompositionCases.has(observation.caseId) ||
|
|
222
|
+
observedDecompositionCases.has(observation.caseId) ||
|
|
223
|
+
!observation.passed ||
|
|
224
|
+
observation.vectorL1Error > proposal.decompositionBenchmark.maximumVectorL1Error) {
|
|
225
|
+
throw new Error("decomposition qualification is incomplete, duplicated, or failed");
|
|
226
|
+
}
|
|
227
|
+
observedDecompositionCases.add(observation.caseId);
|
|
228
|
+
}
|
|
229
|
+
if (qualification.decomposition.expectedCases !== expectedDecompositionCases.size ||
|
|
230
|
+
qualification.decomposition.passedCases !== expectedDecompositionCases.size ||
|
|
231
|
+
observedDecompositionCases.size !== expectedDecompositionCases.size ||
|
|
232
|
+
qualification.decomposition.maximumObservedL1Error >
|
|
233
|
+
proposal.decompositionBenchmark.maximumVectorL1Error) {
|
|
234
|
+
throw new Error("decomposition qualification summary does not match the execution plan");
|
|
235
|
+
}
|
|
236
|
+
const expectedCompositionCases = new Set(plan.selectedCompositionCaseIds);
|
|
237
|
+
const observedCompositionCases = new Set();
|
|
238
|
+
for (const result of qualification.composition.cases) {
|
|
239
|
+
if (!expectedCompositionCases.has(result.caseId) ||
|
|
240
|
+
observedCompositionCases.has(result.caseId)) {
|
|
241
|
+
throw new Error("composition qualification contains an unknown or duplicate case");
|
|
242
|
+
}
|
|
243
|
+
observedCompositionCases.add(result.caseId);
|
|
244
|
+
}
|
|
245
|
+
if (qualification.composition.expectedCases !== expectedCompositionCases.size ||
|
|
246
|
+
observedCompositionCases.size !== expectedCompositionCases.size ||
|
|
247
|
+
qualification.composition.comparableCases < 1 ||
|
|
248
|
+
qualification.composition.agreeingCases !==
|
|
249
|
+
qualification.composition.cases.filter((result) => result.passed).length ||
|
|
250
|
+
Math.abs(qualification.composition.winnerAgreement -
|
|
251
|
+
qualification.composition.agreeingCases / qualification.composition.comparableCases) > 1e-6 ||
|
|
252
|
+
qualification.composition.winnerAgreement <
|
|
253
|
+
proposal.compositionSuite.minimumWinnerAgreement) {
|
|
254
|
+
throw new Error("composition qualification summary does not meet the reviewed threshold");
|
|
255
|
+
}
|
|
256
|
+
const ledger = summarizeEvalRunLedger(report.comparisons, plan.expectedCallCount, qualification.decomposition.observations);
|
|
257
|
+
if (ledger.observedCalls !== plan.expectedCallCount ||
|
|
258
|
+
ledger.observedCandidateRows !== plan.expectedCandidateCalls ||
|
|
259
|
+
!sameLedger(ledger, report.ledger)) {
|
|
260
|
+
throw new Error("qualification ledger does not match the completed evidence");
|
|
261
|
+
}
|
|
262
|
+
};
|
|
263
|
+
const parseObjective = (state, answer) => Effect.gen(function* () {
|
|
264
|
+
const text = yield* nonEmptyAnswer(state, answer);
|
|
265
|
+
const json = yield* Effect.try({
|
|
266
|
+
try: () => JSON.parse(text),
|
|
267
|
+
catch: () => transitionError(state.stage, "routing objective must be a complete JSON object matching one of the shown examples")
|
|
268
|
+
});
|
|
269
|
+
const objective = yield* Schema.decodeUnknownEffect(RoutingObjectivePolicy)(json).pipe(Effect.mapError(() => transitionError(state.stage, "routing objective is incomplete or has invalid quality, weight, or preference values")));
|
|
270
|
+
yield* Effect.try({
|
|
271
|
+
try: () => assertRoutingObjectivePolicy(objective),
|
|
272
|
+
catch: () => transitionError(state.stage, "routing objective is invalid; balanced weights must sum exactly to one")
|
|
273
|
+
});
|
|
274
|
+
return objective;
|
|
275
|
+
});
|
|
276
|
+
const configurationFrom = (progress, constraints) => ({
|
|
277
|
+
workloadDescription: progress.workloadDescription,
|
|
278
|
+
candidateModels: progress.candidateModels,
|
|
279
|
+
classifierModel: progress.classifierModel,
|
|
280
|
+
authorModel: progress.authorModel,
|
|
281
|
+
judgeModel: progress.judgeModel,
|
|
282
|
+
objective: progress.objective,
|
|
283
|
+
maximumUnknownWeight: progress.maximumUnknownWeight,
|
|
284
|
+
...(constraints === undefined ? {} : { constraints })
|
|
285
|
+
});
|
|
286
|
+
const parseMaximumUnknownWeight = (state, answer) => Effect.gen(function* () {
|
|
287
|
+
const text = yield* nonEmptyAnswer(state, answer);
|
|
288
|
+
const value = Number(text);
|
|
289
|
+
if (!Number.isFinite(value) || value < 0 || value > 1) {
|
|
290
|
+
return yield* transitionError(state.stage, "maximum unknown weight must be a finite number from 0 to 1");
|
|
291
|
+
}
|
|
292
|
+
return value;
|
|
293
|
+
});
|
|
294
|
+
const parseConstraints = (state, answer) => Effect.gen(function* () {
|
|
295
|
+
const text = yield* nonEmptyAnswer(state, answer);
|
|
296
|
+
const json = yield* Effect.try({
|
|
297
|
+
try: () => JSON.parse(text),
|
|
298
|
+
catch: () => transitionError(state.stage, "routing constraints must be a JSON object")
|
|
299
|
+
});
|
|
300
|
+
const constraints = yield* Schema.decodeUnknownEffect(RoutingActivationConstraints)(json).pipe(Effect.mapError(() => transitionError(state.stage, "routing constraints contain invalid dimension quality or failure-rate values")));
|
|
301
|
+
return Object.keys(constraints).length === 0 ? undefined : constraints;
|
|
302
|
+
});
|
|
303
|
+
const advanceSetup = (state, answer, now) => Effect.gen(function* () {
|
|
304
|
+
const common = {
|
|
305
|
+
version: state.version,
|
|
306
|
+
projectId: state.projectId,
|
|
307
|
+
revision: state.revision + 1,
|
|
308
|
+
createdAt: state.createdAt,
|
|
309
|
+
updatedAt: now,
|
|
310
|
+
sourceInventory: state.sourceInventory
|
|
311
|
+
};
|
|
312
|
+
switch (state.progress._tag) {
|
|
313
|
+
case "WorkloadDescriptionRequired":
|
|
314
|
+
return {
|
|
315
|
+
...common,
|
|
316
|
+
stage: "setup-required",
|
|
317
|
+
progress: {
|
|
318
|
+
_tag: "CandidateModelsRequired",
|
|
319
|
+
workloadDescription: yield* nonEmptyAnswer(state, answer)
|
|
320
|
+
}
|
|
321
|
+
};
|
|
322
|
+
case "CandidateModelsRequired":
|
|
323
|
+
return {
|
|
324
|
+
...common,
|
|
325
|
+
stage: "setup-required",
|
|
326
|
+
progress: {
|
|
327
|
+
_tag: "ClassifierModelRequired",
|
|
328
|
+
workloadDescription: state.progress.workloadDescription,
|
|
329
|
+
candidateModels: yield* parseCandidateModels(state, answer)
|
|
330
|
+
}
|
|
331
|
+
};
|
|
332
|
+
case "ClassifierModelRequired":
|
|
333
|
+
return {
|
|
334
|
+
...common,
|
|
335
|
+
stage: "setup-required",
|
|
336
|
+
progress: {
|
|
337
|
+
_tag: "AuthorModelRequired",
|
|
338
|
+
workloadDescription: state.progress.workloadDescription,
|
|
339
|
+
candidateModels: state.progress.candidateModels,
|
|
340
|
+
classifierModel: yield* parseModel(state, answer, "classifier")
|
|
341
|
+
}
|
|
342
|
+
};
|
|
343
|
+
case "AuthorModelRequired":
|
|
344
|
+
return {
|
|
345
|
+
...common,
|
|
346
|
+
stage: "setup-required",
|
|
347
|
+
progress: {
|
|
348
|
+
_tag: "JudgeModelRequired",
|
|
349
|
+
workloadDescription: state.progress.workloadDescription,
|
|
350
|
+
candidateModels: state.progress.candidateModels,
|
|
351
|
+
classifierModel: state.progress.classifierModel,
|
|
352
|
+
authorModel: yield* parseModel(state, answer, "author")
|
|
353
|
+
}
|
|
354
|
+
};
|
|
355
|
+
case "JudgeModelRequired":
|
|
356
|
+
return {
|
|
357
|
+
...common,
|
|
358
|
+
stage: "setup-required",
|
|
359
|
+
progress: {
|
|
360
|
+
_tag: "RoutingObjectiveRequired",
|
|
361
|
+
workloadDescription: state.progress.workloadDescription,
|
|
362
|
+
candidateModels: state.progress.candidateModels,
|
|
363
|
+
classifierModel: state.progress.classifierModel,
|
|
364
|
+
authorModel: state.progress.authorModel,
|
|
365
|
+
judgeModel: yield* parseModel(state, answer, "judge")
|
|
366
|
+
}
|
|
367
|
+
};
|
|
368
|
+
case "RoutingObjectiveRequired": {
|
|
369
|
+
const objective = yield* parseObjective(state, answer);
|
|
370
|
+
return {
|
|
371
|
+
...common,
|
|
372
|
+
stage: "setup-required",
|
|
373
|
+
progress: {
|
|
374
|
+
_tag: "MaximumUnknownWeightRequired",
|
|
375
|
+
workloadDescription: state.progress.workloadDescription,
|
|
376
|
+
candidateModels: state.progress.candidateModels,
|
|
377
|
+
classifierModel: state.progress.classifierModel,
|
|
378
|
+
authorModel: state.progress.authorModel,
|
|
379
|
+
judgeModel: state.progress.judgeModel,
|
|
380
|
+
objective
|
|
381
|
+
}
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
case "MaximumUnknownWeightRequired":
|
|
385
|
+
return {
|
|
386
|
+
...common,
|
|
387
|
+
stage: "setup-required",
|
|
388
|
+
progress: {
|
|
389
|
+
_tag: "RoutingConstraintsRequired",
|
|
390
|
+
workloadDescription: state.progress.workloadDescription,
|
|
391
|
+
candidateModels: state.progress.candidateModels,
|
|
392
|
+
classifierModel: state.progress.classifierModel,
|
|
393
|
+
authorModel: state.progress.authorModel,
|
|
394
|
+
judgeModel: state.progress.judgeModel,
|
|
395
|
+
objective: state.progress.objective,
|
|
396
|
+
maximumUnknownWeight: yield* parseMaximumUnknownWeight(state, answer)
|
|
397
|
+
}
|
|
398
|
+
};
|
|
399
|
+
case "RoutingConstraintsRequired": {
|
|
400
|
+
const constraints = yield* parseConstraints(state, answer);
|
|
401
|
+
return {
|
|
402
|
+
...common,
|
|
403
|
+
stage: "dimensions-review",
|
|
404
|
+
configuration: configurationFrom(state.progress, constraints)
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
default:
|
|
408
|
+
return yield* transitionError(state.stage, "unsupported setup progress state");
|
|
409
|
+
}
|
|
410
|
+
});
|
|
411
|
+
export class EvalProjectWorkflow extends Context.Service()("@velum-labs/routekit-eval-setup/EvalProjectWorkflow") {
|
|
412
|
+
}
|
|
413
|
+
export const makeEvalProjectWorkflow = Effect.gen(function* () {
|
|
414
|
+
const store = yield* EvalProjectStore;
|
|
415
|
+
const artifacts = yield* EvalProjectArtifacts;
|
|
416
|
+
const inspector = yield* EvalRepositoryInspector;
|
|
417
|
+
const paths = yield* Path.Path;
|
|
418
|
+
const resolveRoot = (repositoryRoot) => paths.resolve(repositoryRoot);
|
|
419
|
+
const statusOf = (root, state) => Effect.gen(function* () {
|
|
420
|
+
const [basis, basisApproval, evaluations, evaluationsApproval, plans] = yield* Effect.all([
|
|
421
|
+
artifacts.loadBasisProposal(root),
|
|
422
|
+
artifacts.loadBasisApproval(root),
|
|
423
|
+
artifacts.loadEvaluationProposal(root),
|
|
424
|
+
artifacts.loadEvaluationsApproval(root),
|
|
425
|
+
artifacts.listPlans(root)
|
|
426
|
+
]);
|
|
427
|
+
const artifactStatus = {
|
|
428
|
+
...(basis === undefined ? {} : { basisProposalDigest: basis.basisDigest }),
|
|
429
|
+
basisApproved: basis !== undefined &&
|
|
430
|
+
basisApproval?.kind === "routing-basis" &&
|
|
431
|
+
basisApproval.digest === basis.basisDigest,
|
|
432
|
+
...(evaluations === undefined
|
|
433
|
+
? {}
|
|
434
|
+
: { evaluationProposalDigest: evaluations.evaluationDigest }),
|
|
435
|
+
evaluationsApproved: evaluations !== undefined &&
|
|
436
|
+
evaluationsApproval?.kind === "evaluations" &&
|
|
437
|
+
evaluationsApproval.digest === evaluations.evaluationDigest,
|
|
438
|
+
plans
|
|
439
|
+
};
|
|
440
|
+
return {
|
|
441
|
+
state,
|
|
442
|
+
...(state.stage === "setup-required"
|
|
443
|
+
? { question: questionForProgress(state.progress) }
|
|
444
|
+
: {}),
|
|
445
|
+
artifacts: artifactStatus,
|
|
446
|
+
nextAction: nextAction(state, artifactStatus)
|
|
447
|
+
};
|
|
448
|
+
});
|
|
449
|
+
const loadRequired = (root) => store
|
|
450
|
+
.load(root)
|
|
451
|
+
.pipe(Effect.flatMap((state) => state === undefined
|
|
452
|
+
? Effect.fail(transitionError("absent", "no eval project exists; run eval setup first"))
|
|
453
|
+
: Effect.succeed(state)));
|
|
454
|
+
const setup = (repositoryRoot) => Effect.gen(function* () {
|
|
455
|
+
const root = resolveRoot(repositoryRoot);
|
|
456
|
+
const existing = yield* store.load(root);
|
|
457
|
+
if (existing !== undefined)
|
|
458
|
+
return yield* statusOf(root, existing);
|
|
459
|
+
const inspection = yield* inspector.inspect(root);
|
|
460
|
+
const now = yield* isoNow;
|
|
461
|
+
const state = {
|
|
462
|
+
version: EVAL_PROJECT_VERSION,
|
|
463
|
+
projectId: crypto.randomUUID(),
|
|
464
|
+
revision: 0,
|
|
465
|
+
createdAt: now,
|
|
466
|
+
updatedAt: now,
|
|
467
|
+
sourceInventory: [
|
|
468
|
+
...new Set([
|
|
469
|
+
...inspection.surfaces.map(({ path }) => path),
|
|
470
|
+
...inspection.materials.map(({ path }) => path)
|
|
471
|
+
])
|
|
472
|
+
].sort((left, right) => left.localeCompare(right)),
|
|
473
|
+
stage: "setup-required",
|
|
474
|
+
progress: { _tag: "WorkloadDescriptionRequired" }
|
|
475
|
+
};
|
|
476
|
+
yield* store.save(root, state);
|
|
477
|
+
return yield* statusOf(root, state);
|
|
478
|
+
});
|
|
479
|
+
const status = (repositoryRoot) => Effect.gen(function* () {
|
|
480
|
+
const root = resolveRoot(repositoryRoot);
|
|
481
|
+
const state = yield* store.load(root);
|
|
482
|
+
return state === undefined ? undefined : yield* statusOf(root, state);
|
|
483
|
+
});
|
|
484
|
+
const answer = (repositoryRoot, answerText) => Effect.gen(function* () {
|
|
485
|
+
const root = resolveRoot(repositoryRoot);
|
|
486
|
+
const state = yield* store.load(root);
|
|
487
|
+
if (state === undefined) {
|
|
488
|
+
return yield* transitionError("absent", "no eval project exists; run eval setup first");
|
|
489
|
+
}
|
|
490
|
+
if (state.stage !== "setup-required") {
|
|
491
|
+
return yield* transitionError(state.stage, "project setup has no unanswered question");
|
|
492
|
+
}
|
|
493
|
+
const next = yield* advanceSetup(state, answerText, yield* isoNow);
|
|
494
|
+
yield* store.save(root, next);
|
|
495
|
+
return yield* statusOf(root, next);
|
|
496
|
+
});
|
|
497
|
+
const proposeDimensions = (repositoryRoot, dimensions) => Effect.gen(function* () {
|
|
498
|
+
const root = resolveRoot(repositoryRoot);
|
|
499
|
+
const state = yield* loadRequired(root);
|
|
500
|
+
if (state.stage !== "dimensions-review") {
|
|
501
|
+
return yield* transitionError(state.stage, "routing dimensions can only be proposed after setup");
|
|
502
|
+
}
|
|
503
|
+
const basis = {
|
|
504
|
+
version: 2,
|
|
505
|
+
basisDigest: routingBasisDigest(dimensions),
|
|
506
|
+
dimensions
|
|
507
|
+
};
|
|
508
|
+
yield* artifacts.saveBasisProposal(root, basis);
|
|
509
|
+
return yield* statusOf(root, state);
|
|
510
|
+
});
|
|
511
|
+
const approveDimensions = (repositoryRoot, expectedDigest) => Effect.gen(function* () {
|
|
512
|
+
const root = resolveRoot(repositoryRoot);
|
|
513
|
+
const state = yield* loadRequired(root);
|
|
514
|
+
if (state.stage !== "dimensions-review") {
|
|
515
|
+
return yield* transitionError(state.stage, "routing dimensions are not awaiting approval");
|
|
516
|
+
}
|
|
517
|
+
const basis = yield* artifacts.loadBasisProposal(root);
|
|
518
|
+
if (basis === undefined) {
|
|
519
|
+
return yield* transitionError(state.stage, "no routing basis proposal exists");
|
|
520
|
+
}
|
|
521
|
+
if (basis.basisDigest !== expectedDigest) {
|
|
522
|
+
return yield* transitionError(state.stage, "routing basis changed after review; approve its current digest");
|
|
523
|
+
}
|
|
524
|
+
const now = yield* isoNow;
|
|
525
|
+
yield* artifacts.saveBasisApproval(root, {
|
|
526
|
+
version: EVAL_PROJECT_VERSION,
|
|
527
|
+
kind: "routing-basis",
|
|
528
|
+
digest: basis.basisDigest,
|
|
529
|
+
approvedAt: now
|
|
530
|
+
});
|
|
531
|
+
const next = {
|
|
532
|
+
version: state.version,
|
|
533
|
+
projectId: state.projectId,
|
|
534
|
+
revision: state.revision + 1,
|
|
535
|
+
createdAt: state.createdAt,
|
|
536
|
+
updatedAt: now,
|
|
537
|
+
sourceInventory: state.sourceInventory,
|
|
538
|
+
stage: "evaluations-review",
|
|
539
|
+
configuration: state.configuration,
|
|
540
|
+
basisDigest: basis.basisDigest
|
|
541
|
+
};
|
|
542
|
+
yield* store.save(root, next);
|
|
543
|
+
return yield* statusOf(root, next);
|
|
544
|
+
});
|
|
545
|
+
const proposeEvaluations = (repositoryRoot, input) => Effect.gen(function* () {
|
|
546
|
+
const root = resolveRoot(repositoryRoot);
|
|
547
|
+
const state = yield* loadRequired(root);
|
|
548
|
+
if (state.stage !== "evaluations-review") {
|
|
549
|
+
return yield* transitionError(state.stage, "dimension evaluations can only be proposed after routing-basis approval");
|
|
550
|
+
}
|
|
551
|
+
const basis = yield* artifacts.loadBasisProposal(root);
|
|
552
|
+
const approval = yield* artifacts.loadBasisApproval(root);
|
|
553
|
+
if (basis === undefined ||
|
|
554
|
+
approval?.kind !== "routing-basis" ||
|
|
555
|
+
approval.digest !== state.basisDigest ||
|
|
556
|
+
basis.basisDigest !== state.basisDigest) {
|
|
557
|
+
return yield* transitionError(state.stage, "approved routing basis is missing or stale");
|
|
558
|
+
}
|
|
559
|
+
const expectedDimensions = new Set(basis.dimensions.map((dimension) => dimension.id));
|
|
560
|
+
const actualDimensions = new Set(input.suites.map((suite) => suite.dimensionId));
|
|
561
|
+
if (actualDimensions.size !== input.suites.length ||
|
|
562
|
+
actualDimensions.size !== expectedDimensions.size ||
|
|
563
|
+
[...expectedDimensions].some((dimensionId) => !actualDimensions.has(dimensionId))) {
|
|
564
|
+
return yield* transitionError(state.stage, "evaluation proposal must contain exactly one suite for every workload dimension");
|
|
565
|
+
}
|
|
566
|
+
for (const benchmarkCase of input.decompositionBenchmark.cases) {
|
|
567
|
+
yield* Effect.try({
|
|
568
|
+
try: () => assertDecompositionResult(benchmarkCase.expected, basis),
|
|
569
|
+
catch: () => transitionError(state.stage, `decomposition benchmark case ${JSON.stringify(benchmarkCase.id)} does not cover the approved basis`)
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
for (const compositionCase of input.compositionSuite.cases) {
|
|
573
|
+
yield* Effect.try({
|
|
574
|
+
try: () => {
|
|
575
|
+
assertDecompositionResult(compositionCase.decomposition, basis);
|
|
576
|
+
if (compositionCase.decomposition.weights.filter((entry) => entry.weight > 1e-6)
|
|
577
|
+
.length < 2) {
|
|
578
|
+
throw new Error("composition case must activate at least two dimensions");
|
|
579
|
+
}
|
|
580
|
+
},
|
|
581
|
+
catch: () => transitionError(state.stage, `composition benchmark case ${JSON.stringify(compositionCase.id)} has an invalid multi-dimension vector`)
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
const withoutDigest = {
|
|
585
|
+
version: EVAL_PROJECT_VERSION,
|
|
586
|
+
basisDigest: state.basisDigest,
|
|
587
|
+
candidateModels: state.configuration.candidateModels,
|
|
588
|
+
judgeModel: state.configuration.judgeModel,
|
|
589
|
+
suites: input.suites,
|
|
590
|
+
decompositionBenchmark: input.decompositionBenchmark,
|
|
591
|
+
compositionSuite: input.compositionSuite
|
|
592
|
+
};
|
|
593
|
+
const proposal = {
|
|
594
|
+
...withoutDigest,
|
|
595
|
+
evaluationDigest: evaluationProposalDigest(withoutDigest)
|
|
596
|
+
};
|
|
597
|
+
yield* artifacts.saveEvaluationProposal(root, proposal);
|
|
598
|
+
return yield* statusOf(root, state);
|
|
599
|
+
});
|
|
600
|
+
const approveEvaluations = (repositoryRoot, expectedDigest) => Effect.gen(function* () {
|
|
601
|
+
const root = resolveRoot(repositoryRoot);
|
|
602
|
+
const state = yield* loadRequired(root);
|
|
603
|
+
if (state.stage !== "evaluations-review") {
|
|
604
|
+
return yield* transitionError(state.stage, "dimension evaluations are not awaiting approval");
|
|
605
|
+
}
|
|
606
|
+
const proposal = yield* artifacts.loadEvaluationProposal(root);
|
|
607
|
+
if (proposal === undefined) {
|
|
608
|
+
return yield* transitionError(state.stage, "no evaluation proposal exists");
|
|
609
|
+
}
|
|
610
|
+
if (proposal.evaluationDigest !== expectedDigest ||
|
|
611
|
+
proposal.basisDigest !== state.basisDigest) {
|
|
612
|
+
return yield* transitionError(state.stage, "evaluation proposal changed after review; approve its current digest");
|
|
613
|
+
}
|
|
614
|
+
const now = yield* isoNow;
|
|
615
|
+
yield* artifacts.saveEvaluationsApproval(root, {
|
|
616
|
+
version: EVAL_PROJECT_VERSION,
|
|
617
|
+
kind: "evaluations",
|
|
618
|
+
digest: proposal.evaluationDigest,
|
|
619
|
+
approvedAt: now
|
|
620
|
+
});
|
|
621
|
+
const next = {
|
|
622
|
+
version: state.version,
|
|
623
|
+
projectId: state.projectId,
|
|
624
|
+
revision: state.revision + 1,
|
|
625
|
+
createdAt: state.createdAt,
|
|
626
|
+
updatedAt: now,
|
|
627
|
+
sourceInventory: state.sourceInventory,
|
|
628
|
+
stage: "ready",
|
|
629
|
+
configuration: state.configuration,
|
|
630
|
+
basisDigest: state.basisDigest,
|
|
631
|
+
evaluationDigest: proposal.evaluationDigest
|
|
632
|
+
};
|
|
633
|
+
yield* store.save(root, next);
|
|
634
|
+
return yield* statusOf(root, next);
|
|
635
|
+
});
|
|
636
|
+
const createPlan = (repositoryRoot, scope) => Effect.gen(function* () {
|
|
637
|
+
const root = resolveRoot(repositoryRoot);
|
|
638
|
+
const state = yield* loadRequired(root);
|
|
639
|
+
if (state.stage !== "ready" && state.stage !== "qualified") {
|
|
640
|
+
return yield* transitionError(state.stage, "an immutable execution plan requires approved evaluations");
|
|
641
|
+
}
|
|
642
|
+
const proposal = yield* artifacts.loadEvaluationProposal(root);
|
|
643
|
+
const approval = yield* artifacts.loadEvaluationsApproval(root);
|
|
644
|
+
if (proposal === undefined ||
|
|
645
|
+
approval?.kind !== "evaluations" ||
|
|
646
|
+
approval.digest !== proposal.evaluationDigest ||
|
|
647
|
+
proposal.evaluationDigest !== state.evaluationDigest ||
|
|
648
|
+
proposal.basisDigest !== state.basisDigest) {
|
|
649
|
+
return yield* transitionError(state.stage, "approved evaluation artifacts are stale");
|
|
650
|
+
}
|
|
651
|
+
if (scope === "full") {
|
|
652
|
+
const shortSuite = proposal.suites.find((suite) => suite.cases.length < 20);
|
|
653
|
+
if (shortSuite !== undefined ||
|
|
654
|
+
proposal.decompositionBenchmark.cases.length < 20 ||
|
|
655
|
+
proposal.compositionSuite.cases.length < 20) {
|
|
656
|
+
return yield* transitionError(state.stage, shortSuite === undefined
|
|
657
|
+
? "full qualification requires at least 20 reviewed decomposition and composition cases"
|
|
658
|
+
: `full qualification requires at least 20 reviewed cases for dimension ${JSON.stringify(shortSuite.dimensionId)}`);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
const selectedCaseIds = proposal.suites.map((suite) => ({
|
|
662
|
+
dimensionId: suite.dimensionId,
|
|
663
|
+
caseIds: suite.cases
|
|
664
|
+
.slice(0, scope === "pilot" ? 5 : undefined)
|
|
665
|
+
.map((testCase) => testCase.id)
|
|
666
|
+
}));
|
|
667
|
+
const caseCount = selectedCaseIds.reduce((sum, entry) => sum + entry.caseIds.length, 0);
|
|
668
|
+
const selectedDecompositionCaseIds = proposal.decompositionBenchmark.cases
|
|
669
|
+
.slice(0, scope === "pilot" ? 5 : undefined)
|
|
670
|
+
.map((testCase) => testCase.id);
|
|
671
|
+
const selectedCompositionCaseIds = proposal.compositionSuite.cases
|
|
672
|
+
.slice(0, scope === "pilot" ? 5 : undefined)
|
|
673
|
+
.map((testCase) => testCase.id);
|
|
674
|
+
const expectedDimensionCandidateCalls = caseCount * proposal.candidateModels.length;
|
|
675
|
+
const expectedDimensionJudgeCalls = expectedDimensionCandidateCalls;
|
|
676
|
+
const expectedClassifierCalls = selectedDecompositionCaseIds.length;
|
|
677
|
+
const expectedCompositionCandidateCalls = selectedCompositionCaseIds.length * proposal.candidateModels.length;
|
|
678
|
+
const expectedCompositionJudgeCalls = expectedCompositionCandidateCalls;
|
|
679
|
+
const expectedCandidateCalls = expectedDimensionCandidateCalls + expectedCompositionCandidateCalls;
|
|
680
|
+
const expectedJudgeCalls = expectedDimensionJudgeCalls + expectedCompositionJudgeCalls;
|
|
681
|
+
const plan = {
|
|
682
|
+
version: EVAL_PROJECT_VERSION,
|
|
683
|
+
planId: crypto.randomUUID(),
|
|
684
|
+
projectId: state.projectId,
|
|
685
|
+
projectRevision: state.revision,
|
|
686
|
+
createdAt: yield* isoNow,
|
|
687
|
+
scope,
|
|
688
|
+
basisDigest: state.basisDigest,
|
|
689
|
+
evaluationDigest: state.evaluationDigest,
|
|
690
|
+
candidateModels: state.configuration.candidateModels,
|
|
691
|
+
classifierModel: state.configuration.classifierModel,
|
|
692
|
+
authorModel: state.configuration.authorModel,
|
|
693
|
+
judgeModel: state.configuration.judgeModel,
|
|
694
|
+
selectedCaseIds,
|
|
695
|
+
selectedDecompositionCaseIds,
|
|
696
|
+
selectedCompositionCaseIds,
|
|
697
|
+
maximumOutputTokens: Math.max(proposal.compositionSuite.maximumOutputTokens, ...proposal.suites.map((suite) => suite.maximumOutputTokens)),
|
|
698
|
+
expectedDimensionCandidateCalls,
|
|
699
|
+
expectedDimensionJudgeCalls,
|
|
700
|
+
expectedClassifierCalls,
|
|
701
|
+
expectedCompositionCandidateCalls,
|
|
702
|
+
expectedCompositionJudgeCalls,
|
|
703
|
+
expectedCandidateCalls,
|
|
704
|
+
expectedJudgeCalls,
|
|
705
|
+
expectedCallCount: expectedCandidateCalls + expectedJudgeCalls + expectedClassifierCalls
|
|
706
|
+
};
|
|
707
|
+
yield* artifacts.materializePlanSuites(root, plan, proposal);
|
|
708
|
+
yield* artifacts.savePlan(root, plan);
|
|
709
|
+
return plan;
|
|
710
|
+
});
|
|
711
|
+
const startRun = (repositoryRoot, planId) => Effect.gen(function* () {
|
|
712
|
+
const root = resolveRoot(repositoryRoot);
|
|
713
|
+
const state = yield* loadRequired(root);
|
|
714
|
+
if (state.stage !== "ready" && state.stage !== "qualified") {
|
|
715
|
+
return yield* transitionError(state.stage, "an eval run requires current approved evaluation artifacts");
|
|
716
|
+
}
|
|
717
|
+
const plan = yield* artifacts.loadPlan(root, planId);
|
|
718
|
+
if (plan === undefined) {
|
|
719
|
+
return yield* transitionError(state.stage, `execution plan ${JSON.stringify(planId)} does not exist`);
|
|
720
|
+
}
|
|
721
|
+
if (plan.projectId !== state.projectId ||
|
|
722
|
+
plan.projectRevision !== state.revision ||
|
|
723
|
+
plan.basisDigest !== state.basisDigest ||
|
|
724
|
+
plan.evaluationDigest !== state.evaluationDigest ||
|
|
725
|
+
!sameStrings(plan.candidateModels, state.configuration.candidateModels) ||
|
|
726
|
+
plan.classifierModel !== state.configuration.classifierModel ||
|
|
727
|
+
plan.authorModel !== state.configuration.authorModel ||
|
|
728
|
+
plan.judgeModel !== state.configuration.judgeModel) {
|
|
729
|
+
return yield* transitionError(state.stage, "execution plan is stale or does not match the approved project");
|
|
730
|
+
}
|
|
731
|
+
const now = yield* isoNow;
|
|
732
|
+
const runId = crypto.randomUUID();
|
|
733
|
+
const next = {
|
|
734
|
+
version: state.version,
|
|
735
|
+
projectId: state.projectId,
|
|
736
|
+
revision: state.revision + 1,
|
|
737
|
+
createdAt: state.createdAt,
|
|
738
|
+
updatedAt: now,
|
|
739
|
+
sourceInventory: state.sourceInventory,
|
|
740
|
+
stage: "running",
|
|
741
|
+
configuration: state.configuration,
|
|
742
|
+
basisDigest: state.basisDigest,
|
|
743
|
+
evaluationDigest: state.evaluationDigest,
|
|
744
|
+
planId,
|
|
745
|
+
runId
|
|
746
|
+
};
|
|
747
|
+
yield* store.save(root, next);
|
|
748
|
+
return { plan, runId };
|
|
749
|
+
});
|
|
750
|
+
const finishRun = (repositoryRoot, report) => Effect.gen(function* () {
|
|
751
|
+
const root = resolveRoot(repositoryRoot);
|
|
752
|
+
const state = yield* loadRequired(root);
|
|
753
|
+
if (state.stage !== "running" ||
|
|
754
|
+
(report.status !== "passed" && report.status !== "completed") ||
|
|
755
|
+
report.runId !== state.runId ||
|
|
756
|
+
report.planId !== state.planId ||
|
|
757
|
+
report.projectId !== state.projectId ||
|
|
758
|
+
report.basisDigest !== state.basisDigest ||
|
|
759
|
+
report.evaluationDigest !== state.evaluationDigest) {
|
|
760
|
+
return yield* transitionError(state.stage, "qualification report does not match the active run");
|
|
761
|
+
}
|
|
762
|
+
if ((report.target.kind === "configured"
|
|
763
|
+
? !report.cleanup.sessionOpened || !report.cleanup.sessionClosed
|
|
764
|
+
: report.cleanup.sessionOpened || report.cleanup.sessionClosed) ||
|
|
765
|
+
report.activation.basisDigest !== state.basisDigest) {
|
|
766
|
+
return yield* transitionError(state.stage, "a run cannot qualify before session cleanup and activation validation complete");
|
|
767
|
+
}
|
|
768
|
+
const plan = yield* artifacts.loadPlan(root, state.planId);
|
|
769
|
+
if (plan === undefined) {
|
|
770
|
+
return yield* transitionError(state.stage, "the active execution plan is missing");
|
|
771
|
+
}
|
|
772
|
+
const proposal = yield* artifacts.loadEvaluationProposal(root);
|
|
773
|
+
if (proposal === undefined ||
|
|
774
|
+
proposal.evaluationDigest !== plan.evaluationDigest ||
|
|
775
|
+
proposal.basisDigest !== plan.basisDigest) {
|
|
776
|
+
return yield* transitionError(state.stage, "the approved evaluation proposal for the active plan is missing or stale");
|
|
777
|
+
}
|
|
778
|
+
if ((plan.scope === "full" && report.status !== "passed") ||
|
|
779
|
+
(plan.scope === "pilot" && report.status !== "completed")) {
|
|
780
|
+
return yield* transitionError(state.stage, "only full plans may qualify routing activation; pilot plans are completed diagnostics");
|
|
781
|
+
}
|
|
782
|
+
yield* Effect.try({
|
|
783
|
+
try: () => validateQualifiedReport(state, plan, proposal, report),
|
|
784
|
+
catch: (cause) => transitionError(state.stage, cause instanceof Error ? cause.message : "qualification report is invalid")
|
|
785
|
+
});
|
|
786
|
+
const reportPath = yield* artifacts.saveRunReport(root, report);
|
|
787
|
+
const common = {
|
|
788
|
+
version: state.version,
|
|
789
|
+
projectId: state.projectId,
|
|
790
|
+
revision: state.revision + 1,
|
|
791
|
+
createdAt: state.createdAt,
|
|
792
|
+
updatedAt: report.finishedAt,
|
|
793
|
+
sourceInventory: state.sourceInventory,
|
|
794
|
+
configuration: state.configuration,
|
|
795
|
+
basisDigest: state.basisDigest,
|
|
796
|
+
evaluationDigest: state.evaluationDigest
|
|
797
|
+
};
|
|
798
|
+
const next = report.status === "passed"
|
|
799
|
+
? {
|
|
800
|
+
...common,
|
|
801
|
+
stage: "qualified",
|
|
802
|
+
runId: state.runId,
|
|
803
|
+
reportPath
|
|
804
|
+
}
|
|
805
|
+
: {
|
|
806
|
+
...common,
|
|
807
|
+
stage: "ready"
|
|
808
|
+
};
|
|
809
|
+
yield* store.save(root, next);
|
|
810
|
+
return yield* statusOf(root, next);
|
|
811
|
+
});
|
|
812
|
+
const failRun = (repositoryRoot, report) => Effect.gen(function* () {
|
|
813
|
+
const root = resolveRoot(repositoryRoot);
|
|
814
|
+
const state = yield* loadRequired(root);
|
|
815
|
+
if (state.stage !== "running" ||
|
|
816
|
+
report.status !== "failed" ||
|
|
817
|
+
report.runId !== state.runId ||
|
|
818
|
+
report.planId !== state.planId ||
|
|
819
|
+
report.projectId !== state.projectId ||
|
|
820
|
+
report.basisDigest !== state.basisDigest ||
|
|
821
|
+
report.evaluationDigest !== state.evaluationDigest) {
|
|
822
|
+
return yield* transitionError(state.stage, "failure report does not match the active run");
|
|
823
|
+
}
|
|
824
|
+
yield* artifacts.saveRunReport(root, report);
|
|
825
|
+
const next = {
|
|
826
|
+
version: state.version,
|
|
827
|
+
projectId: state.projectId,
|
|
828
|
+
revision: state.revision + 1,
|
|
829
|
+
createdAt: state.createdAt,
|
|
830
|
+
updatedAt: report.finishedAt,
|
|
831
|
+
sourceInventory: state.sourceInventory,
|
|
832
|
+
stage: "ready",
|
|
833
|
+
configuration: state.configuration,
|
|
834
|
+
basisDigest: state.basisDigest,
|
|
835
|
+
evaluationDigest: state.evaluationDigest
|
|
836
|
+
};
|
|
837
|
+
yield* store.save(root, next);
|
|
838
|
+
return yield* statusOf(root, next);
|
|
839
|
+
});
|
|
840
|
+
const result = (repositoryRoot, runId) => Effect.gen(function* () {
|
|
841
|
+
const root = resolveRoot(repositoryRoot);
|
|
842
|
+
yield* loadRequired(root);
|
|
843
|
+
if (runId !== undefined)
|
|
844
|
+
return yield* artifacts.loadRunReport(root, runId);
|
|
845
|
+
const reports = yield* artifacts.listRunReports(root);
|
|
846
|
+
let latest;
|
|
847
|
+
for (const reportId of reports) {
|
|
848
|
+
const report = yield* artifacts.loadRunReport(root, reportId);
|
|
849
|
+
if (report !== undefined &&
|
|
850
|
+
(latest === undefined || report.finishedAt.localeCompare(latest.finishedAt) > 0)) {
|
|
851
|
+
latest = report;
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
return latest;
|
|
855
|
+
});
|
|
856
|
+
const markActivated = (repositoryRoot, runId, targetIdentity) => Effect.gen(function* () {
|
|
857
|
+
const root = resolveRoot(repositoryRoot);
|
|
858
|
+
const state = yield* loadRequired(root);
|
|
859
|
+
if (state.stage !== "qualified" || state.runId !== runId) {
|
|
860
|
+
return yield* transitionError(state.stage, "only the current qualified run may activate");
|
|
861
|
+
}
|
|
862
|
+
const report = yield* artifacts.loadRunReport(root, runId);
|
|
863
|
+
if (report?.status !== "passed" ||
|
|
864
|
+
!report.target.publishAllowed ||
|
|
865
|
+
report.target.identity !== targetIdentity) {
|
|
866
|
+
return yield* transitionError(state.stage, "qualified evidence cannot be published to this target");
|
|
867
|
+
}
|
|
868
|
+
const now = yield* isoNow;
|
|
869
|
+
const next = {
|
|
870
|
+
version: state.version,
|
|
871
|
+
projectId: state.projectId,
|
|
872
|
+
revision: state.revision + 1,
|
|
873
|
+
createdAt: state.createdAt,
|
|
874
|
+
updatedAt: now,
|
|
875
|
+
sourceInventory: state.sourceInventory,
|
|
876
|
+
stage: "activated",
|
|
877
|
+
configuration: state.configuration,
|
|
878
|
+
basisDigest: state.basisDigest,
|
|
879
|
+
evaluationDigest: state.evaluationDigest,
|
|
880
|
+
runId,
|
|
881
|
+
reportPath: state.reportPath,
|
|
882
|
+
evidenceDigest: report.activation.evidenceDigest,
|
|
883
|
+
targetIdentity
|
|
884
|
+
};
|
|
885
|
+
yield* store.save(root, next);
|
|
886
|
+
return yield* statusOf(root, next);
|
|
887
|
+
});
|
|
888
|
+
return EvalProjectWorkflow.of({
|
|
889
|
+
setup,
|
|
890
|
+
status,
|
|
891
|
+
answer,
|
|
892
|
+
proposeDimensions,
|
|
893
|
+
approveDimensions,
|
|
894
|
+
proposeEvaluations,
|
|
895
|
+
approveEvaluations,
|
|
896
|
+
createPlan,
|
|
897
|
+
startRun,
|
|
898
|
+
finishRun,
|
|
899
|
+
failRun,
|
|
900
|
+
result,
|
|
901
|
+
markActivated
|
|
902
|
+
});
|
|
903
|
+
});
|
|
904
|
+
export const EvalProjectWorkflowLive = Layer.effect(EvalProjectWorkflow, makeEvalProjectWorkflow);
|