@rulvar/evals 1.17.0 → 1.18.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/index.d.ts +75 -15
- package/dist/index.js +179 -116
- package/package.json +3 -3
package/dist/index.d.ts
CHANGED
|
@@ -105,6 +105,20 @@ interface EvalCaseResult {
|
|
|
105
105
|
/** The target run's normalized usage. */
|
|
106
106
|
usage: Usage;
|
|
107
107
|
error?: WireError;
|
|
108
|
+
/**
|
|
109
|
+
* Present when grading stopped for a BUDGET reason (v1.17.0 review
|
|
110
|
+
* P1-5): the judge run hit its own per-run ceiling
|
|
111
|
+
* ('judge-exhausted') or the aggregate envelope refused a judge run
|
|
112
|
+
* before it started ('judge-refused'). The paid target evidence and
|
|
113
|
+
* its cost stay on this row, but the case can never count as passed
|
|
114
|
+
* and its cell emits no claim. Unexpected grader errors still throw:
|
|
115
|
+
* a grader that cannot grade for non-budget reasons is a defect of
|
|
116
|
+
* the suite, not a budget event.
|
|
117
|
+
*/
|
|
118
|
+
incomplete?: {
|
|
119
|
+
reason: "judge-exhausted" | "judge-refused";
|
|
120
|
+
detail: string;
|
|
121
|
+
};
|
|
108
122
|
}
|
|
109
123
|
interface RunEvalCaseOptions {
|
|
110
124
|
/** Display-name override; defaults to the workflow name. */
|
|
@@ -125,7 +139,9 @@ interface RunEvalCaseOptions {
|
|
|
125
139
|
declare class EvalJudgeError extends Error {
|
|
126
140
|
readonly judgeRun: string;
|
|
127
141
|
readonly status: RunOutcome<Json>["status"];
|
|
128
|
-
|
|
142
|
+
/** What the failing judge run actually spent (honest cost accounting). */
|
|
143
|
+
readonly costUsd: number;
|
|
144
|
+
constructor(judgeRun: string, status: RunOutcome<Json>["status"], detail?: string, costUsd?: number);
|
|
129
145
|
}
|
|
130
146
|
/**
|
|
131
147
|
* Runs one EvalCase on the given engine: the target workflow as its own
|
|
@@ -137,11 +153,27 @@ declare function runEvalCase(engine: Engine, evalCase: EvalCase, options?: RunEv
|
|
|
137
153
|
/** Aggregate view of a suite run. */
|
|
138
154
|
interface EvalSuiteResult {
|
|
139
155
|
results: EvalCaseResult[];
|
|
140
|
-
/** Fraction of
|
|
156
|
+
/** Fraction of result rows with passed true; 0 for an empty suite. */
|
|
141
157
|
passRate: number;
|
|
142
158
|
totalCostUsd: number;
|
|
143
|
-
/** Arithmetic mean over
|
|
159
|
+
/** Arithmetic mean over result rows; 0 for an empty suite. */
|
|
144
160
|
meanLatencyMs: number;
|
|
161
|
+
/** Cases the caller asked for. */
|
|
162
|
+
plannedN: number;
|
|
163
|
+
/** Result rows actually produced (equals results.length). */
|
|
164
|
+
completedN: number;
|
|
165
|
+
/**
|
|
166
|
+
* Present when the aggregate envelope refused a TARGET run before it
|
|
167
|
+
* started (v1.17.0 review P1-5). The suite stops there and returns
|
|
168
|
+
* everything already measured instead of throwing: completed rows,
|
|
169
|
+
* their costs, and their names survive. Judge refusals never appear
|
|
170
|
+
* here; they normalize into the owning row's `incomplete` marker.
|
|
171
|
+
*/
|
|
172
|
+
refusal?: {
|
|
173
|
+
runLabel: string;
|
|
174
|
+
atCase: string;
|
|
175
|
+
detail: string;
|
|
176
|
+
};
|
|
145
177
|
}
|
|
146
178
|
interface RunEvalSuiteOptions {
|
|
147
179
|
budgetUsd?: number;
|
|
@@ -294,13 +326,18 @@ interface CanaryReport {
|
|
|
294
326
|
/**
|
|
295
327
|
* True only when every probe settled ok. A fingerprint containing a
|
|
296
328
|
* non-ok probe status is a measurement artifact (budget exhaustion,
|
|
297
|
-
* transient provider failure), NOT evidence of
|
|
298
|
-
* feed it to flipStaleOnCanaryDrift.
|
|
329
|
+
* an envelope refusal, transient provider failure), NOT evidence of
|
|
330
|
+
* model drift: never feed it to flipStaleOnCanaryDrift.
|
|
299
331
|
*/
|
|
300
332
|
allOk: boolean;
|
|
333
|
+
/**
|
|
334
|
+
* One row per probe; 'refused' means the aggregate envelope refused
|
|
335
|
+
* the probe before it started (v1.17.0 review P1-5): the loop keeps
|
|
336
|
+
* walking so completed probe evidence survives, and allOk is false.
|
|
337
|
+
*/
|
|
301
338
|
probes: Array<{
|
|
302
339
|
prompt: string;
|
|
303
|
-
status: RunOutcome<unknown>["status"];
|
|
340
|
+
status: RunOutcome<unknown>["status"] | "refused";
|
|
304
341
|
}>;
|
|
305
342
|
}
|
|
306
343
|
/** The committed v1 normalization (OQ-06): NFC, trim, collapse whitespace. */
|
|
@@ -310,10 +347,12 @@ declare function normalizeCanaryOutput(output: unknown): string;
|
|
|
310
347
|
* sequentially in declaration order, one run per probe, so recordings
|
|
311
348
|
* replay deterministically. Each probe run carries the optional
|
|
312
349
|
* immutable ceiling (options.budgetUsd) and authorizes it against the
|
|
313
|
-
* optional envelope before starting
|
|
314
|
-
*
|
|
315
|
-
*
|
|
316
|
-
*
|
|
350
|
+
* optional envelope before starting; an envelope refusal records the
|
|
351
|
+
* probe as 'refused' and keeps walking instead of throwing away the
|
|
352
|
+
* completed probes. A non-ok or refused probe enters the fingerprint
|
|
353
|
+
* as `!status` and clears allOk: callers gate drift flipping on allOk,
|
|
354
|
+
* because a budget-starved or transiently failing probe fingerprints
|
|
355
|
+
* differently without the model having drifted.
|
|
317
356
|
*/
|
|
318
357
|
declare function runCanary(engine: Engine, probes: CanaryProbeSet, options?: CanaryRunOptions): Promise<CanaryReport>;
|
|
319
358
|
/**
|
|
@@ -390,9 +429,12 @@ interface RunSweepOptions {
|
|
|
390
429
|
* ceiling before starting, so the pool times cases times judge-call
|
|
391
430
|
* product cannot exceed it, falsification pool growth included. An
|
|
392
431
|
* envelope requires suite.budgetUsd (and suite.judgeBudgetUsd once a
|
|
393
|
-
* grader judges)
|
|
394
|
-
*
|
|
395
|
-
*
|
|
432
|
+
* grader judges). Refusals are monotone (v1.17.0 review P1-5): a
|
|
433
|
+
* refused target stops that cell's walk but everything already
|
|
434
|
+
* measured stays on the cell (n, costs, caseNames), judge refusals
|
|
435
|
+
* normalize into their row's incomplete marker, and an incomplete
|
|
436
|
+
* cell emits NO claim. Share the instance with the canary loop so
|
|
437
|
+
* probes draw from the same remainder.
|
|
396
438
|
*/
|
|
397
439
|
envelope?: SpendEnvelope;
|
|
398
440
|
/** When given, emitted claims commit through the committer identity. */
|
|
@@ -408,7 +450,14 @@ interface SweepCellReport {
|
|
|
408
450
|
effort?: Effort;
|
|
409
451
|
taskClass: TaskClass;
|
|
410
452
|
passRate: number;
|
|
453
|
+
/** Result rows actually measured (completed count). */
|
|
411
454
|
n: number;
|
|
455
|
+
/**
|
|
456
|
+
* Cases this cell was asked to measure (v1.17.0 review P1-5). A cell
|
|
457
|
+
* with n < plannedN is incomplete: what ran stays reported, and the
|
|
458
|
+
* cell emits no claim.
|
|
459
|
+
*/
|
|
460
|
+
plannedN: number;
|
|
412
461
|
totalCostUsd: number;
|
|
413
462
|
caseNames: string[];
|
|
414
463
|
/**
|
|
@@ -421,10 +470,21 @@ interface SweepCellReport {
|
|
|
421
470
|
*/
|
|
422
471
|
exhaustedRuns?: number;
|
|
423
472
|
/**
|
|
424
|
-
*
|
|
425
|
-
*
|
|
473
|
+
* Count of result rows whose grading stopped on a judge budget event
|
|
474
|
+
* (per-run judge ceiling or envelope refusal of a judge run). The
|
|
475
|
+
* paid target evidence stays on those rows; the cell emits no claim.
|
|
476
|
+
*/
|
|
477
|
+
judgeIncompleteRuns?: number;
|
|
478
|
+
/**
|
|
479
|
+
* The aggregate envelope refused a TARGET run of this cell before it
|
|
480
|
+
* started; everything measured up to that point stays reported and
|
|
481
|
+
* the cell emits no claim.
|
|
426
482
|
*/
|
|
427
483
|
envelopeExhausted?: true;
|
|
484
|
+
/** Why the cell is incomplete, when it is. */
|
|
485
|
+
incompleteReason?: "envelope-exhausted" | "judge-exhausted" | "judge-refused";
|
|
486
|
+
/** The refused run's label, when the envelope refused one. */
|
|
487
|
+
refusedRunLabel?: string;
|
|
428
488
|
}
|
|
429
489
|
interface SweepReport {
|
|
430
490
|
reportId: string;
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,100 @@
|
|
|
1
1
|
import { ConfigError, KnowledgeCasError, claimExpiry, compileVerifiedLayer, defineWorkflow } from "@rulvar/core";
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
|
+
//#region src/envelope.ts
|
|
4
|
+
/**
|
|
5
|
+
* The debit-only aggregate spend envelope (v1.16.2 review P1-2). A
|
|
6
|
+
* sweep multiplies paid runs: pool members times cases for targets,
|
|
7
|
+
* one judge run per GraderContext.judge call, one canary run per
|
|
8
|
+
* probe per member, and the falsification union can grow the pool
|
|
9
|
+
* beyond the config. Per-run ceilings alone do not bound that
|
|
10
|
+
* product, so the envelope authorizes each run's IMMUTABLE ceiling
|
|
11
|
+
* BEFORE the run starts: a run whose ceiling does not fit the
|
|
12
|
+
* remainder is refused before any provider work.
|
|
13
|
+
*
|
|
14
|
+
* Authorizations are never returned: not when a run completes under
|
|
15
|
+
* its ceiling, not on CAS retries (they run no paid work), and not on
|
|
16
|
+
* replay. A replayed run authorizes exactly like a fresh one and then
|
|
17
|
+
* spends nothing; the envelope bounds the authorized worst case, not
|
|
18
|
+
* the observed spend, so replay never double-PAYS anything while the
|
|
19
|
+
* accounting stays one-directional. The envelope lives for one
|
|
20
|
+
* invocation and is never persisted.
|
|
21
|
+
*
|
|
22
|
+
* Accounting is integer micro-USD and conservative at the
|
|
23
|
+
* representation boundary (v1.17.0 review P1-4): the cap converts DOWN
|
|
24
|
+
* (floor), every debit converts UP (ceil), and a cap below one
|
|
25
|
+
* micro-USD is rejected outright, so for any admitted sequence the sum
|
|
26
|
+
* of the ORIGINAL ceilings can never exceed maxTotalUsd and no
|
|
27
|
+
* positive ceiling ever debits zero. Amounts that are integer
|
|
28
|
+
* micro-USD up to float noise stay exact, so 0.1 + 0.2 against a 0.3
|
|
29
|
+
* envelope is a fit, not a float rejection.
|
|
30
|
+
*/
|
|
31
|
+
const MICRO = 1e6;
|
|
32
|
+
/**
|
|
33
|
+
* Relative tolerance for float noise around an integer micro-USD
|
|
34
|
+
* amount: 0.3 * 1e6 is 299999.99999999994 and MUST count as 300000,
|
|
35
|
+
* while a genuinely sub-micro 0.4 must not.
|
|
36
|
+
*/
|
|
37
|
+
const MICRO_NOISE = 1e-6;
|
|
38
|
+
function microOf(usd, direction) {
|
|
39
|
+
const raw = usd * MICRO;
|
|
40
|
+
const nearest = Math.round(raw);
|
|
41
|
+
if (Math.abs(raw - nearest) <= MICRO_NOISE * Math.max(1, Math.abs(nearest))) return nearest;
|
|
42
|
+
return direction === "floor" ? Math.floor(raw) : Math.ceil(raw);
|
|
43
|
+
}
|
|
44
|
+
/** Thrown when authorizing a run's ceiling would exceed the envelope. */
|
|
45
|
+
var SweepBudgetError = class extends Error {
|
|
46
|
+
/** What was about to start, e.g. `eval target 'sweep-math'`. */
|
|
47
|
+
runLabel;
|
|
48
|
+
/** The per-run ceiling that did not fit. */
|
|
49
|
+
ceilingUsd;
|
|
50
|
+
/** Total already authorized before this refusal. */
|
|
51
|
+
authorizedUsd;
|
|
52
|
+
maxTotalUsd;
|
|
53
|
+
constructor(runLabel, ceilingUsd, authorizedUsd, maxTotalUsd) {
|
|
54
|
+
super(`sweep envelope exhausted: authorizing $${String(ceilingUsd)} for ${runLabel} would exceed maxTotalUsd $${String(maxTotalUsd)} ($${String(authorizedUsd)} already authorized); the run was refused before any provider call`);
|
|
55
|
+
this.name = "SweepBudgetError";
|
|
56
|
+
this.runLabel = runLabel;
|
|
57
|
+
this.ceilingUsd = ceilingUsd;
|
|
58
|
+
this.authorizedUsd = authorizedUsd;
|
|
59
|
+
this.maxTotalUsd = maxTotalUsd;
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
/**
|
|
63
|
+
* One envelope bounds one whole sweep invocation: share the instance
|
|
64
|
+
* across the canary loop and runSweepMatrix so canary, target, and
|
|
65
|
+
* judge runs all draw from the same remainder.
|
|
66
|
+
*/
|
|
67
|
+
var SpendEnvelope = class {
|
|
68
|
+
maxTotalUsd;
|
|
69
|
+
maxMicroUsd;
|
|
70
|
+
authorizedMicroUsd = 0;
|
|
71
|
+
constructor(maxTotalUsd) {
|
|
72
|
+
if (!Number.isFinite(maxTotalUsd) || maxTotalUsd <= 0) throw new ConfigError(`SpendEnvelope maxTotalUsd must be a positive finite number, got ${String(maxTotalUsd)}`);
|
|
73
|
+
this.maxTotalUsd = maxTotalUsd;
|
|
74
|
+
this.maxMicroUsd = microOf(maxTotalUsd, "floor");
|
|
75
|
+
if (this.maxMicroUsd < 1) throw new ConfigError(`SpendEnvelope maxTotalUsd ${String(maxTotalUsd)} is below the 1 micro-USD accounting granularity (\$0.000001); such an envelope could never admit a run`);
|
|
76
|
+
}
|
|
77
|
+
/** Total authorized so far (debit-only; never decreases). */
|
|
78
|
+
get authorizedUsd() {
|
|
79
|
+
return this.authorizedMicroUsd / MICRO;
|
|
80
|
+
}
|
|
81
|
+
get remainingUsd() {
|
|
82
|
+
return Math.max(0, this.maxMicroUsd - this.authorizedMicroUsd) / MICRO;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Authorizes one run's immutable ceiling or throws SweepBudgetError.
|
|
86
|
+
* An unbounded run cannot be authorized: under an envelope every run
|
|
87
|
+
* MUST carry an explicit positive ceiling, otherwise the aggregate
|
|
88
|
+
* bound would be unaccountable.
|
|
89
|
+
*/
|
|
90
|
+
authorize(ceilingUsd, runLabel) {
|
|
91
|
+
if (ceilingUsd === void 0 || !Number.isFinite(ceilingUsd) || ceilingUsd <= 0) throw new ConfigError(`the spend envelope requires an explicit positive per-run ceiling for ${runLabel}; got ${String(ceilingUsd)} (an unbounded run under an aggregate envelope would be unaccountable)`);
|
|
92
|
+
const micro = Math.max(1, microOf(ceilingUsd, "ceil"));
|
|
93
|
+
if (this.authorizedMicroUsd + micro > this.maxMicroUsd) throw new SweepBudgetError(runLabel, ceilingUsd, this.authorizedUsd, this.maxTotalUsd);
|
|
94
|
+
this.authorizedMicroUsd += micro;
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
//#endregion
|
|
3
98
|
//#region src/case.ts
|
|
4
99
|
/**
|
|
5
100
|
* @rulvar/evals (M9-T02): EvalCase, the grader contract, and the case and
|
|
@@ -16,11 +111,14 @@ import { createHash } from "node:crypto";
|
|
|
16
111
|
var EvalJudgeError = class extends Error {
|
|
17
112
|
judgeRun;
|
|
18
113
|
status;
|
|
19
|
-
|
|
114
|
+
/** What the failing judge run actually spent (honest cost accounting). */
|
|
115
|
+
costUsd;
|
|
116
|
+
constructor(judgeRun, status, detail, costUsd = 0) {
|
|
20
117
|
super(`eval judge run '${judgeRun}' settled '${status}'${detail === void 0 ? "" : `: ${detail}`}`);
|
|
21
118
|
this.name = "EvalJudgeError";
|
|
22
119
|
this.judgeRun = judgeRun;
|
|
23
120
|
this.status = status;
|
|
121
|
+
this.costUsd = costUsd;
|
|
24
122
|
}
|
|
25
123
|
};
|
|
26
124
|
/**
|
|
@@ -61,18 +159,39 @@ async function runEvalCase(engine, evalCase, options = {}) {
|
|
|
61
159
|
}
|
|
62
160
|
};
|
|
63
161
|
const verdicts = [];
|
|
64
|
-
|
|
162
|
+
let incomplete;
|
|
163
|
+
for (const grader of evalCase.graders) try {
|
|
164
|
+
verdicts.push(await grader.grade(context));
|
|
165
|
+
} catch (error) {
|
|
166
|
+
if (error instanceof SweepBudgetError) {
|
|
167
|
+
incomplete = {
|
|
168
|
+
reason: "judge-refused",
|
|
169
|
+
detail: error.message
|
|
170
|
+
};
|
|
171
|
+
break;
|
|
172
|
+
}
|
|
173
|
+
if (error instanceof EvalJudgeError && error.status === "exhausted") {
|
|
174
|
+
judgeCostUsd += error.costUsd;
|
|
175
|
+
incomplete = {
|
|
176
|
+
reason: "judge-exhausted",
|
|
177
|
+
detail: error.message
|
|
178
|
+
};
|
|
179
|
+
break;
|
|
180
|
+
}
|
|
181
|
+
throw error;
|
|
182
|
+
}
|
|
65
183
|
const latencyMs = timing.start !== void 0 && timing.end !== void 0 ? Math.max(0, Date.parse(timing.end) - Date.parse(timing.start)) : 0;
|
|
66
184
|
return {
|
|
67
185
|
name,
|
|
68
186
|
status: outcome.status,
|
|
69
|
-
passed: outcome.status === "ok" && verdicts.every((verdict) => verdict.passed),
|
|
187
|
+
passed: outcome.status === "ok" && incomplete === void 0 && verdicts.every((verdict) => verdict.passed),
|
|
70
188
|
verdicts,
|
|
71
189
|
costUsd: outcome.cost.totalUsd + judgeCostUsd,
|
|
72
190
|
judgeCostUsd,
|
|
73
191
|
latencyMs,
|
|
74
192
|
usage: outcome.usage,
|
|
75
|
-
...outcome.error === void 0 ? {} : { error: outcome.error }
|
|
193
|
+
...outcome.error === void 0 ? {} : { error: outcome.error },
|
|
194
|
+
...incomplete === void 0 ? {} : { incomplete }
|
|
76
195
|
};
|
|
77
196
|
}
|
|
78
197
|
async function runJudge(engine, judgeName, spec, budgetUsd) {
|
|
@@ -89,7 +208,7 @@ async function runJudge(engine, judgeName, spec, budgetUsd) {
|
|
|
89
208
|
name: workflowName,
|
|
90
209
|
...budgetUsd === void 0 ? {} : { budgetUsd }
|
|
91
210
|
}).result;
|
|
92
|
-
if (outcome.status !== "ok") throw new EvalJudgeError(workflowName, outcome.status, outcome.error?.message);
|
|
211
|
+
if (outcome.status !== "ok") throw new EvalJudgeError(workflowName, outcome.status, outcome.error?.message, outcome.cost.totalUsd);
|
|
93
212
|
return {
|
|
94
213
|
output: outcome.value ?? null,
|
|
95
214
|
costUsd: outcome.cost.totalUsd
|
|
@@ -103,23 +222,37 @@ async function runJudge(engine, judgeName, spec, budgetUsd) {
|
|
|
103
222
|
async function runEvalSuite(engine, cases, options = {}) {
|
|
104
223
|
const seen = /* @__PURE__ */ new Map();
|
|
105
224
|
const results = [];
|
|
225
|
+
let refusal;
|
|
106
226
|
for (const evalCase of cases) {
|
|
107
227
|
const base = evalCase.workflow.name;
|
|
108
228
|
const ordinal = seen.get(base) ?? 0;
|
|
109
229
|
seen.set(base, ordinal + 1);
|
|
110
230
|
const name = ordinal === 0 ? base : `${base}#${ordinal}`;
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
231
|
+
try {
|
|
232
|
+
results.push(await runEvalCase(engine, evalCase, {
|
|
233
|
+
name,
|
|
234
|
+
...options.budgetUsd === void 0 ? {} : { budgetUsd: options.budgetUsd },
|
|
235
|
+
...options.judgeBudgetUsd === void 0 ? {} : { judgeBudgetUsd: options.judgeBudgetUsd },
|
|
236
|
+
...options.envelope === void 0 ? {} : { envelope: options.envelope }
|
|
237
|
+
}));
|
|
238
|
+
} catch (error) {
|
|
239
|
+
if (!(error instanceof SweepBudgetError)) throw error;
|
|
240
|
+
refusal = {
|
|
241
|
+
runLabel: error.runLabel,
|
|
242
|
+
atCase: name,
|
|
243
|
+
detail: error.message
|
|
244
|
+
};
|
|
245
|
+
break;
|
|
246
|
+
}
|
|
117
247
|
}
|
|
118
248
|
return {
|
|
119
249
|
results,
|
|
120
250
|
passRate: results.length === 0 ? 0 : results.filter((r) => r.passed).length / results.length,
|
|
121
251
|
totalCostUsd: results.reduce((sum, r) => sum + r.costUsd, 0),
|
|
122
|
-
meanLatencyMs: results.length === 0 ? 0 : results.reduce((sum, r) => sum + r.latencyMs, 0) / results.length
|
|
252
|
+
meanLatencyMs: results.length === 0 ? 0 : results.reduce((sum, r) => sum + r.latencyMs, 0) / results.length,
|
|
253
|
+
plannedN: cases.length,
|
|
254
|
+
completedN: results.length,
|
|
255
|
+
...refusal === void 0 ? {} : { refusal }
|
|
123
256
|
};
|
|
124
257
|
}
|
|
125
258
|
//#endregion
|
|
@@ -344,16 +477,28 @@ function normalizeCanaryOutput(output) {
|
|
|
344
477
|
* sequentially in declaration order, one run per probe, so recordings
|
|
345
478
|
* replay deterministically. Each probe run carries the optional
|
|
346
479
|
* immutable ceiling (options.budgetUsd) and authorizes it against the
|
|
347
|
-
* optional envelope before starting
|
|
348
|
-
*
|
|
349
|
-
*
|
|
350
|
-
*
|
|
480
|
+
* optional envelope before starting; an envelope refusal records the
|
|
481
|
+
* probe as 'refused' and keeps walking instead of throwing away the
|
|
482
|
+
* completed probes. A non-ok or refused probe enters the fingerprint
|
|
483
|
+
* as `!status` and clears allOk: callers gate drift flipping on allOk,
|
|
484
|
+
* because a budget-starved or transiently failing probe fingerprints
|
|
485
|
+
* differently without the model having drifted.
|
|
351
486
|
*/
|
|
352
487
|
async function runCanary(engine, probes, options = {}) {
|
|
353
488
|
const outputs = [];
|
|
354
489
|
const probeReports = [];
|
|
355
490
|
for (const [index, prompt] of probes.prompts.entries()) {
|
|
356
|
-
|
|
491
|
+
try {
|
|
492
|
+
options.envelope?.authorize(options.budgetUsd, `canary probe ${String(index)}`);
|
|
493
|
+
} catch (error) {
|
|
494
|
+
if (!(error instanceof SweepBudgetError)) throw error;
|
|
495
|
+
probeReports.push({
|
|
496
|
+
prompt,
|
|
497
|
+
status: "refused"
|
|
498
|
+
});
|
|
499
|
+
outputs.push("!refused");
|
|
500
|
+
continue;
|
|
501
|
+
}
|
|
357
502
|
const workflow = defineWorkflow({ name: `kb-canary:${String(index)}` }, async (ctx) => await ctx.agent(prompt, { agentType: probes.agentType }));
|
|
358
503
|
const outcome = await engine.run(workflow, null, options.budgetUsd === void 0 ? {} : { budgetUsd: options.budgetUsd }).result;
|
|
359
504
|
probeReports.push({
|
|
@@ -424,82 +569,6 @@ async function flipStaleOnCanaryDrift(store, model, freshFingerprint, options) {
|
|
|
424
569
|
throw lastCas ?? /* @__PURE__ */ new Error("flipStaleOnCanaryDrift: unreachable");
|
|
425
570
|
}
|
|
426
571
|
//#endregion
|
|
427
|
-
//#region src/envelope.ts
|
|
428
|
-
/**
|
|
429
|
-
* The debit-only aggregate spend envelope (v1.16.2 review P1-2). A
|
|
430
|
-
* sweep multiplies paid runs: pool members times cases for targets,
|
|
431
|
-
* one judge run per GraderContext.judge call, one canary run per
|
|
432
|
-
* probe per member, and the falsification union can grow the pool
|
|
433
|
-
* beyond the config. Per-run ceilings alone do not bound that
|
|
434
|
-
* product, so the envelope authorizes each run's IMMUTABLE ceiling
|
|
435
|
-
* BEFORE the run starts: a run whose ceiling does not fit the
|
|
436
|
-
* remainder is refused before any provider work.
|
|
437
|
-
*
|
|
438
|
-
* Authorizations are never returned: not when a run completes under
|
|
439
|
-
* its ceiling, not on CAS retries (they run no paid work), and not on
|
|
440
|
-
* replay. A replayed run authorizes exactly like a fresh one and then
|
|
441
|
-
* spends nothing; the envelope bounds the authorized worst case, not
|
|
442
|
-
* the observed spend, so replay never double-PAYS anything while the
|
|
443
|
-
* accounting stays one-directional. The envelope lives for one
|
|
444
|
-
* invocation and is never persisted.
|
|
445
|
-
*
|
|
446
|
-
* Accounting is integer micro-USD, so exact fits pass: 0.1 + 0.2
|
|
447
|
-
* against a 0.3 envelope is a fit, not a float rejection.
|
|
448
|
-
*/
|
|
449
|
-
const MICRO = 1e6;
|
|
450
|
-
/** Thrown when authorizing a run's ceiling would exceed the envelope. */
|
|
451
|
-
var SweepBudgetError = class extends Error {
|
|
452
|
-
/** What was about to start, e.g. `eval target 'sweep-math'`. */
|
|
453
|
-
runLabel;
|
|
454
|
-
/** The per-run ceiling that did not fit. */
|
|
455
|
-
ceilingUsd;
|
|
456
|
-
/** Total already authorized before this refusal. */
|
|
457
|
-
authorizedUsd;
|
|
458
|
-
maxTotalUsd;
|
|
459
|
-
constructor(runLabel, ceilingUsd, authorizedUsd, maxTotalUsd) {
|
|
460
|
-
super(`sweep envelope exhausted: authorizing $${String(ceilingUsd)} for ${runLabel} would exceed maxTotalUsd $${String(maxTotalUsd)} ($${String(authorizedUsd)} already authorized); the run was refused before any provider call`);
|
|
461
|
-
this.name = "SweepBudgetError";
|
|
462
|
-
this.runLabel = runLabel;
|
|
463
|
-
this.ceilingUsd = ceilingUsd;
|
|
464
|
-
this.authorizedUsd = authorizedUsd;
|
|
465
|
-
this.maxTotalUsd = maxTotalUsd;
|
|
466
|
-
}
|
|
467
|
-
};
|
|
468
|
-
/**
|
|
469
|
-
* One envelope bounds one whole sweep invocation: share the instance
|
|
470
|
-
* across the canary loop and runSweepMatrix so canary, target, and
|
|
471
|
-
* judge runs all draw from the same remainder.
|
|
472
|
-
*/
|
|
473
|
-
var SpendEnvelope = class {
|
|
474
|
-
maxTotalUsd;
|
|
475
|
-
maxMicroUsd;
|
|
476
|
-
authorizedMicroUsd = 0;
|
|
477
|
-
constructor(maxTotalUsd) {
|
|
478
|
-
if (!Number.isFinite(maxTotalUsd) || maxTotalUsd <= 0) throw new ConfigError(`SpendEnvelope maxTotalUsd must be a positive finite number, got ${String(maxTotalUsd)}`);
|
|
479
|
-
this.maxTotalUsd = maxTotalUsd;
|
|
480
|
-
this.maxMicroUsd = Math.round(maxTotalUsd * MICRO);
|
|
481
|
-
}
|
|
482
|
-
/** Total authorized so far (debit-only; never decreases). */
|
|
483
|
-
get authorizedUsd() {
|
|
484
|
-
return this.authorizedMicroUsd / MICRO;
|
|
485
|
-
}
|
|
486
|
-
get remainingUsd() {
|
|
487
|
-
return Math.max(0, this.maxMicroUsd - this.authorizedMicroUsd) / MICRO;
|
|
488
|
-
}
|
|
489
|
-
/**
|
|
490
|
-
* Authorizes one run's immutable ceiling or throws SweepBudgetError.
|
|
491
|
-
* An unbounded run cannot be authorized: under an envelope every run
|
|
492
|
-
* MUST carry an explicit positive ceiling, otherwise the aggregate
|
|
493
|
-
* bound would be unaccountable.
|
|
494
|
-
*/
|
|
495
|
-
authorize(ceilingUsd, runLabel) {
|
|
496
|
-
if (ceilingUsd === void 0 || !Number.isFinite(ceilingUsd) || ceilingUsd <= 0) throw new ConfigError(`the spend envelope requires an explicit positive per-run ceiling for ${runLabel}; got ${String(ceilingUsd)} (an unbounded run under an aggregate envelope would be unaccountable)`);
|
|
497
|
-
const micro = Math.round(ceilingUsd * MICRO);
|
|
498
|
-
if (this.authorizedMicroUsd + micro > this.maxMicroUsd) throw new SweepBudgetError(runLabel, ceilingUsd, this.authorizedUsd, this.maxTotalUsd);
|
|
499
|
-
this.authorizedMicroUsd += micro;
|
|
500
|
-
}
|
|
501
|
-
};
|
|
502
|
-
//#endregion
|
|
503
572
|
//#region src/checkpoint.ts
|
|
504
573
|
/**
|
|
505
574
|
* The phases 1-2 measured-value checkpoint (M12-T01; the quantitative
|
|
@@ -704,40 +773,34 @@ async function runSweepMatrix(pool, options) {
|
|
|
704
773
|
for (const member of pool.models) {
|
|
705
774
|
const engine = await options.engineFor(member);
|
|
706
775
|
for (const [taskClass, bucket] of byTaskClass) {
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
...options.envelope === void 0 ? {} : { envelope: options.envelope }
|
|
712
|
-
});
|
|
713
|
-
} catch (error) {
|
|
714
|
-
if (!(error instanceof SweepBudgetError)) throw error;
|
|
715
|
-
cells.push({
|
|
716
|
-
model: member.model,
|
|
717
|
-
...member.effort === void 0 ? {} : { effort: member.effort },
|
|
718
|
-
taskClass,
|
|
719
|
-
passRate: 0,
|
|
720
|
-
n: 0,
|
|
721
|
-
totalCostUsd: 0,
|
|
722
|
-
caseNames: [],
|
|
723
|
-
envelopeExhausted: true
|
|
724
|
-
});
|
|
725
|
-
continue;
|
|
726
|
-
}
|
|
776
|
+
const suite = await runEvalSuite(engine, bucket.map((entry) => entry.case), {
|
|
777
|
+
...options.suite ?? {},
|
|
778
|
+
...options.envelope === void 0 ? {} : { envelope: options.envelope }
|
|
779
|
+
});
|
|
727
780
|
const exhaustedRuns = suite.results.filter((result) => result.status === "exhausted").length;
|
|
781
|
+
const judgeIncompleteRuns = suite.results.filter((result) => result.incomplete !== void 0).length;
|
|
782
|
+
const incompleteReason = suite.refusal !== void 0 ? "envelope-exhausted" : suite.results.find((result) => result.incomplete !== void 0)?.incomplete?.reason;
|
|
728
783
|
const cell = {
|
|
729
784
|
model: member.model,
|
|
730
785
|
...member.effort === void 0 ? {} : { effort: member.effort },
|
|
731
786
|
taskClass,
|
|
732
787
|
passRate: suite.passRate,
|
|
733
|
-
n: suite.
|
|
788
|
+
n: suite.completedN,
|
|
789
|
+
plannedN: suite.plannedN,
|
|
734
790
|
totalCostUsd: suite.totalCostUsd,
|
|
735
791
|
caseNames: suite.results.map((result) => result.name),
|
|
736
|
-
...exhaustedRuns === 0 ? {} : { exhaustedRuns }
|
|
792
|
+
...exhaustedRuns === 0 ? {} : { exhaustedRuns },
|
|
793
|
+
...judgeIncompleteRuns === 0 ? {} : { judgeIncompleteRuns },
|
|
794
|
+
...suite.refusal === void 0 ? {} : {
|
|
795
|
+
envelopeExhausted: true,
|
|
796
|
+
refusedRunLabel: suite.refusal.runLabel
|
|
797
|
+
},
|
|
798
|
+
...incompleteReason === void 0 ? {} : { incompleteReason }
|
|
737
799
|
};
|
|
738
800
|
cells.push(cell);
|
|
739
801
|
const polarity = cell.passRate >= thresholds.strength ? "strength" : cell.passRate <= thresholds.weakness ? "weakness" : void 0;
|
|
740
|
-
|
|
802
|
+
const complete = cell.n === cell.plannedN && exhaustedRuns === 0 && judgeIncompleteRuns === 0 && suite.refusal === void 0;
|
|
803
|
+
if (polarity !== void 0 && cell.n > 0 && complete) {
|
|
741
804
|
const epoch = options.modelEpochFor?.(member);
|
|
742
805
|
claims.push({
|
|
743
806
|
id: claimIdOf(options.reportId, member, taskClass),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rulvar/evals",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.18.0",
|
|
4
4
|
"description": "Rulvar evals: eval cases, golden outputs, rubric and judge graders, matrix sweeps, canary fingerprint.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -22,8 +22,8 @@
|
|
|
22
22
|
"access": "public"
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
25
|
-
"@rulvar/core": "1.
|
|
26
|
-
"@rulvar/testing": "1.
|
|
25
|
+
"@rulvar/core": "1.18.0",
|
|
26
|
+
"@rulvar/testing": "1.18.0"
|
|
27
27
|
},
|
|
28
28
|
"devDependencies": {
|
|
29
29
|
"@types/node": "^22.20.0",
|