modelrig 0.6.0 → 0.8.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 (131) hide show
  1. package/CHANGELOG.md +123 -0
  2. package/README.md +65 -1
  3. package/dist/artifacts/run-context.d.ts +10 -0
  4. package/dist/artifacts/run-context.d.ts.map +1 -1
  5. package/dist/artifacts/run-context.js +12 -0
  6. package/dist/bakeoff/disagreement.d.ts +93 -0
  7. package/dist/bakeoff/disagreement.d.ts.map +1 -0
  8. package/dist/bakeoff/disagreement.js +140 -0
  9. package/dist/bakeoff/engine.d.ts.map +1 -1
  10. package/dist/bakeoff/engine.js +58 -2
  11. package/dist/bakeoff/pairwise.d.ts +102 -0
  12. package/dist/bakeoff/pairwise.d.ts.map +1 -0
  13. package/dist/bakeoff/pairwise.js +214 -0
  14. package/dist/bakeoff/replay.d.ts +6 -0
  15. package/dist/bakeoff/replay.d.ts.map +1 -1
  16. package/dist/bakeoff/replay.js +6 -2
  17. package/dist/bakeoff/report.d.ts +7 -0
  18. package/dist/bakeoff/report.d.ts.map +1 -1
  19. package/dist/bakeoff/report.js +46 -0
  20. package/dist/bakeoff/types.d.ts +24 -0
  21. package/dist/bakeoff/types.d.ts.map +1 -1
  22. package/dist/cli.d.ts.map +1 -1
  23. package/dist/cli.js +9 -9
  24. package/dist/mask/apply.d.ts +80 -0
  25. package/dist/mask/apply.d.ts.map +1 -0
  26. package/dist/mask/apply.js +228 -0
  27. package/dist/mask/detect.d.ts +38 -0
  28. package/dist/mask/detect.d.ts.map +1 -0
  29. package/dist/mask/detect.js +167 -0
  30. package/dist/mask/index.d.ts +21 -0
  31. package/dist/mask/index.d.ts.map +1 -0
  32. package/dist/mask/index.js +54 -0
  33. package/dist/mask/mask-policy-cli.d.ts +11 -0
  34. package/dist/mask/mask-policy-cli.d.ts.map +1 -0
  35. package/dist/mask/mask-policy-cli.js +33 -0
  36. package/dist/mask/policy.d.ts +38 -0
  37. package/dist/mask/policy.d.ts.map +1 -0
  38. package/dist/mask/policy.js +187 -0
  39. package/dist/mask/proof.d.ts +54 -0
  40. package/dist/mask/proof.d.ts.map +1 -0
  41. package/dist/mask/proof.js +120 -0
  42. package/dist/mask/receipt.d.ts +30 -0
  43. package/dist/mask/receipt.d.ts.map +1 -0
  44. package/dist/mask/receipt.js +49 -0
  45. package/dist/mask/seam.d.ts +53 -0
  46. package/dist/mask/seam.d.ts.map +1 -0
  47. package/dist/mask/seam.js +120 -0
  48. package/dist/mask/types.d.ts +81 -0
  49. package/dist/mask/types.d.ts.map +1 -0
  50. package/dist/mask/types.js +10 -0
  51. package/dist/mask/vault.d.ts +50 -0
  52. package/dist/mask/vault.d.ts.map +1 -0
  53. package/dist/mask/vault.js +81 -0
  54. package/dist/receipts/types.d.ts +65 -0
  55. package/dist/receipts/types.d.ts.map +1 -0
  56. package/dist/receipts/types.js +134 -0
  57. package/dist/routes/loader-warnings.d.ts +8 -0
  58. package/dist/routes/loader-warnings.d.ts.map +1 -1
  59. package/dist/routes/loader-warnings.js +23 -0
  60. package/dist/routes/loader.d.ts +1 -1
  61. package/dist/routes/loader.d.ts.map +1 -1
  62. package/dist/routes/loader.js +24 -25
  63. package/dist/routes/mask-loader.d.ts +18 -0
  64. package/dist/routes/mask-loader.d.ts.map +1 -0
  65. package/dist/routes/mask-loader.js +39 -0
  66. package/dist/routes/objective-loader.d.ts.map +1 -1
  67. package/dist/routes/objective-loader.js +51 -17
  68. package/dist/routes/screen-loader.d.ts +23 -0
  69. package/dist/routes/screen-loader.d.ts.map +1 -0
  70. package/dist/routes/screen-loader.js +79 -0
  71. package/dist/routes/template.d.ts +10 -0
  72. package/dist/routes/template.d.ts.map +1 -1
  73. package/dist/routes/template.js +10 -3
  74. package/dist/run/accept.d.ts +4 -0
  75. package/dist/run/accept.d.ts.map +1 -1
  76. package/dist/run/accept.js +2 -0
  77. package/dist/run/dispatch.d.ts +35 -0
  78. package/dist/run/dispatch.d.ts.map +1 -1
  79. package/dist/run/dispatch.js +33 -0
  80. package/dist/run/grounding.d.ts +47 -2
  81. package/dist/run/grounding.d.ts.map +1 -1
  82. package/dist/run/grounding.js +113 -6
  83. package/dist/run/raw.d.ts.map +1 -1
  84. package/dist/run/raw.js +10 -3
  85. package/dist/run/record-attempt.d.ts.map +1 -1
  86. package/dist/run/record-attempt.js +12 -1
  87. package/dist/run/run.d.ts.map +1 -1
  88. package/dist/run/run.js +25 -19
  89. package/dist/screen/provenance.d.ts +59 -0
  90. package/dist/screen/provenance.d.ts.map +1 -0
  91. package/dist/screen/provenance.js +104 -0
  92. package/dist/screen/types.d.ts +71 -0
  93. package/dist/screen/types.d.ts.map +1 -0
  94. package/dist/screen/types.js +9 -0
  95. package/dist/status-cli.d.ts +11 -0
  96. package/dist/status-cli.d.ts.map +1 -0
  97. package/dist/status-cli.js +21 -0
  98. package/dist/status-runs.d.ts +38 -0
  99. package/dist/status-runs.d.ts.map +1 -0
  100. package/dist/status-runs.js +80 -0
  101. package/dist/status.d.ts +7 -2
  102. package/dist/status.d.ts.map +1 -1
  103. package/dist/status.js +11 -2
  104. package/dist/telemetry/ingest-protocol.d.ts +8 -0
  105. package/dist/telemetry/ingest-protocol.d.ts.map +1 -1
  106. package/dist/telemetry/store-migrations.d.ts.map +1 -1
  107. package/dist/telemetry/store-migrations.js +11 -0
  108. package/dist/telemetry/store.d.ts.map +1 -1
  109. package/dist/telemetry/store.js +12 -3
  110. package/dist/telemetry/supabase-exporter.d.ts +64 -0
  111. package/dist/telemetry/supabase-exporter.d.ts.map +1 -0
  112. package/dist/telemetry/supabase-exporter.js +211 -0
  113. package/dist/types/provider.d.ts +11 -0
  114. package/dist/types/provider.d.ts.map +1 -1
  115. package/dist/types/provider.js +9 -0
  116. package/dist/types/route.d.ts +55 -0
  117. package/dist/types/route.d.ts.map +1 -1
  118. package/dist/types/telemetry.d.ts +7 -0
  119. package/dist/types/telemetry.d.ts.map +1 -1
  120. package/dist/validate-cli.d.ts.map +1 -1
  121. package/dist/validate-cli.js +13 -0
  122. package/dist/verify/types.d.ts +6 -0
  123. package/dist/verify/types.d.ts.map +1 -1
  124. package/docs/agent-skill/SKILL.md +131 -0
  125. package/docs/how-it-fits.md +34 -0
  126. package/docs/instrumentation.md +221 -0
  127. package/docs/llms.txt +276 -0
  128. package/docs/migration-playbook.md +512 -0
  129. package/docs/quickstart.md +333 -0
  130. package/package.json +18 -2
  131. package/receipts.d.ts +12 -0
@@ -22,6 +22,8 @@ const loader_1 = require("../routes/loader");
22
22
  const replay_1 = require("./replay");
23
23
  const stats_1 = require("./stats");
24
24
  const judge_1 = require("./judge");
25
+ const pairwise_1 = require("./pairwise");
26
+ const disagreement_1 = require("./disagreement");
25
27
  const reference_1 = require("./reference");
26
28
  const samples_1 = require("./samples");
27
29
  /** The arm name carrying the reference (ceiling) metrics. Reserved — a user
@@ -75,6 +77,10 @@ async function runBakeoff(deps, captures, req, samplesSink) {
75
77
  const keepOutputs = req.keepOutputs !== undefined && samplesSink !== undefined;
76
78
  const bakeoffId = (0, node_crypto_1.randomUUID)();
77
79
  const judgeCfg = route.bakeoff?.judge;
80
+ // N5: a declared pairwise judge needs each sample's output retained (arm-vs-
81
+ // reference comparison in the engine) — WITHOUT the keepOutputs sink persist.
82
+ const pairwiseJudgeCfg = route.bakeoff?.pairwise?.judge;
83
+ const retainForPairwise = pairwiseJudgeCfg !== undefined;
78
84
  // Replay every requested arm. Raw samples are kept ALWAYS (paired regret is
79
85
  // computed from per-sample quality); the keepOutputs sink is separate. A fresh
80
86
  // judge scorer per arm keeps `max_samples` per-arm (spec §4.3).
@@ -88,6 +94,7 @@ async function runBakeoff(deps, captures, req, samplesSink) {
88
94
  lastN: req.replayLastN,
89
95
  envelopeUsd: req.envelopeUsdPerVariant,
90
96
  ...(keepOutputs ? { keepOutputs: true } : {}),
97
+ ...(retainForPairwise ? { retainOutputForPairwise: true } : {}),
91
98
  ...(req.expected !== undefined ? { expected: req.expected } : {}),
92
99
  ...(judgeCfg !== undefined ? { judge: (0, judge_1.makeJudgeScorer)(deps, route, judgeCfg) } : {}),
93
100
  ...(req.qualityGate !== undefined ? { qualityGate: req.qualityGate } : {}),
@@ -101,6 +108,10 @@ async function runBakeoff(deps, captures, req, samplesSink) {
101
108
  // arm's paired regret vs it. Partial-result honesty is preserved.
102
109
  const referenceInfo = resolveReference(route, req);
103
110
  let reference = null;
111
+ // N6: pairwise verdicts keyed `${arm} ${captureId}` (a mining input) + the
112
+ // reference samples, hoisted so disagreement mining sees them after the block.
113
+ const pairwiseOutcomes = new Map();
114
+ let refSamplesForMining = [];
104
115
  if (referenceInfo !== null) {
105
116
  const { deps: refDeps } = withReferenceArm(deps, route, referenceInfo.candidate);
106
117
  const refEnvelope = route.bakeoff?.referenceEnvelopeUsd ?? req.envelopeUsdPerVariant;
@@ -109,16 +120,26 @@ async function runBakeoff(deps, captures, req, samplesSink) {
109
120
  variant: exports.REFERENCE_ARM,
110
121
  lastN: req.replayLastN,
111
122
  envelopeUsd: refEnvelope,
123
+ ...(retainForPairwise ? { retainOutputForPairwise: true } : {}),
112
124
  ...(req.expected !== undefined ? { expected: req.expected } : {}),
113
125
  ...(judgeCfg !== undefined ? { judge: (0, judge_1.makeJudgeScorer)(deps, route, judgeCfg) } : {}),
114
126
  ...(req.qualityGate !== undefined ? { qualityGate: req.qualityGate } : {}),
115
127
  });
116
128
  const partial = refSamples.length < replayN || refSamples.some((s) => s.failureClass === "budget_exhausted");
129
+ refSamplesForMining = refSamples;
117
130
  for (let i = 0; i < metrics.length; i += 1) {
118
131
  const raw = rawByArm.get(metrics[i].variant) ?? [];
132
+ // N5: pairwise standing vs the reference — deterministic where both sides
133
+ // carry value accuracy, else the opt-in capped judge (fresh per arm, so
134
+ // maxSamples is per-arm). A REPORTED signal beside regret.
135
+ const pwJudge = pairwiseJudgeCfg !== undefined ? (0, pairwise_1.makePairwiseJudge)(deps, route, pairwiseJudgeCfg) : null;
136
+ const { stats: pairwise, outcomes } = await (0, pairwise_1.scoreArmPairwise)(raw, refSamples, pwJudge);
137
+ for (const [captureId, outcome] of outcomes)
138
+ pairwiseOutcomes.set((0, disagreement_1.pairwiseKey)(metrics[i].variant, captureId), outcome);
119
139
  metrics[i] = {
120
140
  ...metrics[i],
121
141
  regret: (0, stats_1.pairedRegret)(raw, refSamples),
142
+ ...(pairwise !== null ? { pairwise } : {}),
122
143
  ...(partial ? { referencePartial: true } : {}),
123
144
  };
124
145
  }
@@ -181,6 +202,15 @@ async function runBakeoff(deps, captures, req, samplesSink) {
181
202
  "re-run with --keep-outputs to retry the samples.");
182
203
  }
183
204
  }
205
+ // N6: mine the replay/verdict set for disagreements → ranked eval-case
206
+ // candidates (never auto-promoted). Pure over the per-arm raw samples + the
207
+ // reference + pairwise verdicts. Rides the LOCAL artifact only — captureIds are
208
+ // pointers to already-captured inputs and are never mirrored.
209
+ const disagreement = (0, disagreement_1.mineDisagreements)({
210
+ arms: req.variants.map((variant) => ({ arm: variant, samples: rawByArm.get(variant) ?? [] })),
211
+ ...(refSamplesForMining.length > 0 ? { reference: refSamplesForMining } : {}),
212
+ ...(pairwiseOutcomes.size > 0 ? { pairwise: pairwiseOutcomes } : {}),
213
+ });
184
214
  return {
185
215
  id: bakeoffId,
186
216
  route: route.route,
@@ -192,6 +222,7 @@ async function runBakeoff(deps, captures, req, samplesSink) {
192
222
  objective,
193
223
  reference,
194
224
  qualityMetric,
225
+ disagreement,
195
226
  };
196
227
  }
197
228
  function isDeclared(route, candidate) {
@@ -224,6 +255,7 @@ function resolveObjective(route, req) {
224
255
  maxRegret: route.objective?.maxRegret ?? null,
225
256
  priceCeiling: route.objective?.priceCeiling ?? null,
226
257
  lambda: route.objective?.lambda ?? null,
258
+ ...(route.objective?.pairwiseAware === true ? { pairwiseAware: true } : {}),
227
259
  };
228
260
  }
229
261
  const EPS = 1e-9;
@@ -233,6 +265,16 @@ function savingsPct(incumbent, winner) {
233
265
  ? 0
234
266
  : ((inc - winner.effectiveUsdPer1kConformant) / inc) * 100;
235
267
  }
268
+ /** N5 pairwise-aware gate clause (default off). When `objective.pairwiseAware` is
269
+ * set, an eligible arm must NOT be pairwise-worse than the reference within CI —
270
+ * its pairwise win-rate lower bound ≥ 0.5. Fail-closed: an arm with NO pairwise
271
+ * stats cannot be confirmed not-worse, so it is ineligible under the knob. When
272
+ * the knob is off this returns true for every arm ⇒ RB-1 behaviour byte-identical. */
273
+ function pairwiseEligible(m, objective) {
274
+ if (objective.pairwiseAware !== true)
275
+ return true;
276
+ return m.pairwise != null && m.pairwise.winRateCi95[0] >= 0.5 - EPS;
277
+ }
236
278
  /** A price ceiling test: usd_per_1k on the effective-$/1k axis, usd_per_run on
237
279
  * mean cost. Null ceiling ⇒ no cost constraint (regret alone gates). */
238
280
  function withinCeiling(m, ceiling) {
@@ -298,7 +340,8 @@ function computeProposal(requested, metrics, ctx) {
298
340
  const eligible = pool.filter((m) => m.regret != null &&
299
341
  m.regret.nPaired >= 10 &&
300
342
  (objective.maxRegret === null || m.regret.regretCi95[1] <= objective.maxRegret + EPS) &&
301
- withinCeiling(m, objective.priceCeiling));
343
+ withinCeiling(m, objective.priceCeiling) &&
344
+ pairwiseEligible(m, objective));
302
345
  if (eligible.length === 0)
303
346
  return null;
304
347
  // Lowest regret, tie → lowest cost.
@@ -333,7 +376,8 @@ function computeProposal(requested, metrics, ctx) {
333
376
  const eligible = pool.filter((m) => m.regret != null &&
334
377
  m.regret.nPaired >= 10 &&
335
378
  (objective.maxRegret === null || m.regret.regretCi95[1] <= objective.maxRegret + EPS) &&
336
- withinCeiling(m, objective.priceCeiling));
379
+ withinCeiling(m, objective.priceCeiling) &&
380
+ pairwiseEligible(m, objective));
337
381
  if (eligible.length === 0)
338
382
  return null;
339
383
  const winner = [...eligible].sort((a, b) => blended(a) - blended(b))[0];
@@ -395,6 +439,17 @@ function bakeoffEvidence(artifact, spentUsd) {
395
439
  reference: artifact.reference ?? null,
396
440
  regret: artifact.proposal?.regret ?? null,
397
441
  quality_metric: artifact.qualityMetric ?? null,
442
+ // N6: disagreement COUNTS only (numbers — meta-safe) ride the evidence; the
443
+ // captureId candidate list stays on the LOCAL artifact and is never sent to
444
+ // the control plane (content custody).
445
+ disagreement: artifact.disagreement === undefined
446
+ ? null
447
+ : {
448
+ examined: artifact.disagreement.examined,
449
+ found: artifact.disagreement.found,
450
+ capped_at: artifact.disagreement.cappedAt,
451
+ counts: artifact.disagreement.counts,
452
+ },
398
453
  matrix: artifact.variants.map((m) => ({
399
454
  variant: m.variant,
400
455
  samples: m.samples,
@@ -408,6 +463,7 @@ function bakeoffEvidence(artifact, spentUsd) {
408
463
  quality_mean: m.qualityMean,
409
464
  quality_metric: m.qualityMetric,
410
465
  regret: m.regret ?? null,
466
+ pairwise: m.pairwise ?? null,
411
467
  })),
412
468
  };
413
469
  }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * N5 — pairwise preference vs the reference arm (near-optimal brief §3.2 N5).
3
+ *
4
+ * A second, complementary read on regret: for each replayed sample, is the SERVED
5
+ * arm's output BETTER than, tied with, or WORSE than the REFERENCE arm's on the
6
+ * same captured input? Aggregated to a `pairwise_win_rate` (win=1, tie=0.5,
7
+ * loss=0) with a seeded bootstrap CI — the RigIndex pairwise idea, scoped to one
8
+ * bake-off's arm-vs-reference comparison.
9
+ *
10
+ * DETERMINISTIC-FIRST (F5), exactly like the regret quality tiers:
11
+ * 1. When BOTH sides carry a per-sample value-accuracy score (eval case /
12
+ * value_checks / Q1 gate), the verdict is the sign of their difference —
13
+ * no model call. This is the free, always-preferred tier.
14
+ * 2. Only when a sample cannot be decided deterministically does an OPT-IN,
15
+ * CAPPED, METERED pairwise judge (`bakeoff.pairwise.judge`) compare the two
16
+ * outputs. Judge model ∈ declared candidates (#223 F3, enforced at load).
17
+ *
18
+ * The aggregation math is PURE and unit-tested directly; the judge reuses the
19
+ * real `run()` loop (no new dispatch path, no run.ts edit) exactly as judge.ts.
20
+ */
21
+ import type { RunDeps } from "../run/run";
22
+ import type { CandidateSpec, RouteBundle } from "../types/route";
23
+ import type { ReplaySampleResult } from "./replay";
24
+ /** The served arm's standing vs the reference on one sample. `win` = the arm's
25
+ * output is better than the reference's; `loss` = worse; `tie` = indistinguishable. */
26
+ export type PairwiseOutcome = "win" | "tie" | "loss";
27
+ /** How a single verdict was decided (F5): a free deterministic comparison of two
28
+ * value-accuracy scores, or a billed pairwise judge call. */
29
+ export type PairwiseSource = "deterministic" | "judge";
30
+ /** Aggregate pairwise standing of an arm vs the reference (N5). `winRate` counts
31
+ * a win as 1 and a tie as 0.5; the CI is a seeded bootstrap of the per-sample
32
+ * win values, so it is reproducible given the same sample set. */
33
+ export interface PairwiseStats {
34
+ readonly winRate: number;
35
+ readonly winRateCi95: readonly [number, number];
36
+ /** Samples with a verdict (deterministic + judge); the CI's n. */
37
+ readonly nDecided: number;
38
+ readonly wins: number;
39
+ readonly ties: number;
40
+ readonly losses: number;
41
+ /** Paired samples that could NOT be decided (no deterministic score and no
42
+ * judge, or the judge was capped/failed) — excluded from the rate, counted. */
43
+ readonly nUndecided: number;
44
+ /** Where the decided verdicts came from — "deterministic", "judge", or "mixed". */
45
+ readonly source: "deterministic" | "judge" | "mixed";
46
+ }
47
+ /** Seeded so a bake-off's pairwise CI is reproducible (like the regret CI). A
48
+ * distinct seed from the regret bootstrap keeps the two resamplings independent. */
49
+ export declare const PAIRWISE_BOOTSTRAP_REPS = 2000;
50
+ export declare const PAIRWISE_BOOTSTRAP_SEED = 20260824;
51
+ /** The win-value of an outcome (win=1, tie=0.5, loss=0) — the quantity the win
52
+ * rate averages. */
53
+ export declare function winValue(o: PairwiseOutcome): number;
54
+ /**
55
+ * The DETERMINISTIC pairwise verdict (tier 1): the sign of `armQuality −
56
+ * refQuality`. Returns null when EITHER side lacks a value-accuracy score — the
57
+ * caller then falls to the judge. A tie within EPS is a genuine tie.
58
+ */
59
+ export declare function deterministicPairwise(armQuality: number | null, refQuality: number | null): PairwiseOutcome | null;
60
+ /**
61
+ * Aggregate per-sample verdicts into `PairwiseStats` (N5). `decided` are the
62
+ * verdicts that resolved; `nUndecided` the paired samples that did not. The CI is
63
+ * a seeded bootstrap of the per-sample win values (win=1/tie=0.5/loss=0), so it
64
+ * is deterministic given the verdict list + seed. Pure.
65
+ */
66
+ export declare function aggregatePairwise(decided: readonly {
67
+ readonly outcome: PairwiseOutcome;
68
+ readonly source: PairwiseSource;
69
+ }[], nUndecided: number, seed?: number, reps?: number): PairwiseStats;
70
+ /**
71
+ * Score ONE arm's pairwise standing vs the reference over the paired replay set
72
+ * (N5). Deterministic-first: each paired sample's verdict is the sign of the two
73
+ * value-accuracy scores; a sample neither side scored falls to the capped judge
74
+ * (when supplied), else it is undecided (counted, excluded from the rate). Pairs
75
+ * by captureId; unpaired samples are ignored. Returns null stats when there are
76
+ * NO paired samples at all. `outcomes` maps captureId → verdict (for N6 mining).
77
+ */
78
+ export declare function scoreArmPairwise(armSamples: readonly ReplaySampleResult[], refSamples: readonly ReplaySampleResult[], judge: PairwiseJudge | null): Promise<{
79
+ stats: PairwiseStats | null;
80
+ outcomes: Map<string, PairwiseOutcome>;
81
+ }>;
82
+ /** Build the synthetic pairwise-judge route for a base route + judge model. The
83
+ * judge model is declared WITHOUT its trial flag so it serves live here. */
84
+ export declare function buildPairwiseJudgeRoute(baseRoute: RouteBundle, model: CandidateSpec): RouteBundle;
85
+ /** A per-arm pairwise scorer: `compare(armOutput, refOutput, taskInput)` returns
86
+ * the arm's outcome vs the reference, or null when the cap is hit or the judge
87
+ * could not produce a valid verdict. */
88
+ export interface PairwiseJudge {
89
+ compare(armOutput: unknown, refOutput: unknown, taskInput: unknown): Promise<PairwiseOutcome | null>;
90
+ used(): number;
91
+ }
92
+ /**
93
+ * Build a pairwise judge over the run loop (mirrors makeJudgeScorer). Each arm
94
+ * gets a FRESH judge so `maxSamples` is per arm. The comparison is presented in a
95
+ * FIXED order (A = served arm, B = reference); position bias is a known limit,
96
+ * noted in verification — a fixed order keeps the judge DETERMINISTIC for tests.
97
+ */
98
+ export declare function makePairwiseJudge(deps: RunDeps, baseRoute: RouteBundle, judge: {
99
+ readonly model: CandidateSpec;
100
+ readonly maxSamples: number;
101
+ }): PairwiseJudge;
102
+ //# sourceMappingURL=pairwise.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pairwise.d.ts","sourceRoot":"","sources":["../../src/bakeoff/pairwise.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAKH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,YAAY,CAAC;AAG1C,OAAO,KAAK,EAAE,aAAa,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAC;AACjE,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,UAAU,CAAC;AAEnD;uFACuF;AACvF,MAAM,MAAM,eAAe,GAAG,KAAK,GAAG,KAAK,GAAG,MAAM,CAAC;AAErD;6DAC6D;AAC7D,MAAM,MAAM,cAAc,GAAG,eAAe,GAAG,OAAO,CAAC;AAEvD;;kEAEkE;AAClE,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,WAAW,EAAE,SAAS,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAChD,kEAAkE;IAClE,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB;mFAC+E;IAC/E,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,mFAAmF;IACnF,QAAQ,CAAC,MAAM,EAAE,eAAe,GAAG,OAAO,GAAG,OAAO,CAAC;CACtD;AAED;oFACoF;AACpF,eAAO,MAAM,uBAAuB,OAAO,CAAC;AAC5C,eAAO,MAAM,uBAAuB,WAAW,CAAC;AAKhD;oBACoB;AACpB,wBAAgB,QAAQ,CAAC,CAAC,EAAE,eAAe,GAAG,MAAM,CAEnD;AAED;;;;GAIG;AACH,wBAAgB,qBAAqB,CACnC,UAAU,EAAE,MAAM,GAAG,IAAI,EACzB,UAAU,EAAE,MAAM,GAAG,IAAI,GACxB,eAAe,GAAG,IAAI,CAKxB;AAED;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAC/B,OAAO,EAAE,SAAS;IAAE,QAAQ,CAAC,OAAO,EAAE,eAAe,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,cAAc,CAAA;CAAE,EAAE,EAC1F,UAAU,EAAE,MAAM,EAClB,IAAI,GAAE,MAAgC,EACtC,IAAI,GAAE,MAAgC,GACrC,aAAa,CA2Bf;AAED;;;;;;;GAOG;AACH,wBAAsB,gBAAgB,CACpC,UAAU,EAAE,SAAS,kBAAkB,EAAE,EACzC,UAAU,EAAE,SAAS,kBAAkB,EAAE,EACzC,KAAK,EAAE,aAAa,GAAG,IAAI,GAC1B,OAAO,CAAC;IAAE,KAAK,EAAE,aAAa,GAAG,IAAI,CAAC;IAAC,QAAQ,EAAE,GAAG,CAAC,MAAM,EAAE,eAAe,CAAC,CAAA;CAAE,CAAC,CA8BlF;AAmBD;4EAC4E;AAC5E,wBAAgB,uBAAuB,CAAC,SAAS,EAAE,WAAW,EAAE,KAAK,EAAE,aAAa,GAAG,WAAW,CAejG;AAED;;wCAEwC;AACxC,MAAM,WAAW,aAAa;IAC5B,OAAO,CAAC,SAAS,EAAE,OAAO,EAAE,SAAS,EAAE,OAAO,EAAE,SAAS,EAAE,OAAO,GAAG,OAAO,CAAC,eAAe,GAAG,IAAI,CAAC,CAAC;IACrG,IAAI,IAAI,MAAM,CAAC;CAChB;AAMD;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAC/B,IAAI,EAAE,OAAO,EACb,SAAS,EAAE,WAAW,EACtB,KAAK,EAAE;IAAE,QAAQ,CAAC,KAAK,EAAE,aAAa,CAAC;IAAC,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAA;CAAE,GACpE,aAAa,CAmCf"}
@@ -0,0 +1,214 @@
1
+ "use strict";
2
+ /**
3
+ * N5 — pairwise preference vs the reference arm (near-optimal brief §3.2 N5).
4
+ *
5
+ * A second, complementary read on regret: for each replayed sample, is the SERVED
6
+ * arm's output BETTER than, tied with, or WORSE than the REFERENCE arm's on the
7
+ * same captured input? Aggregated to a `pairwise_win_rate` (win=1, tie=0.5,
8
+ * loss=0) with a seeded bootstrap CI — the RigIndex pairwise idea, scoped to one
9
+ * bake-off's arm-vs-reference comparison.
10
+ *
11
+ * DETERMINISTIC-FIRST (F5), exactly like the regret quality tiers:
12
+ * 1. When BOTH sides carry a per-sample value-accuracy score (eval case /
13
+ * value_checks / Q1 gate), the verdict is the sign of their difference —
14
+ * no model call. This is the free, always-preferred tier.
15
+ * 2. Only when a sample cannot be decided deterministically does an OPT-IN,
16
+ * CAPPED, METERED pairwise judge (`bakeoff.pairwise.judge`) compare the two
17
+ * outputs. Judge model ∈ declared candidates (#223 F3, enforced at load).
18
+ *
19
+ * The aggregation math is PURE and unit-tested directly; the judge reuses the
20
+ * real `run()` loop (no new dispatch path, no run.ts edit) exactly as judge.ts.
21
+ */
22
+ Object.defineProperty(exports, "__esModule", { value: true });
23
+ exports.PAIRWISE_BOOTSTRAP_SEED = exports.PAIRWISE_BOOTSTRAP_REPS = void 0;
24
+ exports.winValue = winValue;
25
+ exports.deterministicPairwise = deterministicPairwise;
26
+ exports.aggregatePairwise = aggregatePairwise;
27
+ exports.scoreArmPairwise = scoreArmPairwise;
28
+ exports.buildPairwiseJudgeRoute = buildPairwiseJudgeRoute;
29
+ exports.makePairwiseJudge = makePairwiseJudge;
30
+ const node_crypto_1 = require("node:crypto");
31
+ const bootstrap_1 = require("../rigindex/bootstrap");
32
+ const run_1 = require("../run/run");
33
+ const failures_1 = require("../types/failures");
34
+ /** Seeded so a bake-off's pairwise CI is reproducible (like the regret CI). A
35
+ * distinct seed from the regret bootstrap keeps the two resamplings independent. */
36
+ exports.PAIRWISE_BOOTSTRAP_REPS = 2000;
37
+ exports.PAIRWISE_BOOTSTRAP_SEED = 20260824;
38
+ /** Tie band: value-accuracy scores within EPS are a tie, not a hair-split win. */
39
+ const PAIRWISE_EPS = 1e-9;
40
+ /** The win-value of an outcome (win=1, tie=0.5, loss=0) — the quantity the win
41
+ * rate averages. */
42
+ function winValue(o) {
43
+ return o === "win" ? 1 : o === "tie" ? 0.5 : 0;
44
+ }
45
+ /**
46
+ * The DETERMINISTIC pairwise verdict (tier 1): the sign of `armQuality −
47
+ * refQuality`. Returns null when EITHER side lacks a value-accuracy score — the
48
+ * caller then falls to the judge. A tie within EPS is a genuine tie.
49
+ */
50
+ function deterministicPairwise(armQuality, refQuality) {
51
+ if (armQuality === null || refQuality === null)
52
+ return null;
53
+ const diff = armQuality - refQuality;
54
+ if (Math.abs(diff) <= PAIRWISE_EPS)
55
+ return "tie";
56
+ return diff > 0 ? "win" : "loss";
57
+ }
58
+ /**
59
+ * Aggregate per-sample verdicts into `PairwiseStats` (N5). `decided` are the
60
+ * verdicts that resolved; `nUndecided` the paired samples that did not. The CI is
61
+ * a seeded bootstrap of the per-sample win values (win=1/tie=0.5/loss=0), so it
62
+ * is deterministic given the verdict list + seed. Pure.
63
+ */
64
+ function aggregatePairwise(decided, nUndecided, seed = exports.PAIRWISE_BOOTSTRAP_SEED, reps = exports.PAIRWISE_BOOTSTRAP_REPS) {
65
+ const wins = decided.filter((d) => d.outcome === "win").length;
66
+ const ties = decided.filter((d) => d.outcome === "tie").length;
67
+ const losses = decided.filter((d) => d.outcome === "loss").length;
68
+ const nDecided = decided.length;
69
+ const hasDet = decided.some((d) => d.source === "deterministic");
70
+ const hasJudge = decided.some((d) => d.source === "judge");
71
+ const source = hasDet && hasJudge ? "mixed" : hasJudge ? "judge" : "deterministic";
72
+ if (nDecided === 0) {
73
+ return { winRate: 0, winRateCi95: [0, 0], nDecided: 0, wins: 0, ties: 0, losses: 0, nUndecided, source };
74
+ }
75
+ const values = decided.map((d) => winValue(d.outcome));
76
+ const winRate = values.reduce((a, b) => a + b, 0) / nDecided;
77
+ // Bootstrap the per-sample win values (the pairing is already fixed), take each
78
+ // resample's mean, then the 2.5/97.5 percentiles.
79
+ const rng = (0, bootstrap_1.mulberry32)(seed);
80
+ const means = [];
81
+ for (let b = 0; b < reps; b += 1) {
82
+ let sum = 0;
83
+ for (let i = 0; i < nDecided; i += 1)
84
+ sum += values[Math.floor(rng() * nDecided)];
85
+ means.push(sum / nDecided);
86
+ }
87
+ means.sort((a, b) => a - b);
88
+ const lo = means[Math.floor(0.025 * reps)];
89
+ const hi = means[Math.min(reps - 1, Math.floor(0.975 * reps))];
90
+ return { winRate, winRateCi95: [lo, hi], nDecided, wins, ties, losses, nUndecided, source };
91
+ }
92
+ /**
93
+ * Score ONE arm's pairwise standing vs the reference over the paired replay set
94
+ * (N5). Deterministic-first: each paired sample's verdict is the sign of the two
95
+ * value-accuracy scores; a sample neither side scored falls to the capped judge
96
+ * (when supplied), else it is undecided (counted, excluded from the rate). Pairs
97
+ * by captureId; unpaired samples are ignored. Returns null stats when there are
98
+ * NO paired samples at all. `outcomes` maps captureId → verdict (for N6 mining).
99
+ */
100
+ async function scoreArmPairwise(armSamples, refSamples, judge) {
101
+ const refByCapture = new Map(refSamples.map((s) => [s.captureId, s]));
102
+ const decided = [];
103
+ const outcomes = new Map();
104
+ let paired = 0;
105
+ let undecided = 0;
106
+ for (const arm of armSamples) {
107
+ const ref = refByCapture.get(arm.captureId);
108
+ if (ref === undefined)
109
+ continue;
110
+ paired += 1;
111
+ const det = deterministicPairwise(arm.valueAccuracy, ref.valueAccuracy);
112
+ if (det !== null) {
113
+ decided.push({ outcome: det, source: "deterministic" });
114
+ outcomes.set(arm.captureId, det);
115
+ continue;
116
+ }
117
+ // Deterministic can't decide (a side lacks value accuracy). Only a PRODUCED
118
+ // pair can be judged (both need an output); the judge is capped internally.
119
+ if (judge !== null && arm.failureClass === null && ref.failureClass === null) {
120
+ const verdict = await judge.compare(arm.outputText ?? null, ref.outputText ?? null, arm.inputText ?? null);
121
+ if (verdict !== null) {
122
+ decided.push({ outcome: verdict, source: "judge" });
123
+ outcomes.set(arm.captureId, verdict);
124
+ continue;
125
+ }
126
+ }
127
+ undecided += 1;
128
+ }
129
+ if (paired === 0)
130
+ return { stats: null, outcomes };
131
+ return { stats: aggregatePairwise(decided, undecided), outcomes };
132
+ }
133
+ // ── The opt-in, capped, metered pairwise judge (tier 2) ──────────────────────
134
+ const PAIRWISE_SCHEMA = {
135
+ type: "object",
136
+ properties: { verdict: { type: "string", enum: ["A", "B", "tie"] }, reason: { type: "string" } },
137
+ required: ["verdict"],
138
+ additionalProperties: true,
139
+ };
140
+ const PAIRWISE_RUBRIC = "You are judging PAIRWISE PREFERENCE between two candidate outputs for ONE task — " +
141
+ "which is the better answer for VALUE (correctness and completeness), not merely which " +
142
+ "is better-formed. A is one candidate; B is the other.\n\nTASK INPUT:\n{{task_input}}\n\n" +
143
+ "OUTPUT A:\n{{output_a}}\n\nOUTPUT B:\n{{output_b}}\n\n" +
144
+ 'Respond with ONLY compact JSON: {"verdict": "A" | "B" | "tie", "reason": "<one sentence>"}. ' +
145
+ 'Answer "A" if A is better, "B" if B is better, "tie" if they are equally good.';
146
+ /** Build the synthetic pairwise-judge route for a base route + judge model. The
147
+ * judge model is declared WITHOUT its trial flag so it serves live here. */
148
+ function buildPairwiseJudgeRoute(baseRoute, model) {
149
+ return Object.freeze({
150
+ route: `__bakeoff_pairwise__:${baseRoute.route}`,
151
+ version: baseRoute.version,
152
+ outputSchema: Object.freeze({ ...PAIRWISE_SCHEMA }),
153
+ candidates: Object.freeze([{ provider: model.provider, model: model.model }]),
154
+ require: Object.freeze([]),
155
+ prefer: Object.freeze([]),
156
+ systemTemplate: PAIRWISE_RUBRIC,
157
+ variables: Object.freeze(["output_a", "output_b", "task_input"]),
158
+ policy: Object.freeze({ retries: Object.freeze({}), timeoutMs: baseRoute.policy.timeoutMs, tier: "standard", json: "json_mode" }),
159
+ variants: Object.freeze([]),
160
+ capture: false,
161
+ fingerprintCell: null,
162
+ });
163
+ }
164
+ function asText(v) {
165
+ return typeof v === "string" ? v : JSON.stringify(v ?? null);
166
+ }
167
+ /**
168
+ * Build a pairwise judge over the run loop (mirrors makeJudgeScorer). Each arm
169
+ * gets a FRESH judge so `maxSamples` is per arm. The comparison is presented in a
170
+ * FIXED order (A = served arm, B = reference); position bias is a known limit,
171
+ * noted in verification — a fixed order keeps the judge DETERMINISTIC for tests.
172
+ */
173
+ function makePairwiseJudge(deps, baseRoute, judge) {
174
+ const judgeRoute = buildPairwiseJudgeRoute(baseRoute, judge.model);
175
+ const routes = {
176
+ get: (r) => (r === judgeRoute.route ? judgeRoute : deps.routes.get(r)),
177
+ list: () => Object.freeze([...deps.routes.list(), judgeRoute]),
178
+ };
179
+ const judgeDeps = { ...deps, routes, captures: null, mode: "live" };
180
+ const envelope = `bakeoff-pairwise:${(0, node_crypto_1.randomUUID)()}`;
181
+ let used = 0;
182
+ return {
183
+ used: () => used,
184
+ async compare(armOutput, refOutput, taskInput) {
185
+ if (used >= judge.maxSamples)
186
+ return null;
187
+ used += 1;
188
+ try {
189
+ const result = await (0, run_1.run)(judgeDeps, judgeRoute.route, {
190
+ input: { output_a: asText(armOutput), output_b: asText(refOutput), task_input: asText(taskInput) },
191
+ // `bakeoff_pairwise` marks the row a pairwise-judge inference (the typed
192
+ // `rung` column is recorder-owned, so a distinct tag key is used); `lane:
193
+ // trial` keeps judge spend off the route's live rollups/odometer.
194
+ tags: { bakeoff_pairwise: "1", lane: "trial" },
195
+ budget: { envelope },
196
+ });
197
+ const verdict = result.output?.verdict;
198
+ if (verdict === "A")
199
+ return "win";
200
+ if (verdict === "B")
201
+ return "loss";
202
+ if (verdict === "tie")
203
+ return "tie";
204
+ return null;
205
+ }
206
+ catch (err) {
207
+ if (!(err instanceof failures_1.RigFailureError))
208
+ throw err;
209
+ // The metered attempt row(s) still stand; this sample gets no verdict.
210
+ return null;
211
+ }
212
+ },
213
+ };
214
+ }
@@ -30,6 +30,12 @@ export interface ReplayRequest {
30
30
  * sample for the human value review. Off by default (bounds memory + keeps
31
31
  * the pure-metrics path clean). */
32
32
  readonly keepOutputs?: boolean;
33
+ /** N5: retain `inputText` + `outputText` on each sample for the pairwise judge
34
+ * (arm-vs-reference comparison in the engine), WITHOUT the keepOutputs sink
35
+ * persistence. Set by the engine only when `bakeoff.pairwise.judge` is declared.
36
+ * A superset trigger of `keepOutputs` for retention; the sink stays gated on
37
+ * `keepOutputs` alone so pairwise retention never persists review samples. */
38
+ readonly retainOutputForPairwise?: boolean;
33
39
  /** RB-1: corrected/golden expected outputs keyed by captureId (eval-case path).
34
40
  * Presence in the map ⇒ this sample has an expected value; `value` may be any
35
41
  * JSON (including null). Quality tier 1 (eval case) when present. */
@@ -1 +1 @@
1
- {"version":3,"file":"replay.d.ts","sourceRoot":"","sources":["../../src/bakeoff/replay.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAGH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,sBAAsB,CAAC;AAGzD,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,YAAY,CAAC;AAI1C,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;AAE3C,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAGpD,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B;;uCAEmC;IACnC,QAAQ,CAAC,WAAW,CAAC,EAAE,OAAO,CAAC;IAC/B;;yEAEqE;IACrE,QAAQ,CAAC,QAAQ,CAAC,EAAE,WAAW,CAAC,MAAM,EAAE;QAAE,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAA;KAAE,CAAC,CAAC;IACrE;sFACkF;IAClF,QAAQ,CAAC,KAAK,CAAC,EAAE,WAAW,CAAC;IAC7B;;;;;iFAK6E;IAC7E,QAAQ,CAAC,WAAW,CAAC,EAAE,WAAW,CAAC;CACpC;AAED,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;IAC1B,QAAQ,CAAC,QAAQ,EAAE,OAAO,CAAC;IAC3B;wEACoE;IACpE,QAAQ,CAAC,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;IACtC;;;oCAGgC;IAChC,QAAQ,CAAC,aAAa,EAAE,OAAO,GAAG,IAAI,CAAC;IACvC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,mEAAmE;IACnE,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;IAC/B;yDACqD;IACrD,QAAQ,CAAC,cAAc,EAAE,OAAO,CAAC;IACjC,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;IACrC,oEAAoE;IACpE,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC5B;sDACkD;IAClD,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CACrC;AAED,wBAAsB,aAAa,CACjC,IAAI,EAAE,OAAO,EACb,QAAQ,EAAE,YAAY,EACtB,GAAG,EAAE,aAAa,GACjB,OAAO,CAAC,SAAS,kBAAkB,EAAE,CAAC,CAoIxC"}
1
+ {"version":3,"file":"replay.d.ts","sourceRoot":"","sources":["../../src/bakeoff/replay.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAGH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,sBAAsB,CAAC;AAGzD,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,YAAY,CAAC;AAI1C,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;AAE3C,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAGpD,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B;;uCAEmC;IACnC,QAAQ,CAAC,WAAW,CAAC,EAAE,OAAO,CAAC;IAC/B;;;;kFAI8E;IAC9E,QAAQ,CAAC,uBAAuB,CAAC,EAAE,OAAO,CAAC;IAC3C;;yEAEqE;IACrE,QAAQ,CAAC,QAAQ,CAAC,EAAE,WAAW,CAAC,MAAM,EAAE;QAAE,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAA;KAAE,CAAC,CAAC;IACrE;sFACkF;IAClF,QAAQ,CAAC,KAAK,CAAC,EAAE,WAAW,CAAC;IAC7B;;;;;iFAK6E;IAC7E,QAAQ,CAAC,WAAW,CAAC,EAAE,WAAW,CAAC;CACpC;AAED,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;IAC1B,QAAQ,CAAC,QAAQ,EAAE,OAAO,CAAC;IAC3B;wEACoE;IACpE,QAAQ,CAAC,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;IACtC;;;oCAGgC;IAChC,QAAQ,CAAC,aAAa,EAAE,OAAO,GAAG,IAAI,CAAC;IACvC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,mEAAmE;IACnE,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;IAC/B;yDACqD;IACrD,QAAQ,CAAC,cAAc,EAAE,OAAO,CAAC;IACjC,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;IACrC,oEAAoE;IACpE,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC5B;sDACkD;IAClD,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CACrC;AAED,wBAAsB,aAAa,CACjC,IAAI,EAAE,OAAO,EACb,QAAQ,EAAE,YAAY,EACtB,GAAG,EAAE,aAAa,GACjB,OAAO,CAAC,SAAS,kBAAkB,EAAE,CAAC,CAyIxC"}
@@ -49,6 +49,10 @@ async function replayVariant(deps, captures, req) {
49
49
  envelopeBudgetUsd: req.envelopeUsd,
50
50
  mode,
51
51
  };
52
+ // Retain per-sample input/output text when the caller asked (keepOutputs sink,
53
+ // or N5 pairwise judge). Same fields either way; the sink persist stays gated
54
+ // on keepOutputs alone (engine.ts).
55
+ const retainText = req.keepOutputs === true || req.retainOutputForPairwise === true;
52
56
  const results = [];
53
57
  for (const capture of rows) {
54
58
  const sampleTag = `${replayId}:${capture.inferenceId}`;
@@ -81,7 +85,7 @@ async function replayVariant(deps, captures, req) {
81
85
  repaired = result.meta.repairedBy !== null;
82
86
  outputValue = result.output;
83
87
  produced = true;
84
- if (req.keepOutputs)
88
+ if (retainText)
85
89
  outputText = JSON.stringify(result.output, null, 2);
86
90
  if (req.qualityGate !== undefined) {
87
91
  gated = { output: result.output, provider: result.meta.provider, model: result.meta.model, servedVariant: result.meta.servedVariant };
@@ -145,7 +149,7 @@ async function replayVariant(deps, captures, req) {
145
149
  pricingMissing,
146
150
  latencyMs,
147
151
  failureClass,
148
- ...(req.keepOutputs
152
+ ...(retainText
149
153
  ? { inputText: capture.variablesJson, outputText }
150
154
  : {}),
151
155
  });
@@ -36,6 +36,13 @@ export interface CacheAdjustedRow {
36
36
  * Empty input ⇒ no lines (the section is omitted entirely — back-compat).
37
37
  */
38
38
  export declare function formatCacheAdjustedSection(rows: readonly CacheAdjustedRow[]): string[];
39
+ /**
40
+ * N6 disagreement summary lines: the count of mined eval-case candidates (with
41
+ * the honest "of N found" when the list was capped), the per-reason breakdown,
42
+ * and the top candidates as captureId pointers an operator may promote. Pure
43
+ * (returns strings); empty when nothing disagreed. Exported for direct testing.
44
+ */
45
+ export declare function formatDisagreementSummary(artifact: BakeoffArtifact): string[];
39
46
  export declare function printMatrix(artifact: BakeoffArtifact, cacheAdjusted?: readonly CacheAdjustedRow[]): void;
40
47
  /**
41
48
  * `modelrig bakeoff --from-eval-cases <task> --variants a,b,c` — replay a task's
@@ -1 +1 @@
1
- {"version":3,"file":"report.d.ts","sourceRoot":"","sources":["../../src/bakeoff/report.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAKH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,WAAW,CAAC;AAG3C,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,aAAa,CAAC;AACvD,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,WAAW,CAAC;AACjD,OAAO,KAAK,EAAE,eAAe,EAAkB,MAAM,SAAS,CAAC;AAC/D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAC;AAkCpD;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,QAAQ,EAAE,eAAe,GAAG,MAAM,GAAG,IAAI,CAexE;AAED;;;;GAIG;AACH,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,+EAA+E;IAC/E,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAC;IACjC,gFAAgF;IAChF,QAAQ,CAAC,gBAAgB,EAAE,MAAM,CAAC;IAClC,QAAQ,CAAC,WAAW,EAAE,mBAAmB,CAAC;CAC3C;AAED;;;;;;GAMG;AACH,wBAAgB,0BAA0B,CAAC,IAAI,EAAE,SAAS,gBAAgB,EAAE,GAAG,MAAM,EAAE,CAgCtF;AAED,wBAAgB,WAAW,CAAC,QAAQ,EAAE,eAAe,EAAE,aAAa,GAAE,SAAS,gBAAgB,EAAO,GAAG,IAAI,CAkD5G;AAED;;;;;;GAMG;AACH,wBAAsB,qBAAqB,CACzC,MAAM,EAAE,SAAS,EACjB,IAAI,EAAE,MAAM,EACZ,QAAQ,EAAE,SAAS,MAAM,EAAE,EAC3B,QAAQ,EAAE,MAAM,GAAG,SAAS,EAC5B,IAAI,EAAE,eAAe,GAAG,SAAS,EACjC,MAAM,EAAE,OAAO,EACf,aAAa,CAAC,EAAE,aAAa,GAC5B,OAAO,CAAC,MAAM,CAAC,CA+CjB"}
1
+ {"version":3,"file":"report.d.ts","sourceRoot":"","sources":["../../src/bakeoff/report.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAKH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,WAAW,CAAC;AAG3C,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,aAAa,CAAC;AACvD,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,WAAW,CAAC;AACjD,OAAO,KAAK,EAAE,eAAe,EAAkB,MAAM,SAAS,CAAC;AAC/D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAC;AA2CpD;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,QAAQ,EAAE,eAAe,GAAG,MAAM,GAAG,IAAI,CAexE;AAED;;;;GAIG;AACH,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,+EAA+E;IAC/E,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAC;IACjC,gFAAgF;IAChF,QAAQ,CAAC,gBAAgB,EAAE,MAAM,CAAC;IAClC,QAAQ,CAAC,WAAW,EAAE,mBAAmB,CAAC;CAC3C;AAED;;;;;;GAMG;AACH,wBAAgB,0BAA0B,CAAC,IAAI,EAAE,SAAS,gBAAgB,EAAE,GAAG,MAAM,EAAE,CAgCtF;AAED;;;;;GAKG;AACH,wBAAgB,yBAAyB,CAAC,QAAQ,EAAE,eAAe,GAAG,MAAM,EAAE,CAiB7E;AAED,wBAAgB,WAAW,CAAC,QAAQ,EAAE,eAAe,EAAE,aAAa,GAAE,SAAS,gBAAgB,EAAO,GAAG,IAAI,CAwD5G;AAED;;;;;;GAMG;AACH,wBAAsB,qBAAqB,CACzC,MAAM,EAAE,SAAS,EACjB,IAAI,EAAE,MAAM,EACZ,QAAQ,EAAE,SAAS,MAAM,EAAE,EAC3B,QAAQ,EAAE,MAAM,GAAG,SAAS,EAC5B,IAAI,EAAE,eAAe,GAAG,SAAS,EACjC,MAAM,EAAE,OAAO,EACf,aAAa,CAAC,EAAE,aAAa,GAC5B,OAAO,CAAC,MAAM,CAAC,CA+CjB"}
@@ -8,6 +8,7 @@
8
8
  Object.defineProperty(exports, "__esModule", { value: true });
9
9
  exports.bakeoffHeadline = bakeoffHeadline;
10
10
  exports.formatCacheAdjustedSection = formatCacheAdjustedSection;
11
+ exports.formatDisagreementSummary = formatDisagreementSummary;
11
12
  exports.printMatrix = printMatrix;
12
13
  exports.runEvalCaseBakeoffCli = runEvalCaseBakeoffCli;
13
14
  const node_fs_1 = require("node:fs");
@@ -35,6 +36,16 @@ function fmtRegret(m) {
35
36
  function fmtQuality(m) {
36
37
  return m.qualityMean === null ? "—" : `${(m.qualityMean * 100).toFixed(0)}% ${m.qualityMetric}`;
37
38
  }
39
+ /** N5: an arm's pairwise-vs-reference cell — "63% [51…74] n=30", "ref", or "—". */
40
+ function fmtPairwise(m) {
41
+ if (m.isReference === true)
42
+ return "ref";
43
+ const p = m.pairwise;
44
+ if (p == null)
45
+ return "—";
46
+ const pct = (v) => (v * 100).toFixed(0);
47
+ return `${pct(p.winRate)}% [${pct(p.winRateCi95[0])}…${pct(p.winRateCi95[1])}] n=${p.nDecided}`;
48
+ }
38
49
  /** The reference $/1k or per-run ceiling from the objective, formatted, or null
39
50
  * when no ceiling is declared. */
40
51
  function fmtCeiling(artifact) {
@@ -107,14 +118,43 @@ function formatCacheAdjustedSection(rows) {
107
118
  }
108
119
  return lines;
109
120
  }
121
+ /**
122
+ * N6 disagreement summary lines: the count of mined eval-case candidates (with
123
+ * the honest "of N found" when the list was capped), the per-reason breakdown,
124
+ * and the top candidates as captureId pointers an operator may promote. Pure
125
+ * (returns strings); empty when nothing disagreed. Exported for direct testing.
126
+ */
127
+ function formatDisagreementSummary(artifact) {
128
+ const d = artifact.disagreement;
129
+ if (d === undefined || d.found === 0)
130
+ return [];
131
+ const reasons = Object.entries(d.counts)
132
+ .filter(([, n]) => n > 0)
133
+ .map(([r, n]) => `${r}=${n}`)
134
+ .join(", ");
135
+ const capNote = d.cappedAt !== null ? ` (showing top ${d.candidates.length} of ${d.found} — cap ${d.cappedAt})` : "";
136
+ const lines = [
137
+ `disagreement: ${d.found} eval-case candidate${d.found === 1 ? "" : "s"} mined from ${d.examined} samples${capNote} — ` +
138
+ "NOT auto-promoted; promote with artifact.promoteToEval.",
139
+ ];
140
+ if (reasons !== "")
141
+ lines.push(` reasons: ${reasons}`);
142
+ for (const c of d.candidates.slice(0, 5)) {
143
+ lines.push(` ${c.captureId} [${c.arm}] score ${c.score.toFixed(2)} — ${c.reasons.join(", ")}`);
144
+ }
145
+ return lines;
146
+ }
110
147
  function printMatrix(artifact, cacheAdjusted = []) {
111
148
  console.log(`bakeoff ${artifact.id}\nroute ${artifact.route}@${artifact.routeVersion} · replayed ${artifact.replayN} captured inputs · ${artifact.ranAt}`);
112
149
  // RB-1: a reference arm is present ⇒ show quality + regret columns and badge
113
150
  // the reference row; otherwise the pre-RB-1 matrix stands byte-for-byte.
114
151
  const hasReference = artifact.variants.some((m) => m.isReference === true);
152
+ const hasPairwise = artifact.variants.some((m) => m.pairwise != null);
115
153
  const header = ["variant", "n", "conform", "ci95", "repair", "repair$", "lat(ms)", "eff-$/1K-conf"];
116
154
  if (hasReference)
117
155
  header.push("quality", "regret(pts)");
156
+ if (hasPairwise)
157
+ header.push("pairwise-win");
118
158
  const rows = artifact.variants.map((m) => {
119
159
  const base = [
120
160
  m.isReference === true ? `${m.variant} [ref]` : m.variant,
@@ -128,6 +168,8 @@ function printMatrix(artifact, cacheAdjusted = []) {
128
168
  ];
129
169
  if (hasReference)
130
170
  base.push(fmtQuality(m), fmtRegret(m));
171
+ if (hasPairwise)
172
+ base.push(fmtPairwise(m));
131
173
  return base;
132
174
  });
133
175
  const widths = header.map((h, c) => Math.max(h.length, ...rows.map((r) => r[c].length)));
@@ -153,6 +195,10 @@ function printMatrix(artifact, cacheAdjusted = []) {
153
195
  else {
154
196
  console.log("proposal: none (no variant beats the incumbent's gates)");
155
197
  }
198
+ // N6 disagreement summary — printed only when candidates were mined; absent ⇒
199
+ // byte-identical to the pre-N6 matrix output.
200
+ for (const l of formatDisagreementSummary(artifact))
201
+ console.log(l);
156
202
  // Cache-adjusted section (spec §5.4) — printed only when the caller supplied
157
203
  // simulator rows; absent ⇒ the matrix output is byte-identical to pre-WS-B.
158
204
  for (const l of formatCacheAdjustedSection(cacheAdjusted))