@rulvar/evals 1.16.2 → 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 CHANGED
@@ -1,5 +1,39 @@
1
1
  import { CompiledWorkflow, DeclaredLadder, Effort, Engine, EvidenceRef, Json, JsonSchema, KnowledgeSnapshot, ModelClaim, ModelKnowledgeStore, ModelRef, ModelSpec, RunOutcome, SchemaSpec, TaskClass, Usage, WireError, Workflow } from "@rulvar/core";
2
2
 
3
+ //#region src/envelope.d.ts
4
+ /** Thrown when authorizing a run's ceiling would exceed the envelope. */
5
+ declare class SweepBudgetError extends Error {
6
+ /** What was about to start, e.g. `eval target 'sweep-math'`. */
7
+ readonly runLabel: string;
8
+ /** The per-run ceiling that did not fit. */
9
+ readonly ceilingUsd: number;
10
+ /** Total already authorized before this refusal. */
11
+ readonly authorizedUsd: number;
12
+ readonly maxTotalUsd: number;
13
+ constructor(runLabel: string, ceilingUsd: number, authorizedUsd: number, maxTotalUsd: number);
14
+ }
15
+ /**
16
+ * One envelope bounds one whole sweep invocation: share the instance
17
+ * across the canary loop and runSweepMatrix so canary, target, and
18
+ * judge runs all draw from the same remainder.
19
+ */
20
+ declare class SpendEnvelope {
21
+ readonly maxTotalUsd: number;
22
+ private readonly maxMicroUsd;
23
+ private authorizedMicroUsd;
24
+ constructor(maxTotalUsd: number);
25
+ /** Total authorized so far (debit-only; never decreases). */
26
+ get authorizedUsd(): number;
27
+ get remainingUsd(): number;
28
+ /**
29
+ * Authorizes one run's immutable ceiling or throws SweepBudgetError.
30
+ * An unbounded run cannot be authorized: under an envelope every run
31
+ * MUST carry an explicit positive ceiling, otherwise the aggregate
32
+ * bound would be unaccountable.
33
+ */
34
+ authorize(ceilingUsd: number | undefined, runLabel: string): void;
35
+ }
36
+ //#endregion
3
37
  //#region src/case.d.ts
4
38
  /**
5
39
  * One quality-measurement case. The shape is the
@@ -71,6 +105,20 @@ interface EvalCaseResult {
71
105
  /** The target run's normalized usage. */
72
106
  usage: Usage;
73
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
+ };
74
122
  }
75
123
  interface RunEvalCaseOptions {
76
124
  /** Display-name override; defaults to the workflow name. */
@@ -79,12 +127,21 @@ interface RunEvalCaseOptions {
79
127
  budgetUsd?: number;
80
128
  /** Run ceiling for each judge run. */
81
129
  judgeBudgetUsd?: number;
130
+ /**
131
+ * Aggregate debit-only envelope (v1.16.2 review P1-2): every target
132
+ * and judge run authorizes its ceiling here BEFORE starting, and an
133
+ * envelope requires the matching per-run ceiling to be set. A
134
+ * refusal throws SweepBudgetError before any provider work.
135
+ */
136
+ envelope?: SpendEnvelope;
82
137
  }
83
138
  /** Thrown when a judge run does not settle ok. */
84
139
  declare class EvalJudgeError extends Error {
85
140
  readonly judgeRun: string;
86
141
  readonly status: RunOutcome<Json>["status"];
87
- 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);
88
145
  }
89
146
  /**
90
147
  * Runs one EvalCase on the given engine: the target workflow as its own
@@ -96,15 +153,33 @@ declare function runEvalCase(engine: Engine, evalCase: EvalCase, options?: RunEv
96
153
  /** Aggregate view of a suite run. */
97
154
  interface EvalSuiteResult {
98
155
  results: EvalCaseResult[];
99
- /** Fraction of cases with passed true; 0 for an empty suite. */
156
+ /** Fraction of result rows with passed true; 0 for an empty suite. */
100
157
  passRate: number;
101
158
  totalCostUsd: number;
102
- /** Arithmetic mean over cases; 0 for an empty suite. */
159
+ /** Arithmetic mean over result rows; 0 for an empty suite. */
103
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
+ };
104
177
  }
105
178
  interface RunEvalSuiteOptions {
106
179
  budgetUsd?: number;
107
180
  judgeBudgetUsd?: number;
181
+ /** See RunEvalCaseOptions.envelope; shared across every case of the suite. */
182
+ envelope?: SpendEnvelope;
108
183
  }
109
184
  /**
110
185
  * Runs cases sequentially (deterministic journal and cassette order) and
@@ -232,14 +307,59 @@ interface CanaryProbeSet {
232
307
  /** The fixed prompts; order matters and enters the fingerprint. */
233
308
  prompts: string[];
234
309
  }
310
+ interface CanaryRunOptions {
311
+ /**
312
+ * Immutable ceiling per probe run (v1.16.2 review P1-2): every probe
313
+ * is an ordinary paid engine run and gets its own recorded
314
+ * RunMeta.budgetUsd.
315
+ */
316
+ budgetUsd?: number;
317
+ /**
318
+ * Aggregate debit-only envelope shared with the surrounding sweep;
319
+ * each probe authorizes budgetUsd BEFORE running, and an envelope
320
+ * requires budgetUsd to be set.
321
+ */
322
+ envelope?: SpendEnvelope;
323
+ }
324
+ interface CanaryReport {
325
+ fingerprint: string;
326
+ /**
327
+ * True only when every probe settled ok. A fingerprint containing a
328
+ * non-ok probe status is a measurement artifact (budget exhaustion,
329
+ * an envelope refusal, transient provider failure), NOT evidence of
330
+ * model drift: never feed it to flipStaleOnCanaryDrift.
331
+ */
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
+ */
338
+ probes: Array<{
339
+ prompt: string;
340
+ status: RunOutcome<unknown>["status"] | "refused";
341
+ }>;
342
+ }
235
343
  /** The committed v1 normalization (OQ-06): NFC, trim, collapse whitespace. */
236
344
  declare function normalizeCanaryOutput(output: unknown): string;
237
345
  /**
238
- * Runs the fixed probe set through the ordinary engine and returns the
239
- * fingerprint. Probes run sequentially in declaration order, one run
240
- * per probe, so recordings replay deterministically.
346
+ * Runs the fixed probe set through the ordinary engine. Probes run
347
+ * sequentially in declaration order, one run per probe, so recordings
348
+ * replay deterministically. Each probe run carries the optional
349
+ * immutable ceiling (options.budgetUsd) and authorizes it against the
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.
241
356
  */
242
- declare function canaryFingerprint(engine: Engine, probes: CanaryProbeSet): Promise<string>;
357
+ declare function runCanary(engine: Engine, probes: CanaryProbeSet, options?: CanaryRunOptions): Promise<CanaryReport>;
358
+ /**
359
+ * The fingerprint alone (the pre-v1.16.2-review surface, kept
360
+ * compatible). Prefer runCanary: its allOk is the drift-flip gate.
361
+ */
362
+ declare function canaryFingerprint(engine: Engine, probes: CanaryProbeSet, options?: CanaryRunOptions): Promise<string>;
243
363
  interface CanaryDriftReport {
244
364
  model: ModelRef;
245
365
  freshFingerprint: string;
@@ -253,7 +373,13 @@ interface CanaryDriftReport {
253
373
  * recorded canary fingerprint differs from the fresh one. Claims
254
374
  * without a recorded fingerprint have no baseline and
255
375
  * stay untouched (the documented no-probe posture); a second run is
256
- * an idempotent noop. CAS-rebased like every maintenance commit.
376
+ * an idempotent noop. CAS-rebased like every maintenance commit; the
377
+ * retries run no engine work and pay nothing.
378
+ *
379
+ * Only pass fingerprints from an allOk probe set (runCanary): a
380
+ * fingerprint containing a `!status` probe differs from any healthy
381
+ * baseline by construction, and flipping on it would blame the model
382
+ * for a budget ceiling or a transient provider failure.
257
383
  */
258
384
  declare function flipStaleOnCanaryDrift(store: ModelKnowledgeStore, model: ModelRef, freshFingerprint: string, options?: {
259
385
  attempts?: number;
@@ -297,6 +423,20 @@ interface RunSweepOptions {
297
423
  thresholds?: Partial<SweepThresholds>;
298
424
  /** Passed through to every suite run (budget, VCR hooks ride the engine). */
299
425
  suite?: RunEvalSuiteOptions;
426
+ /**
427
+ * Aggregate debit-only envelope over the WHOLE matrix (v1.16.2
428
+ * review P1-2): every target and judge run authorizes its immutable
429
+ * ceiling before starting, so the pool times cases times judge-call
430
+ * product cannot exceed it, falsification pool growth included. An
431
+ * envelope requires suite.budgetUsd (and suite.judgeBudgetUsd once a
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.
438
+ */
439
+ envelope?: SpendEnvelope;
300
440
  /** When given, emitted claims commit through the committer identity. */
301
441
  store?: ModelKnowledgeStore;
302
442
  /**
@@ -310,9 +450,41 @@ interface SweepCellReport {
310
450
  effort?: Effort;
311
451
  taskClass: TaskClass;
312
452
  passRate: number;
453
+ /** Result rows actually measured (completed count). */
313
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;
314
461
  totalCostUsd: number;
315
462
  caseNames: string[];
463
+ /**
464
+ * Count of case results whose TARGET run settled 'exhausted' (its
465
+ * per-run ceiling, not the envelope). A budget-starved measurement
466
+ * must not become a model belief, so any exhausted target suppresses
467
+ * the cell's claim even when the degraded passRate crosses a
468
+ * threshold: the alternative is committing a false weakness that
469
+ * blames the model for the ceiling.
470
+ */
471
+ exhaustedRuns?: number;
472
+ /**
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.
482
+ */
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;
316
488
  }
317
489
  interface SweepReport {
318
490
  reportId: string;
@@ -417,4 +589,4 @@ declare function runValueCheckpoint(checkpointPool: CheckpointPool, options: Run
417
589
  /** The deterministic render for the M12 gate docs amendment. */
418
590
  declare function renderCheckpointReport(report: CheckpointReport): string;
419
591
  //#endregion
420
- export { type CanaryDriftReport, type CanaryProbeSet, type CheckpointArm, type CheckpointCell, type CheckpointLadder, type CheckpointPool, type CheckpointReport, type CriterionOneReport, type CriterionTwoReport, type EvalCase, type EvalCaseResult, type EvalCommitterOptions, EvalJudgeError, type EvalMatrixReport, type EvalSuiteResult, type GoldenGraderOptions, type Grader, type GraderContext, type GraderVerdict, JUDGE_VERDICT_SCHEMA, type JudgeGraderOptions, type JudgeSpec, type MatrixCell, type MatrixCellReport, type MeasuredClaimInput, type OrchestratedCase, type RubricCriterion, type RubricGraderOptions, type RunCheckpointOptions, type RunEvalCaseOptions, type RunEvalSuiteOptions, type RunSweepOptions, SWEEP_THRESHOLD_DEFAULTS, type SweepCase, type SweepCellReport, type SweepModel, type SweepPool, type SweepReport, type SweepThresholds, canaryFingerprint, commitEvalMeasured, evalMeasuredClaim, flipStaleOnCanaryDrift, goldenGrader, judgeGrader, normalizeCanaryOutput, renderCheckpointReport, rubricGrader, runEvalCase, runEvalMatrix, runEvalSuite, runSweepMatrix, runValueCheckpoint, rungRuleHolds };
592
+ export { type CanaryDriftReport, type CanaryProbeSet, type CanaryReport, type CanaryRunOptions, type CheckpointArm, type CheckpointCell, type CheckpointLadder, type CheckpointPool, type CheckpointReport, type CriterionOneReport, type CriterionTwoReport, type EvalCase, type EvalCaseResult, type EvalCommitterOptions, EvalJudgeError, type EvalMatrixReport, type EvalSuiteResult, type GoldenGraderOptions, type Grader, type GraderContext, type GraderVerdict, JUDGE_VERDICT_SCHEMA, type JudgeGraderOptions, type JudgeSpec, type MatrixCell, type MatrixCellReport, type MeasuredClaimInput, type OrchestratedCase, type RubricCriterion, type RubricGraderOptions, type RunCheckpointOptions, type RunEvalCaseOptions, type RunEvalSuiteOptions, type RunSweepOptions, SWEEP_THRESHOLD_DEFAULTS, SpendEnvelope, SweepBudgetError, type SweepCase, type SweepCellReport, type SweepModel, type SweepPool, type SweepReport, type SweepThresholds, canaryFingerprint, commitEvalMeasured, evalMeasuredClaim, flipStaleOnCanaryDrift, goldenGrader, judgeGrader, normalizeCanaryOutput, renderCheckpointReport, rubricGrader, runCanary, runEvalCase, runEvalMatrix, runEvalSuite, runSweepMatrix, runValueCheckpoint, rungRuleHolds };
package/dist/index.js CHANGED
@@ -1,5 +1,100 @@
1
- import { KnowledgeCasError, claimExpiry, compileVerifiedLayer, defineWorkflow } from "@rulvar/core";
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
  /**
@@ -32,6 +130,7 @@ var EvalJudgeError = class extends Error {
32
130
  async function runEvalCase(engine, evalCase, options = {}) {
33
131
  const name = options.name ?? evalCase.workflow.name;
34
132
  const timing = {};
133
+ options.envelope?.authorize(options.budgetUsd, `eval target '${name}'`);
35
134
  const handle = engine.run(evalCase.workflow, evalCase.args, {
36
135
  name: `eval:${name}`,
37
136
  ...options.budgetUsd === void 0 ? {} : { budgetUsd: options.budgetUsd }
@@ -53,24 +152,46 @@ async function runEvalCase(engine, evalCase, options = {}) {
53
152
  async judge(spec) {
54
153
  const ordinal = judgeOrdinal;
55
154
  judgeOrdinal += 1;
155
+ options.envelope?.authorize(options.judgeBudgetUsd, `eval judge '${name}:${String(ordinal)}'`);
56
156
  const judged = await runJudge(engine, `${name}:${ordinal}`, spec, options.judgeBudgetUsd);
57
157
  judgeCostUsd += judged.costUsd;
58
158
  return judged.output;
59
159
  }
60
160
  };
61
161
  const verdicts = [];
62
- 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
+ }
63
183
  const latencyMs = timing.start !== void 0 && timing.end !== void 0 ? Math.max(0, Date.parse(timing.end) - Date.parse(timing.start)) : 0;
64
184
  return {
65
185
  name,
66
186
  status: outcome.status,
67
- passed: outcome.status === "ok" && verdicts.every((verdict) => verdict.passed),
187
+ passed: outcome.status === "ok" && incomplete === void 0 && verdicts.every((verdict) => verdict.passed),
68
188
  verdicts,
69
189
  costUsd: outcome.cost.totalUsd + judgeCostUsd,
70
190
  judgeCostUsd,
71
191
  latencyMs,
72
192
  usage: outcome.usage,
73
- ...outcome.error === void 0 ? {} : { error: outcome.error }
193
+ ...outcome.error === void 0 ? {} : { error: outcome.error },
194
+ ...incomplete === void 0 ? {} : { incomplete }
74
195
  };
75
196
  }
76
197
  async function runJudge(engine, judgeName, spec, budgetUsd) {
@@ -87,7 +208,7 @@ async function runJudge(engine, judgeName, spec, budgetUsd) {
87
208
  name: workflowName,
88
209
  ...budgetUsd === void 0 ? {} : { budgetUsd }
89
210
  }).result;
90
- 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);
91
212
  return {
92
213
  output: outcome.value ?? null,
93
214
  costUsd: outcome.cost.totalUsd
@@ -101,22 +222,37 @@ async function runJudge(engine, judgeName, spec, budgetUsd) {
101
222
  async function runEvalSuite(engine, cases, options = {}) {
102
223
  const seen = /* @__PURE__ */ new Map();
103
224
  const results = [];
225
+ let refusal;
104
226
  for (const evalCase of cases) {
105
227
  const base = evalCase.workflow.name;
106
228
  const ordinal = seen.get(base) ?? 0;
107
229
  seen.set(base, ordinal + 1);
108
230
  const name = ordinal === 0 ? base : `${base}#${ordinal}`;
109
- results.push(await runEvalCase(engine, evalCase, {
110
- name,
111
- ...options.budgetUsd === void 0 ? {} : { budgetUsd: options.budgetUsd },
112
- ...options.judgeBudgetUsd === void 0 ? {} : { judgeBudgetUsd: options.judgeBudgetUsd }
113
- }));
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
+ }
114
247
  }
115
248
  return {
116
249
  results,
117
250
  passRate: results.length === 0 ? 0 : results.filter((r) => r.passed).length / results.length,
118
251
  totalCostUsd: results.reduce((sum, r) => sum + r.costUsd, 0),
119
- 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 }
120
256
  };
121
257
  }
122
258
  //#endregion
@@ -337,26 +473,66 @@ function normalizeCanaryOutput(output) {
337
473
  return (typeof output === "string" ? output : JSON.stringify(output ?? null)).normalize("NFC").trim().replace(/\s+/gu, " ");
338
474
  }
339
475
  /**
340
- * Runs the fixed probe set through the ordinary engine and returns the
341
- * fingerprint. Probes run sequentially in declaration order, one run
342
- * per probe, so recordings replay deterministically.
476
+ * Runs the fixed probe set through the ordinary engine. Probes run
477
+ * sequentially in declaration order, one run per probe, so recordings
478
+ * replay deterministically. Each probe run carries the optional
479
+ * immutable ceiling (options.budgetUsd) and authorizes it against the
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.
343
486
  */
344
- async function canaryFingerprint(engine, probes) {
487
+ async function runCanary(engine, probes, options = {}) {
345
488
  const outputs = [];
489
+ const probeReports = [];
346
490
  for (const [index, prompt] of probes.prompts.entries()) {
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
+ }
347
502
  const workflow = defineWorkflow({ name: `kb-canary:${String(index)}` }, async (ctx) => await ctx.agent(prompt, { agentType: probes.agentType }));
348
- const outcome = await engine.run(workflow, null).result;
503
+ const outcome = await engine.run(workflow, null, options.budgetUsd === void 0 ? {} : { budgetUsd: options.budgetUsd }).result;
504
+ probeReports.push({
505
+ prompt,
506
+ status: outcome.status
507
+ });
349
508
  outputs.push(outcome.status === "ok" ? normalizeCanaryOutput(outcome.value) : `!${outcome.status}`);
350
509
  }
351
510
  const body = JSON.stringify([probes.prompts.length, outputs]);
352
- return createHash("sha256").update(body, "utf8").digest("hex");
511
+ return {
512
+ fingerprint: createHash("sha256").update(body, "utf8").digest("hex"),
513
+ allOk: probeReports.every((probe) => probe.status === "ok"),
514
+ probes: probeReports
515
+ };
516
+ }
517
+ /**
518
+ * The fingerprint alone (the pre-v1.16.2-review surface, kept
519
+ * compatible). Prefer runCanary: its allOk is the drift-flip gate.
520
+ */
521
+ async function canaryFingerprint(engine, probes, options = {}) {
522
+ return (await runCanary(engine, probes, options)).fingerprint;
353
523
  }
354
524
  /**
355
525
  * Flips the model's ACTIVE eval-measured claims to stale when their
356
526
  * recorded canary fingerprint differs from the fresh one. Claims
357
527
  * without a recorded fingerprint have no baseline and
358
528
  * stay untouched (the documented no-probe posture); a second run is
359
- * an idempotent noop. CAS-rebased like every maintenance commit.
529
+ * an idempotent noop. CAS-rebased like every maintenance commit; the
530
+ * retries run no engine work and pay nothing.
531
+ *
532
+ * Only pass fingerprints from an allOk probe set (runCanary): a
533
+ * fingerprint containing a `!status` probe differs from any healthy
534
+ * baseline by construction, and flipping on it would blame the model
535
+ * for a budget ceiling or a transient provider failure.
360
536
  */
361
537
  async function flipStaleOnCanaryDrift(store, model, freshFingerprint, options) {
362
538
  const attempts = options?.attempts ?? 3;
@@ -549,6 +725,16 @@ function renderCheckpointReport(report) {
549
725
  }
550
726
  //#endregion
551
727
  //#region src/sweeps.ts
728
+ /**
729
+ * Matrix sweeps (M11-T02). The deconfounder of the whole
730
+ * knowledge feature: a FIXED eval matrix (workflow x model x
731
+ * taskClass), independent of current routing, measured through the
732
+ * ordinary engine (journaled, budgeted, VCR-recordable), emitting
733
+ * eval-measured claims through the eval-committer identity.
734
+ *
735
+ * Sweep volume is never authorized by proposal volume: the model pool
736
+ * and the case list are EXPLICIT caller data (fixed pools only).
737
+ */
552
738
  const SWEEP_THRESHOLD_DEFAULTS = {
553
739
  strength: .9,
554
740
  weakness: .5
@@ -575,6 +761,7 @@ async function runSweepMatrix(pool, options) {
575
761
  ...SWEEP_THRESHOLD_DEFAULTS,
576
762
  ...options.thresholds
577
763
  };
764
+ if (options.envelope !== void 0 && options.suite?.budgetUsd === void 0) throw new ConfigError("runSweepMatrix: an aggregate envelope requires suite.budgetUsd (the per-target ceiling); unbounded targets under an envelope would be unaccountable");
578
765
  const byTaskClass = /* @__PURE__ */ new Map();
579
766
  for (const entry of pool.cases) {
580
767
  const bucket = byTaskClass.get(entry.taskClass) ?? [];
@@ -586,19 +773,34 @@ async function runSweepMatrix(pool, options) {
586
773
  for (const member of pool.models) {
587
774
  const engine = await options.engineFor(member);
588
775
  for (const [taskClass, bucket] of byTaskClass) {
589
- const suite = await runEvalSuite(engine, bucket.map((entry) => entry.case), options.suite ?? {});
776
+ const suite = await runEvalSuite(engine, bucket.map((entry) => entry.case), {
777
+ ...options.suite ?? {},
778
+ ...options.envelope === void 0 ? {} : { envelope: options.envelope }
779
+ });
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;
590
783
  const cell = {
591
784
  model: member.model,
592
785
  ...member.effort === void 0 ? {} : { effort: member.effort },
593
786
  taskClass,
594
787
  passRate: suite.passRate,
595
- n: suite.results.length,
788
+ n: suite.completedN,
789
+ plannedN: suite.plannedN,
596
790
  totalCostUsd: suite.totalCostUsd,
597
- caseNames: suite.results.map((result) => result.name)
791
+ caseNames: suite.results.map((result) => result.name),
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 }
598
799
  };
599
800
  cells.push(cell);
600
801
  const polarity = cell.passRate >= thresholds.strength ? "strength" : cell.passRate <= thresholds.weakness ? "weakness" : void 0;
601
- if (polarity !== void 0 && cell.n > 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) {
602
804
  const epoch = options.modelEpochFor?.(member);
603
805
  claims.push({
604
806
  id: claimIdOf(options.reportId, member, taskClass),
@@ -639,4 +841,4 @@ async function runSweepMatrix(pool, options) {
639
841
  return report;
640
842
  }
641
843
  //#endregion
642
- export { EvalJudgeError, JUDGE_VERDICT_SCHEMA, SWEEP_THRESHOLD_DEFAULTS, canaryFingerprint, commitEvalMeasured, evalMeasuredClaim, flipStaleOnCanaryDrift, goldenGrader, judgeGrader, normalizeCanaryOutput, renderCheckpointReport, rubricGrader, runEvalCase, runEvalMatrix, runEvalSuite, runSweepMatrix, runValueCheckpoint, rungRuleHolds };
844
+ export { EvalJudgeError, JUDGE_VERDICT_SCHEMA, SWEEP_THRESHOLD_DEFAULTS, SpendEnvelope, SweepBudgetError, canaryFingerprint, commitEvalMeasured, evalMeasuredClaim, flipStaleOnCanaryDrift, goldenGrader, judgeGrader, normalizeCanaryOutput, renderCheckpointReport, rubricGrader, runCanary, runEvalCase, runEvalMatrix, runEvalSuite, runSweepMatrix, runValueCheckpoint, rungRuleHolds };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rulvar/evals",
3
- "version": "1.16.2",
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/testing": "1.16.2",
26
- "@rulvar/core": "1.16.2"
25
+ "@rulvar/core": "1.18.0",
26
+ "@rulvar/testing": "1.18.0"
27
27
  },
28
28
  "devDependencies": {
29
29
  "@types/node": "^22.20.0",