modelrig 0.7.0 → 0.8.1

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 (125) hide show
  1. package/CHANGELOG.md +118 -0
  2. package/README.md +86 -0
  3. package/dist/bakeoff/disagreement.d.ts +93 -0
  4. package/dist/bakeoff/disagreement.d.ts.map +1 -0
  5. package/dist/bakeoff/disagreement.js +140 -0
  6. package/dist/bakeoff/engine.d.ts.map +1 -1
  7. package/dist/bakeoff/engine.js +58 -2
  8. package/dist/bakeoff/pairwise.d.ts +102 -0
  9. package/dist/bakeoff/pairwise.d.ts.map +1 -0
  10. package/dist/bakeoff/pairwise.js +214 -0
  11. package/dist/bakeoff/replay.d.ts +6 -0
  12. package/dist/bakeoff/replay.d.ts.map +1 -1
  13. package/dist/bakeoff/replay.js +6 -2
  14. package/dist/bakeoff/report.d.ts +7 -0
  15. package/dist/bakeoff/report.d.ts.map +1 -1
  16. package/dist/bakeoff/report.js +46 -0
  17. package/dist/bakeoff/types.d.ts +24 -0
  18. package/dist/bakeoff/types.d.ts.map +1 -1
  19. package/dist/cli.d.ts.map +1 -1
  20. package/dist/cli.js +9 -9
  21. package/dist/mask/apply.d.ts +80 -0
  22. package/dist/mask/apply.d.ts.map +1 -0
  23. package/dist/mask/apply.js +228 -0
  24. package/dist/mask/detect.d.ts +38 -0
  25. package/dist/mask/detect.d.ts.map +1 -0
  26. package/dist/mask/detect.js +167 -0
  27. package/dist/mask/index.d.ts +21 -0
  28. package/dist/mask/index.d.ts.map +1 -0
  29. package/dist/mask/index.js +54 -0
  30. package/dist/mask/mask-policy-cli.d.ts +11 -0
  31. package/dist/mask/mask-policy-cli.d.ts.map +1 -0
  32. package/dist/mask/mask-policy-cli.js +33 -0
  33. package/dist/mask/policy.d.ts +38 -0
  34. package/dist/mask/policy.d.ts.map +1 -0
  35. package/dist/mask/policy.js +187 -0
  36. package/dist/mask/proof.d.ts +54 -0
  37. package/dist/mask/proof.d.ts.map +1 -0
  38. package/dist/mask/proof.js +120 -0
  39. package/dist/mask/receipt.d.ts +30 -0
  40. package/dist/mask/receipt.d.ts.map +1 -0
  41. package/dist/mask/receipt.js +49 -0
  42. package/dist/mask/seam.d.ts +53 -0
  43. package/dist/mask/seam.d.ts.map +1 -0
  44. package/dist/mask/seam.js +120 -0
  45. package/dist/mask/types.d.ts +81 -0
  46. package/dist/mask/types.d.ts.map +1 -0
  47. package/dist/mask/types.js +10 -0
  48. package/dist/mask/vault.d.ts +50 -0
  49. package/dist/mask/vault.d.ts.map +1 -0
  50. package/dist/mask/vault.js +81 -0
  51. package/dist/receipts/types.d.ts +65 -0
  52. package/dist/receipts/types.d.ts.map +1 -0
  53. package/dist/receipts/types.js +134 -0
  54. package/dist/routes/loader-warnings.d.ts +8 -0
  55. package/dist/routes/loader-warnings.d.ts.map +1 -1
  56. package/dist/routes/loader-warnings.js +23 -0
  57. package/dist/routes/loader.d.ts +1 -1
  58. package/dist/routes/loader.d.ts.map +1 -1
  59. package/dist/routes/loader.js +24 -25
  60. package/dist/routes/mask-loader.d.ts +18 -0
  61. package/dist/routes/mask-loader.d.ts.map +1 -0
  62. package/dist/routes/mask-loader.js +39 -0
  63. package/dist/routes/objective-loader.d.ts.map +1 -1
  64. package/dist/routes/objective-loader.js +51 -17
  65. package/dist/routes/screen-loader.d.ts +23 -0
  66. package/dist/routes/screen-loader.d.ts.map +1 -0
  67. package/dist/routes/screen-loader.js +79 -0
  68. package/dist/routes/template.d.ts +10 -0
  69. package/dist/routes/template.d.ts.map +1 -1
  70. package/dist/routes/template.js +10 -3
  71. package/dist/run/accept.d.ts +4 -0
  72. package/dist/run/accept.d.ts.map +1 -1
  73. package/dist/run/accept.js +2 -0
  74. package/dist/run/dispatch.d.ts +35 -0
  75. package/dist/run/dispatch.d.ts.map +1 -1
  76. package/dist/run/dispatch.js +33 -0
  77. package/dist/run/grounding.d.ts +47 -2
  78. package/dist/run/grounding.d.ts.map +1 -1
  79. package/dist/run/grounding.js +113 -6
  80. package/dist/run/raw.d.ts.map +1 -1
  81. package/dist/run/raw.js +6 -1
  82. package/dist/run/record-attempt.d.ts.map +1 -1
  83. package/dist/run/record-attempt.js +12 -1
  84. package/dist/run/run.d.ts.map +1 -1
  85. package/dist/run/run.js +19 -16
  86. package/dist/screen/provenance.d.ts +59 -0
  87. package/dist/screen/provenance.d.ts.map +1 -0
  88. package/dist/screen/provenance.js +104 -0
  89. package/dist/screen/types.d.ts +71 -0
  90. package/dist/screen/types.d.ts.map +1 -0
  91. package/dist/screen/types.js +9 -0
  92. package/dist/status-cli.d.ts +11 -0
  93. package/dist/status-cli.d.ts.map +1 -0
  94. package/dist/status-cli.js +21 -0
  95. package/dist/status-runs.d.ts +38 -0
  96. package/dist/status-runs.d.ts.map +1 -0
  97. package/dist/status-runs.js +80 -0
  98. package/dist/status.d.ts +7 -2
  99. package/dist/status.d.ts.map +1 -1
  100. package/dist/status.js +11 -2
  101. package/dist/telemetry/ingest-protocol.d.ts +8 -0
  102. package/dist/telemetry/ingest-protocol.d.ts.map +1 -1
  103. package/dist/telemetry/store-migrations.d.ts.map +1 -1
  104. package/dist/telemetry/store-migrations.js +11 -0
  105. package/dist/telemetry/store.d.ts.map +1 -1
  106. package/dist/telemetry/store.js +12 -3
  107. package/dist/types/provider.d.ts +11 -0
  108. package/dist/types/provider.d.ts.map +1 -1
  109. package/dist/types/provider.js +9 -0
  110. package/dist/types/route.d.ts +55 -0
  111. package/dist/types/route.d.ts.map +1 -1
  112. package/dist/types/telemetry.d.ts +7 -0
  113. package/dist/types/telemetry.d.ts.map +1 -1
  114. package/dist/validate-cli.d.ts.map +1 -1
  115. package/dist/validate-cli.js +13 -0
  116. package/dist/verify/types.d.ts +6 -0
  117. package/dist/verify/types.d.ts.map +1 -1
  118. package/docs/agent-skill/SKILL.md +131 -0
  119. package/docs/how-it-fits.md +34 -0
  120. package/docs/instrumentation.md +221 -0
  121. package/docs/llms.txt +276 -0
  122. package/docs/migration-playbook.md +512 -0
  123. package/docs/quickstart.md +339 -0
  124. package/package.json +18 -2
  125. package/receipts.d.ts +12 -0
package/CHANGELOG.md CHANGED
@@ -10,6 +10,124 @@ published package diverge.
10
10
  This project adheres to [Semantic Versioning](https://semver.org/). Dates are
11
11
  UTC.
12
12
 
13
+ ## [0.8.1] — 2026-08-24
14
+
15
+ Docs — deploy guidance for the native `better-sqlite3` dependency. The SDK loads
16
+ a native SQLite buffer on import (the local telemetry row written before each run
17
+ returns), so the README Install section and `quickstart.md` now tell customers to
18
+ run on a Node runtime (not Edge/Workers), mark `modelrig` external when bundling
19
+ server code (Next.js `serverExternalPackages`, esbuild `--external:better-sqlite3`,
20
+ webpack `externals`), and set `MODELRIG_API_KEY` on every service/worker/cron;
21
+ plus a short monorepo workspace-source caveat. Docs/packaging only — no SDK
22
+ behaviour change.
23
+
24
+ ## [0.8.0] — 2026-08-24
25
+
26
+ Packaging (GS-3) — the tarball now ships the customer-facing runs docs
27
+ (`docs/quickstart.md`, `docs/instrumentation.md`, `docs/how-it-fits.md`,
28
+ `docs/migration-playbook.md`, `docs/agent-skill/SKILL.md`, and the generated
29
+ `docs/llms.txt`) plus a self-contained **Runs — the standard path** section in
30
+ `README.md`, so `npm i modelrig` + read-the-package teaches run → step → artifact
31
+ offline; and every doc now states the `MODELRIG_ARTIFACTS` default consistently
32
+ (on by default once a control plane is configured; a local-only rig stays off;
33
+ `=0` opts out, `=1` forces on). Docs/packaging only — no SDK behaviour change.
34
+
35
+ Get-started v5 — runs are the standard path (GS-1). The front-door instructions
36
+ (www `/get-started`, the console setup prompt, `quickstart.md`, `how-it-fits.md`,
37
+ the agent `SKILL.md`) now teach **run → step → artifact** as the standard way to
38
+ instrument a pipeline, not an appendix; `instrumentation.md` stays the reference
39
+ they link to. SDK: inside a run context the SDK now also stamps **`tags.step`**
40
+ (the current step key: your explicit `stepKey`, else `tags.step`, else the auto
41
+ `route#n` / `provider/model#n` key) on every attempt row of both lanes when the
42
+ caller set none — the mirror of RC-1's `run_id` stamp; a caller value always wins,
43
+ and outside a run context rows are byte-identical. `modelrig status` prints
44
+ **`runs recorded: N (last: <ts>)`** from a best-effort readonly read of the local
45
+ buffer, and `modelrig validate` **warns when a rig has recorded calls but never
46
+ started a run** (a teaching hint — never a failure). ZERO DDL, no new deps.
47
+
48
+ Provenance-first assembly (screening B3) — additive, opt-in SDK seams for keeping
49
+ customer- and web-derived text out of the trusted system slot. **Default
50
+ behaviour is byte-identical**: a route with no `grounding.assembly` and no
51
+ `x-trust` takes the legacy path unchanged. A route may set
52
+ `grounding.assembly: spotlight` to route search results through the assembler as
53
+ an untrusted user block (search results are delivered as data, not instructions);
54
+ a template variable may be annotated `x-trust: untrusted` to move it out of the
55
+ system slot into that spotlighted block. Model-output citations are allowlisted to
56
+ the injected sources, with a `RunMeta.grounding.citations_dropped` count. `modelrig
57
+ validate` warns on legacy grounding and on an untrusted-shaped variable left
58
+ unannotated in the system slot. `EquivalenceReport.subject` gains
59
+ `"screen-assembly"`. `screen/provenance.ts` + `screen/types.ts` now ship in
60
+ `dist/`. ZERO DDL, no new deps.
61
+
62
+ Near-optimal pairwise + disagreement (N5/N6) — bake-offs gain a second read on
63
+ regret and a fixture-mining output, both additive and **byte-identical for
64
+ routes that don't opt in**. N5 scores each served arm's **pairwise standing vs
65
+ the reference arm** on the paired replay set — a `pairwise_win_rate`
66
+ (win=1/tie=0.5/loss=0) with a seeded-bootstrap CI and n — surfaced as
67
+ `VariantMetrics.pairwise` and a `pairwise-win` column in the report. It is
68
+ DETERMINISTIC-FIRST, exactly like the RB-1 regret tiers: when both an arm and the
69
+ reference carry a per-sample value-accuracy score the verdict is free (the sign
70
+ of their difference); only the samples nothing scored fall to an OPT-IN, CAPPED,
71
+ METERED pairwise judge, declared `bakeoff.pairwise.judge` (judge model ∈ declared
72
+ candidates, #223 F3; every call a metered inference tagged `bakeoff_pairwise`,
73
+ capped per arm). A new `objective.pairwiseAware` (default off) adds one
74
+ eligibility clause to the `quality-first`/`balanced` gate — a proposed arm must
75
+ not be pairwise-worse than the reference within CI — and never touches the
76
+ cost-first gate. N6 mines the same replay for **eval-case candidates**: ranked
77
+ POINTERS (by `captureId`) to the samples where the bake-off's own signals
78
+ disagree (conformance vs value score, the Q1 gate vs conformance, served arm vs
79
+ reference, a pairwise upset), surfaced on `BakeoffArtifact.disagreement` and a
80
+ report summary line. Candidates are **metadata only** (captureId + numbers /
81
+ booleans, asserted meta-safe — never input or output text) and are **never
82
+ auto-promoted**; the full pre-cap `found` count rides the truncated list so a
83
+ capped surface is never mistaken for all of them. No new root exports — additive
84
+ route YAML plus report/artifact fields. ZERO DDL, no new deps. See
85
+ `docs/bakeoffs.md`.
86
+
87
+ Published receipts (RP-1 / RP-1c) — a signed, login-free **Receipt@v1** an org
88
+ can publish from its own measured rows. **This is two halves; only the shape is
89
+ in the npm package.** The `modelrig` package exposes the receipt CONTRACT via the
90
+ new `modelrig/receipts` sub-path export ONLY (never re-exported from `index.ts`):
91
+ the `ReceiptV1` shape, `canonicalizeReceipt` / `hashReceipt` (JCS-style
92
+ canonical body, self-describing `sha256:` hash), and the pure `isReceiptSafe`
93
+ classification gate — `measured` carries numbers / booleans / hashes /
94
+ identifiers only, no content, no tenant ids (fail-closed). The additive
95
+ `exports` map (`.`, `./receipts`, `./data/*`, `./registry/*`) plus a
96
+ `receipts.d.ts` shim keep the historical bare surface and the runtime data /
97
+ registry files reachable now that a sub-path map turns resolution into an
98
+ allowlist; `dist/receipts/**` now ships. **Everything else lives in
99
+ `modelrig-server` (NOT the npm tag):** Ed25519 signing with a `key_id` (node
100
+ crypto, no new dep), authed publish / revoke, the store-only public family —
101
+ `GET /v1/r/:id`, `…/badge.svg`, `GET /v1/verify/:id` (re-canonicalize → re-hash →
102
+ verify signature), `GET /.well-known/modelrig-receipts.json` (key rotation) — a
103
+ handler constructed with NO database client, an org alias that never leaks
104
+ `org_id`, `recipient` visibility (a login-free capability URL never listed
105
+ publicly), and a revoke tombstone that answers 410. RP-1c adds the `run` and
106
+ `fact-sheet` producers + source readers and the `source_ref` lookup (X9 — the
107
+ PR-body "verify this" link). **Honest limit:** publish / revoke / signing degrade
108
+ to **501** until `MODELRIG_RECEIPT_SIGNING_KEY` + `MODELRIG_RECEIPT_KEY_ID` and
109
+ the content store are provisioned (a half-configured signer is refused, not
110
+ half-enabled); the public read / verify surface answers 404 until an object is
111
+ published. ZERO DDL, no new deps. See `docs/receipts.md`.
112
+
113
+ Tenant ledger (TB-1) — lazy per-customer identity and derived statements for a
114
+ platform that resells routed inference. **Server / console / docs only — the
115
+ `modelrig` npm surface is unchanged by TB-1.** TB-1a adds tenant identity as a
116
+ single column: migration `00059` puts a `kind` discriminator
117
+ (`'project'` | `'tenant'`) on `projects`, with a reserved per-org `__tenants__`
118
+ parent and tenant leaves slugged `t_<sha256(org_id:subject)[:24]>`, materialized
119
+ service-role at ingest from the principal's org (never the payload) — **zero new
120
+ tables**. TB-1b derives a per-tenant statement (`TenantStatementV1`) on read,
121
+ **never stored**: counts / cents / hashes only, where `verified` is DETERMINISTIC
122
+ conformance only (run-outcome@v1 / schema-conformant artifact; model judge and
123
+ human feedback are `quality_context` and never gate billable), unpriced models
124
+ fall to a separate "priced at list" line rather than into the total, and a new
125
+ Tenants console renders it. Rate cards and real price / margin are **TB-3, NOT in
126
+ this tag** — every TB-1 statement is "priced at list" with `billable = verified`
127
+ and null price / margin. The migration ships in the repo but is applied by a
128
+ Supabase-enabled session, not this build. ZERO DDL added by this release; no new
129
+ deps. See `docs/tenants.md`.
130
+
13
131
  ## [0.7.0] — 2026-08-23
14
132
 
15
133
  Released with the docs/registry mirror in one R-A ceremony. Headline: wrap a
package/README.md CHANGED
@@ -22,6 +22,29 @@ npm i modelrig
22
22
  > macOS and Linux on x64/arm64 (nearly everyone); an unusual platform will
23
23
  > compile it at install time and needs a C++ toolchain. Node ≥ 20 is assumed.
24
24
 
25
+ `modelrig` is a normal dependency: your existing `npm install` (or `pnpm` /
26
+ `yarn`) step installs it **prebuilt** — there is no separate "build modelrig"
27
+ step. Three things to get right when you deploy:
28
+
29
+ - **Run it on a Node runtime, not an Edge/Workers runtime.** `better-sqlite3`
30
+ is a native module and loads on import, so an Edge runtime can't run it. A
31
+ standard Node host — server, container, or a Node serverless function — works
32
+ after `npm install`.
33
+ - **If your deploy _bundles_ server code, mark modelrig external** so the native
34
+ binary is loaded at runtime instead of bundled:
35
+ - **Next.js** — `serverExternalPackages: ['modelrig']` in `next.config.js`
36
+ (Next 13/14: `experimental.serverComponentsExternalPackages`)
37
+ - **esbuild** — `--external:better-sqlite3` (and `--external:modelrig`)
38
+ - **webpack** — add `better-sqlite3` to `externals`
39
+ - **Set `MODELRIG_API_KEY` on every service, worker, and cron** that runs a rig
40
+ — each process reports its own telemetry, so the key belongs on all of them,
41
+ not just your web service.
42
+
43
+ (In a monorepo that vendors ModelRig as a workspace package — the SDK source
44
+ rather than the published tarball — build that package before the app that
45
+ imports it, so its `dist/` types resolve. Consuming the published package needs
46
+ none of this.)
47
+
25
48
  ## Quickstart
26
49
 
27
50
  This runs with **only a provider key** — no account, no signup.
@@ -229,6 +252,69 @@ lines up with the console's built-ins:
229
252
  `customer`, `tenant`, `user_id`, `team`, … is nudged toward the reserved key it
230
253
  resembles. They stay ordinary tags; nothing is required.
231
254
 
255
+ ## Runs — the standard path
256
+
257
+ Every pipeline execution is a **run** — the standard way ModelRig records what
258
+ your workstream did, not an appendix. Wrap the execution in a run context, name
259
+ one **step** per model-call family, and **save the work products** as artifacts;
260
+ the console's **Runs** tab then shows the run → step → artifact chain you click
261
+ through, and `/projects` groups it by your tags.
262
+
263
+ ```ts
264
+ import { createRig, loadConfigFromEnv } from "modelrig";
265
+
266
+ const rig = createRig(loadConfigFromEnv());
267
+
268
+ // One run per pipeline execution. run.scope isolates concurrent runs; use
269
+ // run.start()/run.end() for one run at a time on a request path.
270
+ await rig.artifacts.run.scope(
271
+ { pipeline: "support", episodeKey: ticketId },
272
+ async () => {
273
+ const result = await rig.run("example.support_summarize", {
274
+ input: { ticket, product, priorContext },
275
+ tags: { subject: customer, feature: "summarize" }, // run_id auto-stamped
276
+ stepKey: "summarize", // names this step on /runs
277
+ });
278
+ // Save the work product — prompt / raw / parsed all save the same way.
279
+ rig.artifacts.artifact.save(result.output, { name: "summary", type: "step_output" });
280
+ },
281
+ );
282
+ rig.close();
283
+ ```
284
+
285
+ - **Tags:** carry `subject` (what the run is *about* — an opaque id) and
286
+ `feature`. Inside a run context the SDK stamps `tags.run_id` (the run's
287
+ `episodeKey`, else its id) and `tags.step` (the current step key) on every
288
+ attempt row of both lanes — a value you pass always wins, and outside a run
289
+ context nothing is stamped, so the row is byte-identical.
290
+ - **Raw lane too:** a `rig.runRaw` call inside a run context records a
291
+ ground-truth step (since 0.5.0), so grounded/cached BYOK pipelines fill the
292
+ same graph.
293
+ - **Acceptance:** the run appears on `/runs` with its steps in order and the
294
+ artifacts you saved attached. `modelrig status` prints `runs recorded: N` as
295
+ the local mirror; `modelrig validate` warns if a rig records calls but never
296
+ starts a run.
297
+
298
+ **On by default under a control plane.** The run/artifact namespace is **on by
299
+ default once a control plane is configured** — set `MODELRIG_API_KEY=rig_sk_…`
300
+ (or the self-host Supabase pair) and runs record automatically, metadata and
301
+ hashes only. A **pure local-only rig** (no sink to ship to) stays off; force it
302
+ on there with `MODELRIG_ARTIFACTS=1`, or opt out anywhere with
303
+ `MODELRIG_ARTIFACTS=0`. With the namespace off it is inert — `run.start` is a
304
+ no-op, `artifact.save` returns null, zero rows are written, and your pipeline
305
+ behaves byte-for-byte as before. `modelrig status` prints the current posture.
306
+
307
+ > **Metadata and hashes only, this release.** Content custody (holding the
308
+ > serialized bytes) is early access; today the value you save is hashed (sha256)
309
+ > and the bytes are discarded — the metadata row and its integrity hash are what
310
+ > persist. A `zeroRetention` run refuses every artifact fail-closed at the SDK
311
+ > gate.
312
+
313
+ The full artifact API — lineage (`link`), evaluations (`evaluate`), the reusable
314
+ per-step seam, and `run.scope` vs `run.start` — is the
315
+ [instrumentation guide](./docs/instrumentation.md), shipped in this package so
316
+ you can read it offline.
317
+
232
318
  ## CLI
233
319
 
234
320
  The package ships a `modelrig` binary:
@@ -0,0 +1,93 @@
1
+ /**
2
+ * N6 — disagreement mining into eval-case candidates (near-optimal brief §3.2 N6).
3
+ *
4
+ * The discriminating-fixtures gap (brief §2): a route's fixtures separate *valid*
5
+ * from *invalid* but rarely *good* from *great*. The richest place to look for the
6
+ * fixtures that WOULD discriminate is where the bake-off's own signals DISAGREE on
7
+ * a replayed sample:
8
+ * - deterministic conformance says one thing, the value score another
9
+ * (schema-valid-but-wrong, or invalid-but-scored-right);
10
+ * - the Q1 quality gate's verdict contradicts conformance;
11
+ * - the SERVED arm and the REFERENCE arm diverge on the same capture (the
12
+ * top-of-distribution signal the brief names);
13
+ * - the pairwise judge upsets the expected order (arm beats / loses to the ref).
14
+ *
15
+ * Each such sample becomes an eval-case CANDIDATE — a ranked POINTER (captureId)
16
+ * an operator may promote through the existing E4 path. NEVER auto-promoted.
17
+ *
18
+ * CONTENT CUSTODY (P6 / spec): a candidate carries ONLY already-captured
19
+ * metadata — the captureId (an identifier), booleans, and numeric scores. No
20
+ * output text, no input text. Every string field is asserted meta-safe
21
+ * (`isMetaSafeString`) before it is surfaced, the same predicate the WI-1
22
+ * envelope uses. This module is PURE and fixture-tested.
23
+ */
24
+ import type { ReplaySampleResult } from "./replay";
25
+ import type { PairwiseOutcome } from "./pairwise";
26
+ export type DisagreementReason = "conform_vs_value" | "conform_vs_gate" | "arm_vs_reference" | "pairwise_upset";
27
+ /** A per-sample verdict divergence, surfaced as a ranked eval-case candidate. All
28
+ * fields are already-captured metadata — a POINTER to promote, never content. */
29
+ export interface DisagreementCandidate {
30
+ /** The captured input's inference id — the promote path rehydrates from it. */
31
+ readonly captureId: string;
32
+ /** Which served arm this divergence is on (never the reference arm). */
33
+ readonly arm: string;
34
+ /** 0..1 magnitude of the disagreement — the ranking key (desc). */
35
+ readonly score: number;
36
+ /** Every signal pair that fired (ranked candidates usually have one; the
37
+ * top ones several). */
38
+ readonly reasons: readonly DisagreementReason[];
39
+ /** The already-captured numbers/booleans behind the score (no content). */
40
+ readonly signals: {
41
+ readonly armConform: boolean;
42
+ readonly armValueAccuracy: number | null;
43
+ readonly armQualityPassed: boolean | null;
44
+ readonly refConform?: boolean;
45
+ readonly refValueAccuracy?: number | null;
46
+ readonly pairwise?: PairwiseOutcome;
47
+ };
48
+ }
49
+ export interface DisagreementReport {
50
+ /** Paired/served samples examined across all arms. */
51
+ readonly examined: number;
52
+ /** Ranked candidates (score desc, then captureId asc), truncated to the cap. */
53
+ readonly candidates: readonly DisagreementCandidate[];
54
+ /** How many candidates existed BEFORE the cap — surfaced so a truncated list is
55
+ * never mistaken for "all of them" (no silent caps). */
56
+ readonly found: number;
57
+ /** The cap applied when `found > candidates.length`, else null. */
58
+ readonly cappedAt: number | null;
59
+ /** Count of candidates each reason contributed to (a candidate with two
60
+ * reasons counts once under each). */
61
+ readonly counts: Readonly<Record<DisagreementReason, number>>;
62
+ }
63
+ /** Default cap on the surfaced candidate list — enough to act on, bounded so a
64
+ * noisy replay set does not flood the surface. The full `found` count rides
65
+ * alongside so the truncation is legible. */
66
+ export declare const DEFAULT_DISAGREEMENT_CAP = 50;
67
+ export interface MineInput {
68
+ /** One entry per SERVED arm (never the reference): its per-sample results. */
69
+ readonly arms: readonly {
70
+ readonly arm: string;
71
+ readonly samples: readonly ReplaySampleResult[];
72
+ }[];
73
+ /** The reference arm's per-sample results, keyed for the arm-vs-reference and
74
+ * pairwise signals. Absent ⇒ those two reasons never fire (only the
75
+ * within-arm signals do). */
76
+ readonly reference?: readonly ReplaySampleResult[];
77
+ /** Pairwise verdicts keyed `${arm}\0${captureId}` → outcome, when a pairwise
78
+ * signal was computed. */
79
+ readonly pairwise?: ReadonlyMap<string, PairwiseOutcome>;
80
+ /** Surfaced-list cap (default {@link DEFAULT_DISAGREEMENT_CAP}). */
81
+ readonly maxCandidates?: number;
82
+ }
83
+ /** The pairwise map key — arm + captureId, NUL-joined so neither can spoof the
84
+ * other's boundary. */
85
+ export declare function pairwiseKey(arm: string, captureId: string): string;
86
+ /**
87
+ * Mine a replayed bake-off's per-sample results for verdict disagreements and
88
+ * rank them as eval-case candidates (N6). Pure over its inputs; deterministic
89
+ * ordering (score desc, then captureId asc). A produced sample with NO
90
+ * divergence past {@link SIGNAL_EPS} yields no candidate.
91
+ */
92
+ export declare function mineDisagreements(input: MineInput): DisagreementReport;
93
+ //# sourceMappingURL=disagreement.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"disagreement.d.ts","sourceRoot":"","sources":["../../src/bakeoff/disagreement.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAGH,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,UAAU,CAAC;AACnD,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC;AAElD,MAAM,MAAM,kBAAkB,GAC1B,kBAAkB,GAClB,iBAAiB,GACjB,kBAAkB,GAClB,gBAAgB,CAAC;AAErB;iFACiF;AACjF,MAAM,WAAW,qBAAqB;IACpC,+EAA+E;IAC/E,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,wEAAwE;IACxE,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,mEAAmE;IACnE,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB;4BACwB;IACxB,QAAQ,CAAC,OAAO,EAAE,SAAS,kBAAkB,EAAE,CAAC;IAChD,2EAA2E;IAC3E,QAAQ,CAAC,OAAO,EAAE;QAChB,QAAQ,CAAC,UAAU,EAAE,OAAO,CAAC;QAC7B,QAAQ,CAAC,gBAAgB,EAAE,MAAM,GAAG,IAAI,CAAC;QACzC,QAAQ,CAAC,gBAAgB,EAAE,OAAO,GAAG,IAAI,CAAC;QAC1C,QAAQ,CAAC,UAAU,CAAC,EAAE,OAAO,CAAC;QAC9B,QAAQ,CAAC,gBAAgB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;QAC1C,QAAQ,CAAC,QAAQ,CAAC,EAAE,eAAe,CAAC;KACrC,CAAC;CACH;AAED,MAAM,WAAW,kBAAkB;IACjC,sDAAsD;IACtD,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,gFAAgF;IAChF,QAAQ,CAAC,UAAU,EAAE,SAAS,qBAAqB,EAAE,CAAC;IACtD;4DACwD;IACxD,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,mEAAmE;IACnE,QAAQ,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACjC;0CACsC;IACtC,QAAQ,CAAC,MAAM,EAAE,QAAQ,CAAC,MAAM,CAAC,kBAAkB,EAAE,MAAM,CAAC,CAAC,CAAC;CAC/D;AAED;;6CAE6C;AAC7C,eAAO,MAAM,wBAAwB,KAAK,CAAC;AAY3C,MAAM,WAAW,SAAS;IACxB,8EAA8E;IAC9E,QAAQ,CAAC,IAAI,EAAE,SAAS;QAAE,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,OAAO,EAAE,SAAS,kBAAkB,EAAE,CAAA;KAAE,EAAE,CAAC;IACpG;;iCAE6B;IAC7B,QAAQ,CAAC,SAAS,CAAC,EAAE,SAAS,kBAAkB,EAAE,CAAC;IACnD;8BAC0B;IAC1B,QAAQ,CAAC,QAAQ,CAAC,EAAE,WAAW,CAAC,MAAM,EAAE,eAAe,CAAC,CAAC;IACzD,oEAAoE;IACpE,QAAQ,CAAC,aAAa,CAAC,EAAE,MAAM,CAAC;CACjC;AAED;uBACuB;AACvB,wBAAgB,WAAW,CAAC,GAAG,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,CAElE;AAED;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,SAAS,GAAG,kBAAkB,CA0FtE"}
@@ -0,0 +1,140 @@
1
+ "use strict";
2
+ /**
3
+ * N6 — disagreement mining into eval-case candidates (near-optimal brief §3.2 N6).
4
+ *
5
+ * The discriminating-fixtures gap (brief §2): a route's fixtures separate *valid*
6
+ * from *invalid* but rarely *good* from *great*. The richest place to look for the
7
+ * fixtures that WOULD discriminate is where the bake-off's own signals DISAGREE on
8
+ * a replayed sample:
9
+ * - deterministic conformance says one thing, the value score another
10
+ * (schema-valid-but-wrong, or invalid-but-scored-right);
11
+ * - the Q1 quality gate's verdict contradicts conformance;
12
+ * - the SERVED arm and the REFERENCE arm diverge on the same capture (the
13
+ * top-of-distribution signal the brief names);
14
+ * - the pairwise judge upsets the expected order (arm beats / loses to the ref).
15
+ *
16
+ * Each such sample becomes an eval-case CANDIDATE — a ranked POINTER (captureId)
17
+ * an operator may promote through the existing E4 path. NEVER auto-promoted.
18
+ *
19
+ * CONTENT CUSTODY (P6 / spec): a candidate carries ONLY already-captured
20
+ * metadata — the captureId (an identifier), booleans, and numeric scores. No
21
+ * output text, no input text. Every string field is asserted meta-safe
22
+ * (`isMetaSafeString`) before it is surfaced, the same predicate the WI-1
23
+ * envelope uses. This module is PURE and fixture-tested.
24
+ */
25
+ Object.defineProperty(exports, "__esModule", { value: true });
26
+ exports.DEFAULT_DISAGREEMENT_CAP = void 0;
27
+ exports.pairwiseKey = pairwiseKey;
28
+ exports.mineDisagreements = mineDisagreements;
29
+ const attempt_meta_1 = require("../telemetry/attempt-meta");
30
+ /** Default cap on the surfaced candidate list — enough to act on, bounded so a
31
+ * noisy replay set does not flood the surface. The full `found` count rides
32
+ * alongside so the truncation is legible. */
33
+ exports.DEFAULT_DISAGREEMENT_CAP = 50;
34
+ /** Per-sample quality on the SAME 0..1 scale regret uses: the value-accuracy
35
+ * score when present, else 1/0 conformance. */
36
+ function quality01(s) {
37
+ return s.valueAccuracy ?? (s.conform ? 1 : 0);
38
+ }
39
+ /** A signal is "meaningfully divergent" past this magnitude (below it is noise —
40
+ * e.g. a 0.98-vs-1.0 field-accuracy hair-split is not a fixture worth promoting). */
41
+ const SIGNAL_EPS = 0.05;
42
+ /** The pairwise map key — arm + captureId, NUL-joined so neither can spoof the
43
+ * other's boundary. */
44
+ function pairwiseKey(arm, captureId) {
45
+ return `${arm}\0${captureId}`;
46
+ }
47
+ /**
48
+ * Mine a replayed bake-off's per-sample results for verdict disagreements and
49
+ * rank them as eval-case candidates (N6). Pure over its inputs; deterministic
50
+ * ordering (score desc, then captureId asc). A produced sample with NO
51
+ * divergence past {@link SIGNAL_EPS} yields no candidate.
52
+ */
53
+ function mineDisagreements(input) {
54
+ const cap = input.maxCandidates ?? exports.DEFAULT_DISAGREEMENT_CAP;
55
+ const refByCapture = new Map((input.reference ?? []).map((s) => [s.captureId, s]));
56
+ const counts = {
57
+ conform_vs_value: 0,
58
+ conform_vs_gate: 0,
59
+ arm_vs_reference: 0,
60
+ pairwise_upset: 0,
61
+ };
62
+ const candidates = [];
63
+ let examined = 0;
64
+ for (const { arm, samples } of input.arms) {
65
+ for (const s of samples) {
66
+ // Only PRODUCED samples carry verdicts to disagree about; a failed sample
67
+ // (no output) has nothing to mine. Guard the captureId is meta-safe before
68
+ // it is surfaced (captureIds are identifiers, always safe — asserted, never
69
+ // assumed, per the content-custody rule).
70
+ if (s.failureClass !== null)
71
+ continue;
72
+ if (!(0, attempt_meta_1.isMetaSafeString)(s.captureId))
73
+ continue;
74
+ examined += 1;
75
+ const reasons = [];
76
+ let score = 0;
77
+ const conform01 = s.conform ? 1 : 0;
78
+ // conform_vs_value: schema conformance disagrees with the value score.
79
+ if (s.valueAccuracy !== null) {
80
+ const gap = Math.abs(conform01 - s.valueAccuracy);
81
+ if (gap > SIGNAL_EPS) {
82
+ reasons.push("conform_vs_value");
83
+ score = Math.max(score, gap);
84
+ }
85
+ }
86
+ // conform_vs_gate: the Q1 boolean verdict contradicts conformance.
87
+ if (s.qualityPassed !== null && s.qualityPassed !== s.conform) {
88
+ reasons.push("conform_vs_gate");
89
+ score = Math.max(score, 1);
90
+ }
91
+ const ref = refByCapture.get(s.captureId);
92
+ const signals = {
93
+ armConform: s.conform,
94
+ armValueAccuracy: s.valueAccuracy,
95
+ armQualityPassed: s.qualityPassed,
96
+ ...(ref !== undefined ? { refConform: ref.conform, refValueAccuracy: ref.valueAccuracy } : {}),
97
+ };
98
+ // arm_vs_reference: the served arm and the reference diverge on this capture
99
+ // (the top-of-distribution signal — where the fixture corpus learns to tell
100
+ // good from great).
101
+ if (ref !== undefined) {
102
+ const gap = Math.abs(quality01(s) - quality01(ref));
103
+ if (gap > SIGNAL_EPS) {
104
+ reasons.push("arm_vs_reference");
105
+ score = Math.max(score, gap);
106
+ }
107
+ }
108
+ // pairwise_upset: a non-tie pairwise verdict is itself a divergence worth a
109
+ // fixture (an arm beating OR losing to the reference by preference).
110
+ const pw = input.pairwise?.get(pairwiseKey(arm, s.captureId));
111
+ if (pw !== undefined && pw !== "tie") {
112
+ reasons.push("pairwise_upset");
113
+ score = Math.max(score, 1);
114
+ }
115
+ if (reasons.length === 0)
116
+ continue;
117
+ for (const r of reasons)
118
+ counts[r] += 1;
119
+ candidates.push({
120
+ captureId: s.captureId,
121
+ arm,
122
+ score,
123
+ reasons,
124
+ signals: pw !== undefined ? { ...signals, pairwise: pw } : signals,
125
+ });
126
+ }
127
+ }
128
+ // Deterministic ranking: strongest disagreement first, captureId as a stable
129
+ // tiebreak so the same replay set always ranks identically.
130
+ candidates.sort((a, b) => b.score - a.score || a.captureId.localeCompare(b.captureId));
131
+ const found = candidates.length;
132
+ const surfaced = candidates.slice(0, cap);
133
+ return {
134
+ examined,
135
+ candidates: surfaced,
136
+ found,
137
+ cappedAt: found > surfaced.length ? cap : null,
138
+ counts,
139
+ };
140
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"engine.d.ts","sourceRoot":"","sources":["../../src/bakeoff/engine.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"engine.d.ts","sourceRoot":"","sources":["../../src/bakeoff/engine.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAGH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,sBAAsB,CAAC;AACzD,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,YAAY,CAAC;AAG1C,OAAO,KAAK,EAAE,aAAa,EAAE,aAAa,EAAE,WAAW,EAAgB,MAAM,gBAAgB,CAAC;AAS9F,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAEpD,OAAO,KAAK,EAAE,UAAU,EAAE,eAAe,EAAE,MAAM,WAAW,CAAC;AAC7D,OAAO,KAAK,EAAE,eAAe,EAAE,gBAAgB,EAAmC,cAAc,EAAE,MAAM,SAAS,CAAC;AAElH;;+BAE+B;AAC/B,eAAO,MAAM,aAAa,kBAAkB,CAAC;AAE7C;;;gFAGgF;AAChF,MAAM,MAAM,kBAAkB,GAAG,CAAC,KAAK,EAAE;IACvC,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,IAAI,EAAE,SAAS,UAAU,EAAE,CAAC;CACtC,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;AAgBpB,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,qEAAqE;IACrE,QAAQ,CAAC,QAAQ,EAAE,SAAS,MAAM,EAAE,CAAC;IACrC,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,2EAA2E;IAC3E,QAAQ,CAAC,qBAAqB,EAAE,MAAM,CAAC;IACvC;;wEAEoE;IACpE,QAAQ,CAAC,WAAW,CAAC,EAAE,eAAe,CAAC;IACvC;iEAC6D;IAC7D,QAAQ,CAAC,aAAa,CAAC,EAAE,aAAa,CAAC;IACvC;;wEAEoE;IACpE,QAAQ,CAAC,oBAAoB,CAAC,EAAE,CAAC,KAAK,EAAE,WAAW,KAAK,aAAa,GAAG,IAAI,CAAC;IAC7E;+BAC2B;IAC3B,QAAQ,CAAC,QAAQ,CAAC,EAAE,WAAW,CAAC,MAAM,EAAE;QAAE,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAA;KAAE,CAAC,CAAC;IACrE;;;;;+EAK2E;IAC3E,QAAQ,CAAC,WAAW,CAAC,EAAE,WAAW,CAAC;CACpC;AAsBD,wBAAsB,UAAU,CAC9B,IAAI,EAAE,OAAO,EACb,QAAQ,EAAE,YAAY,EACtB,GAAG,EAAE,cAAc,EACnB,WAAW,CAAC,EAAE,kBAAkB,GAC/B,OAAO,CAAC,eAAe,CAAC,CA8K1B;AAuCD;iEACiE;AACjE,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,SAAS,CAAC,EAAE,gBAAgB,CAAC;IACtC,mEAAmE;IACnE,QAAQ,CAAC,gBAAgB,CAAC,EAAE,MAAM,CAAC;CACpC;AA8BD;;;;GAIG;AACH,wBAAgB,eAAe,CAC7B,SAAS,EAAE,SAAS,MAAM,EAAE,EAC5B,OAAO,EAAE,SAAS,cAAc,EAAE,EAClC,GAAG,CAAC,EAAE,eAAe,GACpB,eAAe,CAAC,UAAU,CAAC,CA8G7B;AAED,MAAM,WAAW,mBAAmB;IAClC,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAC;IAChC,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,OAAO,CAAC,EAAE,OAAO,KAAK,CAAC;CACjC;AAED;;uDAEuD;AACvD,wBAAsB,aAAa,CACjC,QAAQ,EAAE,eAAe,EACzB,MAAM,EAAE,mBAAmB,GAC1B,OAAO,CAAC,IAAI,CAAC,CA6Bf;AAED;;;;;GAKG;AACH,wBAAgB,eAAe,CAAC,QAAQ,EAAE,eAAe,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAuCpG"}
@@ -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
  }