@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.
Files changed (3) hide show
  1. package/dist/index.d.ts +75 -15
  2. package/dist/index.js +201 -116
  3. 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
- constructor(judgeRun: string, status: RunOutcome<Json>["status"], detail?: string);
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 cases with passed true; 0 for an empty suite. */
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 cases; 0 for an empty suite. */
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 model drift: never
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. A non-ok probe enters the
314
- * fingerprint as `!status` and clears allOk: callers gate drift
315
- * flipping on allOk, because a budget-starved or transiently failing
316
- * probe fingerprints differently without the model having drifted.
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); a cell refused by the envelope lands in the report
394
- * as envelopeExhausted with NO claim. Share the instance with the
395
- * canary loop so probes draw from the same remainder.
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
- * The aggregate envelope refused a run of this cell before it
425
- * started; stats cover nothing reliable and the cell emits no claim.
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
- constructor(judgeRun, status, detail) {
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
- for (const grader of evalCase.graders) verdicts.push(await grader.grade(context));
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
- results.push(await runEvalCase(engine, evalCase, {
112
- name,
113
- ...options.budgetUsd === void 0 ? {} : { budgetUsd: options.budgetUsd },
114
- ...options.judgeBudgetUsd === void 0 ? {} : { judgeBudgetUsd: options.judgeBudgetUsd },
115
- ...options.envelope === void 0 ? {} : { envelope: options.envelope }
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. A non-ok probe enters the
348
- * fingerprint as `!status` and clears allOk: callers gate drift
349
- * flipping on allOk, because a budget-starved or transiently failing
350
- * probe fingerprints differently without the model having drifted.
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
- options.envelope?.authorize(options.budgetUsd, `canary probe ${String(index)}`);
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
- let suite;
708
- try {
709
- suite = await runEvalSuite(engine, bucket.map((entry) => entry.case), {
710
- ...options.suite ?? {},
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.results.length,
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
- if (polarity !== void 0 && cell.n > 0 && exhaustedRuns === 0) {
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.17.0",
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/core": "1.17.0",
26
- "@rulvar/testing": "1.17.0"
25
+ "@rulvar/testing": "1.19.0",
26
+ "@rulvar/core": "1.19.0"
27
27
  },
28
28
  "devDependencies": {
29
29
  "@types/node": "^22.20.0",