arkaos 5.17.2 → 5.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/THE-ARKAOS-GUIDE.md +2 -2
  2. package/VERSION +1 -1
  3. package/arka/SKILL.md +2 -1
  4. package/arka/skills/costs/SKILL.md +16 -0
  5. package/config/claude-agents/marta-cqo.md +10 -0
  6. package/config/decisions/corpora/bash-effect.jsonl +38 -0
  7. package/config/decisions/corpora/creation-intent.jsonl +47 -0
  8. package/config/decisions/corpora/dispatch-role.jsonl +32 -0
  9. package/config/decisions/corpora/forge-complexity.jsonl +102 -0
  10. package/config/decisions/corpora/forge-departments.jsonl +34 -0
  11. package/config/decisions/corpora/heldout/learning-signal.jsonl +17 -0
  12. package/config/decisions/corpora/heldout/qg-prescreen.jsonl +20 -0
  13. package/config/decisions/corpora/heldout/slop-score.jsonl +20 -0
  14. package/config/decisions/corpora/learning-signal.jsonl +35 -0
  15. package/config/decisions/corpora/phantom-action.jsonl +34 -0
  16. package/config/decisions/corpora/qg-prescreen.jsonl +46 -0
  17. package/config/decisions/corpora/refine.jsonl +104 -0
  18. package/config/decisions/corpora/route.jsonl +33 -0
  19. package/config/decisions/corpora/skill-hints.jsonl +34 -0
  20. package/config/decisions/corpora/skill-proposer.jsonl +32 -0
  21. package/config/decisions/corpora/slop-score.jsonl +33 -0
  22. package/config/decisions/corpora/subagent-discipline.jsonl +32 -0
  23. package/config/decisions/corpora/sycophancy.jsonl +34 -0
  24. package/config/decisions/corpora/topic-drift.jsonl +34 -0
  25. package/config/decisions/corpora/ui-in-ts.jsonl +60 -0
  26. package/config/hooks/gate-manifest.json +1 -0
  27. package/config/models.yaml +10 -2
  28. package/core/decisions/__init__.py +8 -0
  29. package/core/decisions/backoff.py +137 -0
  30. package/core/decisions/cache.py +92 -0
  31. package/core/decisions/client.py +191 -0
  32. package/core/decisions/config.py +159 -0
  33. package/core/decisions/cost_effect.py +242 -0
  34. package/core/decisions/engine.py +400 -0
  35. package/core/decisions/models.py +118 -0
  36. package/core/decisions/paths.py +39 -0
  37. package/core/decisions/privacy.py +378 -0
  38. package/core/decisions/promotion.py +126 -0
  39. package/core/decisions/registry.py +23 -0
  40. package/core/decisions/replay.py +852 -0
  41. package/core/decisions/replay_report.py +413 -0
  42. package/core/decisions/shadow.py +147 -0
  43. package/core/decisions/site.py +343 -0
  44. package/core/decisions/sites/__init__.py +1 -0
  45. package/core/decisions/sites/command.py +58 -0
  46. package/core/decisions/sites/dispatch.py +161 -0
  47. package/core/decisions/sites/forge.py +230 -0
  48. package/core/decisions/sites/governance.py +361 -0
  49. package/core/decisions/sites/prompt.py +195 -0
  50. package/core/decisions/sites/quality.py +281 -0
  51. package/core/decisions/telemetry.py +335 -0
  52. package/core/decisions/telemetry_cli.py +149 -0
  53. package/core/decisions/transport.py +97 -0
  54. package/core/egress/audit.py +46 -6
  55. package/core/egress/credentials.py +1296 -0
  56. package/core/egress/policy.py +7 -4
  57. package/core/evals/verdict_labels.py +25 -5
  58. package/core/forge/budget.py +32 -0
  59. package/core/forge/complexity.py +11 -3
  60. package/core/forge/jev_sites.py +80 -0
  61. package/core/forge/orchestrator.py +76 -72
  62. package/core/governance/aggregate_guard.py +175 -77
  63. package/core/governance/carry_advisor.py +36 -19
  64. package/core/governance/evidence_checks.py +134 -55
  65. package/core/governance/fence_recovery.py +165 -0
  66. package/core/governance/harness_scanner.py +4 -0
  67. package/core/governance/jev_advisory.py +154 -0
  68. package/core/governance/leak_scanner.py +2 -2
  69. package/core/governance/literal_git.py +66 -0
  70. package/core/governance/qg_prescreen.py +518 -0
  71. package/core/governance/qg_tier.py +17 -0
  72. package/core/governance/reviewer_ledger.py +225 -46
  73. package/core/governance/skill_proposer.py +38 -13
  74. package/core/governance/slop_check.py +169 -0
  75. package/core/hooks/operator_message.py +128 -0
  76. package/core/hooks/stop.py +418 -38
  77. package/core/hooks/stop_budget.py +43 -0
  78. package/core/hooks/subagent_stop.py +39 -9
  79. package/core/hooks/ups_dispatch.py +243 -0
  80. package/core/hooks/user_prompt_submit.py +458 -106
  81. package/core/keys.py +4 -0
  82. package/core/runtime/model_router.py +38 -0
  83. package/core/runtime/pricing.py +8 -0
  84. package/core/synapse/command_menu.py +70 -0
  85. package/core/synapse/layers.py +108 -66
  86. package/core/workflow/flow_enforcer.py +71 -1
  87. package/core/workflow/frontend_gate.py +57 -1
  88. package/core/workflow/transcript_scope.py +34 -25
  89. package/departments/quality/SKILL.md +29 -2
  90. package/harness/codex/AGENTS.md +1 -1
  91. package/harness/copilot/copilot-instructions.md +1 -1
  92. package/harness/cursor/rules/arkaos.mdc +2 -2
  93. package/harness/gemini/GEMINI.md +1 -1
  94. package/harness/opencode/AGENTS.md +1 -1
  95. package/harness/opencode/agents/arka-architect-gabriel.md +1 -1
  96. package/harness/opencode/agents/arka-brand-director-valentina.md +1 -1
  97. package/harness/opencode/agents/arka-cfo-helena.md +1 -1
  98. package/harness/opencode/agents/arka-chief-of-staff-afonso.md +1 -1
  99. package/harness/opencode/agents/arka-community-strategist-beatriz.md +1 -1
  100. package/harness/opencode/agents/arka-content-strategist-rafael.md +1 -1
  101. package/harness/opencode/agents/arka-conversion-strategist-ines.md +1 -1
  102. package/harness/opencode/agents/arka-coo-sofia.md +1 -1
  103. package/harness/opencode/agents/arka-copy-director-eduardo.md +1 -1
  104. package/harness/opencode/agents/arka-cqo-marta.md +1 -1
  105. package/harness/opencode/agents/arka-cto-marco.md +1 -1
  106. package/harness/opencode/agents/arka-design-ops-lead-iris.md +1 -1
  107. package/harness/opencode/agents/arka-ecom-director-ricardo.md +1 -1
  108. package/harness/opencode/agents/arka-knowledge-director-clara.md +1 -1
  109. package/harness/opencode/agents/arka-leadership-director-rodrigo.md +1 -1
  110. package/harness/opencode/agents/arka-marketing-director-luna.md +1 -1
  111. package/harness/opencode/agents/arka-ops-lead-daniel.md +1 -1
  112. package/harness/opencode/agents/arka-pm-director-carolina.md +1 -1
  113. package/harness/opencode/agents/arka-revops-lead-vicente.md +1 -1
  114. package/harness/opencode/agents/arka-saas-strategist-tiago.md +1 -1
  115. package/harness/opencode/agents/arka-sales-director-miguel.md +1 -1
  116. package/harness/opencode/agents/arka-strategy-director-tomas.md +1 -1
  117. package/harness/opencode/agents/arka-tech-director-francisca.md +1 -1
  118. package/harness/opencode/agents/arka-tech-lead-paulo.md +1 -1
  119. package/harness/opencode/agents/arka-video-producer-simao.md +1 -1
  120. package/harness/zed/.rules +1 -1
  121. package/installer/config-seed.js +71 -1
  122. package/installer/keys.js +1 -1
  123. package/installer/update.js +29 -0
  124. package/knowledge/commands-registry.json +17 -4
  125. package/knowledge/commands-registry.json.bak +6739 -0
  126. package/knowledge/skills-manifest.json +1 -1
  127. package/package.json +1 -1
  128. package/pyproject.toml +1 -1
  129. package/scripts/synapse-bridge.py +17 -1
  130. package/scripts/tools/corpus_merge.py +402 -0
@@ -0,0 +1,852 @@
1
+ """Replay harness — the evidence gate for moving a site between modes.
2
+
3
+ Runs a labelled corpus (``config/decisions/corpora/<site>.jsonl``) through
4
+ the site's heuristic and, online, through Jev. Gate (online):
5
+
6
+ * Jev precision on the cases it answers ≥ heuristic precision on the SAME
7
+ cases + 5 pp;
8
+ * abstain rate (abstain + unavailable) ≤ 25 %;
9
+ * escalate-only sites: false escalations ≤ 5 % of the cases where the
10
+ heuristic was already right.
11
+
12
+ Corpus validity (both modes): ≥ 30 cases, ≥ 50 % pt-PT, and every
13
+ ``expected`` of the site's type (:data:`EXPECTED`: bool, a department, a
14
+ role, a tier, a department list, a registry command id, a learning signal,
15
+ a ``[verdict, blocker]`` pair or five 1-10 slop scores). ``--offline``
16
+ scores the heuristic only and passes on a valid corpus.
17
+
18
+ Sites whose value is not the corpus label compare through :data:`LABELS`
19
+ (forge-complexity: dimensions → tier; forge-departments: order-free set;
20
+ learning-signal: the ``signal``; qg-prescreen: ``[verdict, blocker]``;
21
+ slop-score: the five scores in rubric order).
22
+
23
+ Two PR3 sites have no heuristic, so "Jev vs heuristic" is not their gate:
24
+
25
+ * ``qg-prescreen`` replays against the neutral ``{"verdict": "unknown"}``
26
+ baseline, which never matches a label: the report measures Jev against
27
+ the real label only (the precision rule reduces to Jev ≥ 5 %, the
28
+ abstain rule is what binds).
29
+ * ``slop-score`` (:data:`MAE_SITES`): the precision rule is replaced by
30
+ the mean absolute error of Jev's TOTAL (5-50) against the labelled total
31
+ on the answered cases, ``mean_abs_error`` ≤ :data:`MAX_SLOP_MAE`; exact
32
+ five-score agreement is still reported as ``jev_accuracy``.
33
+
34
+ Cases may carry ``context``: the site inputs the live call site has
35
+ besides the text (phantom-action ``tool_uses``, ui-in-ts ``path``).
36
+ Exit codes: 0 pass, 1 fail, 2 usage.
37
+ """
38
+
39
+ from __future__ import annotations
40
+
41
+ import argparse
42
+ import json
43
+ import re
44
+ import statistics
45
+ import sys
46
+ from collections.abc import Callable, Sequence
47
+ from dataclasses import asdict, dataclass, field, replace
48
+ from functools import lru_cache
49
+ from pathlib import Path
50
+ from typing import Literal
51
+
52
+ from pydantic import BaseModel, ConfigDict, Field, StrictBool, StrictInt, ValidationError
53
+
54
+ from core.decisions.client import LOCAL_MARK
55
+ from core.decisions.config import (
56
+ DecisionsConfig,
57
+ load_decisions_config,
58
+ site_timeout_ms,
59
+ threshold_for,
60
+ )
61
+ from core.decisions.engine import resolve, run_sync
62
+ from core.decisions.models import Answer, State
63
+ from core.decisions.paths import repo_root
64
+ from core.decisions.registry import SITES
65
+ from core.decisions.site import Outcome, Site, SiteCall
66
+ from core.decisions.sites import governance as gov
67
+ from core.decisions.sites import quality
68
+ from core.decisions.sites.command import command_state
69
+ from core.decisions.sites.dispatch import skill_state
70
+ from core.decisions.sites.forge import forge_state, single_cell_complexity
71
+ from core.decisions.sites.prompt import prompt_state
72
+ from core.decisions.telemetry import REPLAY_COST_CATEGORY, REPLAY_SESSION_ID, call_cost_usd
73
+ from core.decisions.transport import Transport, resolve_transport
74
+
75
+ # dispatch-role and subagent-discipline: the keyword baselines the UPS hook
76
+ # ships as those sites' heuristics — replayed as the SAME functions.
77
+ from core.hooks.ups_dispatch import keyword_dispatch_role, keyword_needs_isolation
78
+
79
+ MIN_CASES = 30
80
+ MIN_PT_SHARE = 0.5
81
+ MARGIN = 0.05
82
+ MAX_ABSTAIN = 0.25
83
+ MAX_FALSE_ESCALATION = 0.05
84
+ MAX_SLOP_MAE = 5.0 # of a 5-50 total: one point per dimension on average
85
+ MAE_SITES = frozenset({"slop-score"})
86
+ # Held-out sets (spec PR5 D7): small generalisation checks, never mixed
87
+ # into the corpora above; a held-out run is reported on its own.
88
+ HELDOUT_SITES = ("learning-signal", "qg-prescreen", "slop-score")
89
+ MIN_HELDOUT_CASES = 15
90
+
91
+ Gate = Literal["pass", "fail", "offline"]
92
+ CorpusKind = Literal["corpus", "heldout"]
93
+
94
+
95
+ # ``seed`` or a labeller batch ``handwritten-<batch>``; anything else is a typo.
96
+ SOURCE_PATTERN = r"^(seed|handwritten-[A-Za-z0-9][A-Za-z0-9-]*)$"
97
+
98
+
99
+ class ReplayCase(BaseModel):
100
+ """One labelled prompt.
101
+
102
+ ``source`` says where the case came from and is validated against
103
+ :data:`SOURCE_PATTERN`: ``seed`` for the PR1-PR3 corpora, or
104
+ ``handwritten-<batch>`` for a labeller's relabel batch (e.g.
105
+ ``handwritten-2026-09-25-A``); the adjudication itself lives in the
106
+ spec, never in the jsonl. ``gap`` is refine's missing piece
107
+ (:data:`~core.decisions.sites.prompt.REFINE_GAPS`), compared with
108
+ Jev's ``missing`` choice as a secondary score; the vague bool in
109
+ ``expected`` stays the gate's label.
110
+ """
111
+
112
+ model_config = ConfigDict(extra="forbid")
113
+
114
+ id: str
115
+ lang: Literal["pt", "en"]
116
+ prompt: str
117
+ prior: list[str] = []
118
+ context: dict[str, StrictInt | str] = {}
119
+ expected: StrictBool | str | list[str] | list[StrictInt]
120
+ source: str = Field("seed", pattern=SOURCE_PATTERN)
121
+ gap: str | None = None
122
+
123
+
124
+ @dataclass
125
+ class ReplayReport:
126
+ """Scores and gate verdict for one site's corpus."""
127
+
128
+ site: str
129
+ cases: int
130
+ pt_share: float
131
+ heuristic_accuracy: float
132
+ jev_accuracy: float | None = None
133
+ heuristic_on_answered: float | None = None
134
+ act_accuracy: float | None = None
135
+ abstain_rate: float | None = None
136
+ false_escalation_rate: float | None = None
137
+ # MAE_SITES only: mean |Jev total - labelled total| on the answered cases.
138
+ mean_abs_error: float | None = None
139
+ unavailable: int = 0
140
+ # G3 evidence: median latency of the calls that really went out
141
+ # (cache hits and an open breaker excluded) and their summed cost.
142
+ # None whenever nothing was measured (offline, or every answer from
143
+ # the cache), so "not measured" never reads as "0 ms measured".
144
+ p50_latency_ms: int | None = None
145
+ cost_usd: float | None = None
146
+ # The per-call ceiling of an online run: the site's own ceiling (what
147
+ # the live call site uses) unless ``--timeout-ms`` overrides it.
148
+ timeout_ms: int | None = None
149
+ # Online only: cases whose site asked a question (a designed
150
+ # "no-questions" is not asked) and, of those, calls that reached the
151
+ # endpoint. ``replay_report`` counts a run only when enough reached.
152
+ asked: int | None = None
153
+ reached: int | None = None
154
+ by_lang: dict[str, dict[str, float | None]] = field(default_factory=dict)
155
+ # refine only: Jev's ``missing`` choice vs the labelled ``gap`` on the
156
+ # cases where both exist (secondary: never part of the gate).
157
+ gap_accuracy: float | None = None
158
+ # forge-complexity only (D6): the abstain rate the pre-D6 single-cell
159
+ # reading gives on the SAME answers, reported next to the window one.
160
+ single_cell_abstain_rate: float | None = None
161
+ corpus: CorpusKind = "corpus"
162
+ gate: Gate = "offline"
163
+ failures: list[str] = field(default_factory=list)
164
+
165
+
166
+ # --- heuristics: the SAME pure functions the live call sites run ------------
167
+
168
+ def _heuristic_topic_drift(case: ReplayCase) -> bool:
169
+ from core.hooks.user_prompt_submit import keyword_topic_shift
170
+
171
+ return keyword_topic_shift(case.prompt, "\n".join(case.prior))
172
+
173
+
174
+ def _heuristic_refine(case: ReplayCase) -> bool:
175
+ from core.hooks.user_prompt_submit import (
176
+ _names_concrete_target,
177
+ _refine_heuristic,
178
+ _wf_classify,
179
+ )
180
+
181
+ text = case.prompt
182
+ if not _wf_classify(text) or text.strip().startswith("/"):
183
+ return False
184
+ return not _names_concrete_target(text) and _refine_heuristic(text)
185
+
186
+
187
+ def _heuristic_creation(case: ReplayCase) -> bool:
188
+ from core.hooks.user_prompt_submit import _wf_classify
189
+
190
+ return _wf_classify(case.prompt)
191
+
192
+
193
+ def _heuristic_route(case: ReplayCase) -> str:
194
+ from core.synapse.layers import keyword_department
195
+
196
+ return keyword_department(case.prompt) or ""
197
+
198
+
199
+ def _heuristic_bash_effect(case: ReplayCase) -> bool:
200
+ from core.workflow.flow_enforcer import bash_is_effect
201
+
202
+ return bash_is_effect(case.prompt)
203
+
204
+
205
+ def forge_estimates(prompt: str) -> tuple[list[str], list[str]]:
206
+ """``(affected_files, departments)`` exactly as Forge step 3 estimates them.
207
+
208
+ Both estimators are ``ForgeOrchestrator`` methods that read no instance
209
+ state, so a bare instance (no ``__init__``, no dispatcher) runs the
210
+ real code without its side effects.
211
+ """
212
+ from core.forge.orchestrator import ForgeOrchestrator
213
+
214
+ bare = ForgeOrchestrator.__new__(ForgeOrchestrator)
215
+ return bare._estimate_affected_files(prompt), bare._estimate_departments(prompt)
216
+
217
+
218
+ def _heuristic_forge_departments(case: ReplayCase) -> list[str]:
219
+ return sorted(forge_estimates(case.prompt)[1])
220
+
221
+
222
+ def _heuristic_forge_complexity(case: ReplayCase) -> dict[str, int]:
223
+ # No similar plans / reused patterns: replay has no plan history, so
224
+ # novelty scores as the heuristic's maximum (90) on every case.
225
+ from core.forge.complexity import score_dimensions
226
+
227
+ files, departments = forge_estimates(case.prompt)
228
+ return score_dimensions(case.prompt, files, departments, [], []).model_dump()
229
+
230
+
231
+ @lru_cache(maxsize=1)
232
+ def registry_commands() -> tuple[dict[str, object], ...]:
233
+ """``knowledge/commands-registry.json`` commands (read once)."""
234
+ path = repo_root() / "knowledge" / "commands-registry.json"
235
+ return tuple(json.loads(path.read_text(encoding="utf-8"))["commands"])
236
+
237
+
238
+ def skill_candidates_for(prompt: str, dept: str | None = None) -> list[dict[str, str]]:
239
+ """The skill-hints menu, built by the SAME function the UPS hook uses
240
+ (``core.synapse.command_menu.skill_hint_candidates``): top-20 keyword
241
+ commands + the routed department's commands, capped at 60.
242
+
243
+ ``dept`` is the department the route site settled on; None falls back
244
+ to the L1 keyword route, which is what the hook uses when the route
245
+ site does not act.
246
+ """
247
+ from core.synapse.command_menu import skill_hint_candidates
248
+ from core.synapse.layers import keyword_department
249
+
250
+ routed = (keyword_department(prompt) or "") if dept is None else dept
251
+ return skill_hint_candidates(registry_commands(), prompt, routed)
252
+
253
+
254
+ def replay_route(case: ReplayCase) -> str | None:
255
+ """The route the skill-hints replay assumes: the expected command's department.
256
+
257
+ The replay scores the skill-hints site on its own job (pick the command
258
+ from the menu), with the route taken as correct; the route site has its
259
+ own replay (90.9 % on 2026-09-23), and end-to-end coverage in the hook
260
+ is bounded by it. A case whose expected answer is "no command" keeps
261
+ the keyword route (None).
262
+ """
263
+ from core.synapse.command_menu import command_department
264
+
265
+ for command in registry_commands():
266
+ if command.get("id") == case.expected:
267
+ return command_department(command) or None
268
+ return None
269
+
270
+
271
+ def _heuristic_skill_hint(case: ReplayCase) -> str:
272
+ # The live L5 hint is keyword-only: the routed-department fill of the
273
+ # menu never becomes a hint by itself.
274
+ from core.synapse.layers import _score_commands
275
+
276
+ top = _score_commands(list(registry_commands()), case.prompt.lower())
277
+ ids = {str(c.get("command", "")): str(c["id"]) for c in reversed(registry_commands())}
278
+ return ids.get(top[0][1], "") if top else ""
279
+
280
+
281
+ def _heuristic_phantom(case: ReplayCase) -> bool:
282
+ return gov.heuristic_unbacked_effect(case.prompt, case.context.get("tool_uses"))
283
+
284
+
285
+ def _heuristic_ui(case: ReplayCase) -> bool:
286
+ return gov.heuristic_ui_code(str(case.context.get("path", "")), case.prompt)
287
+
288
+
289
+ HEURISTICS: dict[str, Callable[[ReplayCase], object]] = {
290
+ "topic-drift": _heuristic_topic_drift,
291
+ "refine": _heuristic_refine,
292
+ "creation-intent": _heuristic_creation,
293
+ "route": _heuristic_route,
294
+ "bash-effect": _heuristic_bash_effect,
295
+ "forge-departments": _heuristic_forge_departments,
296
+ "forge-complexity": _heuristic_forge_complexity,
297
+ "skill-hints": _heuristic_skill_hint,
298
+ "dispatch-role": lambda case: keyword_dispatch_role(case.prompt),
299
+ "subagent-discipline": lambda case: keyword_needs_isolation(case.prompt),
300
+ "sycophancy": lambda case: gov.heuristic_sycophantic(case.prompt),
301
+ "phantom-action": _heuristic_phantom,
302
+ "skill-proposer": lambda case: gov.heuristic_repeatable_capability(case.prompt),
303
+ "learning-signal": lambda case: gov.heuristic_learning_signal(case.prompt),
304
+ "ui-in-ts": _heuristic_ui,
305
+ "qg-prescreen": lambda case: quality.prescreen_heuristic(),
306
+ "slop-score": lambda case: None, # no slop heuristic exists in code
307
+ }
308
+
309
+
310
+ def _forge_case_state(case: ReplayCase) -> State:
311
+ files, departments = forge_estimates(case.prompt)
312
+ return forge_state(case.prompt, files, departments)
313
+
314
+
315
+ def _sycophancy_state(case: ReplayCase) -> State:
316
+ return gov.stop_state(case.prompt, user_message=case.prior[-1] if case.prior else "")
317
+
318
+
319
+ def _phantom_state(case: ReplayCase) -> State:
320
+ tool_uses = case.context.get("tool_uses")
321
+ return gov.stop_state(case.prompt, tool_uses=tool_uses if isinstance(tool_uses, int) else None)
322
+
323
+
324
+ _GIT_B_PATH = re.compile(r"^diff --git a/\S+ b/(\S+)$", re.M)
325
+
326
+
327
+ def _diff_path(diff: str) -> str:
328
+ """The file a corpus diff names first ('' when none: privacy then refuses it)."""
329
+ m = _GIT_B_PATH.search(diff)
330
+ return m.group(1) if m else ""
331
+
332
+
333
+ STATES: dict[str, Callable[[ReplayCase], State]] = {
334
+ "bash-effect": lambda case: command_state(case.prompt),
335
+ "forge-departments": _forge_case_state,
336
+ "forge-complexity": _forge_case_state,
337
+ "skill-hints": lambda case: skill_state(
338
+ case.prompt, skill_candidates_for(case.prompt, replay_route(case))),
339
+ "sycophancy": _sycophancy_state,
340
+ "phantom-action": _phantom_state,
341
+ "skill-proposer": lambda case: gov.stop_state(case.prompt),
342
+ "learning-signal": lambda case: gov.stop_state("", user_message=case.prompt),
343
+ "ui-in-ts": lambda case: gov.ui_state(str(case.context.get("path", "")), case.prompt),
344
+ "qg-prescreen": lambda case: quality.diff_state(case.prompt, _diff_path(case.prompt)),
345
+ # A corpus row is prose, as the live site's .md/.mdx/.txt files are.
346
+ "slop-score": lambda case: quality.prose_state(case.prompt, f"{case.id}.md"),
347
+ }
348
+
349
+
350
+ def case_state(site: str, case: ReplayCase) -> State:
351
+ """The state the live call site would send for this case."""
352
+ builder = STATES.get(site)
353
+ return builder(case) if builder else prompt_state(case.prompt, case.prior)
354
+
355
+
356
+ # --- corpus labels -----------------------------------------------------------
357
+
358
+ TIERS = frozenset({"SHALLOW", "STANDARD", "DEEP"})
359
+
360
+
361
+ def _is_department(value: object) -> bool:
362
+ from core.synapse.layers import DEPARTMENT_PATTERNS
363
+
364
+ return isinstance(value, str) and value in DEPARTMENT_PATTERNS
365
+
366
+
367
+ def _is_department_list(value: object) -> bool:
368
+ return (isinstance(value, list) and 1 <= len(value) <= 4
369
+ and len(set(value)) == len(value) and all(_is_department(v) for v in value))
370
+
371
+
372
+ def _is_role(value: object) -> bool:
373
+ from core.runtime.model_router import ROLE_DESCRIPTIONS
374
+
375
+ return isinstance(value, str) and value in ROLE_DESCRIPTIONS
376
+
377
+
378
+ def _is_command_id(value: object) -> bool:
379
+ return isinstance(value, str) and value in {c.get("id") for c in registry_commands()}
380
+
381
+
382
+ def _is_bool(value: object) -> bool:
383
+ return isinstance(value, bool)
384
+
385
+
386
+ def _is_prescreen_label(value: object) -> bool:
387
+ """``[verdict, blocker]``: approved pairs with none, rejected with a real class."""
388
+ if not (isinstance(value, list) and len(value) == 2):
389
+ return False
390
+ pair: list[object] = value
391
+ verdict, blocker = pair
392
+ if verdict == "approved":
393
+ return blocker == quality.NO_BLOCKER
394
+ return verdict == "rejected" and blocker in set(quality.BLOCKERS) - {quality.NO_BLOCKER}
395
+
396
+
397
+ def _is_slop_label(value: object) -> bool:
398
+ """Five ints in 1..10, rubric order."""
399
+ return (isinstance(value, list) and len(value) == len(quality.SLOP_DIMENSIONS)
400
+ and all(isinstance(v, int) and not isinstance(v, bool)
401
+ and 1 <= v <= quality.SLOP_LEVELS for v in value))
402
+
403
+
404
+ EXPECTED: dict[str, Callable[[object], bool]] = {
405
+ "topic-drift": _is_bool, "refine": _is_bool, "creation-intent": _is_bool,
406
+ "bash-effect": _is_bool, "subagent-discipline": _is_bool,
407
+ "route": lambda v: v == "" or _is_department(v),
408
+ "dispatch-role": _is_role,
409
+ "forge-complexity": lambda v: isinstance(v, str) and v in TIERS,
410
+ "forge-departments": _is_department_list,
411
+ "skill-hints": _is_command_id,
412
+ "sycophancy": _is_bool, "phantom-action": _is_bool, "skill-proposer": _is_bool,
413
+ "ui-in-ts": _is_bool,
414
+ "learning-signal": lambda v: isinstance(v, str) and v in gov.LEARNING_SIGNALS,
415
+ "qg-prescreen": _is_prescreen_label,
416
+ "slop-score": _is_slop_label,
417
+ }
418
+
419
+
420
+ def complexity_tier(value: object) -> object:
421
+ """Dimensions (0-100 each) → ``SHALLOW|STANDARD|DEEP`` with the Forge's weights."""
422
+ if not isinstance(value, dict):
423
+ return value
424
+ from core.forge.complexity import calculate_weighted_score, determine_tier
425
+ from core.forge.schema import ComplexityDimensions
426
+
427
+ tier = determine_tier(calculate_weighted_score(ComplexityDimensions(**value)))
428
+ return tier.value.upper()
429
+
430
+
431
+ def _department_set(value: object) -> object:
432
+ return sorted(value) if isinstance(value, list) else value
433
+
434
+
435
+ def _learning_label(value: object) -> object:
436
+ return value.get("signal") if isinstance(value, dict) else value
437
+
438
+
439
+ def _prescreen_label(value: object) -> object:
440
+ if not isinstance(value, dict):
441
+ return value
442
+ return [value.get("verdict"), value.get("blocker")]
443
+
444
+
445
+ def _slop_label(value: object) -> object:
446
+ if not isinstance(value, dict):
447
+ return value
448
+ return [value.get(d) for d in quality.SLOP_DIMENSIONS]
449
+
450
+
451
+ LABELS: dict[str, Callable[[object], object]] = {
452
+ "forge-complexity": complexity_tier,
453
+ "forge-departments": _department_set,
454
+ "learning-signal": _learning_label,
455
+ "qg-prescreen": _prescreen_label,
456
+ "slop-score": _slop_label,
457
+ }
458
+
459
+
460
+ def default_corpus_path(site: str) -> Path:
461
+ """``config/decisions/corpora/<site>.jsonl`` in this checkout."""
462
+ return repo_root() / "config" / "decisions" / "corpora" / f"{site}.jsonl"
463
+
464
+
465
+ def heldout_corpus_path(site: str) -> Path:
466
+ """``config/decisions/corpora/heldout/<site>.jsonl`` in this checkout."""
467
+ return repo_root() / "config" / "decisions" / "corpora" / "heldout" / f"{site}.jsonl"
468
+
469
+
470
+ def _gap_error(site: str, case: ReplayCase) -> str | None:
471
+ """Why ``case.gap`` is invalid for ``site``, or None."""
472
+ if case.gap is None:
473
+ return None
474
+ if site != "refine":
475
+ return f"gap is a refine field, not a {site} one"
476
+ from core.decisions.sites.prompt import REFINE_GAPS
477
+
478
+ if case.gap not in REFINE_GAPS:
479
+ return f"gap {case.gap!r} is not one of {sorted(REFINE_GAPS)}"
480
+ if (case.gap == "none") is bool(case.expected):
481
+ return f"gap {case.gap!r} contradicts expected {case.expected!r}"
482
+ return None
483
+
484
+
485
+ def load_corpus(site: str, path: Path | None = None) -> list[ReplayCase]:
486
+ """Parse a JSONL corpus; ValueError names the first bad line."""
487
+ source = path or default_corpus_path(site)
488
+ check = EXPECTED.get(site)
489
+ cases: list[ReplayCase] = []
490
+ for number, line in enumerate(source.read_text(encoding="utf-8").splitlines(), 1):
491
+ if not line.strip():
492
+ continue
493
+ try:
494
+ case = ReplayCase.model_validate(json.loads(line))
495
+ except (ValueError, ValidationError) as exc:
496
+ raise ValueError(f"{source}:{number}: {exc}") from exc
497
+ if check is not None and not check(case.expected):
498
+ raise ValueError(f"{source}:{number}: expected {case.expected!r} is not a {site} label")
499
+ gap_error = _gap_error(site, case)
500
+ if gap_error:
501
+ raise ValueError(f"{source}:{number}: {gap_error}")
502
+ cases.append(case)
503
+ return cases
504
+
505
+
506
+ # --- scoring -----------------------------------------------------------------
507
+
508
+ Row = tuple[ReplayCase, object, Outcome | None]
509
+
510
+
511
+ def _labelled(site: str, rows: Sequence[Row]) -> list[Row]:
512
+ label = LABELS.get(site)
513
+ if label is None:
514
+ return list(rows)
515
+ # Both sides go through the label: a department list is compared as a
516
+ # set, so the corpus order must not matter either.
517
+ return [(case.model_copy(update={"expected": label(case.expected)}), label(h),
518
+ _label_outcome(label, out)) for case, h, out in rows]
519
+
520
+
521
+ def _label_outcome(label: Callable[[object], object], out: Outcome | None) -> Outcome | None:
522
+ if out is None:
523
+ return None
524
+ jev = None if out.jev is None else label(out.jev)
525
+ return replace(out, value=label(out.value), heuristic=label(out.heuristic), jev=jev)
526
+
527
+
528
+ def _rate(hits: int, total: int) -> float | None:
529
+ return round(hits / total, 4) if total else None
530
+
531
+
532
+ def _accuracy(rows: Sequence[Row], pick: Callable[[Row], object]) -> float | None:
533
+ return _rate(sum(1 for r in rows if pick(r) == r[0].expected), len(rows))
534
+
535
+
536
+ def _answered(rows: Sequence[Row]) -> list[Row]:
537
+ return [r for r in rows if r[2] is not None and r[2].jev is not None]
538
+
539
+
540
+ def _false_escalation(site: Site, rows: Sequence[Row]) -> float | None:
541
+ if site.direction != "escalate_only":
542
+ return None
543
+ at_risk = [r for r in rows if r[1] == r[0].expected]
544
+ wrong = [r for r in at_risk if r[2] is not None and r[2].acted_on == "jev"
545
+ and r[2].value != r[0].expected]
546
+ return _rate(len(wrong), len(at_risk))
547
+
548
+
549
+ def _by_lang(rows: Sequence[Row], online: bool) -> dict[str, dict[str, float | None]]:
550
+ out: dict[str, dict[str, float | None]] = {}
551
+ for lang in sorted({r[0].lang for r in rows}):
552
+ subset = [r for r in rows if r[0].lang == lang]
553
+ entry = {"cases": float(len(subset)), "heuristic": _accuracy(subset, lambda r: r[1])}
554
+ if online:
555
+ entry["jev"] = _accuracy(_answered(subset), lambda r: r[2].jev if r[2] else None)
556
+ out[lang] = entry
557
+ return out
558
+
559
+
560
+ def _base_report(site: Site, rows: Sequence[Row]) -> ReplayReport:
561
+ pt = sum(1 for r in rows if r[0].lang == "pt")
562
+ return ReplayReport(
563
+ site=site.name, cases=len(rows), pt_share=_rate(pt, len(rows)) or 0.0,
564
+ heuristic_accuracy=_accuracy(rows, lambda r: r[1]) or 0.0,
565
+ )
566
+
567
+
568
+ def _online_scores(site: Site, rows: Sequence[Row], report: ReplayReport) -> None:
569
+ answered = _answered(rows)
570
+ report.jev_accuracy = _accuracy(answered, lambda r: r[2].jev if r[2] else None)
571
+ report.heuristic_on_answered = _accuracy(answered, lambda r: r[1])
572
+ report.act_accuracy = _accuracy(rows, lambda r: r[2].value if r[2] else r[1])
573
+ report.abstain_rate = _rate(len(rows) - len(answered), len(rows))
574
+ report.false_escalation_rate = _false_escalation(site, rows)
575
+ if site.name in MAE_SITES:
576
+ report.mean_abs_error = _mean_abs_error(answered)
577
+ # "no-questions" is a site choosing not to ask (phantom-action with tools on
578
+ # record, a skill bypass marker): an abstain by design, not an outage.
579
+ report.unavailable = sum(1 for r in rows if r[2] is None or r[2].reason not in
580
+ ("jev", "abstain", "shadow", "downgrade-blocked", "no-questions"))
581
+
582
+
583
+ def _total(value: object) -> int | None:
584
+ if not isinstance(value, list) or not all(isinstance(v, int) for v in value):
585
+ return None
586
+ return sum(value)
587
+
588
+
589
+ def _mean_abs_error(answered: Sequence[Row]) -> float | None:
590
+ """Mean |Jev total - labelled total| over the answered rows (5-list labels)."""
591
+ errors = []
592
+ for case, _, out in answered:
593
+ jev, want = _total(out.jev if out else None), _total(case.expected)
594
+ if jev is not None and want is not None:
595
+ errors.append(abs(jev - want))
596
+ return round(statistics.fmean(errors), 4) if errors else None
597
+
598
+
599
+ def _secondary_scores(site: Site, rows: Sequence[Row], report: ReplayReport,
600
+ threshold: float) -> None:
601
+ """Scores reported next to the gate, never part of it."""
602
+ if site.name == "refine":
603
+ report.gap_accuracy = _gap_accuracy(rows)
604
+ reader = ALT_READERS.get(site.name)
605
+ if reader is not None:
606
+ answered = sum(1 for _, _, out in rows
607
+ if out is not None and reader(dict(out.answers), threshold) is not None)
608
+ report.single_cell_abstain_rate = _rate(len(rows) - answered, len(rows))
609
+
610
+
611
+ def _gap_accuracy(rows: Sequence[Row]) -> float | None:
612
+ """Jev's ``missing`` choice vs the labelled ``gap``, where both exist."""
613
+ pairs = [(case.gap, out.answers["missing"].choice) for case, _, out in rows
614
+ if case.gap is not None and out is not None and "missing" in out.answers
615
+ and out.answers["missing"].choice]
616
+ return _rate(sum(1 for want, got in pairs if want == got), len(pairs))
617
+
618
+
619
+ # D6: the pre-window reading of a site, run on the same answers for the report.
620
+ ALT_READERS: dict[str, Callable[[dict[str, Answer], float], object | None]] = {
621
+ "forge-complexity": single_cell_complexity,
622
+ }
623
+
624
+
625
+ def _corpus_failures(report: ReplayReport) -> list[str]:
626
+ failures = []
627
+ floor = MIN_HELDOUT_CASES if report.corpus == "heldout" else MIN_CASES
628
+ if report.cases < floor:
629
+ failures.append(f"corpus has {report.cases} cases (< {floor})")
630
+ if report.pt_share < MIN_PT_SHARE:
631
+ failures.append(f"pt-PT share {report.pt_share:.0%} (< {MIN_PT_SHARE:.0%})")
632
+ return failures
633
+
634
+
635
+ def _precision_failures(report: ReplayReport) -> list[str]:
636
+ jev, base = report.jev_accuracy, report.heuristic_on_answered
637
+ if jev is None or base is None:
638
+ return ["Jev answered no case"]
639
+ if report.site in MAE_SITES:
640
+ mae = report.mean_abs_error
641
+ if mae is None or mae > MAX_SLOP_MAE:
642
+ return [f"mean absolute error {mae} > {MAX_SLOP_MAE} (total, 5-50)"]
643
+ return []
644
+ if jev < base + MARGIN:
645
+ return [f"Jev precision {jev:.1%} < heuristic {base:.1%} + {MARGIN:.0%}"]
646
+ return []
647
+
648
+
649
+ def _gate_failures(report: ReplayReport) -> list[str]:
650
+ failures = _precision_failures(report)
651
+ if report.abstain_rate is not None and report.abstain_rate > MAX_ABSTAIN:
652
+ failures.append(f"abstain {report.abstain_rate:.1%} > {MAX_ABSTAIN:.0%}")
653
+ fe = report.false_escalation_rate
654
+ if fe is not None and fe > MAX_FALSE_ESCALATION:
655
+ failures.append(f"false escalation {fe:.1%} > {MAX_FALSE_ESCALATION:.0%}")
656
+ return failures
657
+
658
+
659
+ # Reasons decided on this machine, before any byte left it.
660
+ LOCAL_REASONS = frozenset({"cache-hit", "no-questions", "deadline", "backoff", "egress-denied"})
661
+
662
+
663
+ def reached_endpoint(reason: str) -> bool:
664
+ """True when the endpoint decided the call (answered, or erred there).
665
+
666
+ Not reached: a :data:`LOCAL_REASONS` head, and any ``<reason>:local``
667
+ (:data:`~core.decisions.client.LOCAL_MARK`): ``invalid-shape:local``
668
+ (a state or body that never serialised) and ``timeout:local`` (our own
669
+ wall-clock deadline, not an answer or an error from the endpoint).
670
+ """
671
+ head, _, cause = reason.partition(":")
672
+ return head not in LOCAL_REASONS and cause != LOCAL_MARK
673
+
674
+
675
+ @dataclass(frozen=True)
676
+ class CallSample:
677
+ """What one replay call cost: latency when it went out, and USD."""
678
+
679
+ latency_ms: int | None
680
+ cost_usd: float
681
+ reached: bool = False
682
+ asked: bool = True
683
+
684
+
685
+ def _ask(
686
+ site: Site,
687
+ case: ReplayCase,
688
+ heuristic: object,
689
+ transport: Transport,
690
+ cfg: DecisionsConfig,
691
+ timeout_ms: int,
692
+ ) -> tuple[Outcome, CallSample]:
693
+ call = SiteCall(site, heuristic)
694
+ state = case_state(site.name, case)
695
+ response, reason, latency = run_sync(
696
+ [call], state, transport=transport,
697
+ cfg=cfg, timeout_s=timeout_ms / 1000, session_id=REPLAY_SESSION_ID,
698
+ cost_category=REPLAY_COST_CATEGORY, mark_local=True,
699
+ )
700
+ outcome = resolve(call, response, "act", threshold_for(cfg, site), reason, state=state)
701
+ went_out = reason != "cache-hit" and not reason.startswith("backoff")
702
+ fresh = reason == "ok" and response is not None
703
+ cost = (call_cost_usd(transport, response.usage) or 0.0) if fresh and response else 0.0
704
+ sample = CallSample(latency if went_out else None, cost,
705
+ reached=reached_endpoint(reason), asked=reason != "no-questions")
706
+ return outcome, sample
707
+
708
+
709
+ def _call_scores(samples: Sequence[CallSample], report: ReplayReport) -> None:
710
+ report.asked = sum(1 for s in samples if s.asked)
711
+ report.reached = sum(1 for s in samples if s.asked and s.reached)
712
+ latencies = [s.latency_ms for s in samples if s.latency_ms is not None]
713
+ if not latencies:
714
+ report.p50_latency_ms = report.cost_usd = None
715
+ return
716
+ report.p50_latency_ms = int(statistics.median(latencies))
717
+ report.cost_usd = round(sum(s.cost_usd for s in samples), 10)
718
+
719
+
720
+ def _score_cases(
721
+ site: Site,
722
+ cases: Sequence[ReplayCase],
723
+ transport: Transport | None,
724
+ cfg: DecisionsConfig,
725
+ ceiling_ms: int,
726
+ ) -> tuple[list[Row], list[CallSample]]:
727
+ """The heuristic for every case and, with a transport, Jev's outcome."""
728
+ heuristic = HEURISTICS[site.name]
729
+ rows: list[Row] = []
730
+ samples: list[CallSample] = []
731
+ for case in cases:
732
+ h = heuristic(case)
733
+ outcome = None
734
+ if transport is not None:
735
+ outcome, sample = _ask(site, case, h, transport, cfg, ceiling_ms)
736
+ samples.append(sample)
737
+ rows.append((case, h, outcome))
738
+ return rows, samples
739
+
740
+
741
+ def replay(
742
+ site: str,
743
+ cases: Sequence[ReplayCase],
744
+ *,
745
+ transport: Transport | None,
746
+ offline: bool = False,
747
+ timeout_ms: int | None = None,
748
+ cfg: DecisionsConfig | None = None,
749
+ corpus: CorpusKind = "corpus",
750
+ ) -> ReplayReport:
751
+ """Score ``cases`` for ``site``; offline (or no transport) = heuristic only.
752
+
753
+ Each online call is capped at ``timeout_ms``, else at the site's own
754
+ ceiling: the replay cuts a call where the live site would. ``cfg``
755
+ defaults to the operator's config (its thresholds are what ships);
756
+ ``replay_report`` passes a copy with the answer cache off. ``corpus=
757
+ "heldout"`` marks a held-out set (its own, smaller size floor).
758
+ """
759
+ target = SITES[site]
760
+ online = not offline and transport is not None
761
+ cfg = cfg or load_decisions_config()
762
+ ceiling = timeout_ms or site_timeout_ms(cfg, target)
763
+ rows, samples = _score_cases(target, cases, transport if online else None, cfg, ceiling)
764
+ rows = _labelled(site, rows)
765
+ report = _base_report(target, rows)
766
+ report.corpus = corpus
767
+ report.by_lang = _by_lang(rows, online)
768
+ if online:
769
+ report.timeout_ms = ceiling
770
+ _online_scores(target, rows, report)
771
+ _secondary_scores(target, rows, report, threshold_for(cfg, target))
772
+ _call_scores(samples, report)
773
+ return _judged(report, online)
774
+
775
+
776
+ def _judged(report: ReplayReport, online: bool) -> ReplayReport:
777
+ report.failures = _corpus_failures(report) + (_gate_failures(report) if online else [])
778
+ report.gate = "fail" if report.failures else ("pass" if online else "offline")
779
+ return report
780
+
781
+
782
+ def _call_line(report: ReplayReport) -> str:
783
+ if report.abstain_rate is None: # set only by an online run
784
+ return "- p50 latency / cost: not measured (offline)"
785
+ if report.p50_latency_ms is None or report.cost_usd is None:
786
+ return "- p50 latency / cost: not measured (all cache hits)"
787
+ return f"- p50 latency: {report.p50_latency_ms} ms; cost: ${report.cost_usd:.6f}"
788
+
789
+
790
+ def _render(report: ReplayReport) -> str:
791
+ kind = ", held-out" if report.corpus == "heldout" else ""
792
+ lines = [f"# Replay — {report.site} ({report.gate}{kind})",
793
+ f"- cases: {report.cases} (pt-PT {report.pt_share:.0%})",
794
+ f"- heuristic accuracy: {report.heuristic_accuracy:.1%}"]
795
+ if report.jev_accuracy is not None:
796
+ lines += [f"- Jev precision (answered): {report.jev_accuracy:.1%} vs heuristic "
797
+ f"{(report.heuristic_on_answered or 0):.1%} on the same cases",
798
+ f"- act accuracy: {(report.act_accuracy or 0):.1%}",
799
+ f"- abstain: {(report.abstain_rate or 0):.1%} (unavailable {report.unavailable})"]
800
+ lines.append(_call_line(report))
801
+ if report.false_escalation_rate is not None:
802
+ lines.append(f"- false escalation: {report.false_escalation_rate:.1%}")
803
+ if report.mean_abs_error is not None:
804
+ lines.append(f"- mean absolute error (total, 5-50): {report.mean_abs_error}")
805
+ if report.gap_accuracy is not None:
806
+ lines.append(f"- missing-piece choice vs gap label: {report.gap_accuracy:.1%}")
807
+ if report.single_cell_abstain_rate is not None:
808
+ lines.append(f"- abstain with the single-cell reading (pre-D6): "
809
+ f"{report.single_cell_abstain_rate:.1%}")
810
+ for lang, scores in report.by_lang.items():
811
+ lines.append(f"- {lang}: " + ", ".join(f"{k}={v}" for k, v in scores.items()))
812
+ lines += [f"- FAIL: {f}" for f in report.failures]
813
+ return "\n".join(lines)
814
+
815
+
816
+ def _parse_args(argv: Sequence[str] | None) -> argparse.Namespace:
817
+ parser = argparse.ArgumentParser(prog="python -m core.decisions.replay")
818
+ parser.add_argument("--site", required=True, choices=sorted(SITES))
819
+ parser.add_argument("--corpus", type=Path, default=None)
820
+ parser.add_argument("--heldout", action="store_true",
821
+ help="replay the held-out set (corpora/heldout/<site>.jsonl)")
822
+ parser.add_argument("--offline", action="store_true")
823
+ parser.add_argument("--json", action="store_true")
824
+ parser.add_argument("--timeout-ms", type=int, default=None,
825
+ help="per-call ceiling (default: the site's own timeout_ms)")
826
+ return parser.parse_args(argv)
827
+
828
+
829
+ def main(argv: Sequence[str] | None = None) -> int:
830
+ """CLI: exit 0 pass/offline-valid, 1 gate failed, 2 usage error."""
831
+ try:
832
+ ns = _parse_args(argv)
833
+ except SystemExit as exc:
834
+ return 0 if exc.code == 0 else 2
835
+ heldout = ns.heldout and ns.corpus is None
836
+ try:
837
+ cases = load_corpus(ns.site, heldout_corpus_path(ns.site) if heldout else ns.corpus)
838
+ except (OSError, ValueError) as exc:
839
+ print(f"error: {exc}", file=sys.stderr)
840
+ return 2
841
+ transport = None if ns.offline else resolve_transport(load_decisions_config())
842
+ if not ns.offline and transport is None:
843
+ print("error: no transport (set OPENROUTER_API_KEY) — or pass --offline", file=sys.stderr)
844
+ return 2
845
+ report = replay(ns.site, cases, transport=transport, offline=ns.offline,
846
+ timeout_ms=ns.timeout_ms, corpus="heldout" if ns.heldout else "corpus")
847
+ print(json.dumps(asdict(report), ensure_ascii=False, indent=2) if ns.json else _render(report))
848
+ return 0 if report.gate != "fail" else 1
849
+
850
+
851
+ if __name__ == "__main__":
852
+ raise SystemExit(main())