@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.
Files changed (3) hide show
  1. package/dist/index.d.ts +75 -15
  2. package/dist/index.js +179 -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,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
- constructor(judgeRun, status, detail) {
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
- for (const grader of evalCase.graders) verdicts.push(await grader.grade(context));
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
- 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
- }));
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. 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.
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
- options.envelope?.authorize(options.budgetUsd, `canary probe ${String(index)}`);
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
- 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
- }
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.results.length,
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
- if (polarity !== void 0 && cell.n > 0 && exhaustedRuns === 0) {
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.17.0",
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.17.0",
26
- "@rulvar/testing": "1.17.0"
25
+ "@rulvar/core": "1.18.0",
26
+ "@rulvar/testing": "1.18.0"
27
27
  },
28
28
  "devDependencies": {
29
29
  "@types/node": "^22.20.0",