modelrig 0.7.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 (128) hide show
  1. package/CHANGELOG.md +107 -0
  2. package/README.md +63 -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/telemetry/supabase-exporter.d.ts +64 -0
  108. package/dist/telemetry/supabase-exporter.d.ts.map +1 -0
  109. package/dist/telemetry/supabase-exporter.js +211 -0
  110. package/dist/types/provider.d.ts +11 -0
  111. package/dist/types/provider.d.ts.map +1 -1
  112. package/dist/types/provider.js +9 -0
  113. package/dist/types/route.d.ts +55 -0
  114. package/dist/types/route.d.ts.map +1 -1
  115. package/dist/types/telemetry.d.ts +7 -0
  116. package/dist/types/telemetry.d.ts.map +1 -1
  117. package/dist/validate-cli.d.ts.map +1 -1
  118. package/dist/validate-cli.js +13 -0
  119. package/dist/verify/types.d.ts +6 -0
  120. package/dist/verify/types.d.ts.map +1 -1
  121. package/docs/agent-skill/SKILL.md +131 -0
  122. package/docs/how-it-fits.md +34 -0
  123. package/docs/instrumentation.md +221 -0
  124. package/docs/llms.txt +276 -0
  125. package/docs/migration-playbook.md +512 -0
  126. package/docs/quickstart.md +333 -0
  127. package/package.json +18 -2
  128. package/receipts.d.ts +12 -0
package/CHANGELOG.md CHANGED
@@ -10,6 +10,113 @@ published package diverge.
10
10
  This project adheres to [Semantic Versioning](https://semver.org/). Dates are
11
11
  UTC.
12
12
 
13
+ ## [0.8.0] — 2026-08-24
14
+
15
+ Packaging (GS-3) — the tarball now ships the customer-facing runs docs
16
+ (`docs/quickstart.md`, `docs/instrumentation.md`, `docs/how-it-fits.md`,
17
+ `docs/migration-playbook.md`, `docs/agent-skill/SKILL.md`, and the generated
18
+ `docs/llms.txt`) plus a self-contained **Runs — the standard path** section in
19
+ `README.md`, so `npm i modelrig` + read-the-package teaches run → step → artifact
20
+ offline; and every doc now states the `MODELRIG_ARTIFACTS` default consistently
21
+ (on by default once a control plane is configured; a local-only rig stays off;
22
+ `=0` opts out, `=1` forces on). Docs/packaging only — no SDK behaviour change.
23
+
24
+ Get-started v5 — runs are the standard path (GS-1). The front-door instructions
25
+ (www `/get-started`, the console setup prompt, `quickstart.md`, `how-it-fits.md`,
26
+ the agent `SKILL.md`) now teach **run → step → artifact** as the standard way to
27
+ instrument a pipeline, not an appendix; `instrumentation.md` stays the reference
28
+ they link to. SDK: inside a run context the SDK now also stamps **`tags.step`**
29
+ (the current step key: your explicit `stepKey`, else `tags.step`, else the auto
30
+ `route#n` / `provider/model#n` key) on every attempt row of both lanes when the
31
+ caller set none — the mirror of RC-1's `run_id` stamp; a caller value always wins,
32
+ and outside a run context rows are byte-identical. `modelrig status` prints
33
+ **`runs recorded: N (last: <ts>)`** from a best-effort readonly read of the local
34
+ buffer, and `modelrig validate` **warns when a rig has recorded calls but never
35
+ started a run** (a teaching hint — never a failure). ZERO DDL, no new deps.
36
+
37
+ Provenance-first assembly (screening B3) — additive, opt-in SDK seams for keeping
38
+ customer- and web-derived text out of the trusted system slot. **Default
39
+ behaviour is byte-identical**: a route with no `grounding.assembly` and no
40
+ `x-trust` takes the legacy path unchanged. A route may set
41
+ `grounding.assembly: spotlight` to route search results through the assembler as
42
+ an untrusted user block (search results are delivered as data, not instructions);
43
+ a template variable may be annotated `x-trust: untrusted` to move it out of the
44
+ system slot into that spotlighted block. Model-output citations are allowlisted to
45
+ the injected sources, with a `RunMeta.grounding.citations_dropped` count. `modelrig
46
+ validate` warns on legacy grounding and on an untrusted-shaped variable left
47
+ unannotated in the system slot. `EquivalenceReport.subject` gains
48
+ `"screen-assembly"`. `screen/provenance.ts` + `screen/types.ts` now ship in
49
+ `dist/`. ZERO DDL, no new deps.
50
+
51
+ Near-optimal pairwise + disagreement (N5/N6) — bake-offs gain a second read on
52
+ regret and a fixture-mining output, both additive and **byte-identical for
53
+ routes that don't opt in**. N5 scores each served arm's **pairwise standing vs
54
+ the reference arm** on the paired replay set — a `pairwise_win_rate`
55
+ (win=1/tie=0.5/loss=0) with a seeded-bootstrap CI and n — surfaced as
56
+ `VariantMetrics.pairwise` and a `pairwise-win` column in the report. It is
57
+ DETERMINISTIC-FIRST, exactly like the RB-1 regret tiers: when both an arm and the
58
+ reference carry a per-sample value-accuracy score the verdict is free (the sign
59
+ of their difference); only the samples nothing scored fall to an OPT-IN, CAPPED,
60
+ METERED pairwise judge, declared `bakeoff.pairwise.judge` (judge model ∈ declared
61
+ candidates, #223 F3; every call a metered inference tagged `bakeoff_pairwise`,
62
+ capped per arm). A new `objective.pairwiseAware` (default off) adds one
63
+ eligibility clause to the `quality-first`/`balanced` gate — a proposed arm must
64
+ not be pairwise-worse than the reference within CI — and never touches the
65
+ cost-first gate. N6 mines the same replay for **eval-case candidates**: ranked
66
+ POINTERS (by `captureId`) to the samples where the bake-off's own signals
67
+ disagree (conformance vs value score, the Q1 gate vs conformance, served arm vs
68
+ reference, a pairwise upset), surfaced on `BakeoffArtifact.disagreement` and a
69
+ report summary line. Candidates are **metadata only** (captureId + numbers /
70
+ booleans, asserted meta-safe — never input or output text) and are **never
71
+ auto-promoted**; the full pre-cap `found` count rides the truncated list so a
72
+ capped surface is never mistaken for all of them. No new root exports — additive
73
+ route YAML plus report/artifact fields. ZERO DDL, no new deps. See
74
+ `docs/bakeoffs.md`.
75
+
76
+ Published receipts (RP-1 / RP-1c) — a signed, login-free **Receipt@v1** an org
77
+ can publish from its own measured rows. **This is two halves; only the shape is
78
+ in the npm package.** The `modelrig` package exposes the receipt CONTRACT via the
79
+ new `modelrig/receipts` sub-path export ONLY (never re-exported from `index.ts`):
80
+ the `ReceiptV1` shape, `canonicalizeReceipt` / `hashReceipt` (JCS-style
81
+ canonical body, self-describing `sha256:` hash), and the pure `isReceiptSafe`
82
+ classification gate — `measured` carries numbers / booleans / hashes /
83
+ identifiers only, no content, no tenant ids (fail-closed). The additive
84
+ `exports` map (`.`, `./receipts`, `./data/*`, `./registry/*`) plus a
85
+ `receipts.d.ts` shim keep the historical bare surface and the runtime data /
86
+ registry files reachable now that a sub-path map turns resolution into an
87
+ allowlist; `dist/receipts/**` now ships. **Everything else lives in
88
+ `modelrig-server` (NOT the npm tag):** Ed25519 signing with a `key_id` (node
89
+ crypto, no new dep), authed publish / revoke, the store-only public family —
90
+ `GET /v1/r/:id`, `…/badge.svg`, `GET /v1/verify/:id` (re-canonicalize → re-hash →
91
+ verify signature), `GET /.well-known/modelrig-receipts.json` (key rotation) — a
92
+ handler constructed with NO database client, an org alias that never leaks
93
+ `org_id`, `recipient` visibility (a login-free capability URL never listed
94
+ publicly), and a revoke tombstone that answers 410. RP-1c adds the `run` and
95
+ `fact-sheet` producers + source readers and the `source_ref` lookup (X9 — the
96
+ PR-body "verify this" link). **Honest limit:** publish / revoke / signing degrade
97
+ to **501** until `MODELRIG_RECEIPT_SIGNING_KEY` + `MODELRIG_RECEIPT_KEY_ID` and
98
+ the content store are provisioned (a half-configured signer is refused, not
99
+ half-enabled); the public read / verify surface answers 404 until an object is
100
+ published. ZERO DDL, no new deps. See `docs/receipts.md`.
101
+
102
+ Tenant ledger (TB-1) — lazy per-customer identity and derived statements for a
103
+ platform that resells routed inference. **Server / console / docs only — the
104
+ `modelrig` npm surface is unchanged by TB-1.** TB-1a adds tenant identity as a
105
+ single column: migration `00059` puts a `kind` discriminator
106
+ (`'project'` | `'tenant'`) on `projects`, with a reserved per-org `__tenants__`
107
+ parent and tenant leaves slugged `t_<sha256(org_id:subject)[:24]>`, materialized
108
+ service-role at ingest from the principal's org (never the payload) — **zero new
109
+ tables**. TB-1b derives a per-tenant statement (`TenantStatementV1`) on read,
110
+ **never stored**: counts / cents / hashes only, where `verified` is DETERMINISTIC
111
+ conformance only (run-outcome@v1 / schema-conformant artifact; model judge and
112
+ human feedback are `quality_context` and never gate billable), unpriced models
113
+ fall to a separate "priced at list" line rather than into the total, and a new
114
+ Tenants console renders it. Rate cards and real price / margin are **TB-3, NOT in
115
+ this tag** — every TB-1 statement is "priced at list" with `billable = verified`
116
+ and null price / margin. The migration ships in the repo but is applied by a
117
+ Supabase-enabled session, not this build. ZERO DDL added by this release; no new
118
+ deps. See `docs/tenants.md`.
119
+
13
120
  ## [0.7.0] — 2026-08-23
14
121
 
15
122
  Released with the docs/registry mirror in one R-A ceremony. Headline: wrap a
package/README.md CHANGED
@@ -229,6 +229,69 @@ lines up with the console's built-ins:
229
229
  `customer`, `tenant`, `user_id`, `team`, … is nudged toward the reserved key it
230
230
  resembles. They stay ordinary tags; nothing is required.
231
231
 
232
+ ## Runs — the standard path
233
+
234
+ Every pipeline execution is a **run** — the standard way ModelRig records what
235
+ your workstream did, not an appendix. Wrap the execution in a run context, name
236
+ one **step** per model-call family, and **save the work products** as artifacts;
237
+ the console's **Runs** tab then shows the run → step → artifact chain you click
238
+ through, and `/projects` groups it by your tags.
239
+
240
+ ```ts
241
+ import { createRig, loadConfigFromEnv } from "modelrig";
242
+
243
+ const rig = createRig(loadConfigFromEnv());
244
+
245
+ // One run per pipeline execution. run.scope isolates concurrent runs; use
246
+ // run.start()/run.end() for one run at a time on a request path.
247
+ await rig.artifacts.run.scope(
248
+ { pipeline: "support", episodeKey: ticketId },
249
+ async () => {
250
+ const result = await rig.run("example.support_summarize", {
251
+ input: { ticket, product, priorContext },
252
+ tags: { subject: customer, feature: "summarize" }, // run_id auto-stamped
253
+ stepKey: "summarize", // names this step on /runs
254
+ });
255
+ // Save the work product — prompt / raw / parsed all save the same way.
256
+ rig.artifacts.artifact.save(result.output, { name: "summary", type: "step_output" });
257
+ },
258
+ );
259
+ rig.close();
260
+ ```
261
+
262
+ - **Tags:** carry `subject` (what the run is *about* — an opaque id) and
263
+ `feature`. Inside a run context the SDK stamps `tags.run_id` (the run's
264
+ `episodeKey`, else its id) and `tags.step` (the current step key) on every
265
+ attempt row of both lanes — a value you pass always wins, and outside a run
266
+ context nothing is stamped, so the row is byte-identical.
267
+ - **Raw lane too:** a `rig.runRaw` call inside a run context records a
268
+ ground-truth step (since 0.5.0), so grounded/cached BYOK pipelines fill the
269
+ same graph.
270
+ - **Acceptance:** the run appears on `/runs` with its steps in order and the
271
+ artifacts you saved attached. `modelrig status` prints `runs recorded: N` as
272
+ the local mirror; `modelrig validate` warns if a rig records calls but never
273
+ starts a run.
274
+
275
+ **On by default under a control plane.** The run/artifact namespace is **on by
276
+ default once a control plane is configured** — set `MODELRIG_API_KEY=rig_sk_…`
277
+ (or the self-host Supabase pair) and runs record automatically, metadata and
278
+ hashes only. A **pure local-only rig** (no sink to ship to) stays off; force it
279
+ on there with `MODELRIG_ARTIFACTS=1`, or opt out anywhere with
280
+ `MODELRIG_ARTIFACTS=0`. With the namespace off it is inert — `run.start` is a
281
+ no-op, `artifact.save` returns null, zero rows are written, and your pipeline
282
+ behaves byte-for-byte as before. `modelrig status` prints the current posture.
283
+
284
+ > **Metadata and hashes only, this release.** Content custody (holding the
285
+ > serialized bytes) is early access; today the value you save is hashed (sha256)
286
+ > and the bytes are discarded — the metadata row and its integrity hash are what
287
+ > persist. A `zeroRetention` run refuses every artifact fail-closed at the SDK
288
+ > gate.
289
+
290
+ The full artifact API — lineage (`link`), evaluations (`evaluate`), the reusable
291
+ per-step seam, and `run.scope` vs `run.start` — is the
292
+ [instrumentation guide](./docs/instrumentation.md), shipped in this package so
293
+ you can read it offline.
294
+
232
295
  ## CLI
233
296
 
234
297
  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":"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"}
@@ -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
  }