@alisio/core 0.6.0 → 0.8.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/dist/application.d.ts +15 -0
- package/dist/application.js +38 -3
- package/dist/changelog/data.js +39 -0
- package/dist/commands/builtins.js +7 -0
- package/dist/config.d.ts +30 -0
- package/dist/config.js +32 -0
- package/dist/core/runner.d.ts +4 -0
- package/dist/core/runner.js +57 -1
- package/dist/eval/canonical.d.ts +14 -0
- package/dist/eval/canonical.js +40 -0
- package/dist/eval/cassette.d.ts +180 -0
- package/dist/eval/cassette.js +424 -0
- package/dist/eval/compare.d.ts +147 -0
- package/dist/eval/compare.js +436 -0
- package/dist/eval/engine.d.ts +145 -0
- package/dist/eval/engine.js +731 -0
- package/dist/eval/export.d.ts +40 -0
- package/dist/eval/export.js +90 -0
- package/dist/eval/fingerprint.d.ts +88 -0
- package/dist/eval/fingerprint.js +186 -0
- package/dist/eval/normalize.d.ts +68 -0
- package/dist/eval/normalize.js +138 -0
- package/dist/eval/paths.d.ts +11 -0
- package/dist/eval/paths.js +50 -0
- package/dist/eval/plan.d.ts +26 -0
- package/dist/eval/plan.js +44 -0
- package/dist/eval/report.d.ts +53 -0
- package/dist/eval/report.js +258 -0
- package/dist/eval/run-suite.d.ts +68 -0
- package/dist/eval/run-suite.js +94 -0
- package/dist/eval/secrets.d.ts +14 -0
- package/dist/eval/secrets.js +55 -0
- package/dist/eval/stats.d.ts +17 -0
- package/dist/eval/stats.js +50 -0
- package/dist/eval/suite.d.ts +20 -0
- package/dist/eval/suite.js +165 -0
- package/dist/eval/variants.d.ts +35 -0
- package/dist/eval/variants.js +137 -0
- package/dist/eval/verifiers.d.ts +49 -0
- package/dist/eval/verifiers.js +264 -0
- package/dist/forge/javascript/hosts/worker-host.d.ts +9 -0
- package/dist/forge/javascript/hosts/worker-host.js +16 -1
- package/dist/forge/kernel.js +3 -2
- package/dist/goal/command.d.ts +7 -2
- package/dist/goal/command.js +10 -2
- package/dist/goal/format.js +2 -0
- package/dist/goal/machine.d.ts +50 -3
- package/dist/goal/machine.js +80 -2
- package/dist/goal/service.d.ts +22 -2
- package/dist/goal/service.js +94 -7
- package/dist/goal/store.js +22 -3
- package/dist/index.d.ts +18 -2
- package/dist/index.js +18 -2
- package/dist/plan/criteria.d.ts +30 -0
- package/dist/plan/criteria.js +256 -0
- package/dist/plan/exit-plan.d.ts +8 -0
- package/dist/plan/exit-plan.js +91 -9
- package/dist/plan/manifest.d.ts +4 -2
- package/dist/plan/manifest.js +6 -1
- package/dist/plan/state.d.ts +13 -2
- package/dist/plan/state.js +7 -1
- package/dist/plugins/host.d.ts +7 -0
- package/dist/plugins/host.js +15 -0
- package/dist/proof/command.d.ts +24 -0
- package/dist/proof/command.js +109 -0
- package/dist/proof/coverage.d.ts +40 -0
- package/dist/proof/coverage.js +102 -0
- package/dist/proof/divergence.d.ts +24 -0
- package/dist/proof/divergence.js +120 -0
- package/dist/proof/export.d.ts +65 -0
- package/dist/proof/export.js +144 -0
- package/dist/proof/index.d.ts +12 -0
- package/dist/proof/index.js +12 -0
- package/dist/proof/ledger.d.ts +38 -0
- package/dist/proof/ledger.js +57 -0
- package/dist/proof/plugin-verifiers.d.ts +23 -0
- package/dist/proof/plugin-verifiers.js +39 -0
- package/dist/proof/recorder.d.ts +69 -0
- package/dist/proof/recorder.js +347 -0
- package/dist/proof/scope.d.ts +12 -0
- package/dist/proof/scope.js +121 -0
- package/dist/proof/store.d.ts +52 -0
- package/dist/proof/store.js +192 -0
- package/dist/proof/tree.d.ts +6 -0
- package/dist/proof/tree.js +78 -0
- package/dist/proof/types.d.ts +21 -0
- package/dist/proof/types.js +1 -0
- package/dist/proof/verifiers.d.ts +46 -0
- package/dist/proof/verifiers.js +176 -0
- package/dist/proof/verify.d.ts +36 -0
- package/dist/proof/verify.js +171 -0
- package/dist/runtime/ids.d.ts +21 -0
- package/dist/runtime/ids.js +41 -1
- package/dist/runtime/store.js +63 -2
- package/dist/tools/goal.d.ts +5 -1
- package/dist/tools/goal.js +35 -4
- package/package.json +2 -2
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cassettes of the reliability lab (specs/010 5.3, D10, D11): `cassette/v1` JSONL, the provider
|
|
3
|
+
* that records a live run and the provider that serves it back.
|
|
4
|
+
*
|
|
5
|
+
* line 1 header: {v:1, cassetteVersion, suiteId, taskId, harnessVersion, recordedAt, configHash,
|
|
6
|
+
* provider, model, models?, texts, toolSets}
|
|
7
|
+
* line n one call to `ModelProvider.stream`: {call, lane, fingerprint, parts, messages?, events,
|
|
8
|
+
* error?}
|
|
9
|
+
*
|
|
10
|
+
* `completed.message` is stored VERBATIM (tool-call ids, `providerData`, `truncated`) together with
|
|
11
|
+
* its usage; `reasoning_delta` is dropped (the SDK says it is never replayed). A provider error
|
|
12
|
+
* (for example the truncation signal) is stored as `error: {message, code}` so recovery replays.
|
|
13
|
+
*
|
|
14
|
+
* Replay serves the recorded events in strict order PER LANE. A lane is a session (the registered
|
|
15
|
+
* main session by its `<SESSION#n>` placeholder); a session the run did not register (a child /
|
|
16
|
+
* subagent) is bound to the lane whose first request has the same fingerprint, so concurrent
|
|
17
|
+
* children may interleave differently. v1 limitation: two children with an identical first request
|
|
18
|
+
* are interchangeable, and a child whose first request changes is reported as a divergence.
|
|
19
|
+
*
|
|
20
|
+
* This module (and everything it imports) must not touch the network: a test checks that
|
|
21
|
+
* statically, and replay needs no credentials. Replay promises no timing.
|
|
22
|
+
*/
|
|
23
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
24
|
+
import { dirname, join } from "node:path";
|
|
25
|
+
import { absorb, computeFingerprint, diffFingerprints, emptyStore, } from "./fingerprint.js";
|
|
26
|
+
import { findSecrets } from "./secrets.js";
|
|
27
|
+
export const MAX_CASSETTE_BYTES = 256 * 1024;
|
|
28
|
+
export const MAX_CASSETTE_DIR_BYTES = 2 * 1024 * 1024;
|
|
29
|
+
export const CASSETTE_SUFFIX = ".cassette.jsonl";
|
|
30
|
+
const NO_SESSION = "<NO_SESSION>";
|
|
31
|
+
export class CassetteFormatError extends Error {
|
|
32
|
+
constructor(message) {
|
|
33
|
+
super(message);
|
|
34
|
+
this.name = "CassetteFormatError";
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
export class CassetteWriteError extends Error {
|
|
38
|
+
reason;
|
|
39
|
+
constructor(message, reason) {
|
|
40
|
+
super(message);
|
|
41
|
+
this.reason = reason;
|
|
42
|
+
this.name = "CassetteWriteError";
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
// ---------------------------------------------------------------------------------------------
|
|
46
|
+
// File format
|
|
47
|
+
// ---------------------------------------------------------------------------------------------
|
|
48
|
+
export function serializeCassette(cassette) {
|
|
49
|
+
return `${[cassette.header, ...cassette.calls].map((line) => JSON.stringify(line)).join("\n")}\n`;
|
|
50
|
+
}
|
|
51
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
52
|
+
export function parseCassette(text) {
|
|
53
|
+
const lines = text.split("\n").filter((line) => line.trim() !== "");
|
|
54
|
+
if (lines.length === 0)
|
|
55
|
+
throw new CassetteFormatError("The cassette is empty.");
|
|
56
|
+
const parsed = lines.map((line, index) => {
|
|
57
|
+
try {
|
|
58
|
+
return JSON.parse(line);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
throw new CassetteFormatError(`Cassette line ${index + 1} is not valid JSON.`);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
const header = parsed[0];
|
|
65
|
+
if (!isRecord(header) || header.v !== 1)
|
|
66
|
+
throw new CassetteFormatError('Unsupported cassette: line 1 must be a cassette/v1 header ({"v":1,...}). Record it again with this version of alisio.');
|
|
67
|
+
for (const key of ["suiteId", "taskId", "harnessVersion", "configHash", "provider", "model"])
|
|
68
|
+
if (typeof header[key] !== "string")
|
|
69
|
+
throw new CassetteFormatError(`Cassette header line 1 lacks the string "${key}".`);
|
|
70
|
+
if (!isRecord(header.texts) || !isRecord(header.toolSets))
|
|
71
|
+
throw new CassetteFormatError("Cassette header line 1 lacks its texts or toolSets.");
|
|
72
|
+
const calls = parsed.slice(1).map((line, index) => {
|
|
73
|
+
const at = index + 2;
|
|
74
|
+
if (!isRecord(line) ||
|
|
75
|
+
typeof line.call !== "number" ||
|
|
76
|
+
typeof line.lane !== "string" ||
|
|
77
|
+
typeof line.fingerprint !== "string" ||
|
|
78
|
+
!isRecord(line.parts) ||
|
|
79
|
+
!Array.isArray(line.events))
|
|
80
|
+
throw new CassetteFormatError(`Cassette line ${at} is not a call record.`);
|
|
81
|
+
if (line.call !== index)
|
|
82
|
+
throw new CassetteFormatError(`Cassette line ${at}: call ${line.call}, expected ${index} (calls must be numbered in order).`);
|
|
83
|
+
for (const event of line.events)
|
|
84
|
+
if (!isRecord(event) || (event.type !== "text_delta" && event.type !== "completed"))
|
|
85
|
+
throw new CassetteFormatError(`Cassette line ${at} holds an unknown event.`);
|
|
86
|
+
return line;
|
|
87
|
+
});
|
|
88
|
+
return { header: header, calls };
|
|
89
|
+
}
|
|
90
|
+
export function readCassette(path) {
|
|
91
|
+
let text;
|
|
92
|
+
try {
|
|
93
|
+
text = readFileSync(path, "utf8");
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
throw new CassetteFormatError(`Cannot read the cassette ${path}. Record it first with \`alisio eval record\`.`);
|
|
97
|
+
}
|
|
98
|
+
try {
|
|
99
|
+
return parseCassette(text);
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
throw new CassetteFormatError(`${path}: ${error instanceof Error ? error.message : String(error)}`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
/** Every string leaf with its path (`calls[0].events[1].message.text`). */
|
|
106
|
+
function* leaves(value, path) {
|
|
107
|
+
if (typeof value === "string")
|
|
108
|
+
yield [path, value];
|
|
109
|
+
else if (Array.isArray(value))
|
|
110
|
+
for (let i = 0; i < value.length; i++)
|
|
111
|
+
yield* leaves(value[i], `${path}[${i}]`);
|
|
112
|
+
else if (value && typeof value === "object")
|
|
113
|
+
for (const [key, member] of Object.entries(value))
|
|
114
|
+
yield* leaves(member, `${path}.${key}`);
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Writes `cassette` to `path` unless it holds a secret (REFUSES: it never strips, which would make
|
|
118
|
+
* the cassette lie about what the model saw) or exceeds the size limits (256 KB each, 2 MB for the
|
|
119
|
+
* directory). Nothing is written on refusal.
|
|
120
|
+
*/
|
|
121
|
+
export function writeCassette(path, cassette, options = {}) {
|
|
122
|
+
for (const [where, value] of [
|
|
123
|
+
...leaves(cassette.header, "header"),
|
|
124
|
+
...leaves(cassette.calls, "calls"),
|
|
125
|
+
]) {
|
|
126
|
+
const hits = findSecrets(value, options.secrets ?? []);
|
|
127
|
+
if (hits.length > 0)
|
|
128
|
+
throw new CassetteWriteError(`Refusing to write the cassette: ${where} looks like a secret (${hits.join("; ")}). Remove it from the fixture, prompt or tool output and record again.`, "secret");
|
|
129
|
+
}
|
|
130
|
+
const text = serializeCassette(cassette);
|
|
131
|
+
const bytes = Buffer.byteLength(text);
|
|
132
|
+
const maxBytes = options.maxBytes ?? MAX_CASSETTE_BYTES;
|
|
133
|
+
if (bytes > maxBytes)
|
|
134
|
+
throw new CassetteWriteError(`Refusing to write the cassette: ${bytes} bytes exceeds the ${Math.round(maxBytes / 1024)} KB limit per cassette. Record a shorter task.`, "size");
|
|
135
|
+
const dir = dirname(path);
|
|
136
|
+
let others = 0;
|
|
137
|
+
if (existsSync(dir))
|
|
138
|
+
for (const name of readdirSync(dir))
|
|
139
|
+
if (name.endsWith(CASSETTE_SUFFIX) && join(dir, name) !== path)
|
|
140
|
+
others += statSync(join(dir, name)).size;
|
|
141
|
+
const maxDir = options.maxDirBytes ?? MAX_CASSETTE_DIR_BYTES;
|
|
142
|
+
if (others + bytes > maxDir)
|
|
143
|
+
throw new CassetteWriteError(`Refusing to write the cassette: the cassettes in ${dir} would total ${others + bytes} bytes, over the ${Math.round(maxDir / (1024 * 1024))} MB limit.`, "total");
|
|
144
|
+
mkdirSync(dir, { recursive: true });
|
|
145
|
+
writeFileSync(path, text);
|
|
146
|
+
}
|
|
147
|
+
// ---------------------------------------------------------------------------------------------
|
|
148
|
+
// Lanes
|
|
149
|
+
// ---------------------------------------------------------------------------------------------
|
|
150
|
+
/** Lane of a session the run did not register: named after its first request. */
|
|
151
|
+
const childLaneBase = (fingerprint) => `L:${fingerprint.slice(7, 19)}`;
|
|
152
|
+
const childLaneName = (base, n) => (n === 1 ? base : `${base}#${n}`);
|
|
153
|
+
/** Collects the calls a `RecordingProvider` sees; `toCassette` turns them into the file content. */
|
|
154
|
+
export class CassetteRecorder {
|
|
155
|
+
calls = [];
|
|
156
|
+
/** Why the recording cannot be trusted (set, never cleared); `toCassette` refuses then. */
|
|
157
|
+
unstable;
|
|
158
|
+
models;
|
|
159
|
+
normalizer;
|
|
160
|
+
store = emptyStore();
|
|
161
|
+
childLanes = new Map();
|
|
162
|
+
laneCounts = new Map();
|
|
163
|
+
bind(normalizer) {
|
|
164
|
+
this.normalizer = normalizer;
|
|
165
|
+
}
|
|
166
|
+
/** Opens the record of the next call. */
|
|
167
|
+
begin(request) {
|
|
168
|
+
if (!this.normalizer)
|
|
169
|
+
throw new Error("CassetteRecorder.bind(normalizer) was not called.");
|
|
170
|
+
const material = computeFingerprint(request, this.normalizer);
|
|
171
|
+
const messages = absorb(this.store, material);
|
|
172
|
+
const entry = {
|
|
173
|
+
call: this.calls.length,
|
|
174
|
+
lane: this.laneFor(request, material),
|
|
175
|
+
fingerprint: material.fingerprint,
|
|
176
|
+
parts: material.parts,
|
|
177
|
+
...(Object.keys(messages).length > 0 ? { messages } : {}),
|
|
178
|
+
events: [],
|
|
179
|
+
};
|
|
180
|
+
this.calls.push(entry);
|
|
181
|
+
return entry;
|
|
182
|
+
}
|
|
183
|
+
laneFor(request, material) {
|
|
184
|
+
const label = this.normalizer?.sessionLabel(request.sessionId);
|
|
185
|
+
if (label)
|
|
186
|
+
return label;
|
|
187
|
+
if (request.sessionId === undefined)
|
|
188
|
+
return NO_SESSION;
|
|
189
|
+
let lane = this.childLanes.get(request.sessionId);
|
|
190
|
+
if (!lane) {
|
|
191
|
+
const base = childLaneBase(material.fingerprint);
|
|
192
|
+
const n = (this.laneCounts.get(base) ?? 0) + 1;
|
|
193
|
+
this.laneCounts.set(base, n);
|
|
194
|
+
lane = childLaneName(base, n);
|
|
195
|
+
this.childLanes.set(request.sessionId, lane);
|
|
196
|
+
}
|
|
197
|
+
return lane;
|
|
198
|
+
}
|
|
199
|
+
markUnstable(reason) {
|
|
200
|
+
this.unstable ??= `${reason} The recording is unstable: re-record.`;
|
|
201
|
+
}
|
|
202
|
+
toCassette(meta) {
|
|
203
|
+
if (this.unstable)
|
|
204
|
+
throw new Error(`Cannot build the cassette. ${this.unstable}`);
|
|
205
|
+
return {
|
|
206
|
+
header: {
|
|
207
|
+
v: 1,
|
|
208
|
+
cassetteVersion: meta.cassetteVersion ?? 1,
|
|
209
|
+
suiteId: meta.suiteId,
|
|
210
|
+
taskId: meta.taskId,
|
|
211
|
+
harnessVersion: meta.harnessVersion,
|
|
212
|
+
recordedAt: meta.recordedAt,
|
|
213
|
+
configHash: meta.configHash,
|
|
214
|
+
provider: meta.provider,
|
|
215
|
+
model: meta.model,
|
|
216
|
+
...(this.models ? { models: this.models } : {}),
|
|
217
|
+
texts: this.store.texts,
|
|
218
|
+
toolSets: this.store.toolSets,
|
|
219
|
+
},
|
|
220
|
+
calls: this.calls,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Wraps a real provider and tees what it emits into `sink`. The caller still receives every event
|
|
226
|
+
* (reasoning included). A call aborted before any event (a first-token retry, a timeout) or one the
|
|
227
|
+
* consumer abandons makes the recording unstable: replaying it would not reproduce the run.
|
|
228
|
+
*/
|
|
229
|
+
export class RecordingProvider {
|
|
230
|
+
inner;
|
|
231
|
+
sink;
|
|
232
|
+
id;
|
|
233
|
+
model;
|
|
234
|
+
listModels;
|
|
235
|
+
dispose;
|
|
236
|
+
constructor(inner, sink) {
|
|
237
|
+
this.inner = inner;
|
|
238
|
+
this.sink = sink;
|
|
239
|
+
this.id = inner.id;
|
|
240
|
+
this.model = inner.model;
|
|
241
|
+
const list = inner.listModels?.bind(inner);
|
|
242
|
+
if (list)
|
|
243
|
+
this.listModels = async (signal) => {
|
|
244
|
+
const models = await list(signal);
|
|
245
|
+
sink.models = models;
|
|
246
|
+
return models;
|
|
247
|
+
};
|
|
248
|
+
const dispose = inner.dispose?.bind(inner);
|
|
249
|
+
if (dispose)
|
|
250
|
+
this.dispose = () => dispose();
|
|
251
|
+
}
|
|
252
|
+
async *stream(request) {
|
|
253
|
+
const entry = this.sink.begin(request);
|
|
254
|
+
let finished = false;
|
|
255
|
+
try {
|
|
256
|
+
for await (const event of this.inner.stream(request)) {
|
|
257
|
+
if (event.type === "text_delta")
|
|
258
|
+
entry.events.push({ type: "text_delta", delta: event.delta });
|
|
259
|
+
else if (event.type === "completed")
|
|
260
|
+
entry.events.push({
|
|
261
|
+
type: "completed",
|
|
262
|
+
message: event.message,
|
|
263
|
+
...(event.usage ? { usage: event.usage } : {}),
|
|
264
|
+
});
|
|
265
|
+
yield event;
|
|
266
|
+
}
|
|
267
|
+
finished = true;
|
|
268
|
+
}
|
|
269
|
+
catch (error) {
|
|
270
|
+
if (request.signal.aborted) {
|
|
271
|
+
this.sink.markUnstable(`Call ${entry.call} was aborted ${entry.events.length === 0 ? "before any event" : "mid-stream"} (a first-token retry, a timeout or a budget).`);
|
|
272
|
+
}
|
|
273
|
+
else {
|
|
274
|
+
const { message } = error instanceof Error ? error : new Error(String(error));
|
|
275
|
+
const code = error?.code;
|
|
276
|
+
entry.error = { message, ...(typeof code === "string" ? { code } : {}) };
|
|
277
|
+
}
|
|
278
|
+
finished = true;
|
|
279
|
+
throw error;
|
|
280
|
+
}
|
|
281
|
+
finally {
|
|
282
|
+
if (!finished)
|
|
283
|
+
this.sink.markUnstable(`Call ${entry.call} was abandoned by the consumer${request.signal.aborted ? " after an abort" : ""}.`);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
// ---------------------------------------------------------------------------------------------
|
|
288
|
+
// Replay
|
|
289
|
+
// ---------------------------------------------------------------------------------------------
|
|
290
|
+
const describeDiff = (diff) => diff.length === 0 ? "the request changed" : diff.map((entry) => entry.field).join(", ");
|
|
291
|
+
/** The request of a call differs from the recorded one. Carries the structured diff. */
|
|
292
|
+
export class ReplayDivergenceError extends Error {
|
|
293
|
+
call;
|
|
294
|
+
lane;
|
|
295
|
+
diff;
|
|
296
|
+
constructor(call, lane, diff) {
|
|
297
|
+
super(`Replay diverged at call ${call} (lane ${lane}): ${describeDiff(diff)}`);
|
|
298
|
+
this.call = call;
|
|
299
|
+
this.lane = lane;
|
|
300
|
+
this.diff = diff;
|
|
301
|
+
this.name = "ReplayDivergenceError";
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
/** The run asked for a call the cassette has no (more) recording for. */
|
|
305
|
+
export class ReplayExhausted extends Error {
|
|
306
|
+
call;
|
|
307
|
+
lane;
|
|
308
|
+
constructor(call, lane, detail) {
|
|
309
|
+
super(`Replay exhausted at request ${call} (lane ${lane}): ${detail}`);
|
|
310
|
+
this.call = call;
|
|
311
|
+
this.lane = lane;
|
|
312
|
+
this.name = "ReplayExhausted";
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
/** Recorded calls the run never asked for (`ReplayProvider.assertConsumed`). */
|
|
316
|
+
export class ReplayUnconsumedError extends Error {
|
|
317
|
+
leftover;
|
|
318
|
+
constructor(leftover) {
|
|
319
|
+
super(`${leftover.length} recorded call(s) were never requested (first: call ${leftover[0]?.call} in lane ${leftover[0]?.lane}).`);
|
|
320
|
+
this.leftover = leftover;
|
|
321
|
+
this.name = "ReplayUnconsumedError";
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
export class ReplayProvider {
|
|
325
|
+
id;
|
|
326
|
+
model;
|
|
327
|
+
listModels;
|
|
328
|
+
store;
|
|
329
|
+
lanes = new Map();
|
|
330
|
+
position = new Map();
|
|
331
|
+
bindings = new Map();
|
|
332
|
+
claimed = new Set();
|
|
333
|
+
requests = 0;
|
|
334
|
+
normalizer;
|
|
335
|
+
constructor(cassette, options) {
|
|
336
|
+
this.normalizer = options.normalizer;
|
|
337
|
+
this.id = cassette.header.provider;
|
|
338
|
+
this.model = cassette.header.model;
|
|
339
|
+
const models = cassette.header.models;
|
|
340
|
+
if (models)
|
|
341
|
+
this.listModels = async () => models;
|
|
342
|
+
this.store = {
|
|
343
|
+
texts: cassette.header.texts,
|
|
344
|
+
toolSets: cassette.header.toolSets,
|
|
345
|
+
messages: Object.assign({}, ...cassette.calls.map((c) => c.messages ?? {})),
|
|
346
|
+
};
|
|
347
|
+
for (const call of cassette.calls) {
|
|
348
|
+
const queue = this.lanes.get(call.lane) ?? [];
|
|
349
|
+
queue.push(call);
|
|
350
|
+
this.lanes.set(call.lane, queue);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
/** The first divergence or exhaustion, kept even if the runner wraps the error it received. */
|
|
354
|
+
failure;
|
|
355
|
+
async *stream(request) {
|
|
356
|
+
request.signal.throwIfAborted();
|
|
357
|
+
let expected;
|
|
358
|
+
try {
|
|
359
|
+
expected = this.match(request);
|
|
360
|
+
}
|
|
361
|
+
catch (error) {
|
|
362
|
+
if (error instanceof ReplayDivergenceError || error instanceof ReplayExhausted)
|
|
363
|
+
this.failure ??= error;
|
|
364
|
+
throw error;
|
|
365
|
+
}
|
|
366
|
+
for (const event of expected.events) {
|
|
367
|
+
request.signal.throwIfAborted();
|
|
368
|
+
yield event;
|
|
369
|
+
}
|
|
370
|
+
if (expected.error)
|
|
371
|
+
throw Object.assign(new Error(expected.error.message), expected.error.code ? { code: expected.error.code } : {});
|
|
372
|
+
}
|
|
373
|
+
/** The recorded call this request must be, consumed; throws when it is not. */
|
|
374
|
+
match(request) {
|
|
375
|
+
const number = this.requests++;
|
|
376
|
+
const material = computeFingerprint(request, this.normalizer);
|
|
377
|
+
const lane = this.laneFor(request, material, number);
|
|
378
|
+
const expected = this.lanes.get(lane)?.[this.position.get(lane) ?? 0];
|
|
379
|
+
if (!expected)
|
|
380
|
+
throw new ReplayExhausted(number, lane, `the cassette has no further recorded call for this lane (${this.lanes.get(lane)?.length ?? 0} recorded).`);
|
|
381
|
+
if (expected.fingerprint !== material.fingerprint)
|
|
382
|
+
throw new ReplayDivergenceError(expected.call, lane, diffFingerprints(expected.call, expected.parts, this.store, material));
|
|
383
|
+
this.position.set(lane, (this.position.get(lane) ?? 0) + 1);
|
|
384
|
+
return expected;
|
|
385
|
+
}
|
|
386
|
+
laneFor(request, material, number) {
|
|
387
|
+
const label = this.normalizer.sessionLabel(request.sessionId);
|
|
388
|
+
if (label)
|
|
389
|
+
return label;
|
|
390
|
+
if (request.sessionId === undefined)
|
|
391
|
+
return NO_SESSION;
|
|
392
|
+
const bound = this.bindings.get(request.sessionId);
|
|
393
|
+
if (bound)
|
|
394
|
+
return bound;
|
|
395
|
+
const base = childLaneBase(material.fingerprint);
|
|
396
|
+
for (let n = 1; this.lanes.has(childLaneName(base, n)); n++) {
|
|
397
|
+
const candidate = childLaneName(base, n);
|
|
398
|
+
if (!this.claimed.has(candidate)) {
|
|
399
|
+
this.claimed.add(candidate);
|
|
400
|
+
this.bindings.set(request.sessionId, candidate);
|
|
401
|
+
return candidate;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
// No recorded child starts with this request: explain it against the next unclaimed child.
|
|
405
|
+
const next = [...this.lanes.entries()]
|
|
406
|
+
.filter(([lane]) => lane.startsWith("L:") && !this.claimed.has(lane))
|
|
407
|
+
.sort(([, a], [, b]) => (a[0]?.call ?? 0) - (b[0]?.call ?? 0))[0];
|
|
408
|
+
const first = next?.[1][0];
|
|
409
|
+
if (next && first) {
|
|
410
|
+
throw new ReplayDivergenceError(first.call, next[0], diffFingerprints(first.call, first.parts, this.store, material));
|
|
411
|
+
}
|
|
412
|
+
throw new ReplayExhausted(number, base, "no recorded child session starts with this request.");
|
|
413
|
+
}
|
|
414
|
+
/** Throws `ReplayUnconsumedError` when recorded calls were never requested (extra calls). */
|
|
415
|
+
assertConsumed() {
|
|
416
|
+
const leftover = [];
|
|
417
|
+
for (const [lane, queue] of this.lanes)
|
|
418
|
+
for (const call of queue.slice(this.position.get(lane) ?? 0))
|
|
419
|
+
leftover.push({ call: call.call, lane });
|
|
420
|
+
leftover.sort((a, b) => a.call - b.call);
|
|
421
|
+
if (leftover.length > 0)
|
|
422
|
+
throw new ReplayUnconsumedError(leftover);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Comparison of two reliability reports and of config variants (specs/010 D7, D14, D15, O3).
|
|
3
|
+
* Pure: no I/O.
|
|
4
|
+
*
|
|
5
|
+
* Statistical rules (the review of Phase 3 starts here):
|
|
6
|
+
* - Only reports of the same `suiteId` AND `suiteVersion` are comparable (typed error otherwise).
|
|
7
|
+
* - Success rates are compared through their 95 % Wilson intervals. The comparison is
|
|
8
|
+
* `inconclusive` when the closed intervals overlap (touching counts as overlap) and `low_power`
|
|
9
|
+
* when either side has fewer than 5 judged runs (`MIN_POWER_K`) even if the intervals are
|
|
10
|
+
* disjoint. `improved` / `regressed` appear only when both sides have k >= 5 and the intervals
|
|
11
|
+
* are disjoint. This is a conservative non-overlap rule, not a hypothesis test: it is stricter
|
|
12
|
+
* than a 5 % test, so it misses real differences instead of inventing them. No multiplicity
|
|
13
|
+
* correction is applied across tasks.
|
|
14
|
+
* - No direction is claimed when the comparison is not like for like: either side has errored runs
|
|
15
|
+
* (the judged runs are then a filtered sample), the modes differ, or (replay) the repetitions
|
|
16
|
+
* are identical so an interval means nothing; replay reports get `changed` / `unchanged` only.
|
|
17
|
+
* - Token and cost deltas are refused when the compaction settings differ: compaction calls are
|
|
18
|
+
* not in the measured tokens, so the two sides would not count the same work.
|
|
19
|
+
* - Nothing is paired: the seed only orders the run plan and never reaches the provider, so the
|
|
20
|
+
* two sides are independent samples and the unpaired intervals are the model.
|
|
21
|
+
* - Every other measured metric gets a plain delta (candidate - baseline) with no direction label:
|
|
22
|
+
* they have no interval here, so no claim is made about them.
|
|
23
|
+
* - Deltas are computed inside `measured` and inside `estimated` and never across them. Costs are
|
|
24
|
+
* compared only when both reports carry the same rates.
|
|
25
|
+
*/
|
|
26
|
+
import type { EvalReport } from "@alisio/sdk";
|
|
27
|
+
export declare class EvalCompareError extends Error {
|
|
28
|
+
readonly reason: "suite_id" | "suite_version";
|
|
29
|
+
constructor(message: string, reason: "suite_id" | "suite_version");
|
|
30
|
+
}
|
|
31
|
+
export type Conditions = EvalReport["conditions"];
|
|
32
|
+
export type Verdict = "improved" | "regressed" | "inconclusive" | "low_power" | "not comparable: errored runs" | "not comparable: mode differs"
|
|
33
|
+
/** Replay only: the pass status or a measured count differs / does not. */
|
|
34
|
+
| "changed" | "unchanged";
|
|
35
|
+
export type TokenComparison = "comparable" | "not comparable: compaction settings differ";
|
|
36
|
+
export interface MetricDelta {
|
|
37
|
+
baseline: number;
|
|
38
|
+
candidate: number;
|
|
39
|
+
/** candidate - baseline */
|
|
40
|
+
delta: number;
|
|
41
|
+
}
|
|
42
|
+
export interface Interval {
|
|
43
|
+
value: number;
|
|
44
|
+
/** Absent for replay reports: identical repetitions give an interval no meaning. */
|
|
45
|
+
wilsonLow?: number;
|
|
46
|
+
wilsonHigh?: number;
|
|
47
|
+
}
|
|
48
|
+
declare const CHANGED_FIELDS: readonly ["configHash", "model", "harnessVersion", "mode", "provider"];
|
|
49
|
+
declare const MEASURED_KEYS: readonly ["medianWallMs", "varianceWallMs", "varianceTokens", "tokensPerSuccess", "wallMsPerSuccess"];
|
|
50
|
+
type MeasuredKey = (typeof MEASURED_KEYS)[number];
|
|
51
|
+
export interface TaskComparison {
|
|
52
|
+
taskId: string;
|
|
53
|
+
/** Judged runs on each side. */
|
|
54
|
+
k: {
|
|
55
|
+
baseline: number;
|
|
56
|
+
candidate: number;
|
|
57
|
+
};
|
|
58
|
+
/** Either side has fewer than 5 judged runs. */
|
|
59
|
+
lowPower: boolean;
|
|
60
|
+
/** The judged-run counts differ, so pass^k is not comparable between the two sides. */
|
|
61
|
+
kMismatch: boolean;
|
|
62
|
+
/** Runs excluded as infrastructure errors on each side. */
|
|
63
|
+
errored: {
|
|
64
|
+
baseline: number;
|
|
65
|
+
candidate: number;
|
|
66
|
+
};
|
|
67
|
+
successRate: {
|
|
68
|
+
baseline: Interval;
|
|
69
|
+
candidate: Interval;
|
|
70
|
+
delta: number;
|
|
71
|
+
/** Absent when no interval was compared (replay, or a mode mismatch). */
|
|
72
|
+
overlap?: boolean;
|
|
73
|
+
verdict: Verdict;
|
|
74
|
+
};
|
|
75
|
+
/** Only for two replay reports: deterministic changed/unchanged facts. */
|
|
76
|
+
replay?: {
|
|
77
|
+
baselinePass: boolean;
|
|
78
|
+
candidatePass: boolean;
|
|
79
|
+
passChanged: boolean;
|
|
80
|
+
countsChanged: boolean;
|
|
81
|
+
};
|
|
82
|
+
passAtK: {
|
|
83
|
+
baseline: boolean;
|
|
84
|
+
candidate: boolean;
|
|
85
|
+
};
|
|
86
|
+
passPowK: {
|
|
87
|
+
baseline: boolean;
|
|
88
|
+
candidate: boolean;
|
|
89
|
+
};
|
|
90
|
+
/** Only the metrics both sides could compute. */
|
|
91
|
+
measured: Partial<Record<MeasuredKey, MetricDelta>>;
|
|
92
|
+
/** Present only when both reports carry the same rates and both could compute the cost. */
|
|
93
|
+
estimated?: {
|
|
94
|
+
costUsdPerSuccess?: MetricDelta;
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
export interface EvalComparison {
|
|
98
|
+
schemaVersion: 1;
|
|
99
|
+
suiteId: string;
|
|
100
|
+
suiteVersion: string;
|
|
101
|
+
baseline: Conditions;
|
|
102
|
+
candidate: Conditions;
|
|
103
|
+
/** Conditions that differ between the two reports (`configHash` over the SHARED tasks only). */
|
|
104
|
+
changed: Array<{
|
|
105
|
+
field: (typeof CHANGED_FIELDS)[number];
|
|
106
|
+
baseline: string;
|
|
107
|
+
candidate: string;
|
|
108
|
+
}>;
|
|
109
|
+
/** Runs excluded as infrastructure errors on each side (every task, shared or not). */
|
|
110
|
+
errored: {
|
|
111
|
+
baseline: number;
|
|
112
|
+
candidate: number;
|
|
113
|
+
};
|
|
114
|
+
/** Whether token and cost deltas were computed (they need equal compaction settings). */
|
|
115
|
+
tokenComparison: TokenComparison;
|
|
116
|
+
tasks: TaskComparison[];
|
|
117
|
+
onlyInBaseline: string[];
|
|
118
|
+
onlyInCandidate: string[];
|
|
119
|
+
warnings: string[];
|
|
120
|
+
}
|
|
121
|
+
/** Compares `candidate` against `baseline`; throws `EvalCompareError` for different suites. */
|
|
122
|
+
export declare function compareReports(baseline: EvalReport, candidate: EvalReport): EvalComparison;
|
|
123
|
+
export interface VariantComparison {
|
|
124
|
+
suiteId: string;
|
|
125
|
+
suiteVersion: string;
|
|
126
|
+
baselineId: string;
|
|
127
|
+
/** The config overrides each variant ran with (what the variants file declared). */
|
|
128
|
+
baselineOverrides: Record<string, unknown>;
|
|
129
|
+
variants: Array<{
|
|
130
|
+
id: string;
|
|
131
|
+
overrides: Record<string, unknown>;
|
|
132
|
+
comparison: EvalComparison;
|
|
133
|
+
}>;
|
|
134
|
+
}
|
|
135
|
+
/** Compares every variant after the first against the first (the baseline). */
|
|
136
|
+
export declare function compareVariants(variants: Array<{
|
|
137
|
+
id: string;
|
|
138
|
+
report: EvalReport;
|
|
139
|
+
overrides?: Record<string, unknown>;
|
|
140
|
+
}>): VariantComparison;
|
|
141
|
+
/** Validates the parsed JSON of a `report.json`; throws an actionable `Error` otherwise. */
|
|
142
|
+
export declare function parseReport(value: unknown): EvalReport;
|
|
143
|
+
/** Markdown of a comparison: what changed, verdicts, then Measured and Estimated apart. */
|
|
144
|
+
export declare function renderComparisonMarkdown(c: EvalComparison): string;
|
|
145
|
+
/** Markdown of a variants comparison: one section per candidate variant against the baseline. */
|
|
146
|
+
export declare function renderVariantComparisonMarkdown(v: VariantComparison): string;
|
|
147
|
+
export {};
|