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.
- package/dist/archive-artifacts-cli.d.ts +50 -0
- package/dist/archive-artifacts-cli.d.ts.map +1 -0
- package/dist/artifact-archive.d.ts +48 -0
- package/dist/artifact-archive.d.ts.map +1 -0
- package/dist/artifact-pull.d.ts +22 -0
- package/dist/artifact-pull.d.ts.map +1 -0
- package/dist/banner.d.ts +3 -0
- package/dist/banner.d.ts.map +1 -0
- package/dist/connectors-cli.d.ts +31 -0
- package/dist/connectors-cli.d.ts.map +1 -0
- package/dist/credentials.d.ts +46 -0
- package/dist/credentials.d.ts.map +1 -0
- package/dist/csv.d.ts +49 -0
- package/dist/csv.d.ts.map +1 -0
- package/dist/dataset-download.d.ts +6 -0
- package/dist/dataset-download.d.ts.map +1 -0
- package/dist/dataset-edit-rows.d.ts +10 -0
- package/dist/dataset-edit-rows.d.ts.map +1 -0
- package/dist/dataset-upload-snapshot.d.ts +13 -0
- package/dist/dataset-upload-snapshot.d.ts.map +1 -0
- package/dist/daytona-slice-rehearsal.d.ts +89 -0
- package/dist/daytona-slice-rehearsal.d.ts.map +1 -0
- package/dist/daytona-slice.d.ts +106 -0
- package/dist/daytona-slice.d.ts.map +1 -0
- package/dist/deploy-key-merge-core.d.ts +121 -0
- package/dist/deploy-key-merge-core.d.ts.map +1 -0
- package/dist/diff-comments-cli.d.ts +12 -0
- package/dist/diff-comments-cli.d.ts.map +1 -0
- package/dist/diff-comments-cli.js +3 -0
- package/dist/egress-allowlist-cli.d.ts +23 -0
- package/dist/egress-allowlist-cli.d.ts.map +1 -0
- package/dist/egress-policy.d.ts +108 -0
- package/dist/egress-policy.d.ts.map +1 -0
- package/dist/error-response.d.ts +9 -0
- package/dist/error-response.d.ts.map +1 -0
- package/dist/file-parser.d.ts +5 -0
- package/dist/file-parser.d.ts.map +1 -0
- package/dist/gepa-engine-dispatch.d.ts +9 -0
- package/dist/gepa-engine-dispatch.d.ts.map +1 -0
- package/dist/gepa-engine-dispatch.js +48 -2
- package/dist/gepa-python-command.d.ts +6 -0
- package/dist/gepa-python-command.d.ts.map +1 -0
- package/dist/gepa-python-paths.d.ts +6 -0
- package/dist/gepa-python-paths.d.ts.map +1 -0
- package/dist/gepa-python-paths.js +6 -3
- package/dist/git-credential.d.ts +69 -0
- package/dist/git-credential.d.ts.map +1 -0
- package/dist/git-recovery-environment.d.ts +31 -0
- package/dist/git-recovery-environment.d.ts.map +1 -0
- package/dist/github-setup.d.ts +122 -0
- package/dist/github-setup.d.ts.map +1 -0
- package/dist/global-flags.d.ts +12 -0
- package/dist/global-flags.d.ts.map +1 -0
- package/dist/help.d.ts +46 -0
- package/dist/help.d.ts.map +1 -0
- package/dist/help.js +91 -23
- package/dist/hosted-boot.d.ts +273 -0
- package/dist/hosted-boot.d.ts.map +1 -0
- package/dist/hosted-bootstrap-rehearsal.d.ts +86 -0
- package/dist/hosted-bootstrap-rehearsal.d.ts.map +1 -0
- package/dist/hosted-bootstrap.d.ts +188 -0
- package/dist/hosted-bootstrap.d.ts.map +1 -0
- package/dist/hosted-broker-server.d.ts +50 -0
- package/dist/hosted-broker-server.d.ts.map +1 -0
- package/dist/hosted-git-http-server.d.ts +36 -0
- package/dist/hosted-git-http-server.d.ts.map +1 -0
- package/dist/hosted-harness-claude.d.ts +195 -0
- package/dist/hosted-harness-claude.d.ts.map +1 -0
- package/dist/hosted-harness-opencode.d.ts +227 -0
- package/dist/hosted-harness-opencode.d.ts.map +1 -0
- package/dist/hosted-harness.d.ts +118 -0
- package/dist/hosted-harness.d.ts.map +1 -0
- package/dist/hosted-harvest.d.ts +68 -0
- package/dist/hosted-harvest.d.ts.map +1 -0
- package/dist/hosted-headless.d.ts +72 -0
- package/dist/hosted-headless.d.ts.map +1 -0
- package/dist/hosted-loop-lifecycle.d.ts +206 -0
- package/dist/hosted-loop-lifecycle.d.ts.map +1 -0
- package/dist/hosted-loop.d.ts +170 -0
- package/dist/hosted-loop.d.ts.map +1 -0
- package/dist/hosted-run-event-sink.d.ts +222 -0
- package/dist/hosted-run-event-sink.d.ts.map +1 -0
- package/dist/hosted-runtime-assets.d.ts +186 -0
- package/dist/hosted-runtime-assets.d.ts.map +1 -0
- package/dist/hosted-session-cli.d.ts +258 -0
- package/dist/hosted-session-cli.d.ts.map +1 -0
- package/dist/hosted-skill-staging.d.ts +73 -0
- package/dist/hosted-skill-staging.d.ts.map +1 -0
- package/dist/http.d.ts +8 -0
- package/dist/http.d.ts.map +1 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +88 -92
- package/dist/instruction-set-gepa-launch.d.ts +3 -0
- package/dist/instruction-set-gepa-launch.d.ts.map +1 -0
- package/dist/instruction-set-gepa-launch.js +15 -0
- package/dist/instruction-set-loader.d.ts +6 -0
- package/dist/instruction-set-loader.d.ts.map +1 -0
- package/dist/instruction-set-loader.js +59 -0
- package/dist/instruction-set-manifest.d.ts +14 -0
- package/dist/instruction-set-manifest.d.ts.map +1 -0
- package/dist/instruction-set-manifest.js +162 -0
- package/dist/instruction-sets-cli.d.ts +40 -0
- package/dist/instruction-sets-cli.d.ts.map +1 -0
- package/dist/instruction-sets-cli.js +361 -0
- package/dist/json-response.d.ts +4 -0
- package/dist/json-response.d.ts.map +1 -0
- package/dist/jsonl-stream.d.ts +8 -0
- package/dist/jsonl-stream.d.ts.map +1 -0
- package/dist/kill-switch-cli.d.ts +30 -0
- package/dist/kill-switch-cli.d.ts.map +1 -0
- package/dist/loopback-serve.d.ts +25 -0
- package/dist/loopback-serve.d.ts.map +1 -0
- package/dist/manifests-cli.d.ts +22 -0
- package/dist/manifests-cli.d.ts.map +1 -0
- package/dist/markdown-report-input.d.ts +7 -0
- package/dist/markdown-report-input.d.ts.map +1 -0
- package/dist/merge-job.d.ts +155 -0
- package/dist/merge-job.d.ts.map +1 -0
- package/dist/model-configs-cli.d.ts +19 -0
- package/dist/model-configs-cli.d.ts.map +1 -0
- package/dist/model-configs-cli.js +104 -0
- package/dist/optimization-export-cli.d.ts +19 -0
- package/dist/optimization-export-cli.d.ts.map +1 -0
- package/dist/optimizations-list-cli.d.ts +20 -0
- package/dist/optimizations-list-cli.d.ts.map +1 -0
- package/dist/optimizations-promote-cli.d.ts +8 -0
- package/dist/optimizations-promote-cli.d.ts.map +1 -0
- package/dist/optimizations-promote-cli.js +34 -0
- package/dist/option-validation.d.ts +15 -0
- package/dist/option-validation.d.ts.map +1 -0
- package/dist/preview-runtime.d.ts +63 -0
- package/dist/preview-runtime.d.ts.map +1 -0
- package/dist/prompt-draft-push.d.ts +60 -0
- package/dist/prompt-draft-push.d.ts.map +1 -0
- package/dist/prompt-label-cli.d.ts +11 -0
- package/dist/prompt-label-cli.d.ts.map +1 -0
- package/dist/prompt-label-cli.js +29 -0
- package/dist/prompt-length-wire.d.ts +28 -0
- package/dist/prompt-length-wire.d.ts.map +1 -0
- package/dist/prompt-report-cli.d.ts +10 -0
- package/dist/prompt-report-cli.d.ts.map +1 -0
- package/dist/prompt-summary-table.d.ts +15 -0
- package/dist/prompt-summary-table.d.ts.map +1 -0
- package/dist/prompt-summary-table.js +6 -0
- package/dist/registration-tag-warning.d.ts +32 -0
- package/dist/registration-tag-warning.d.ts.map +1 -0
- package/dist/report-comments-cli.d.ts +16 -0
- package/dist/report-comments-cli.d.ts.map +1 -0
- package/dist/report-markdown-blankness.d.ts +2 -0
- package/dist/report-markdown-blankness.d.ts.map +1 -0
- package/dist/report-replacement-warning.d.ts +17 -0
- package/dist/report-replacement-warning.d.ts.map +1 -0
- package/dist/repository-backup-core.d.ts +225 -0
- package/dist/repository-backup-core.d.ts.map +1 -0
- package/dist/repository-recovery-manifest.d.ts +83 -0
- package/dist/repository-recovery-manifest.d.ts.map +1 -0
- package/dist/run-event-digest.d.ts +74 -0
- package/dist/run-event-digest.d.ts.map +1 -0
- package/dist/runner-dir-verify.d.ts +66 -0
- package/dist/runner-dir-verify.d.ts.map +1 -0
- package/dist/runner-instruction-set-contract.d.ts +24 -0
- package/dist/runner-instruction-set-contract.d.ts.map +1 -0
- package/dist/runner-instruction-set-contract.js +50 -0
- package/dist/sandbox-hygiene.d.ts +45 -0
- package/dist/sandbox-hygiene.d.ts.map +1 -0
- package/dist/sandbox-provider.d.ts +193 -0
- package/dist/sandbox-provider.d.ts.map +1 -0
- package/dist/scorer-draft-push.d.ts +50 -0
- package/dist/scorer-draft-push.d.ts.map +1 -0
- package/dist/scorers-cli.d.ts +20 -0
- package/dist/scorers-cli.d.ts.map +1 -0
- package/dist/scorers-cli.js +22 -0
- package/dist/scorers-verify-parity-bridge.d.ts +16 -0
- package/dist/scorers-verify-parity-bridge.d.ts.map +1 -0
- package/dist/scorers-verify-parity-bridge.js +90 -0
- package/dist/scorers-verify-parity.d.ts +81 -0
- package/dist/scorers-verify-parity.d.ts.map +1 -0
- package/dist/scorers-verify-parity.js +502 -0
- package/dist/scores-accept-cli.d.ts +21 -0
- package/dist/scores-accept-cli.d.ts.map +1 -0
- package/dist/secret-redaction.d.ts +47 -0
- package/dist/secret-redaction.d.ts.map +1 -0
- package/dist/session-timeline.d.ts +54 -0
- package/dist/session-timeline.d.ts.map +1 -0
- package/dist/setup-prompt.d.ts +5 -0
- package/dist/setup-prompt.d.ts.map +1 -0
- package/dist/skill-installer.d.ts +68 -0
- package/dist/skill-installer.d.ts.map +1 -0
- package/dist/skilled-proposer-launch.d.ts +7 -0
- package/dist/skilled-proposer-launch.d.ts.map +1 -0
- package/dist/skilled-proposer-launch.js +60 -0
- package/dist/smoke.d.ts +102 -0
- package/dist/smoke.d.ts.map +1 -0
- package/dist/start-intent.d.ts +12 -0
- package/dist/start-intent.d.ts.map +1 -0
- package/dist/task-assignment-manifest.d.ts +15 -0
- package/dist/task-assignment-manifest.d.ts.map +1 -0
- package/dist/task-create-error.d.ts +18 -0
- package/dist/task-create-error.d.ts.map +1 -0
- package/dist/team-connectors-cli.d.ts +29 -0
- package/dist/team-connectors-cli.d.ts.map +1 -0
- package/dist/types.d.ts +39 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/vercel-sandbox-provider.d.ts +156 -0
- package/dist/vercel-sandbox-provider.d.ts.map +1 -0
- package/dist/workbench-cli.d.ts +233 -0
- package/dist/workbench-cli.d.ts.map +1 -0
- package/dist/workspace-sync.d.ts +207 -0
- package/dist/workspace-sync.d.ts.map +1 -0
- package/dist/workspace.d.ts +92 -0
- package/dist/workspace.d.ts.map +1 -0
- package/dist/zip-draft-push.d.ts +55 -0
- package/dist/zip-draft-push.d.ts.map +1 -0
- package/package.json +14 -1
- package/requirements/README.md +138 -0
- package/requirements/skilled-proposer.in +66 -0
- package/requirements/skilled-proposer.lock +265 -0
- package/scripts/ensure-skilled-proposer-venv.mjs +5 -0
- package/scripts/refresh-skilled-proposer-lock.mjs +256 -0
- package/src/diff-comments-cli.ts +4 -0
- package/src/gepa-engine-dispatch.ts +42 -2
- package/src/gepa-python-paths.ts +6 -3
- package/src/help.ts +93 -23
- package/src/index.ts +99 -126
- package/src/instruction-set-gepa-launch.ts +28 -0
- package/src/instruction-set-loader.ts +37 -0
- package/src/instruction-set-manifest.ts +120 -0
- package/src/instruction-sets-cli.ts +306 -0
- package/src/model-configs-cli.ts +119 -0
- package/src/optimizations-promote-cli.ts +41 -0
- package/src/prompt-label-cli.ts +55 -0
- package/src/prompt-summary-table.ts +12 -0
- package/src/runner-instruction-set-contract.ts +64 -0
- package/src/scorers-cli.ts +40 -0
- package/src/scorers-verify-parity-bridge.ts +90 -0
- package/src/scorers-verify-parity.ts +564 -0
- package/src/skilled-proposer-launch.ts +89 -0
- package/src/skilled-proposer-venv-manager.mjs +757 -0
- package/tsconfig.json +2 -0
- package/vendor/orizu-gepa/src/orizu_gepa_connector/__init__.py +2 -1
- package/vendor/orizu-gepa/src/orizu_gepa_connector/adapter.py +8 -2
- package/vendor/orizu-gepa/src/orizu_gepa_connector/callbacks.py +17 -3
- package/vendor/orizu-gepa/src/orizu_gepa_connector/engine.py +100 -8
- package/vendor/orizu-gepa/src/orizu_gepa_connector/instruction_set_loader.py +52 -0
- package/vendor/orizu-gepa/src/orizu_gepa_connector/reflection.py +92 -12
- package/vendor/orizu-gepa/src/orizu_gepa_connector/runtime.py +100 -22
- package/vendor/orizu-gepa/src/orizu_gepa_connector/skilled_proposer_bridge.py +381 -0
- package/vendor/orizu-gepa/src/orizu_gepa_connector/stop_conditions.py +17 -0
- package/vendor/orizu-gepa/src/orizu_gepa_connector/translator.py +2 -1
- package/vendor/orizu-gepa-python/src/orizu_gepa/client.py +52 -7
- package/vendor/orizu-gepa-python/src/orizu_gepa/local_log.py +4 -1
- package/vendor/orizu-gepa-python/src/orizu_gepa/optimizer.py +10 -5
- package/vendor/orizu-gepa-python/src/orizu_gepa/reflection.py +323 -68
- package/vendor/orizu-gepa-python/src/orizu_gepa/runner.py +207 -13
- package/vendor/skills/orizu-cli/SKILL.md +2 -0
- package/vendor/skills/orizu-cli/references/cli-reference.md +85 -1
- package/vendor/skills/orizu-cli/references/migrate-existing-gepa-setup.md +303 -0
- package/vendor/skills/orizu-cli/references/optimization-with-gepa.md +25 -0
- 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
|
+
}
|