orizu 0.5.20 → 0.5.21

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 (260) hide show
  1. package/dist/archive-artifacts-cli.d.ts +50 -0
  2. package/dist/archive-artifacts-cli.d.ts.map +1 -0
  3. package/dist/artifact-archive.d.ts +48 -0
  4. package/dist/artifact-archive.d.ts.map +1 -0
  5. package/dist/artifact-pull.d.ts +22 -0
  6. package/dist/artifact-pull.d.ts.map +1 -0
  7. package/dist/banner.d.ts +3 -0
  8. package/dist/banner.d.ts.map +1 -0
  9. package/dist/connectors-cli.d.ts +31 -0
  10. package/dist/connectors-cli.d.ts.map +1 -0
  11. package/dist/credentials.d.ts +46 -0
  12. package/dist/credentials.d.ts.map +1 -0
  13. package/dist/csv.d.ts +49 -0
  14. package/dist/csv.d.ts.map +1 -0
  15. package/dist/dataset-download.d.ts +6 -0
  16. package/dist/dataset-download.d.ts.map +1 -0
  17. package/dist/dataset-edit-rows.d.ts +10 -0
  18. package/dist/dataset-edit-rows.d.ts.map +1 -0
  19. package/dist/dataset-upload-snapshot.d.ts +13 -0
  20. package/dist/dataset-upload-snapshot.d.ts.map +1 -0
  21. package/dist/daytona-slice-rehearsal.d.ts +89 -0
  22. package/dist/daytona-slice-rehearsal.d.ts.map +1 -0
  23. package/dist/daytona-slice.d.ts +106 -0
  24. package/dist/daytona-slice.d.ts.map +1 -0
  25. package/dist/deploy-key-merge-core.d.ts +121 -0
  26. package/dist/deploy-key-merge-core.d.ts.map +1 -0
  27. package/dist/diff-comments-cli.d.ts +12 -0
  28. package/dist/diff-comments-cli.d.ts.map +1 -0
  29. package/dist/diff-comments-cli.js +3 -0
  30. package/dist/egress-allowlist-cli.d.ts +23 -0
  31. package/dist/egress-allowlist-cli.d.ts.map +1 -0
  32. package/dist/egress-policy.d.ts +108 -0
  33. package/dist/egress-policy.d.ts.map +1 -0
  34. package/dist/error-response.d.ts +9 -0
  35. package/dist/error-response.d.ts.map +1 -0
  36. package/dist/file-parser.d.ts +5 -0
  37. package/dist/file-parser.d.ts.map +1 -0
  38. package/dist/gepa-engine-dispatch.d.ts +9 -0
  39. package/dist/gepa-engine-dispatch.d.ts.map +1 -0
  40. package/dist/gepa-engine-dispatch.js +48 -2
  41. package/dist/gepa-python-command.d.ts +6 -0
  42. package/dist/gepa-python-command.d.ts.map +1 -0
  43. package/dist/gepa-python-paths.d.ts +6 -0
  44. package/dist/gepa-python-paths.d.ts.map +1 -0
  45. package/dist/gepa-python-paths.js +6 -3
  46. package/dist/git-credential.d.ts +69 -0
  47. package/dist/git-credential.d.ts.map +1 -0
  48. package/dist/git-recovery-environment.d.ts +31 -0
  49. package/dist/git-recovery-environment.d.ts.map +1 -0
  50. package/dist/github-setup.d.ts +122 -0
  51. package/dist/github-setup.d.ts.map +1 -0
  52. package/dist/global-flags.d.ts +12 -0
  53. package/dist/global-flags.d.ts.map +1 -0
  54. package/dist/help.d.ts +46 -0
  55. package/dist/help.d.ts.map +1 -0
  56. package/dist/help.js +91 -23
  57. package/dist/hosted-boot.d.ts +273 -0
  58. package/dist/hosted-boot.d.ts.map +1 -0
  59. package/dist/hosted-bootstrap-rehearsal.d.ts +86 -0
  60. package/dist/hosted-bootstrap-rehearsal.d.ts.map +1 -0
  61. package/dist/hosted-bootstrap.d.ts +188 -0
  62. package/dist/hosted-bootstrap.d.ts.map +1 -0
  63. package/dist/hosted-broker-server.d.ts +50 -0
  64. package/dist/hosted-broker-server.d.ts.map +1 -0
  65. package/dist/hosted-git-http-server.d.ts +36 -0
  66. package/dist/hosted-git-http-server.d.ts.map +1 -0
  67. package/dist/hosted-harness-claude.d.ts +195 -0
  68. package/dist/hosted-harness-claude.d.ts.map +1 -0
  69. package/dist/hosted-harness-opencode.d.ts +227 -0
  70. package/dist/hosted-harness-opencode.d.ts.map +1 -0
  71. package/dist/hosted-harness.d.ts +118 -0
  72. package/dist/hosted-harness.d.ts.map +1 -0
  73. package/dist/hosted-harvest.d.ts +68 -0
  74. package/dist/hosted-harvest.d.ts.map +1 -0
  75. package/dist/hosted-headless.d.ts +72 -0
  76. package/dist/hosted-headless.d.ts.map +1 -0
  77. package/dist/hosted-loop-lifecycle.d.ts +206 -0
  78. package/dist/hosted-loop-lifecycle.d.ts.map +1 -0
  79. package/dist/hosted-loop.d.ts +170 -0
  80. package/dist/hosted-loop.d.ts.map +1 -0
  81. package/dist/hosted-run-event-sink.d.ts +222 -0
  82. package/dist/hosted-run-event-sink.d.ts.map +1 -0
  83. package/dist/hosted-runtime-assets.d.ts +186 -0
  84. package/dist/hosted-runtime-assets.d.ts.map +1 -0
  85. package/dist/hosted-session-cli.d.ts +258 -0
  86. package/dist/hosted-session-cli.d.ts.map +1 -0
  87. package/dist/hosted-skill-staging.d.ts +73 -0
  88. package/dist/hosted-skill-staging.d.ts.map +1 -0
  89. package/dist/http.d.ts +8 -0
  90. package/dist/http.d.ts.map +1 -0
  91. package/dist/index.d.ts +22 -0
  92. package/dist/index.d.ts.map +1 -0
  93. package/dist/index.js +88 -92
  94. package/dist/instruction-set-gepa-launch.d.ts +3 -0
  95. package/dist/instruction-set-gepa-launch.d.ts.map +1 -0
  96. package/dist/instruction-set-gepa-launch.js +15 -0
  97. package/dist/instruction-set-loader.d.ts +6 -0
  98. package/dist/instruction-set-loader.d.ts.map +1 -0
  99. package/dist/instruction-set-loader.js +59 -0
  100. package/dist/instruction-set-manifest.d.ts +14 -0
  101. package/dist/instruction-set-manifest.d.ts.map +1 -0
  102. package/dist/instruction-set-manifest.js +162 -0
  103. package/dist/instruction-sets-cli.d.ts +40 -0
  104. package/dist/instruction-sets-cli.d.ts.map +1 -0
  105. package/dist/instruction-sets-cli.js +361 -0
  106. package/dist/json-response.d.ts +4 -0
  107. package/dist/json-response.d.ts.map +1 -0
  108. package/dist/jsonl-stream.d.ts +8 -0
  109. package/dist/jsonl-stream.d.ts.map +1 -0
  110. package/dist/kill-switch-cli.d.ts +30 -0
  111. package/dist/kill-switch-cli.d.ts.map +1 -0
  112. package/dist/loopback-serve.d.ts +25 -0
  113. package/dist/loopback-serve.d.ts.map +1 -0
  114. package/dist/manifests-cli.d.ts +22 -0
  115. package/dist/manifests-cli.d.ts.map +1 -0
  116. package/dist/markdown-report-input.d.ts +7 -0
  117. package/dist/markdown-report-input.d.ts.map +1 -0
  118. package/dist/merge-job.d.ts +155 -0
  119. package/dist/merge-job.d.ts.map +1 -0
  120. package/dist/model-configs-cli.d.ts +19 -0
  121. package/dist/model-configs-cli.d.ts.map +1 -0
  122. package/dist/model-configs-cli.js +104 -0
  123. package/dist/optimization-export-cli.d.ts +19 -0
  124. package/dist/optimization-export-cli.d.ts.map +1 -0
  125. package/dist/optimizations-list-cli.d.ts +20 -0
  126. package/dist/optimizations-list-cli.d.ts.map +1 -0
  127. package/dist/optimizations-promote-cli.d.ts +8 -0
  128. package/dist/optimizations-promote-cli.d.ts.map +1 -0
  129. package/dist/optimizations-promote-cli.js +34 -0
  130. package/dist/option-validation.d.ts +15 -0
  131. package/dist/option-validation.d.ts.map +1 -0
  132. package/dist/preview-runtime.d.ts +63 -0
  133. package/dist/preview-runtime.d.ts.map +1 -0
  134. package/dist/prompt-draft-push.d.ts +60 -0
  135. package/dist/prompt-draft-push.d.ts.map +1 -0
  136. package/dist/prompt-label-cli.d.ts +11 -0
  137. package/dist/prompt-label-cli.d.ts.map +1 -0
  138. package/dist/prompt-label-cli.js +29 -0
  139. package/dist/prompt-length-wire.d.ts +28 -0
  140. package/dist/prompt-length-wire.d.ts.map +1 -0
  141. package/dist/prompt-report-cli.d.ts +10 -0
  142. package/dist/prompt-report-cli.d.ts.map +1 -0
  143. package/dist/prompt-summary-table.d.ts +15 -0
  144. package/dist/prompt-summary-table.d.ts.map +1 -0
  145. package/dist/prompt-summary-table.js +6 -0
  146. package/dist/registration-tag-warning.d.ts +32 -0
  147. package/dist/registration-tag-warning.d.ts.map +1 -0
  148. package/dist/report-comments-cli.d.ts +16 -0
  149. package/dist/report-comments-cli.d.ts.map +1 -0
  150. package/dist/report-markdown-blankness.d.ts +2 -0
  151. package/dist/report-markdown-blankness.d.ts.map +1 -0
  152. package/dist/report-replacement-warning.d.ts +17 -0
  153. package/dist/report-replacement-warning.d.ts.map +1 -0
  154. package/dist/repository-backup-core.d.ts +225 -0
  155. package/dist/repository-backup-core.d.ts.map +1 -0
  156. package/dist/repository-recovery-manifest.d.ts +83 -0
  157. package/dist/repository-recovery-manifest.d.ts.map +1 -0
  158. package/dist/run-event-digest.d.ts +74 -0
  159. package/dist/run-event-digest.d.ts.map +1 -0
  160. package/dist/runner-dir-verify.d.ts +66 -0
  161. package/dist/runner-dir-verify.d.ts.map +1 -0
  162. package/dist/runner-instruction-set-contract.d.ts +24 -0
  163. package/dist/runner-instruction-set-contract.d.ts.map +1 -0
  164. package/dist/runner-instruction-set-contract.js +50 -0
  165. package/dist/sandbox-hygiene.d.ts +45 -0
  166. package/dist/sandbox-hygiene.d.ts.map +1 -0
  167. package/dist/sandbox-provider.d.ts +193 -0
  168. package/dist/sandbox-provider.d.ts.map +1 -0
  169. package/dist/scorer-draft-push.d.ts +50 -0
  170. package/dist/scorer-draft-push.d.ts.map +1 -0
  171. package/dist/scorers-cli.d.ts +20 -0
  172. package/dist/scorers-cli.d.ts.map +1 -0
  173. package/dist/scorers-cli.js +22 -0
  174. package/dist/scorers-verify-parity-bridge.d.ts +16 -0
  175. package/dist/scorers-verify-parity-bridge.d.ts.map +1 -0
  176. package/dist/scorers-verify-parity-bridge.js +90 -0
  177. package/dist/scorers-verify-parity.d.ts +81 -0
  178. package/dist/scorers-verify-parity.d.ts.map +1 -0
  179. package/dist/scorers-verify-parity.js +502 -0
  180. package/dist/scores-accept-cli.d.ts +21 -0
  181. package/dist/scores-accept-cli.d.ts.map +1 -0
  182. package/dist/secret-redaction.d.ts +47 -0
  183. package/dist/secret-redaction.d.ts.map +1 -0
  184. package/dist/session-timeline.d.ts +54 -0
  185. package/dist/session-timeline.d.ts.map +1 -0
  186. package/dist/setup-prompt.d.ts +5 -0
  187. package/dist/setup-prompt.d.ts.map +1 -0
  188. package/dist/skill-installer.d.ts +68 -0
  189. package/dist/skill-installer.d.ts.map +1 -0
  190. package/dist/skilled-proposer-launch.d.ts +7 -0
  191. package/dist/skilled-proposer-launch.d.ts.map +1 -0
  192. package/dist/skilled-proposer-launch.js +60 -0
  193. package/dist/smoke.d.ts +102 -0
  194. package/dist/smoke.d.ts.map +1 -0
  195. package/dist/start-intent.d.ts +12 -0
  196. package/dist/start-intent.d.ts.map +1 -0
  197. package/dist/task-assignment-manifest.d.ts +15 -0
  198. package/dist/task-assignment-manifest.d.ts.map +1 -0
  199. package/dist/task-create-error.d.ts +18 -0
  200. package/dist/task-create-error.d.ts.map +1 -0
  201. package/dist/team-connectors-cli.d.ts +29 -0
  202. package/dist/team-connectors-cli.d.ts.map +1 -0
  203. package/dist/types.d.ts +39 -0
  204. package/dist/types.d.ts.map +1 -0
  205. package/dist/vercel-sandbox-provider.d.ts +156 -0
  206. package/dist/vercel-sandbox-provider.d.ts.map +1 -0
  207. package/dist/workbench-cli.d.ts +233 -0
  208. package/dist/workbench-cli.d.ts.map +1 -0
  209. package/dist/workspace-sync.d.ts +207 -0
  210. package/dist/workspace-sync.d.ts.map +1 -0
  211. package/dist/workspace.d.ts +92 -0
  212. package/dist/workspace.d.ts.map +1 -0
  213. package/dist/zip-draft-push.d.ts +55 -0
  214. package/dist/zip-draft-push.d.ts.map +1 -0
  215. package/package.json +14 -1
  216. package/requirements/README.md +138 -0
  217. package/requirements/skilled-proposer.in +66 -0
  218. package/requirements/skilled-proposer.lock +265 -0
  219. package/scripts/ensure-skilled-proposer-venv.mjs +5 -0
  220. package/scripts/refresh-skilled-proposer-lock.mjs +256 -0
  221. package/src/diff-comments-cli.ts +4 -0
  222. package/src/gepa-engine-dispatch.ts +42 -2
  223. package/src/gepa-python-paths.ts +6 -3
  224. package/src/help.ts +93 -23
  225. package/src/index.ts +99 -126
  226. package/src/instruction-set-gepa-launch.ts +28 -0
  227. package/src/instruction-set-loader.ts +37 -0
  228. package/src/instruction-set-manifest.ts +120 -0
  229. package/src/instruction-sets-cli.ts +306 -0
  230. package/src/model-configs-cli.ts +119 -0
  231. package/src/optimizations-promote-cli.ts +41 -0
  232. package/src/prompt-label-cli.ts +55 -0
  233. package/src/prompt-summary-table.ts +12 -0
  234. package/src/runner-instruction-set-contract.ts +64 -0
  235. package/src/scorers-cli.ts +40 -0
  236. package/src/scorers-verify-parity-bridge.ts +90 -0
  237. package/src/scorers-verify-parity.ts +564 -0
  238. package/src/skilled-proposer-launch.ts +89 -0
  239. package/src/skilled-proposer-venv-manager.mjs +757 -0
  240. package/tsconfig.json +2 -0
  241. package/vendor/orizu-gepa/src/orizu_gepa_connector/__init__.py +2 -1
  242. package/vendor/orizu-gepa/src/orizu_gepa_connector/adapter.py +8 -2
  243. package/vendor/orizu-gepa/src/orizu_gepa_connector/callbacks.py +17 -3
  244. package/vendor/orizu-gepa/src/orizu_gepa_connector/engine.py +100 -8
  245. package/vendor/orizu-gepa/src/orizu_gepa_connector/instruction_set_loader.py +52 -0
  246. package/vendor/orizu-gepa/src/orizu_gepa_connector/reflection.py +92 -12
  247. package/vendor/orizu-gepa/src/orizu_gepa_connector/runtime.py +100 -22
  248. package/vendor/orizu-gepa/src/orizu_gepa_connector/skilled_proposer_bridge.py +381 -0
  249. package/vendor/orizu-gepa/src/orizu_gepa_connector/stop_conditions.py +17 -0
  250. package/vendor/orizu-gepa/src/orizu_gepa_connector/translator.py +2 -1
  251. package/vendor/orizu-gepa-python/src/orizu_gepa/client.py +52 -7
  252. package/vendor/orizu-gepa-python/src/orizu_gepa/local_log.py +4 -1
  253. package/vendor/orizu-gepa-python/src/orizu_gepa/optimizer.py +10 -5
  254. package/vendor/orizu-gepa-python/src/orizu_gepa/reflection.py +323 -68
  255. package/vendor/orizu-gepa-python/src/orizu_gepa/runner.py +207 -13
  256. package/vendor/skills/orizu-cli/SKILL.md +2 -0
  257. package/vendor/skills/orizu-cli/references/cli-reference.md +85 -1
  258. package/vendor/skills/orizu-cli/references/migrate-existing-gepa-setup.md +303 -0
  259. package/vendor/skills/orizu-cli/references/optimization-with-gepa.md +25 -0
  260. package/vendor/skills/orizu-cli/references/prompt-control-plane.md +108 -8
@@ -0,0 +1,90 @@
1
+ /**
2
+ * The customer-side half of `orizu scorers verify-parity` (ALI-1554).
3
+ *
4
+ * Embedded as a string constant and written to a temp file at run time on
5
+ * purpose: no packaging (`files`/`vendor`) change, so there is nothing for the
6
+ * published tarball to lose. It runs in the CUSTOMER's interpreter and the
7
+ * customer's unscrubbed environment — it is their metric in their process.
8
+ *
9
+ * Contract: reads `{rows: [{row_id, row, model_output}]}` from
10
+ * ORIZU_PARITY_INPUT_PATH, calls `<module>:<function>(row, model_output)` per
11
+ * row, and writes `{scores: [{row_id, score} | {row_id, error}]}` to
12
+ * ORIZU_PARITY_OUTPUT_PATH. Exit 2 (with the reason on stderr) means the check
13
+ * could not run at all; a per-row exception is that row's error, not an abort.
14
+ */
15
+ export const PARITY_BRIDGE_SOURCE = `import json, math, os, sys, traceback
16
+
17
+
18
+ def fail(message):
19
+ sys.stderr.write(message + "\\n")
20
+ sys.exit(2)
21
+
22
+
23
+ def last_line(text):
24
+ lines = [line for line in text.strip().splitlines() if line.strip()]
25
+ return lines[-1] if lines else "unknown error"
26
+
27
+
28
+ def extract(value):
29
+ # Mirrors orizu_gepa.optimizer._score_from_scorer, minus the clamp: the
30
+ # CLI clamps both sides so the compared number is what GEPA optimizes.
31
+ # bool is deliberately NOT rejected: measured, _score_from_scorer(extra=
32
+ # {'score': True}) -> (1.0, None) and False -> (0.0, None), because bool is an
33
+ # int subclass and Python's numeric branch accepts it. An exact-match DSPy
34
+ # metric returning True must compare, not error.
35
+ if isinstance(value, (int, float)):
36
+ raw = value
37
+ elif isinstance(value, dict):
38
+ raw = value.get("score")
39
+ if raw is None and isinstance(value.get("model_response"), dict):
40
+ raw = value["model_response"].get("score")
41
+ elif hasattr(value, "score"):
42
+ # official GEPA's EvaluationResult NamedTuple and friends;
43
+ # objective_scores is deliberately ignored.
44
+ raw = value.score
45
+ else:
46
+ raise TypeError("scorer result must be a number, a dict with a numeric 'score', or an object with a numeric .score attribute")
47
+ if isinstance(raw, str):
48
+ raw = float(raw)
49
+ if not isinstance(raw, (int, float)):
50
+ raise TypeError("scorer result must include a numeric score")
51
+ raw = float(raw)
52
+ if not math.isfinite(raw):
53
+ raise ValueError("scorer result score must be finite")
54
+ return raw
55
+
56
+
57
+ target = os.environ.get("ORIZU_PARITY_ORIGINAL", "")
58
+ module_name, separator, function_name = target.partition(":")
59
+ if not separator or not module_name or not function_name:
60
+ fail("--original must be <module>:<function> (got %r)" % target)
61
+
62
+ # The customer's metric lives beside the command's cwd, not beside this
63
+ # generated bridge file, so cwd must win over the script directory.
64
+ sys.path.insert(0, os.getcwd())
65
+ try:
66
+ import importlib
67
+
68
+ module = importlib.import_module(module_name)
69
+ except Exception:
70
+ fail("Failed to import original metric module '%s': %s" % (module_name, last_line(traceback.format_exc())))
71
+
72
+ if not hasattr(module, function_name):
73
+ fail("Original metric module '%s' has no attribute '%s'" % (module_name, function_name))
74
+ metric = getattr(module, function_name)
75
+ if not callable(metric):
76
+ fail("Original metric '%s:%s' is not callable" % (module_name, function_name))
77
+
78
+ with open(os.environ["ORIZU_PARITY_INPUT_PATH"], encoding="utf-8") as handle:
79
+ payload = json.load(handle)
80
+
81
+ scores = []
82
+ for item in payload["rows"]:
83
+ try:
84
+ scores.append({"row_id": item["row_id"], "score": extract(metric(item["row"], item["model_output"]))})
85
+ except Exception:
86
+ scores.append({"row_id": item["row_id"], "error": last_line(traceback.format_exc())})
87
+
88
+ with open(os.environ["ORIZU_PARITY_OUTPUT_PATH"], "w", encoding="utf-8") as handle:
89
+ json.dump({"scores": scores}, handle)
90
+ `
@@ -0,0 +1,564 @@
1
+ import { spawnSync } from 'node:child_process'
2
+ import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+
6
+ import { extractErrorMessage } from './error-response.js'
7
+ import { getGepaPythonCommand } from './gepa-python-command.js'
8
+ import { authedFetch } from './http.js'
9
+ import { sanitizeTerminalText } from './json-response.js'
10
+ import { assertSnapshotManifestConfined, verifyRunnerDirRegistered } from './runner-dir-verify.js'
11
+ import { runnerInputPrompt } from './runner-instruction-set-contract.js'
12
+ import { PARITY_BRIDGE_SOURCE } from './scorers-verify-parity-bridge.js'
13
+
14
+ /**
15
+ * `orizu scorers verify-parity` (ALI-1554): prove a migrated Orizu scorer
16
+ * runner scores the same rows the same way the customer's original Python
17
+ * metric does, UNDER THE PAYLOAD `run-gepa` sends the runner, before the first
18
+ * optimization spends tokens on a scorer that silently disagrees.
19
+ *
20
+ * Honesty note: `run-gepa` executes runners through the vendored Python
21
+ * `run_file_contract_runner`, not this TypeScript path. What this proves is
22
+ * "the runner, under the run-gepa flat_row payload, via the CLI's file
23
+ * contract". The payload is asserted key-for-key by
24
+ * test/scorers-verify-parity.test.ts (H9) against a literal hand-derived from
25
+ * `make_scorer_runner`'s flat_row branch — NOT against a fixture shared with
26
+ * `test_flat_row_payload_shape_mirrors_score_run_exec`, so a rename on the
27
+ * Python side turns only the Python test red until the shared fixture lands
28
+ * (ALI-1556). The env allowlist is the TypeScript one.
29
+ */
30
+ export interface VerifyParityIo {
31
+ json: boolean
32
+ print: (line: string) => void
33
+ printErr: (line: string) => void
34
+ materializeRunnerVersion: (runnerVersionId: string) => Promise<{ runnerDir: string; cleanup: () => void }>
35
+ readRunnerManifest: (runnerDir: string) => { command: string[]; supports_body_kind?: string[] }
36
+ runnerSubprocessEnv: (inputPath: string, outputPath: string) => NodeJS.ProcessEnv
37
+ timeoutMs: number
38
+ maxOutputBytes: number
39
+ fetcher?: (path: string, init?: RequestInit) => Promise<Response>
40
+ /** Test seam for the pairing tests (H8); production always uses the embedded bridge. */
41
+ bridgeSource?: string
42
+ }
43
+
44
+ interface ParityMismatch { row_id: string; orizu: number; original: number }
45
+ interface ParityError { row_id: string | null; side: 'orizu' | 'original'; message: string }
46
+ interface ParityClamp { row_id: string; side: 'orizu' | 'original'; raw: number; score: number }
47
+ interface ExtractedScore { raw: number; score: number }
48
+ interface SampleRow { id: string; row: Record<string, unknown> }
49
+
50
+ const USAGE = 'Usage: orizu scorers verify-parity --scorer-version <id> --dataset-version <id> --split-set <id> [--split <name>] --outputs <outputs.jsonl> --original <module:function> [--scorer-input-contract gepa|flat_row] [--scorer-candidate-field <row-field>] [--runner-dir <dir>] [--python <cmd>] [--tolerance <float>] [--limit <n>] [--json]'
51
+
52
+ function isRecord(value: unknown): value is Record<string, unknown> {
53
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
54
+ }
55
+
56
+ /**
57
+ * LAST occurrence wins, mirroring run-gepa's scalar options
58
+ * (`gepa-engine-dispatch.ts` `translateOfficialOptions` assigns
59
+ * `connectorEnvironment[option.environment]` on every occurrence, so the final
60
+ * value overwrites earlier ones). First-wins would check parity against a
61
+ * wrapper's default while the optimization used the operator's override.
62
+ */
63
+ function option(args: readonly string[], name: string): string | null {
64
+ const index = args.lastIndexOf(name)
65
+ const value = index === -1 ? undefined : args[index + 1]
66
+ return value === undefined || value.startsWith('--') ? null : value
67
+ }
68
+
69
+ function messageOf(error: unknown): string {
70
+ return sanitizeTerminalText(error instanceof Error ? error.message : String(error))
71
+ }
72
+
73
+ /**
74
+ * The score extraction rule, mirroring `orizu_gepa.optimizer._score_from_scorer`
75
+ * so the number this command compares is the number GEPA would optimize:
76
+ * a top-level `score` beats `model_response.score`, numeric strings coerce,
77
+ * non-finite is an error, and the result clamps to [0, 1].
78
+ */
79
+ const PYTHON_FLOAT_SPECIAL = /^[+-]?(inf(inity)?|nan)$/i
80
+ const PYTHON_FLOAT_DECIMAL = /^[+-]?(\d(_?\d)*(\.(\d(_?\d)*)?)?|\.\d(_?\d)*)([eE][+-]?\d(_?\d)*)?$/
81
+
82
+ /**
83
+ * Python's `float()` grammar, not JavaScript's `Number()`. Measured 2026-08-23:
84
+ * `float` REJECTS '0x10', '0b1', '0o7', '' and '1,5' (all of which `Number`
85
+ * happily converts or turns into 0), and ACCEPTS ' 0.5 ', '1e3', '1_0', '.5',
86
+ * '5.', '+3', 'inf', 'Infinity' and 'nan'. Anything `_score_from_scorer` would
87
+ * refuse during the real optimization must be refused here too, or this gate
88
+ * certifies a scorer run-gepa cannot use. `inf`/`nan` parse and are then
89
+ * rejected by the finite check, exactly as Python does.
90
+ */
91
+ export function pythonFloat(text: string): number | null {
92
+ const trimmed = text.trim()
93
+ if (PYTHON_FLOAT_SPECIAL.test(trimmed)) return Number(trimmed.replace(/infinity/i, 'Infinity'))
94
+ if (!PYTHON_FLOAT_DECIMAL.test(trimmed)) return null
95
+ const parsed = Number(trimmed.replace(/_/g, ''))
96
+ return Number.isNaN(parsed) ? null : parsed
97
+ }
98
+
99
+ export function extractParityScore(value: unknown, label: string): ExtractedScore {
100
+ if (!isRecord(value)) throw new Error(`${label} must be a JSON object with a numeric score`)
101
+ const source = !('score' in value) && isRecord(value.model_response)
102
+ ? { ...value.model_response, ...value }
103
+ : value
104
+ let raw = source.score
105
+ // Measured: `_score_from_scorer(extra={'score': True})` -> (1.0, None) and
106
+ // `False` -> (0.0, None) — bool is an int subclass, so Python's numeric branch
107
+ // accepts it. An exact-match DSPy metric returning a bool must compare, not error.
108
+ if (typeof raw === 'boolean') raw = raw ? 1 : 0
109
+ if (typeof raw === 'string') {
110
+ const parsed = pythonFloat(raw)
111
+ raw = parsed === null ? undefined : parsed
112
+ }
113
+ if (typeof raw !== 'number') throw new Error(`${label} must include a numeric score`)
114
+ if (!Number.isFinite(raw)) throw new Error(`${label} score must be finite`)
115
+ return { raw, score: clamp(raw) }
116
+ }
117
+
118
+ function clamp(value: number): number {
119
+ return Math.max(0, Math.min(1, value))
120
+ }
121
+
122
+ interface OutputsFile { byRowId: Map<string, string> }
123
+
124
+ function readOutputs(path: string): OutputsFile {
125
+ const byRowId = new Map<string, string>()
126
+ const text = readFileSync(path, 'utf8')
127
+ let lineNumber = 0
128
+ for (const line of text.split('\n')) {
129
+ lineNumber += 1
130
+ if (!line.trim()) continue
131
+ let parsed: unknown
132
+ try {
133
+ parsed = JSON.parse(line)
134
+ } catch {
135
+ throw new Error(`--outputs line ${lineNumber} is not valid JSON`)
136
+ }
137
+ if (!isRecord(parsed) || typeof parsed.row_id !== 'string' || !parsed.row_id) {
138
+ throw new Error(`--outputs line ${lineNumber} must have a non-empty string row_id`)
139
+ }
140
+ if (typeof parsed.model_output !== 'string') {
141
+ throw new Error(`--outputs line ${lineNumber} (row ${parsed.row_id}) must have a string model_output`)
142
+ }
143
+ if (byRowId.has(parsed.row_id)) {
144
+ throw new Error(`--outputs has duplicate row_id ${parsed.row_id} on line ${lineNumber}`)
145
+ }
146
+ byRowId.set(parsed.row_id, parsed.model_output)
147
+ }
148
+ return { byRowId }
149
+ }
150
+
151
+ interface ExecContext {
152
+ prompt: { body?: string | null; bodyKind: string; providerSettings: Record<string, unknown>; promptVersionId: string; runnerVersionId: string }
153
+ scorer?: { versionId: string; metricKey: string; higherIsBetter: boolean } | null
154
+ /**
155
+ * Declared so it cannot be silently dropped: the route returns it for
156
+ * scorerVersion queries too, and `run-gepa` sends both the `instruction_set`
157
+ * payload key and ORIZU_INSTRUCTION_SET_DIR. Building that payload here is
158
+ * ALI-1556; until then an instruction-set-backed scorer is REFUSED, because
159
+ * proving parity for a payload the optimization will not send is the exact
160
+ * silent agreement this command exists to prevent.
161
+ */
162
+ instructionSet?: Record<string, unknown> | null
163
+ rows: SampleRow[]
164
+ }
165
+
166
+ /** One hour / 64 MiB: a bridge asking for more is a runaway, not a big split. */
167
+ const BRIDGE_MAX_TIMEOUT_MS = 60 * 60_000
168
+ const BRIDGE_MAX_OUTPUT_BYTES = 64 * 1024 * 1024
169
+
170
+ /**
171
+ * The Orizu side spawns the runner ONCE PER ROW, so each row gets the full
172
+ * per-row budget. The original side scores the whole sample in ONE bridge
173
+ * process, so handing it the per-row budget gives an N-row split 1/N of the
174
+ * time the Orizu side had: a 40-row split with an LLM-judge metric is SIGTERMed
175
+ * before it can write output.json and every row's work is lost (round-2 review).
176
+ * Scale by the sample length, bounded so a huge split cannot ask for forever.
177
+ */
178
+ export function bridgeBudget(
179
+ perRowTimeoutMs: number,
180
+ perRowMaxOutputBytes: number,
181
+ sampleLength: number
182
+ ): { timeoutMs: number; maxOutputBytes: number } {
183
+ // Never 0: spawnSync reads a 0 timeout/maxBuffer as "kill immediately".
184
+ const rows = Math.max(1, sampleLength)
185
+ return {
186
+ timeoutMs: Math.min(perRowTimeoutMs * rows, BRIDGE_MAX_TIMEOUT_MS),
187
+ maxOutputBytes: Math.min(perRowMaxOutputBytes * rows, BRIDGE_MAX_OUTPUT_BYTES),
188
+ }
189
+ }
190
+
191
+ const SCORER_INPUT_CONTRACTS = ['gepa', 'flat_row'] as const
192
+ const DEFAULT_CANDIDATE_OUTPUT_FIELD = 'model_output'
193
+
194
+ /**
195
+ * Resolve the scorer input contract and candidate-output row field with the
196
+ * SAME precedence `run-gepa` uses (`orizu_gepa.runner.resolve_scorer_input_contract`):
197
+ * explicit flag > runner manifest (`scorer_input_contract`, `candidate_output_field`)
198
+ * > defaults (`gepa`, `model_output`). Resolution is by PRESENCE, not truthiness,
199
+ * so a blank manifest value fails validation instead of silently defaulting.
200
+ *
201
+ * Parity may only be claimed for the payload run-gepa will actually send, so a
202
+ * contract that resolves to anything but `flat_row` is refused here rather than
203
+ * assumed: proving parity on a shape the optimization never uses is the exact
204
+ * silent-agreement failure this command exists to prevent.
205
+ */
206
+ export function resolveScorerInputContract(
207
+ manifest: Record<string, unknown>,
208
+ flagContract: string | null,
209
+ flagField: string | null
210
+ ): { contract: string; field: string } {
211
+ const manifestContract = manifest.scorer_input_contract
212
+ const contract = flagContract !== null ? flagContract : manifestContract !== undefined ? manifestContract : 'gepa'
213
+ if (typeof contract !== 'string' || !(SCORER_INPUT_CONTRACTS as readonly string[]).includes(contract)) {
214
+ throw new Error(`Unknown scorer input contract ${JSON.stringify(contract)}; expected one of: ${SCORER_INPUT_CONTRACTS.join(', ')}`)
215
+ }
216
+ if (contract !== 'flat_row') {
217
+ throw new Error(
218
+ `The scorer runner's input contract resolves to '${contract}', but verify-parity can only prove parity ` +
219
+ "under the 'flat_row' payload. Declare \"scorer_input_contract\": \"flat_row\" in the runner manifest " +
220
+ '(or pass --scorer-input-contract flat_row), and pass the same --scorer-input-contract flat_row to ' +
221
+ '`orizu optimizations run-gepa` so the payload proven here is the payload the optimization sends.'
222
+ )
223
+ }
224
+ const explicitField = flagField !== null ? flagField : manifest.candidate_output_field
225
+ const field = explicitField !== undefined ? explicitField : DEFAULT_CANDIDATE_OUTPUT_FIELD
226
+ if (typeof field !== 'string' || field.trim() === '') {
227
+ throw new Error(
228
+ `candidate output field must be a non-empty string (got ${JSON.stringify(field)} via ` +
229
+ '--scorer-candidate-field / manifest candidate_output_field)'
230
+ )
231
+ }
232
+ if (field === 'candidate_error') {
233
+ throw new Error("candidate output field 'candidate_error' is reserved for the adapter's error companion; choose a different row field")
234
+ }
235
+ return { contract, field }
236
+ }
237
+
238
+ /** The runner manifest's raw JSON, for the two keys `readRunnerManifest` does not surface. */
239
+ function readManifestExtras(runnerDir: string): Record<string, unknown> {
240
+ const parsed = JSON.parse(readFileSync(join(runnerDir, 'manifest.json'), 'utf8')) as unknown
241
+ return isRecord(parsed) ? parsed : {}
242
+ }
243
+
244
+ /**
245
+ * The flat_row payload EXACTLY as `run-gepa` builds it
246
+ * (`orizu_gepa.runner.make_scorer_runner`, flat_row branch): the dataset row
247
+ * with the candidate output injected under the candidate field, `candidate_error`
248
+ * reserved, plus the top-level score-run companions. Not the bare `runners exec`
249
+ * shape, which reads `model_output` from a stale dataset field.
250
+ */
251
+ function flatRowInput(context: ExecContext, entry: SampleRow, candidate: string, candidateField: string): Record<string, unknown> {
252
+ const scorerVersionId = context.scorer?.versionId ?? context.prompt.promptVersionId
253
+ return {
254
+ model_output: candidate,
255
+ subject: { type: 'scorer_row', row_id: entry.id, scorer_version_id: scorerVersionId, prompt_version_id: context.prompt.promptVersionId },
256
+ scorer: { version_id: scorerVersionId, metric_key: context.scorer?.metricKey ?? 'score', higher_is_better: context.scorer?.higherIsBetter ?? true },
257
+ gepa: { candidate_id: 'verify-parity', candidate_raw_response: null, candidate_error: null },
258
+ row: { ...entry.row, [candidateField]: candidate, candidate_error: null },
259
+ prompt: runnerInputPrompt(context.prompt),
260
+ prompt_version_id: context.prompt.promptVersionId,
261
+ runner_version_id: context.prompt.runnerVersionId,
262
+ run_id: null,
263
+ }
264
+ }
265
+
266
+ export async function verifyScorerParityCommand(args: string[], io: VerifyParityIo): Promise<number> {
267
+ const tolerance = Number(option(args, '--tolerance') ?? '0')
268
+ const clamped: ParityClamp[] = []
269
+ const mismatches: ParityMismatch[] = []
270
+ const errors: ParityError[] = []
271
+ // Resolved from the runner manifest once it is readable; reported either way so
272
+ // the operator can see which payload parity was (or was not) proven under.
273
+ let resolved: { contract: string; field: string } | null = null
274
+
275
+ const fail = (message: string): number => {
276
+ io.printErr(sanitizeTerminalText(message))
277
+ if (io.json) {
278
+ // Carry the evidence already collected: row errors gathered before the
279
+ // failure are exactly what the agent has to fix next, and dropping them
280
+ // leaves an empty `errors` behind an opaque "could not run".
281
+ io.print(JSON.stringify({
282
+ parity: false, compared: 0, mismatches: [], errors, tolerance, clamped,
283
+ scorer_input_contract: resolved?.contract ?? null,
284
+ candidate_output_field: resolved?.field ?? null,
285
+ error: sanitizeTerminalText(message),
286
+ }))
287
+ }
288
+ return 2
289
+ }
290
+
291
+ let python: string
292
+ let rest: string[]
293
+ try {
294
+ const pythonCommand = getGepaPythonCommand(args, process.env)
295
+ python = pythonCommand.python
296
+ rest = pythonCommand.args
297
+ } catch (error) {
298
+ return fail(messageOf(error))
299
+ }
300
+
301
+ const scorerVersion = option(rest, '--scorer-version')
302
+ const datasetVersion = option(rest, '--dataset-version')
303
+ const splitSet = option(rest, '--split-set')
304
+ const split = option(rest, '--split') ?? 'validation'
305
+ const outputsPath = option(rest, '--outputs')
306
+ const original = option(rest, '--original')
307
+ const contractFlag = option(rest, '--scorer-input-contract')
308
+ const candidateFieldFlag = option(rest, '--scorer-candidate-field')
309
+ const runnerDirArg = option(rest, '--runner-dir')
310
+ const limitArg = option(rest, '--limit')
311
+ if (!scorerVersion || !datasetVersion || !splitSet || !outputsPath || !original) return fail(USAGE)
312
+ if (!Number.isFinite(tolerance) || tolerance < 0) return fail('--tolerance must be a finite number >= 0')
313
+ const limit = limitArg === null ? null : Number(limitArg)
314
+ if (limit !== null && (!Number.isInteger(limit) || limit < 1)) return fail('--limit must be a positive integer')
315
+
316
+ let outputs: OutputsFile
317
+ try {
318
+ outputs = readOutputs(outputsPath)
319
+ } catch (error) {
320
+ return fail(messageOf(error))
321
+ }
322
+
323
+ const fetcher = io.fetcher ?? authedFetch
324
+ const query = new URLSearchParams({ scorerVersion, datasetVersion, splitSet, split })
325
+ let contextResponse: Response
326
+ try {
327
+ // A transport rejection (server unreachable) is a check that COULD NOT RUN,
328
+ // not a mismatch: uncaught it escapes the command, exits 1 and prints no
329
+ // --json failure report at all.
330
+ contextResponse = await fetcher(`/api/cli/runners/exec-context?${query.toString()}`)
331
+ } catch (error) {
332
+ return fail(`Failed to reach the server for the runner execution context: ${messageOf(error)}`)
333
+ }
334
+ if (!contextResponse.ok) {
335
+ return fail(`Failed to fetch runner execution context: ${await extractErrorMessage(contextResponse)}`)
336
+ }
337
+ let context: ExecContext
338
+ try {
339
+ context = await contextResponse.json() as ExecContext
340
+ } catch (error) {
341
+ return fail(`Runner exec context was not valid JSON: ${messageOf(error)}`)
342
+ }
343
+ if (!Array.isArray(context.rows)) return fail('Runner exec context did not return rows')
344
+ if (context.instructionSet !== undefined && context.instructionSet !== null) {
345
+ return fail(
346
+ 'This scorer is backed by an instruction set, and verify-parity cannot yet prove parity for one: ' +
347
+ 'run-gepa sends the runner an `instruction_set` payload key and an ORIZU_INSTRUCTION_SET_DIR that ' +
348
+ 'this command does not build (ALI-1556). Refusing rather than proving parity for a payload the ' +
349
+ 'optimization will not send.'
350
+ )
351
+ }
352
+ const sample = limit === null ? context.rows : context.rows.slice(0, limit)
353
+ if (sample.length === 0) {
354
+ return fail('The selected split has no rows, so parity cannot be proven. Pick a split with rows.')
355
+ }
356
+
357
+ const sampleIds = new Set(sample.map(entry => entry.id))
358
+ // Outputs must COVER the limited sample, but membership is checked against the
359
+ // whole partition: `--limit` is a smoke check over the head of the same
360
+ // outputs.jsonl the recipe builds for the full split, not a reason to hand-trim it.
361
+ const partitionIds = new Set(context.rows.map(entry => entry.id))
362
+ for (const entry of sample) {
363
+ if (!outputs.byRowId.has(entry.id)) return fail(`--outputs has no model_output for sample row ${entry.id}`)
364
+ }
365
+ for (const rowId of outputs.byRowId.keys()) {
366
+ if (!partitionIds.has(rowId)) return fail(`--outputs row_id ${rowId} is not in the ${split} rows of this split set`)
367
+ }
368
+
369
+ const runnerVersionId = context.prompt?.runnerVersionId
370
+ if (!runnerVersionId) return fail('Runner exec context did not resolve a runner version id for this scorer')
371
+
372
+ let runner: { runnerDir: string; cleanup: () => void }
373
+ try {
374
+ runner = runnerDirArg
375
+ ? await (async () => {
376
+ const verified = await verifyRunnerDirRegistered({ runnerVersionId, dir: runnerDirArg, flag: '--runner-dir', fetcher: io.fetcher })
377
+ return { runnerDir: verified.snapshotDir, cleanup: verified.cleanup }
378
+ })()
379
+ : await io.materializeRunnerVersion(runnerVersionId)
380
+ } catch (error) {
381
+ return fail(messageOf(error))
382
+ }
383
+
384
+ const orizuScores = new Map<string, ExtractedScore>()
385
+ // Resolved from the runner manifest below; the ORIGINAL side must see the row
386
+ // injected at the SAME field, or the two sides read different candidates.
387
+ let candidateField = DEFAULT_CANDIDATE_OUTPUT_FIELD
388
+ try {
389
+ let manifest: { command: string[] }
390
+ try {
391
+ assertSnapshotManifestConfined(runner.runnerDir, runnerDirArg ? '--runner-dir' : '--runner-version')
392
+ manifest = io.readRunnerManifest(runner.runnerDir)
393
+ resolved = resolveScorerInputContract(readManifestExtras(runner.runnerDir), contractFlag, candidateFieldFlag)
394
+ candidateField = resolved.field
395
+ // run-gepa overwrites a colliding key too (`{**source_row, field: ...}`,
396
+ // runner.py:449-458), but the CUSTOMER'S ORIGINAL run never did: their
397
+ // metric read the row's own value. Injecting over it hands both sides the
398
+ // candidate, they agree, and the agreement says nothing about the run being
399
+ // migrated. Refuse and make the operator pick a free field.
400
+ const collision = sample.find(entry => Object.prototype.hasOwnProperty.call(entry.row, candidateField))
401
+ if (collision) {
402
+ throw new Error(
403
+ `Dataset row ${collision.id} already has a '${candidateField}' field, so injecting the candidate ` +
404
+ 'there would replace a value the original metric reads. run-gepa overwrites it silently, but the ' +
405
+ "customer's own run never did, so parity proven this way would be meaningless. Pass " +
406
+ '--scorer-candidate-field <a row field that does not exist> (and the same value to run-gepa).'
407
+ )
408
+ }
409
+ } catch (error) {
410
+ return fail(messageOf(error))
411
+ }
412
+
413
+ for (const entry of sample) {
414
+ const candidate = outputs.byRowId.get(entry.id)!
415
+ const tempDir = mkdtempSync(join(tmpdir(), 'orizu-parity-runner-'))
416
+ const inputPath = join(tempDir, 'input.json')
417
+ const outputPath = join(tempDir, 'output.json')
418
+ try {
419
+ writeFileSync(inputPath, JSON.stringify(flatRowInput(context, entry, candidate, candidateField)))
420
+ const result = spawnSync(manifest.command[0]!, manifest.command.slice(1), {
421
+ cwd: runner.runnerDir,
422
+ env: io.runnerSubprocessEnv(inputPath, outputPath),
423
+ encoding: 'utf8',
424
+ maxBuffer: io.maxOutputBytes,
425
+ timeout: io.timeoutMs,
426
+ })
427
+ if (result.error) throw result.error
428
+ if (result.status !== 0) {
429
+ throw new Error(`runner exited with code ${result.status}: ${(result.stderr || result.stdout || '').slice(0, 500)}`)
430
+ }
431
+ if (statSync(outputPath).size > io.maxOutputBytes) throw new Error('runner output.json exceeds the output size limit')
432
+ const runnerOutput = JSON.parse(readFileSync(outputPath, 'utf8')) as unknown
433
+ // `run_file_contract_runner` preserves output.json `error` as
434
+ // RunnerCallResult.error and run-gepa records it on the RowEvaluation, so a
435
+ // seed answering `{score, error}` on every row is rejected as an all-error
436
+ // scorer. Accepting the score here would certify exactly that scorer.
437
+ const reportedError = isRecord(runnerOutput) ? runnerOutput.error : undefined
438
+ if (typeof reportedError === 'string' && reportedError.trim() !== '') {
439
+ throw new Error(`runner reported an error alongside its score: ${reportedError}`)
440
+ }
441
+ const extracted = extractParityScore(runnerOutput, 'Runner output.json')
442
+ orizuScores.set(entry.id, extracted)
443
+ if (extracted.raw !== extracted.score) clamped.push({ row_id: entry.id, side: 'orizu', raw: extracted.raw, score: extracted.score })
444
+ } catch (error) {
445
+ errors.push({ row_id: entry.id, side: 'orizu', message: messageOf(error) })
446
+ } finally {
447
+ rmSync(tempDir, { recursive: true, force: true })
448
+ }
449
+ }
450
+ } finally {
451
+ runner.cleanup()
452
+ }
453
+
454
+ const bridgeDir = mkdtempSync(join(tmpdir(), 'orizu-parity-bridge-'))
455
+ const originalScores = new Map<string, ExtractedScore>()
456
+ try {
457
+ const bridgePath = join(bridgeDir, 'orizu_parity_bridge.py')
458
+ const bridgeInput = join(bridgeDir, 'input.json')
459
+ const bridgeOutput = join(bridgeDir, 'output.json')
460
+ writeFileSync(bridgePath, io.bridgeSource ?? PARITY_BRIDGE_SOURCE)
461
+ writeFileSync(bridgeInput, JSON.stringify({
462
+ rows: sample.map(entry => {
463
+ const candidate = outputs.byRowId.get(entry.id)!
464
+ return { row_id: entry.id, row: { ...entry.row, [candidateField]: candidate, candidate_error: null }, model_output: candidate }
465
+ }),
466
+ }))
467
+ // The customer's full environment, inherited UNSCRUBBED: it is their
468
+ // metric in their process, exactly as their own script would run it.
469
+ // (The runner side stays scrubbed by runnerSubprocessEnv.)
470
+ const budget = bridgeBudget(io.timeoutMs, io.maxOutputBytes, sample.length)
471
+ const result = spawnSync(python, [bridgePath], {
472
+ cwd: process.cwd(),
473
+ env: { ...process.env, ORIZU_PARITY_INPUT_PATH: bridgeInput, ORIZU_PARITY_OUTPUT_PATH: bridgeOutput, ORIZU_PARITY_ORIGINAL: original },
474
+ encoding: 'utf8',
475
+ maxBuffer: budget.maxOutputBytes,
476
+ timeout: budget.timeoutMs,
477
+ })
478
+ if (result.error) return fail(`Failed to run the original metric with '${python}': ${messageOf(result.error)}`)
479
+ if (result.status !== 0) {
480
+ return fail(`The original metric could not run: ${(result.stderr || result.stdout || '').trim().slice(0, 1000)}`)
481
+ }
482
+ let bridgeScores: unknown
483
+ try {
484
+ bridgeScores = (JSON.parse(readFileSync(bridgeOutput, 'utf8')) as { scores?: unknown }).scores
485
+ } catch (error) {
486
+ return fail(`The original metric produced no readable result: ${messageOf(error)}`)
487
+ }
488
+ if (!Array.isArray(bridgeScores)) return fail('The original metric produced no scores array')
489
+
490
+ for (const item of bridgeScores) {
491
+ if (!isRecord(item) || typeof item.row_id !== 'string') {
492
+ errors.push({ row_id: null, side: 'original', message: 'original result entry has no string row_id' })
493
+ continue
494
+ }
495
+ if (!sampleIds.has(item.row_id)) {
496
+ errors.push({ row_id: item.row_id, side: 'original', message: `original scored row_id ${item.row_id}, which is not in the sample` })
497
+ continue
498
+ }
499
+ if (typeof item.error === 'string') {
500
+ errors.push({ row_id: item.row_id, side: 'original', message: sanitizeTerminalText(item.error) })
501
+ continue
502
+ }
503
+ try {
504
+ const extracted = extractParityScore({ score: item.score }, `Original metric result for row ${item.row_id}`)
505
+ originalScores.set(item.row_id, extracted)
506
+ if (extracted.raw !== extracted.score) clamped.push({ row_id: item.row_id, side: 'original', raw: extracted.raw, score: extracted.score })
507
+ } catch (error) {
508
+ errors.push({ row_id: item.row_id, side: 'original', message: messageOf(error) })
509
+ }
510
+ }
511
+ } finally {
512
+ rmSync(bridgeDir, { recursive: true, force: true })
513
+ }
514
+
515
+ // Pairing is by row_id on BOTH sides, never positional: the bridge is a
516
+ // separate process whose result order is not this loop's order.
517
+ let compared = 0
518
+ for (const entry of sample) {
519
+ const orizu = orizuScores.get(entry.id)
520
+ const originalScore = originalScores.get(entry.id)
521
+ if (!orizu) continue
522
+ if (!originalScore) {
523
+ if (!errors.some(item => item.row_id === entry.id && item.side === 'original')) {
524
+ errors.push({ row_id: entry.id, side: 'original', message: 'the original metric returned no score for this row' })
525
+ }
526
+ continue
527
+ }
528
+ compared += 1
529
+ if (Math.abs(orizu.score - originalScore.score) > tolerance) {
530
+ mismatches.push({ row_id: entry.id, orizu: orizu.score, original: originalScore.score })
531
+ }
532
+ }
533
+
534
+ // `compared >= 1` is NOT a conjunct here: the empty-split refusal guarantees
535
+ // sample.length >= 1 and every row that fails to pair pushes an error, so
536
+ // `compared === 0` already implies `errors.length > 0`. No test can reach a
537
+ // zero-compared, zero-error state, so asserting it would be an unkillable check.
538
+ const clean = mismatches.length === 0 && errors.length === 0
539
+ const total = context.rows.length
540
+ // A limited run never looked at rows compared+1..total, so a mismatch there is
541
+ // indistinguishable from a proof unless the two results are named differently.
542
+ const parity = clean && compared === total
543
+ if (io.json) {
544
+ io.print(JSON.stringify({
545
+ parity, compared, scope: { compared, total }, mismatches, errors, tolerance, clamped,
546
+ scorer_input_contract: resolved?.contract ?? null,
547
+ candidate_output_field: resolved?.field ?? null,
548
+ }))
549
+ } else {
550
+ io.print(parity
551
+ ? `Parity proven on all ${total} rows (tolerance ${tolerance}).`
552
+ : clean
553
+ ? `Smoke check passed on ${compared} of ${total} rows — rerun without --limit to prove parity.`
554
+ : `Parity NOT proven: ${compared} of ${total} rows compared, ${mismatches.length} mismatches, ${errors.length} errors (tolerance ${tolerance}).`)
555
+ for (const item of mismatches) io.print(`mismatch ${sanitizeTerminalText(item.row_id)}: orizu ${item.orizu} vs original ${item.original}`)
556
+ for (const item of errors) io.print(`error ${sanitizeTerminalText(item.row_id ?? '-')} (${item.side}): ${item.message}`)
557
+ for (const item of clamped) io.print(`clamped ${sanitizeTerminalText(item.row_id)} (${item.side}): raw ${item.raw} -> ${item.score}`)
558
+ }
559
+ // Exit 0 covers a clean full proof AND a clean smoke check; the report is what
560
+ // distinguishes them, so a limited run cannot be mistaken for a proof by a
561
+ // reader while still being usable as a fast pre-flight. Exit 2 is reserved for
562
+ // the fail() paths above — checks that could not run at all.
563
+ return clean ? 0 : 1
564
+ }