@rulvar/evals 1.17.0 → 1.19.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 +201 -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,122 @@
|
|
|
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, hardened by the v1.18.0
|
|
24
|
+
* review P1-4): the cap converts DOWN (floor), every debit converts UP
|
|
25
|
+
* (ceil), only ULP-scale representation noise may snap to the
|
|
26
|
+
* neighboring integer, amounts outside the safe integer micro-USD
|
|
27
|
+
* domain are rejected, and a cap below one micro-USD is rejected
|
|
28
|
+
* outright, so for any admitted sequence the sum of the ORIGINAL
|
|
29
|
+
* ceilings can never exceed maxTotalUsd and no positive ceiling ever
|
|
30
|
+
* debits zero. Amounts that are integer micro-USD up to float noise
|
|
31
|
+
* stay exact, so 0.1 + 0.2 against a 0.3 envelope is a fit, not a
|
|
32
|
+
* float rejection.
|
|
33
|
+
*/
|
|
34
|
+
const MICRO = 1e6;
|
|
35
|
+
/**
|
|
36
|
+
* Directed-rounding snap window, in ULPs of the product `usd * 1e6`:
|
|
37
|
+
* only genuine IEEE-754 representation noise may snap to the neighboring
|
|
38
|
+
* integer (0.3 * 1e6 is 299999.99999999994, about 6e-11 away, and MUST
|
|
39
|
+
* count as 300000), while a genuinely sub-micro fraction like 0.4 must
|
|
40
|
+
* round in the conservative direction. The window scales with the ULP,
|
|
41
|
+
* never with a relative fraction of the amount: the v1.17.0 fix used a
|
|
42
|
+
* 1e-6 RELATIVE tolerance, which already reaches half a micro-USD at
|
|
43
|
+
* $0.50 and turned directed rounding into round-to-nearest, admitting
|
|
44
|
+
* aggregates above the cap (v1.18.0 review P1-4).
|
|
45
|
+
*/
|
|
46
|
+
const SNAP_ULPS = 4;
|
|
47
|
+
function microOf(usd, direction) {
|
|
48
|
+
const raw = usd * MICRO;
|
|
49
|
+
const nearest = Math.round(raw);
|
|
50
|
+
if (Math.abs(raw - nearest) <= SNAP_ULPS * Math.abs(raw) * Number.EPSILON) return nearest;
|
|
51
|
+
return direction === "floor" ? Math.floor(raw) : Math.ceil(raw);
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* The accounting domain is integer micro-USD within Number's safe
|
|
55
|
+
* integer range (up to about $9.007e9). Outside it (Number.MAX_VALUE
|
|
56
|
+
* caps overflow to Infinity, huge finite amounts lose integer
|
|
57
|
+
* precision) the envelope arithmetic silently degrades: Infinity plus
|
|
58
|
+
* anything compares false against Infinity and every authorization
|
|
59
|
+
* would be admitted with remainingUsd NaN (v1.18.0 review P1-4), so
|
|
60
|
+
* out-of-domain amounts are rejected up front.
|
|
61
|
+
*/
|
|
62
|
+
function requireSafeMicro(micro, what, usd) {
|
|
63
|
+
if (!Number.isSafeInteger(micro)) throw new ConfigError(`${what} ${String(usd)} USD is outside the safe integer micro-USD accounting domain (at most \$9007199254.740991)`);
|
|
64
|
+
return micro;
|
|
65
|
+
}
|
|
66
|
+
/** Thrown when authorizing a run's ceiling would exceed the envelope. */
|
|
67
|
+
var SweepBudgetError = class extends Error {
|
|
68
|
+
/** What was about to start, e.g. `eval target 'sweep-math'`. */
|
|
69
|
+
runLabel;
|
|
70
|
+
/** The per-run ceiling that did not fit. */
|
|
71
|
+
ceilingUsd;
|
|
72
|
+
/** Total already authorized before this refusal. */
|
|
73
|
+
authorizedUsd;
|
|
74
|
+
maxTotalUsd;
|
|
75
|
+
constructor(runLabel, ceilingUsd, authorizedUsd, maxTotalUsd) {
|
|
76
|
+
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`);
|
|
77
|
+
this.name = "SweepBudgetError";
|
|
78
|
+
this.runLabel = runLabel;
|
|
79
|
+
this.ceilingUsd = ceilingUsd;
|
|
80
|
+
this.authorizedUsd = authorizedUsd;
|
|
81
|
+
this.maxTotalUsd = maxTotalUsd;
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
/**
|
|
85
|
+
* One envelope bounds one whole sweep invocation: share the instance
|
|
86
|
+
* across the canary loop and runSweepMatrix so canary, target, and
|
|
87
|
+
* judge runs all draw from the same remainder.
|
|
88
|
+
*/
|
|
89
|
+
var SpendEnvelope = class {
|
|
90
|
+
maxTotalUsd;
|
|
91
|
+
maxMicroUsd;
|
|
92
|
+
authorizedMicroUsd = 0;
|
|
93
|
+
constructor(maxTotalUsd) {
|
|
94
|
+
if (!Number.isFinite(maxTotalUsd) || maxTotalUsd <= 0) throw new ConfigError(`SpendEnvelope maxTotalUsd must be a positive finite number, got ${String(maxTotalUsd)}`);
|
|
95
|
+
this.maxTotalUsd = maxTotalUsd;
|
|
96
|
+
this.maxMicroUsd = requireSafeMicro(microOf(maxTotalUsd, "floor"), "SpendEnvelope maxTotalUsd", maxTotalUsd);
|
|
97
|
+
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`);
|
|
98
|
+
}
|
|
99
|
+
/** Total authorized so far (debit-only; never decreases). */
|
|
100
|
+
get authorizedUsd() {
|
|
101
|
+
return this.authorizedMicroUsd / MICRO;
|
|
102
|
+
}
|
|
103
|
+
get remainingUsd() {
|
|
104
|
+
return Math.max(0, this.maxMicroUsd - this.authorizedMicroUsd) / MICRO;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Authorizes one run's immutable ceiling or throws SweepBudgetError.
|
|
108
|
+
* An unbounded run cannot be authorized: under an envelope every run
|
|
109
|
+
* MUST carry an explicit positive ceiling, otherwise the aggregate
|
|
110
|
+
* bound would be unaccountable.
|
|
111
|
+
*/
|
|
112
|
+
authorize(ceilingUsd, runLabel) {
|
|
113
|
+
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)`);
|
|
114
|
+
const micro = requireSafeMicro(Math.max(1, microOf(ceilingUsd, "ceil")), `per-run ceiling for ${runLabel}`, ceilingUsd);
|
|
115
|
+
if (this.authorizedMicroUsd + micro > this.maxMicroUsd) throw new SweepBudgetError(runLabel, ceilingUsd, this.authorizedUsd, this.maxTotalUsd);
|
|
116
|
+
this.authorizedMicroUsd += micro;
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
//#endregion
|
|
3
120
|
//#region src/case.ts
|
|
4
121
|
/**
|
|
5
122
|
* @rulvar/evals (M9-T02): EvalCase, the grader contract, and the case and
|
|
@@ -16,11 +133,14 @@ import { createHash } from "node:crypto";
|
|
|
16
133
|
var EvalJudgeError = class extends Error {
|
|
17
134
|
judgeRun;
|
|
18
135
|
status;
|
|
19
|
-
|
|
136
|
+
/** What the failing judge run actually spent (honest cost accounting). */
|
|
137
|
+
costUsd;
|
|
138
|
+
constructor(judgeRun, status, detail, costUsd = 0) {
|
|
20
139
|
super(`eval judge run '${judgeRun}' settled '${status}'${detail === void 0 ? "" : `: ${detail}`}`);
|
|
21
140
|
this.name = "EvalJudgeError";
|
|
22
141
|
this.judgeRun = judgeRun;
|
|
23
142
|
this.status = status;
|
|
143
|
+
this.costUsd = costUsd;
|
|
24
144
|
}
|
|
25
145
|
};
|
|
26
146
|
/**
|
|
@@ -61,18 +181,39 @@ async function runEvalCase(engine, evalCase, options = {}) {
|
|
|
61
181
|
}
|
|
62
182
|
};
|
|
63
183
|
const verdicts = [];
|
|
64
|
-
|
|
184
|
+
let incomplete;
|
|
185
|
+
for (const grader of evalCase.graders) try {
|
|
186
|
+
verdicts.push(await grader.grade(context));
|
|
187
|
+
} catch (error) {
|
|
188
|
+
if (error instanceof SweepBudgetError) {
|
|
189
|
+
incomplete = {
|
|
190
|
+
reason: "judge-refused",
|
|
191
|
+
detail: error.message
|
|
192
|
+
};
|
|
193
|
+
break;
|
|
194
|
+
}
|
|
195
|
+
if (error instanceof EvalJudgeError && error.status === "exhausted") {
|
|
196
|
+
judgeCostUsd += error.costUsd;
|
|
197
|
+
incomplete = {
|
|
198
|
+
reason: "judge-exhausted",
|
|
199
|
+
detail: error.message
|
|
200
|
+
};
|
|
201
|
+
break;
|
|
202
|
+
}
|
|
203
|
+
throw error;
|
|
204
|
+
}
|
|
65
205
|
const latencyMs = timing.start !== void 0 && timing.end !== void 0 ? Math.max(0, Date.parse(timing.end) - Date.parse(timing.start)) : 0;
|
|
66
206
|
return {
|
|
67
207
|
name,
|
|
68
208
|
status: outcome.status,
|
|
69
|
-
passed: outcome.status === "ok" && verdicts.every((verdict) => verdict.passed),
|
|
209
|
+
passed: outcome.status === "ok" && incomplete === void 0 && verdicts.every((verdict) => verdict.passed),
|
|
70
210
|
verdicts,
|
|
71
211
|
costUsd: outcome.cost.totalUsd + judgeCostUsd,
|
|
72
212
|
judgeCostUsd,
|
|
73
213
|
latencyMs,
|
|
74
214
|
usage: outcome.usage,
|
|
75
|
-
...outcome.error === void 0 ? {} : { error: outcome.error }
|
|
215
|
+
...outcome.error === void 0 ? {} : { error: outcome.error },
|
|
216
|
+
...incomplete === void 0 ? {} : { incomplete }
|
|
76
217
|
};
|
|
77
218
|
}
|
|
78
219
|
async function runJudge(engine, judgeName, spec, budgetUsd) {
|
|
@@ -89,7 +230,7 @@ async function runJudge(engine, judgeName, spec, budgetUsd) {
|
|
|
89
230
|
name: workflowName,
|
|
90
231
|
...budgetUsd === void 0 ? {} : { budgetUsd }
|
|
91
232
|
}).result;
|
|
92
|
-
if (outcome.status !== "ok") throw new EvalJudgeError(workflowName, outcome.status, outcome.error?.message);
|
|
233
|
+
if (outcome.status !== "ok") throw new EvalJudgeError(workflowName, outcome.status, outcome.error?.message, outcome.cost.totalUsd);
|
|
93
234
|
return {
|
|
94
235
|
output: outcome.value ?? null,
|
|
95
236
|
costUsd: outcome.cost.totalUsd
|
|
@@ -103,23 +244,37 @@ async function runJudge(engine, judgeName, spec, budgetUsd) {
|
|
|
103
244
|
async function runEvalSuite(engine, cases, options = {}) {
|
|
104
245
|
const seen = /* @__PURE__ */ new Map();
|
|
105
246
|
const results = [];
|
|
247
|
+
let refusal;
|
|
106
248
|
for (const evalCase of cases) {
|
|
107
249
|
const base = evalCase.workflow.name;
|
|
108
250
|
const ordinal = seen.get(base) ?? 0;
|
|
109
251
|
seen.set(base, ordinal + 1);
|
|
110
252
|
const name = ordinal === 0 ? base : `${base}#${ordinal}`;
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
253
|
+
try {
|
|
254
|
+
results.push(await runEvalCase(engine, evalCase, {
|
|
255
|
+
name,
|
|
256
|
+
...options.budgetUsd === void 0 ? {} : { budgetUsd: options.budgetUsd },
|
|
257
|
+
...options.judgeBudgetUsd === void 0 ? {} : { judgeBudgetUsd: options.judgeBudgetUsd },
|
|
258
|
+
...options.envelope === void 0 ? {} : { envelope: options.envelope }
|
|
259
|
+
}));
|
|
260
|
+
} catch (error) {
|
|
261
|
+
if (!(error instanceof SweepBudgetError)) throw error;
|
|
262
|
+
refusal = {
|
|
263
|
+
runLabel: error.runLabel,
|
|
264
|
+
atCase: name,
|
|
265
|
+
detail: error.message
|
|
266
|
+
};
|
|
267
|
+
break;
|
|
268
|
+
}
|
|
117
269
|
}
|
|
118
270
|
return {
|
|
119
271
|
results,
|
|
120
272
|
passRate: results.length === 0 ? 0 : results.filter((r) => r.passed).length / results.length,
|
|
121
273
|
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
|
|
274
|
+
meanLatencyMs: results.length === 0 ? 0 : results.reduce((sum, r) => sum + r.latencyMs, 0) / results.length,
|
|
275
|
+
plannedN: cases.length,
|
|
276
|
+
completedN: results.length,
|
|
277
|
+
...refusal === void 0 ? {} : { refusal }
|
|
123
278
|
};
|
|
124
279
|
}
|
|
125
280
|
//#endregion
|
|
@@ -344,16 +499,28 @@ function normalizeCanaryOutput(output) {
|
|
|
344
499
|
* sequentially in declaration order, one run per probe, so recordings
|
|
345
500
|
* replay deterministically. Each probe run carries the optional
|
|
346
501
|
* immutable ceiling (options.budgetUsd) and authorizes it against the
|
|
347
|
-
* optional envelope before starting
|
|
348
|
-
*
|
|
349
|
-
*
|
|
350
|
-
*
|
|
502
|
+
* optional envelope before starting; an envelope refusal records the
|
|
503
|
+
* probe as 'refused' and keeps walking instead of throwing away the
|
|
504
|
+
* completed probes. A non-ok or refused probe enters the fingerprint
|
|
505
|
+
* as `!status` and clears allOk: callers gate drift flipping on allOk,
|
|
506
|
+
* because a budget-starved or transiently failing probe fingerprints
|
|
507
|
+
* differently without the model having drifted.
|
|
351
508
|
*/
|
|
352
509
|
async function runCanary(engine, probes, options = {}) {
|
|
353
510
|
const outputs = [];
|
|
354
511
|
const probeReports = [];
|
|
355
512
|
for (const [index, prompt] of probes.prompts.entries()) {
|
|
356
|
-
|
|
513
|
+
try {
|
|
514
|
+
options.envelope?.authorize(options.budgetUsd, `canary probe ${String(index)}`);
|
|
515
|
+
} catch (error) {
|
|
516
|
+
if (!(error instanceof SweepBudgetError)) throw error;
|
|
517
|
+
probeReports.push({
|
|
518
|
+
prompt,
|
|
519
|
+
status: "refused"
|
|
520
|
+
});
|
|
521
|
+
outputs.push("!refused");
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
357
524
|
const workflow = defineWorkflow({ name: `kb-canary:${String(index)}` }, async (ctx) => await ctx.agent(prompt, { agentType: probes.agentType }));
|
|
358
525
|
const outcome = await engine.run(workflow, null, options.budgetUsd === void 0 ? {} : { budgetUsd: options.budgetUsd }).result;
|
|
359
526
|
probeReports.push({
|
|
@@ -424,82 +591,6 @@ async function flipStaleOnCanaryDrift(store, model, freshFingerprint, options) {
|
|
|
424
591
|
throw lastCas ?? /* @__PURE__ */ new Error("flipStaleOnCanaryDrift: unreachable");
|
|
425
592
|
}
|
|
426
593
|
//#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
594
|
//#region src/checkpoint.ts
|
|
504
595
|
/**
|
|
505
596
|
* The phases 1-2 measured-value checkpoint (M12-T01; the quantitative
|
|
@@ -704,40 +795,34 @@ async function runSweepMatrix(pool, options) {
|
|
|
704
795
|
for (const member of pool.models) {
|
|
705
796
|
const engine = await options.engineFor(member);
|
|
706
797
|
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
|
-
}
|
|
798
|
+
const suite = await runEvalSuite(engine, bucket.map((entry) => entry.case), {
|
|
799
|
+
...options.suite ?? {},
|
|
800
|
+
...options.envelope === void 0 ? {} : { envelope: options.envelope }
|
|
801
|
+
});
|
|
727
802
|
const exhaustedRuns = suite.results.filter((result) => result.status === "exhausted").length;
|
|
803
|
+
const judgeIncompleteRuns = suite.results.filter((result) => result.incomplete !== void 0).length;
|
|
804
|
+
const incompleteReason = suite.refusal !== void 0 ? "envelope-exhausted" : suite.results.find((result) => result.incomplete !== void 0)?.incomplete?.reason;
|
|
728
805
|
const cell = {
|
|
729
806
|
model: member.model,
|
|
730
807
|
...member.effort === void 0 ? {} : { effort: member.effort },
|
|
731
808
|
taskClass,
|
|
732
809
|
passRate: suite.passRate,
|
|
733
|
-
n: suite.
|
|
810
|
+
n: suite.completedN,
|
|
811
|
+
plannedN: suite.plannedN,
|
|
734
812
|
totalCostUsd: suite.totalCostUsd,
|
|
735
813
|
caseNames: suite.results.map((result) => result.name),
|
|
736
|
-
...exhaustedRuns === 0 ? {} : { exhaustedRuns }
|
|
814
|
+
...exhaustedRuns === 0 ? {} : { exhaustedRuns },
|
|
815
|
+
...judgeIncompleteRuns === 0 ? {} : { judgeIncompleteRuns },
|
|
816
|
+
...suite.refusal === void 0 ? {} : {
|
|
817
|
+
envelopeExhausted: true,
|
|
818
|
+
refusedRunLabel: suite.refusal.runLabel
|
|
819
|
+
},
|
|
820
|
+
...incompleteReason === void 0 ? {} : { incompleteReason }
|
|
737
821
|
};
|
|
738
822
|
cells.push(cell);
|
|
739
823
|
const polarity = cell.passRate >= thresholds.strength ? "strength" : cell.passRate <= thresholds.weakness ? "weakness" : void 0;
|
|
740
|
-
|
|
824
|
+
const complete = cell.n === cell.plannedN && exhaustedRuns === 0 && judgeIncompleteRuns === 0 && suite.refusal === void 0;
|
|
825
|
+
if (polarity !== void 0 && cell.n > 0 && complete) {
|
|
741
826
|
const epoch = options.modelEpochFor?.(member);
|
|
742
827
|
claims.push({
|
|
743
828
|
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.19.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/
|
|
26
|
-
"@rulvar/
|
|
25
|
+
"@rulvar/testing": "1.19.0",
|
|
26
|
+
"@rulvar/core": "1.19.0"
|
|
27
27
|
},
|
|
28
28
|
"devDependencies": {
|
|
29
29
|
"@types/node": "^22.20.0",
|