@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.
Files changed (97) hide show
  1. package/dist/application.d.ts +15 -0
  2. package/dist/application.js +38 -3
  3. package/dist/changelog/data.js +39 -0
  4. package/dist/commands/builtins.js +7 -0
  5. package/dist/config.d.ts +30 -0
  6. package/dist/config.js +32 -0
  7. package/dist/core/runner.d.ts +4 -0
  8. package/dist/core/runner.js +57 -1
  9. package/dist/eval/canonical.d.ts +14 -0
  10. package/dist/eval/canonical.js +40 -0
  11. package/dist/eval/cassette.d.ts +180 -0
  12. package/dist/eval/cassette.js +424 -0
  13. package/dist/eval/compare.d.ts +147 -0
  14. package/dist/eval/compare.js +436 -0
  15. package/dist/eval/engine.d.ts +145 -0
  16. package/dist/eval/engine.js +731 -0
  17. package/dist/eval/export.d.ts +40 -0
  18. package/dist/eval/export.js +90 -0
  19. package/dist/eval/fingerprint.d.ts +88 -0
  20. package/dist/eval/fingerprint.js +186 -0
  21. package/dist/eval/normalize.d.ts +68 -0
  22. package/dist/eval/normalize.js +138 -0
  23. package/dist/eval/paths.d.ts +11 -0
  24. package/dist/eval/paths.js +50 -0
  25. package/dist/eval/plan.d.ts +26 -0
  26. package/dist/eval/plan.js +44 -0
  27. package/dist/eval/report.d.ts +53 -0
  28. package/dist/eval/report.js +258 -0
  29. package/dist/eval/run-suite.d.ts +68 -0
  30. package/dist/eval/run-suite.js +94 -0
  31. package/dist/eval/secrets.d.ts +14 -0
  32. package/dist/eval/secrets.js +55 -0
  33. package/dist/eval/stats.d.ts +17 -0
  34. package/dist/eval/stats.js +50 -0
  35. package/dist/eval/suite.d.ts +20 -0
  36. package/dist/eval/suite.js +165 -0
  37. package/dist/eval/variants.d.ts +35 -0
  38. package/dist/eval/variants.js +137 -0
  39. package/dist/eval/verifiers.d.ts +49 -0
  40. package/dist/eval/verifiers.js +264 -0
  41. package/dist/forge/javascript/hosts/worker-host.d.ts +9 -0
  42. package/dist/forge/javascript/hosts/worker-host.js +16 -1
  43. package/dist/forge/kernel.js +3 -2
  44. package/dist/goal/command.d.ts +7 -2
  45. package/dist/goal/command.js +10 -2
  46. package/dist/goal/format.js +2 -0
  47. package/dist/goal/machine.d.ts +50 -3
  48. package/dist/goal/machine.js +80 -2
  49. package/dist/goal/service.d.ts +22 -2
  50. package/dist/goal/service.js +94 -7
  51. package/dist/goal/store.js +22 -3
  52. package/dist/index.d.ts +18 -2
  53. package/dist/index.js +18 -2
  54. package/dist/plan/criteria.d.ts +30 -0
  55. package/dist/plan/criteria.js +256 -0
  56. package/dist/plan/exit-plan.d.ts +8 -0
  57. package/dist/plan/exit-plan.js +91 -9
  58. package/dist/plan/manifest.d.ts +4 -2
  59. package/dist/plan/manifest.js +6 -1
  60. package/dist/plan/state.d.ts +13 -2
  61. package/dist/plan/state.js +7 -1
  62. package/dist/plugins/host.d.ts +7 -0
  63. package/dist/plugins/host.js +15 -0
  64. package/dist/proof/command.d.ts +24 -0
  65. package/dist/proof/command.js +109 -0
  66. package/dist/proof/coverage.d.ts +40 -0
  67. package/dist/proof/coverage.js +102 -0
  68. package/dist/proof/divergence.d.ts +24 -0
  69. package/dist/proof/divergence.js +120 -0
  70. package/dist/proof/export.d.ts +65 -0
  71. package/dist/proof/export.js +144 -0
  72. package/dist/proof/index.d.ts +12 -0
  73. package/dist/proof/index.js +12 -0
  74. package/dist/proof/ledger.d.ts +38 -0
  75. package/dist/proof/ledger.js +57 -0
  76. package/dist/proof/plugin-verifiers.d.ts +23 -0
  77. package/dist/proof/plugin-verifiers.js +39 -0
  78. package/dist/proof/recorder.d.ts +69 -0
  79. package/dist/proof/recorder.js +347 -0
  80. package/dist/proof/scope.d.ts +12 -0
  81. package/dist/proof/scope.js +121 -0
  82. package/dist/proof/store.d.ts +52 -0
  83. package/dist/proof/store.js +192 -0
  84. package/dist/proof/tree.d.ts +6 -0
  85. package/dist/proof/tree.js +78 -0
  86. package/dist/proof/types.d.ts +21 -0
  87. package/dist/proof/types.js +1 -0
  88. package/dist/proof/verifiers.d.ts +46 -0
  89. package/dist/proof/verifiers.js +176 -0
  90. package/dist/proof/verify.d.ts +36 -0
  91. package/dist/proof/verify.js +171 -0
  92. package/dist/runtime/ids.d.ts +21 -0
  93. package/dist/runtime/ids.js +41 -1
  94. package/dist/runtime/store.js +63 -2
  95. package/dist/tools/goal.d.ts +5 -1
  96. package/dist/tools/goal.js +35 -4
  97. package/package.json +2 -2
@@ -0,0 +1,436 @@
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 { createHash } from "node:crypto";
27
+ import { z } from "zod";
28
+ import { MIN_POWER_K } from "./report.js";
29
+ export class EvalCompareError extends Error {
30
+ reason;
31
+ constructor(message, reason) {
32
+ super(message);
33
+ this.reason = reason;
34
+ this.name = "EvalCompareError";
35
+ }
36
+ }
37
+ const CHANGED_FIELDS = ["configHash", "model", "harnessVersion", "mode", "provider"];
38
+ const MEASURED_KEYS = [
39
+ "medianWallMs",
40
+ "varianceWallMs",
41
+ "varianceTokens",
42
+ "tokensPerSuccess",
43
+ "wallMsPerSuccess",
44
+ ];
45
+ const delta = (baseline, candidate) => ({
46
+ baseline,
47
+ candidate,
48
+ delta: candidate - baseline,
49
+ });
50
+ const TOKEN_KEYS = ["varianceTokens", "tokensPerSuccess"];
51
+ function compareTask(a, b, ctx) {
52
+ const lowPower = a.k < MIN_POWER_K || b.k < MIN_POWER_K;
53
+ const difference = b.successRate.value - a.successRate.value;
54
+ const erroredA = a.errored ?? 0;
55
+ const erroredB = b.errored ?? 0;
56
+ const measured = {};
57
+ for (const key of MEASURED_KEYS) {
58
+ if (!ctx.tokensComparable && TOKEN_KEYS.includes(key))
59
+ continue;
60
+ const before = a.measured[key];
61
+ const after = b.measured[key];
62
+ if (before !== undefined && after !== undefined)
63
+ measured[key] = delta(before, after);
64
+ }
65
+ const costBefore = a.estimated?.costUsdPerSuccess;
66
+ const costAfter = b.estimated?.costUsdPerSuccess;
67
+ const replayFacts = ctx.replay && !ctx.modeDiffers
68
+ ? {
69
+ baselinePass: a.passPowK,
70
+ candidatePass: b.passPowK,
71
+ passChanged: a.passPowK !== b.passPowK || difference !== 0,
72
+ countsChanged: ctx.tokensComparable &&
73
+ (a.measured.tokensPerSuccess !== b.measured.tokensPerSuccess ||
74
+ a.measured.varianceTokens !== b.measured.varianceTokens),
75
+ }
76
+ : undefined;
77
+ let overlap;
78
+ let verdict;
79
+ const withWilson = !replayFacts;
80
+ if (withWilson)
81
+ overlap =
82
+ a.successRate.wilsonLow <= b.successRate.wilsonHigh &&
83
+ b.successRate.wilsonLow <= a.successRate.wilsonHigh;
84
+ if (ctx.modeDiffers)
85
+ verdict = "not comparable: mode differs";
86
+ else if (erroredA > 0 || erroredB > 0)
87
+ verdict = "not comparable: errored runs";
88
+ else if (replayFacts)
89
+ verdict = replayFacts.passChanged || replayFacts.countsChanged ? "changed" : "unchanged";
90
+ else if (overlap)
91
+ verdict = "inconclusive";
92
+ else if (lowPower)
93
+ verdict = "low_power";
94
+ else
95
+ verdict = difference > 0 ? "improved" : "regressed";
96
+ const side = (m) => withWilson ? { ...m.successRate } : { value: m.successRate.value };
97
+ return {
98
+ taskId: a.taskId,
99
+ k: { baseline: a.k, candidate: b.k },
100
+ lowPower,
101
+ kMismatch: a.k !== b.k,
102
+ errored: { baseline: erroredA, candidate: erroredB },
103
+ successRate: {
104
+ baseline: side(a),
105
+ candidate: side(b),
106
+ delta: difference,
107
+ ...(overlap === undefined ? {} : { overlap }),
108
+ verdict,
109
+ },
110
+ ...(replayFacts ? { replay: replayFacts } : {}),
111
+ passAtK: { baseline: a.passAtK, candidate: b.passAtK },
112
+ passPowK: { baseline: a.passPowK, candidate: b.passPowK },
113
+ measured,
114
+ ...(ctx.sameRates && ctx.tokensComparable && costBefore !== undefined && costAfter !== undefined
115
+ ? { estimated: { costUsdPerSuccess: delta(costBefore, costAfter) } }
116
+ : {}),
117
+ };
118
+ }
119
+ const sameRates = (a, b) => a !== undefined &&
120
+ b !== undefined &&
121
+ a.inputPerMTokUsd === b.inputPerMTokUsd &&
122
+ a.outputPerMTokUsd === b.outputPerMTokUsd;
123
+ /** Compares `candidate` against `baseline`; throws `EvalCompareError` for different suites. */
124
+ export function compareReports(baseline, candidate) {
125
+ if (baseline.suiteId !== candidate.suiteId)
126
+ throw new EvalCompareError(`Cannot compare different suites: baseline is "${baseline.suiteId}", candidate is "${candidate.suiteId}".`, "suite_id");
127
+ if (baseline.suiteVersion !== candidate.suiteVersion)
128
+ throw new EvalCompareError(`Cannot compare different suite versions of "${baseline.suiteId}": baseline is ${baseline.suiteVersion}, candidate is ${candidate.suiteVersion}. A changed fixture or verifier changes what is measured; rerun both on the same version.`, "suite_version");
129
+ const candidateTasks = new Map(candidate.tasks.map((t) => [t.taskId, t]));
130
+ const baselineIds = new Set(baseline.tasks.map((t) => t.taskId));
131
+ const rates = sameRates(baseline.rates, candidate.rates);
132
+ const modeDiffers = baseline.conditions.mode !== candidate.conditions.mode;
133
+ const compactionA = baseline.conditions.compactionHash;
134
+ const compactionB = candidate.conditions.compactionHash;
135
+ const compactionDiffers = compactionA !== undefined && compactionB !== undefined && compactionA !== compactionB;
136
+ const context = {
137
+ sameRates: rates,
138
+ tokensComparable: !compactionDiffers,
139
+ modeDiffers,
140
+ replay: baseline.conditions.mode === "replay" && candidate.conditions.mode === "replay",
141
+ };
142
+ const tasks = baseline.tasks.flatMap((t) => {
143
+ const other = candidateTasks.get(t.taskId);
144
+ return other ? [compareTask(t, other, context)] : [];
145
+ });
146
+ const warnings = [];
147
+ const weak = tasks.filter((t) => t.lowPower).map((t) => t.taskId);
148
+ if (weak.length > 0 && !context.replay)
149
+ warnings.push(`Low power: ${weak.join(", ")} ${weak.length === 1 ? "has" : "have"} fewer than ${MIN_POWER_K} judged runs on at least one side; no improvement or regression is claimed for ${weak.length === 1 ? "it" : "them"}.`);
150
+ const erroredTasks = tasks.filter((t) => t.errored.baseline > 0 || t.errored.candidate > 0);
151
+ if (erroredTasks.length > 0)
152
+ warnings.push(`Errored runs (infrastructure failures excluded from the rates) on ${erroredTasks.map((t) => `${t.taskId} (base ${t.errored.baseline} / cand ${t.errored.candidate})`).join(", ")}: the judged runs are a filtered sample, so no improvement or regression is claimed for ${erroredTasks.length === 1 ? "it" : "them"}. Fix the cause and rerun.`);
153
+ const mismatched = tasks.filter((t) => t.kMismatch).map((t) => t.taskId);
154
+ if (mismatched.length > 0)
155
+ warnings.push(`Different k between the sides on ${mismatched.join(", ")}: pass^k is only comparable at equal k.`);
156
+ if (baseline.rates && candidate.rates && !rates)
157
+ warnings.push("The two reports were estimated with different rates: costs are not compared.");
158
+ if (modeDiffers)
159
+ warnings.push(`The modes differ (${baseline.conditions.mode} vs ${candidate.conditions.mode}): replay measures the harness, not the model, so these are different kinds of measurement; no improvement or regression is claimed.`);
160
+ if (compactionDiffers)
161
+ warnings.push("The compaction settings differ between the reports: token and cost deltas are not computed, because compaction calls are not in the measured main-loop tokens.");
162
+ else if ((compactionA === undefined) !== (compactionB === undefined))
163
+ warnings.push("The compaction settings of one report are unknown (it predates the compaction identity): token and cost deltas assume they are equal.");
164
+ const shared = baseline.tasks
165
+ .map((t) => t.taskId)
166
+ .filter((id) => candidateTasks.has(id))
167
+ .sort();
168
+ const hashA = sharedConfigHash(baseline, shared);
169
+ const hashB = sharedConfigHash(candidate, shared);
170
+ const valueOf = (field, r, shared) => (field === "configHash" ? (shared ?? r.conditions.configHash) : r.conditions[field]);
171
+ return {
172
+ schemaVersion: 1,
173
+ suiteId: baseline.suiteId,
174
+ suiteVersion: baseline.suiteVersion,
175
+ baseline: baseline.conditions,
176
+ candidate: candidate.conditions,
177
+ changed: CHANGED_FIELDS.flatMap((field) => {
178
+ const before = valueOf(field, baseline, hashA);
179
+ const after = valueOf(field, candidate, hashB);
180
+ return before === after ? [] : [{ field, baseline: before, candidate: after }];
181
+ }),
182
+ errored: { baseline: baseline.summary.errored, candidate: candidate.summary.errored },
183
+ tokenComparison: compactionDiffers
184
+ ? "not comparable: compaction settings differ"
185
+ : "comparable",
186
+ tasks,
187
+ onlyInBaseline: baseline.tasks.map((t) => t.taskId).filter((id) => !candidateTasks.has(id)),
188
+ onlyInCandidate: candidate.tasks.map((t) => t.taskId).filter((id) => !baselineIds.has(id)),
189
+ warnings,
190
+ };
191
+ }
192
+ /**
193
+ * The `configHash` of a report restricted to `ids`: the hash every shared task has, or the hash of
194
+ * the sorted pairs (the scheme `buildReport` uses). `undefined` when a task carries no hash, so
195
+ * the caller falls back to the report-level condition. Comparing only the SHARED tasks keeps a
196
+ * subset run (`--tasks`) from looking like a configuration change.
197
+ */
198
+ function sharedConfigHash(report, ids) {
199
+ if (ids.length === 0)
200
+ return undefined;
201
+ const byId = new Map(report.tasks.map((t) => [t.taskId, t.configHash]));
202
+ const pairs = ids.map((id) => [id, byId.get(id)]);
203
+ if (pairs.some(([, hash]) => hash === undefined))
204
+ return undefined;
205
+ const distinct = new Set(pairs.map(([, hash]) => hash));
206
+ if (distinct.size === 1)
207
+ return pairs[0]?.[1];
208
+ return `sha256:${createHash("sha256").update(JSON.stringify(pairs)).digest("hex")}`;
209
+ }
210
+ const overridesOf = (v) => v.overrides ?? v.report.variant?.overrides ?? {};
211
+ /** Compares every variant after the first against the first (the baseline). */
212
+ export function compareVariants(variants) {
213
+ const [baseline, ...rest] = variants;
214
+ if (!baseline || rest.length === 0)
215
+ throw new EvalCompareError("Comparing variants needs at least two reports.", "suite_id");
216
+ return {
217
+ suiteId: baseline.report.suiteId,
218
+ suiteVersion: baseline.report.suiteVersion,
219
+ baselineId: baseline.id,
220
+ baselineOverrides: overridesOf(baseline),
221
+ variants: rest.map((v) => ({
222
+ id: v.id,
223
+ overrides: overridesOf(v),
224
+ comparison: compareReports(baseline.report, v.report),
225
+ })),
226
+ };
227
+ }
228
+ // ---------------------------------------------------------------------------------------------
229
+ // Report files
230
+ // ---------------------------------------------------------------------------------------------
231
+ const intervalSchema = z.object({
232
+ value: z.number(),
233
+ wilsonLow: z.number(),
234
+ wilsonHigh: z.number(),
235
+ });
236
+ const reportSchema = z.object({
237
+ schemaVersion: z.literal(1, { error: "unsupported schemaVersion (expected 1)" }),
238
+ suiteId: z.string(),
239
+ suiteVersion: z.string(),
240
+ conditions: z.object({
241
+ harnessVersion: z.string(),
242
+ configHash: z.string(),
243
+ provider: z.string(),
244
+ model: z.string(),
245
+ mode: z.enum(["live", "replay"]),
246
+ k: z.number(),
247
+ seedBase: z.number(),
248
+ compactionHash: z.string().optional(),
249
+ }),
250
+ variant: z.object({ id: z.string(), overrides: z.record(z.string(), z.unknown()) }).optional(),
251
+ notes: z.array(z.string()).optional(),
252
+ tasks: z.array(z.object({
253
+ taskId: z.string(),
254
+ k: z.number(),
255
+ successRate: intervalSchema,
256
+ passAtK: z.boolean(),
257
+ passPowK: z.boolean(),
258
+ errored: z.number().optional(),
259
+ configHash: z.string().optional(),
260
+ measured: z.object({
261
+ medianWallMs: z.number().optional(),
262
+ varianceWallMs: z.number().optional(),
263
+ varianceTokens: z.number().optional(),
264
+ tokensPerSuccess: z.number().optional(),
265
+ wallMsPerSuccess: z.number().optional(),
266
+ }),
267
+ estimated: z.object({ costUsdPerSuccess: z.number().optional() }).optional(),
268
+ })),
269
+ runs: z.array(z.string()),
270
+ summary: z.object({
271
+ tasks: z.number(),
272
+ passed: z.number(),
273
+ errored: z.number(),
274
+ budgetExceeded: z.number(),
275
+ runs: z.number().optional(),
276
+ failed: z.number().optional(),
277
+ }),
278
+ rates: z.object({ inputPerMTokUsd: z.number(), outputPerMTokUsd: z.number() }).optional(),
279
+ });
280
+ /** Validates the parsed JSON of a `report.json`; throws an actionable `Error` otherwise. */
281
+ export function parseReport(value) {
282
+ const parsed = reportSchema.safeParse(value);
283
+ if (!parsed.success) {
284
+ const issue = parsed.error.issues[0];
285
+ const where = issue?.path.length ? ` at "${issue.path.join(".")}"` : "";
286
+ throw new Error(`Not a valid reliability report (report.json written by \`alisio eval run\`): ${issue?.message ?? "invalid"}${where}.`);
287
+ }
288
+ return parsed.data;
289
+ }
290
+ // ---------------------------------------------------------------------------------------------
291
+ // Markdown
292
+ // ---------------------------------------------------------------------------------------------
293
+ const cell = (text) => text.replace(/\|/g, "\\|").replace(/\r?\n/g, " ");
294
+ const percent = (value) => `${Math.round(value * 1000) / 10}%`;
295
+ const signed = (value, format) => `${value > 0 ? "+" : value < 0 ? "-" : ""}${format(Math.abs(value))}`;
296
+ const points = (value) => `${Math.round(value * 1000) / 10} pp`;
297
+ const interval = (i, k) => k === 0
298
+ ? "n/a"
299
+ : i.wilsonLow === undefined || i.wilsonHigh === undefined
300
+ ? percent(i.value)
301
+ : `${percent(i.value)} [${percent(i.wilsonLow)}, ${percent(i.wilsonHigh)}]`;
302
+ const VERDICT_TEXT = {
303
+ improved: "improved",
304
+ regressed: "regressed",
305
+ inconclusive: "inconclusive (intervals overlap)",
306
+ low_power: "low power (k<5, no claim)",
307
+ "not comparable: errored runs": "not comparable: errored runs",
308
+ "not comparable: mode differs": "not comparable: mode differs",
309
+ changed: "changed",
310
+ unchanged: "unchanged",
311
+ };
312
+ const METRIC_LABEL = {
313
+ medianWallMs: "Median wall (ms)",
314
+ varianceWallMs: "Wall variance (ms^2)",
315
+ varianceTokens: "Token variance (main-loop tokens)",
316
+ tokensPerSuccess: "Tokens per success (main-loop tokens, excludes compaction calls)",
317
+ wallMsPerSuccess: "Wall per success (ms)",
318
+ };
319
+ const number = (value) => String(Math.round(value * 1000) / 1000);
320
+ function table(headers, rows) {
321
+ return [
322
+ `| ${headers.join(" | ")} |`,
323
+ `| ${headers.map(() => "---").join(" | ")} |`,
324
+ ...rows.map((row) => `| ${row.join(" | ")} |`),
325
+ ].join("\n");
326
+ }
327
+ function body(c, level) {
328
+ const lines = [
329
+ `- Baseline: \`${c.baseline.model}\`, mode ${c.baseline.mode}, harness ${c.baseline.harnessVersion}, config \`${c.baseline.configHash}\``,
330
+ `- Candidate: \`${c.candidate.model}\`, mode ${c.candidate.mode}, harness ${c.candidate.harnessVersion}, config \`${c.candidate.configHash}\``,
331
+ "",
332
+ `${level} What changed`,
333
+ "",
334
+ c.changed.length === 0
335
+ ? "Nothing among the hashed conditions (configHash, model, harnessVersion, mode, provider)."
336
+ : table(["Condition", "Baseline", "Candidate"], c.changed.map((x) => [x.field, `\`${cell(x.baseline)}\``, `\`${cell(x.candidate)}\``])),
337
+ "",
338
+ "> Built-in plugin options and provider endpoint settings are not hashed: a change in either does not show up here.",
339
+ "",
340
+ ];
341
+ if (c.baseline.mode === "replay" || c.candidate.mode === "replay")
342
+ lines.push("> Replay measures the harness, not the model: a replay comparison says nothing about model quality.", "");
343
+ for (const warning of c.warnings)
344
+ lines.push(`> ${warning}`, "");
345
+ if (c.onlyInBaseline.length > 0)
346
+ lines.push(`Only in the baseline (not compared): ${c.onlyInBaseline.join(", ")}`, "");
347
+ if (c.onlyInCandidate.length > 0)
348
+ lines.push(`Only in the candidate (not compared): ${c.onlyInCandidate.join(", ")}`, "");
349
+ const replayOnly = c.baseline.mode === "replay" && c.candidate.mode === "replay";
350
+ lines.push(`${level} Success rate`, "", replayOnly
351
+ ? "Replay repeats an identical recording, so no interval is computed: each task is reported as changed or unchanged (pass status and measured counts)."
352
+ : "Wilson 95% intervals. A direction is claimed only with k>=5 on both sides, disjoint intervals and no errored runs; otherwise it stays inconclusive or not comparable.", "", table([
353
+ "Task",
354
+ "k (base / cand)",
355
+ "Errored (base / cand)",
356
+ "Baseline",
357
+ "Candidate",
358
+ "Delta",
359
+ "Verdict",
360
+ ], c.tasks.map((t) => [
361
+ cell(t.taskId),
362
+ `${t.k.baseline} / ${t.k.candidate}${t.kMismatch ? " (k differs)" : ""}`,
363
+ `${t.errored.baseline} / ${t.errored.candidate}`,
364
+ interval(t.successRate.baseline, t.k.baseline),
365
+ interval(t.successRate.candidate, t.k.candidate),
366
+ signed(t.successRate.delta, points),
367
+ VERDICT_TEXT[t.successRate.verdict],
368
+ ])), "", `${level} Measured`, "", c.tokenComparison === "comparable"
369
+ ? "Deltas are candidate minus baseline over provider-reported usage and wall time. Token figures are main-loop tokens (excludes compaction calls). They carry no verdict."
370
+ : "Deltas are candidate minus baseline over wall time. Token deltas are not compared: not comparable: compaction settings differ (compaction calls are not in the main-loop tokens). They carry no verdict.", "");
371
+ const measuredRows = c.tasks.flatMap((t) => MEASURED_KEYS.flatMap((key) => {
372
+ const d = t.measured[key];
373
+ return d
374
+ ? [
375
+ [
376
+ cell(t.taskId),
377
+ METRIC_LABEL[key],
378
+ number(d.baseline),
379
+ number(d.candidate),
380
+ signed(d.delta, number),
381
+ ],
382
+ ]
383
+ : [];
384
+ }));
385
+ lines.push(measuredRows.length === 0
386
+ ? "No metric could be computed on both sides."
387
+ : table(["Task", "Metric", "Baseline", "Candidate", "Delta"], measuredRows), "", `${level} Estimated`, "");
388
+ const estimatedRows = c.tasks.flatMap((t) => {
389
+ const d = t.estimated?.costUsdPerSuccess;
390
+ return d
391
+ ? [
392
+ [
393
+ cell(t.taskId),
394
+ `$${d.baseline.toFixed(4)}`,
395
+ `$${d.candidate.toFixed(4)}`,
396
+ signed(d.delta, (v) => `$${v.toFixed(4)}`),
397
+ ],
398
+ ]
399
+ : [];
400
+ });
401
+ lines.push(estimatedRows.length === 0
402
+ ? "Not compared: the reports carry no cost estimate, were estimated with different rates, or differ in compaction settings."
403
+ : table(["Task", "Cost per success (base)", "Candidate", "Delta"], estimatedRows));
404
+ return lines;
405
+ }
406
+ /** The footnote every comparison Markdown carries (statistical limits, stated once, verbatim). */
407
+ function footnote(tasks, errored, drift) {
408
+ return `*improved/regressed = 95% Wilson intervals disjoint with at least 5 judged runs per side. Conservative screen, not a significance test; 'inconclusive' is not 'no difference'. Assumes independent repetitions; ${drift}; no correction across ${tasks} tasks; ${errored} runs excluded as infrastructure errors; seeds order runs only and do not reach the provider.*`;
409
+ }
410
+ /** Markdown of a comparison: what changed, verdicts, then Measured and Estimated apart. */
411
+ export function renderComparisonMarkdown(c) {
412
+ const note = footnote(c.tasks.length, c.errored.baseline + c.errored.candidate, "the two reports ran separately, so time drift is not controlled");
413
+ return `${[`# Comparison: ${c.suiteId} ${c.suiteVersion}`, "", ...body(c, "##"), "", note].join("\n")}\n`;
414
+ }
415
+ /** Markdown of a variants comparison: one section per candidate variant against the baseline. */
416
+ export function renderVariantComparisonMarkdown(v) {
417
+ const overrides = (o) => Object.keys(o).length === 0
418
+ ? "no overrides"
419
+ : Object.entries(o)
420
+ .map(([key, value]) => `\`${key}\` = \`${JSON.stringify(value)}\``)
421
+ .join(", ");
422
+ const lines = [
423
+ `# Variants: ${v.suiteId} ${v.suiteVersion}`,
424
+ "",
425
+ `Baseline variant: \`${v.baselineId}\` (${overrides(v.baselineOverrides)}). Each section compares one variant against it, over the same tasks; seeds only order the plan, nothing is paired, and the unpaired intervals are independent.`,
426
+ "",
427
+ ];
428
+ for (const { id, overrides: own, comparison } of v.variants)
429
+ lines.push(`## ${id} vs ${v.baselineId}`, "", `Overrides of \`${id}\`: ${overrides(own)}.`, "", ...body(comparison, "###"), "");
430
+ const first = v.variants[0]?.comparison;
431
+ const errored = (first?.errored.baseline ?? 0) +
432
+ v.variants.reduce((n, x) => n + x.comparison.errored.candidate, 0);
433
+ const tasks = v.variants.reduce((n, x) => n + x.comparison.tasks.length, 0);
434
+ lines.push(footnote(tasks, errored, "variants ran interleaved but time drift is not fully controlled"));
435
+ return `${lines.join("\n").trimEnd()}\n`;
436
+ }
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Live engine of the reliability lab (specs/010 Phase 2): runs ONE task repetition through the
3
+ * real Application (runner, tools, store) on a throwaway copy of its fixture and returns an
4
+ * `EvalRunRecord` with the conditions it was measured under.
5
+ *
6
+ * What the isolation guarantees, and what it does not:
7
+ * - The fixture is copied; the original is never touched. Config, state, home and tmp live in the
8
+ * run directory and the process environment points at them while the application exists.
9
+ * - File tools are mediated: a write to `../x` or to an absolute path outside the copy is refused
10
+ * (`safePath`). That is path confinement, NOT an OS sandbox.
11
+ * - A granted `process` effect (or a `command` verifier) runs with the user's privileges and can
12
+ * write anywhere the user can. When `process` is granted the "nothing outside changes" claim does
13
+ * not hold. A subprocess is not a sandbox.
14
+ *
15
+ * Runs never overlap: `ALISIO_CONFIG_HOME`/`ALISIO_STATE_HOME` are process-global, so `runTask`
16
+ * serializes concurrent callers.
17
+ */
18
+ import type { EvalBudget, EvalRunRecord, EvalTask, ModelProvider, RunEvent } from "@alisio/sdk";
19
+ import { type BuiltinPlugin, createApplication } from "../application.ts";
20
+ import { type Config } from "../config.ts";
21
+ import type { RunOptions } from "../core/runner.ts";
22
+ import { type Cassette, CassetteRecorder } from "./cassette.ts";
23
+ import type { LoadedSuite } from "./suite.ts";
24
+ export type EvalApplication = Awaited<ReturnType<typeof createApplication>>;
25
+ type BudgetKey = NonNullable<EvalRunRecord["budgetExceeded"]>;
26
+ /** The run was refused before it started (policy, `full` without opt-in, unsafe fixture). */
27
+ export declare class EvalRunRefusedError extends Error {
28
+ constructor(message: string);
29
+ }
30
+ /** Abort reason of the engine's own budgets (`maxWallMs`, `maxToolCalls`). */
31
+ export declare class EvalBudgetExceeded extends Error {
32
+ readonly budget: BudgetKey;
33
+ constructor(budget: BudgetKey);
34
+ }
35
+ /** Effects the invoker (the person or CI job running the lab) holds. A task can never exceed them. */
36
+ export interface InvokerPermissions {
37
+ write: boolean;
38
+ process: boolean;
39
+ external: boolean;
40
+ analysis: boolean;
41
+ readOnly?: boolean;
42
+ }
43
+ /**
44
+ * `live` talks to `provider`; `record` is a live run whose provider calls are also written into the
45
+ * `CassetteRecorder` passed as `cassette`; `replay` serves the recorded `Cassette` passed as
46
+ * `cassette` and needs no provider. Record and replay install a seeded id source for the run (so
47
+ * session, plan and execution ids do not depend on fresh randomness) and wait for the model
48
+ * catalog before the first request (so the output limit does not depend on a race).
49
+ */
50
+ export type EvalRunMode = "live" | "record" | "replay";
51
+ export interface RunTaskOptions {
52
+ suite: LoadedSuite;
53
+ /**
54
+ * Already resolved by the caller; injected into the application (no network lookups here).
55
+ * Required except in `replay` mode, where the cassette supplies the provider.
56
+ */
57
+ provider?: ModelProvider;
58
+ mode?: EvalRunMode;
59
+ cassette?: CassetteRecorder | Cassette;
60
+ /** Receives every `RunEvent` of the run (tests and tooling; the engine keeps its own counts). */
61
+ onEvent?: (event: RunEvent) => void;
62
+ /**
63
+ * Written (stripped of plugins, skills, MCP, extra roots, scratch root and memory `dbPath`) as the global
64
+ * config layer of the run. Phase 3 variant overrides (D15) MUST be applied to this object before
65
+ * calling: the `configHash` is computed from what is written.
66
+ */
67
+ config: Config;
68
+ /** Orders runs only. The provider never receives it, so it is not determinism. */
69
+ seed: number;
70
+ repetition: number;
71
+ invoker: InvokerPermissions;
72
+ /** Required for a task asking for `full`, regardless of what the invoker holds. */
73
+ allowSuiteFull?: boolean;
74
+ keep?: boolean;
75
+ builtins?: BuiltinPlugin[];
76
+ /** Turns `task.agent` into run options; without it a task naming an agent ends `error`. */
77
+ resolveAgent?: (app: EvalApplication, agent: string) => RunOptions | Promise<RunOptions>;
78
+ /** Parent of the run directory (default `os.tmpdir()`). */
79
+ tmpRoot?: string;
80
+ }
81
+ /**
82
+ * Which top-level config keys feed `configHash`. The `satisfies` clause fails the typecheck, and
83
+ * `tests/eval-engine.test.ts` fails at runtime, when a key is added to the schema without being
84
+ * classified here: a new setting must be consciously hashed or excluded.
85
+ */
86
+ export declare const CONFIG_HASH_COVERAGE: {
87
+ readonly limits: "hashed";
88
+ readonly compaction: "hashed";
89
+ readonly context: "hashed";
90
+ readonly forge: "hashed";
91
+ readonly analysis: "hashed";
92
+ readonly websearch: "hashed";
93
+ readonly decisions: "hashed";
94
+ readonly skillOverrides: "hashed";
95
+ readonly schemaVersion: "excluded";
96
+ readonly provider: "excluded";
97
+ readonly plugins: "excluded";
98
+ readonly skills: "excluded";
99
+ readonly additionalDirectories: "excluded";
100
+ readonly mcp: "excluded";
101
+ readonly builtinPlugins: "excluded";
102
+ readonly pluginOverrides: "excluded";
103
+ readonly pluginHooks: "excluded";
104
+ readonly tasks: "excluded";
105
+ readonly goal: "excluded";
106
+ readonly plan: "excluded";
107
+ readonly proof: "excluded";
108
+ readonly tui: "excluded";
109
+ readonly agents: "excluded";
110
+ readonly web: "excluded";
111
+ };
112
+ export interface ConfigHashExtras {
113
+ /** The effective policy of the run after the invoker check and `readOnly`. */
114
+ policy: {
115
+ write: boolean;
116
+ process: boolean;
117
+ external: boolean;
118
+ analysis: boolean;
119
+ readOnly?: boolean;
120
+ };
121
+ approvalMode: string;
122
+ budget: EvalBudget;
123
+ /** Ids of the built-in plugins handed to the application (kept only when not disabled). */
124
+ builtinIds: string[];
125
+ }
126
+ /**
127
+ * `sha256:` of the allowlisted effective configuration plus policy, approval mode, budget and
128
+ * enabled built-ins. Excludes secrets, env names, plugin options, absolute paths, endpoints, the
129
+ * provider and the model (those are separate columns of the run record).
130
+ */
131
+ export declare function computeConfigHash(config: Config, extras: ConfigHashExtras): string;
132
+ /**
133
+ * Throws the same `EvalRunRefusedError` `runTask` would, without running anything. Lets a caller
134
+ * refuse a whole batch up front instead of discovering a refusal after earlier runs finished.
135
+ */
136
+ export declare function checkTaskPolicy(task: EvalTask, opts: Pick<RunTaskOptions, "invoker" | "allowSuiteFull">): void;
137
+ /**
138
+ * Runs one repetition of `task`. Refusals (`EvalRunRefusedError`) reject before anything is
139
+ * created; infrastructure failures resolve with `status: "error"` and `error` set.
140
+ */
141
+ export declare function runTask(task: EvalTask, opts: RunTaskOptions): Promise<{
142
+ record: EvalRunRecord;
143
+ keptDir?: string;
144
+ }>;
145
+ export {};