bantamkit 0.27.0__py3-none-any.whl

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 (119) hide show
  1. bantamkit/__init__.py +32 -0
  2. bantamkit/agent.py +458 -0
  3. bantamkit/assets/contracts/default.yaml +90 -0
  4. bantamkit/assets/evals/devteam/manifest.yaml +351 -0
  5. bantamkit/assets/evals/devteam/repo/HISTORY.md +18 -0
  6. bantamkit/assets/evals/devteam/repo/README.md +12 -0
  7. bantamkit/assets/evals/devteam/repo/docs/architecture.md +17 -0
  8. bantamkit/assets/evals/devteam/repo/docs/runbook.md +10 -0
  9. bantamkit/assets/evals/devteam/repo/issues/142-settlement-timeout.md +23 -0
  10. bantamkit/assets/evals/devteam/repo/patches/0009-retry-budget.patch +38 -0
  11. bantamkit/assets/evals/devteam/repo/src/ledger/__init__.py +3 -0
  12. bantamkit/assets/evals/devteam/repo/src/ledger/config.py +35 -0
  13. bantamkit/assets/evals/devteam/repo/src/ledger/errors.py +13 -0
  14. bantamkit/assets/evals/devteam/repo/src/ledger/posting.py +12 -0
  15. bantamkit/assets/evals/devteam/repo/src/ledger/registry.py +7 -0
  16. bantamkit/assets/evals/devteam/repo/src/ledger/report.py +9 -0
  17. bantamkit/assets/evals/devteam/repo/src/ledger/retry.py +17 -0
  18. bantamkit/assets/evals/devteam/repo/src/ledger/settle.py +16 -0
  19. bantamkit/assets/evals/devteam/repo/src/ledger/validate.py +14 -0
  20. bantamkit/assets/evals/devteam/repo/tests/test_posting.py +13 -0
  21. bantamkit/assets/evals/devteam/repo/tests/test_settle.py +9 -0
  22. bantamkit/assets/evals/devteam/tasks/dt-error-contract.yaml +186 -0
  23. bantamkit/assets/evals/devteam/tasks/dt-handler-map.yaml +183 -0
  24. bantamkit/assets/evals/devteam/tasks/dt-patch-before-after.yaml +182 -0
  25. bantamkit/assets/evals/devteam/tasks/dt-retry-attempts.yaml +181 -0
  26. bantamkit/assets/evals/devteam/tasks/dt-settlement-config.yaml +185 -0
  27. bantamkit/assets/evals/devteam/tasks/dt-symbol-home.yaml +181 -0
  28. bantamkit/assets/evals/devteam/tasks/dt-trace-blame.yaml +182 -0
  29. bantamkit/assets/evals/devteam/tasks/dt-unread-key.yaml +180 -0
  30. bantamkit/assets/evals/document/tasks/doc-large-in-137.yaml +38 -0
  31. bantamkit/assets/evals/document/tasks/doc-large-in-359.yaml +44 -0
  32. bantamkit/assets/evals/document/tasks/doc-large-in-372.yaml +38 -0
  33. bantamkit/assets/evals/document/tasks/doc-large-out-11764.yaml +37 -0
  34. bantamkit/assets/evals/document/tasks/doc-large-out-4137.yaml +37 -0
  35. bantamkit/assets/evals/document/tasks/doc-large-out-8022.yaml +37 -0
  36. bantamkit/assets/evals/document/tasks/doc-small-137.yaml +37 -0
  37. bantamkit/assets/evals/document/tasks/doc-small-261.yaml +37 -0
  38. bantamkit/assets/evals/document/tasks/doc-small-388.yaml +37 -0
  39. bantamkit/assets/evals/fixtures/.gitkeep +0 -0
  40. bantamkit/assets/evals/fixtures/catalog.json +6 -0
  41. bantamkit/assets/evals/perturbations/task-completion.yaml +576 -0
  42. bantamkit/assets/evals/tasks/.gitkeep +0 -0
  43. bantamkit/assets/evals/tasks/extract-contact.yaml +14 -0
  44. bantamkit/assets/evals/tasks/extract-invoice.yaml +14 -0
  45. bantamkit/assets/evals/tasks/extract-order.yaml +15 -0
  46. bantamkit/assets/evals/tasks/extract-schedule.yaml +14 -0
  47. bantamkit/assets/evals/tasks/extract-versions.yaml +17 -0
  48. bantamkit/assets/evals/tasks/nav-prod-port.yaml +84 -0
  49. bantamkit/assets/evals/tasks/nav-release-bundle.yaml +87 -0
  50. bantamkit/assets/evals/tasks/recall-audit-retention.yaml +17 -0
  51. bantamkit/assets/evals/tasks/recall-cache-ttl.yaml +13 -0
  52. bantamkit/assets/evals/tasks/recall-db-port.yaml +17 -0
  53. bantamkit/assets/evals/tasks/recall-deploy.yaml +13 -0
  54. bantamkit/assets/evals/tasks/recall-env-endpoint.yaml +18 -0
  55. bantamkit/assets/evals/tasks/recall-oncall-rotation.yaml +21 -0
  56. bantamkit/assets/evals/tasks/recall-oncall.yaml +13 -0
  57. bantamkit/assets/evals/tasks/recall-org-quota.yaml +18 -0
  58. bantamkit/assets/evals/tasks/recall-owner.yaml +13 -0
  59. bantamkit/assets/evals/tasks/shop-basket-total.yaml +10 -0
  60. bantamkit/assets/evals/tasks/shop-cheapest.yaml +9 -0
  61. bantamkit/assets/evals/tasks/shop-compare.yaml +9 -0
  62. bantamkit/assets/evals/tasks/shop-gadget-value.yaml +9 -0
  63. bantamkit/assets/evals/tasks/shop-stock-total.yaml +9 -0
  64. bantamkit/assets/evals/tasks/shop-total.yaml +9 -0
  65. bantamkit/assets/profiles/default.yaml +31 -0
  66. bantamkit/assets/profiles/patient.yaml +31 -0
  67. bantamkit/assets/rubrics/.gitkeep +0 -0
  68. bantamkit/assets/rubrics/code-quality.yaml +20 -0
  69. bantamkit/assets/rubrics/grounded-completion.yaml +37 -0
  70. bantamkit/assets/rubrics/task-completion.yaml +28 -0
  71. bantamkit/assets/schemas/shiftwork-checkpoint.json +188 -0
  72. bantamkit/assets/skills/.gitkeep +0 -0
  73. bantamkit/assets/skills/file-graph.md +7 -0
  74. bantamkit/assets/skills/memory.md +35 -0
  75. bantamkit/assets/tools/.gitkeep +0 -0
  76. bantamkit/assets/tools/bantamkit_read.json +48 -0
  77. bantamkit/assets/tools/bantamkit_status.json +25 -0
  78. bantamkit/assets/tools/build_identity.json +17 -0
  79. bantamkit/assets/tools/document_list.json +12 -0
  80. bantamkit/assets/tools/document_read.json +31 -0
  81. bantamkit/assets/tools/file_graph.json +12 -0
  82. bantamkit/assets/tools/memory_compact.json +31 -0
  83. bantamkit/assets/tools/memory_recall.json +38 -0
  84. bantamkit/assets/tools/memory_save.json +61 -0
  85. bantamkit/assets/tools/shiftwork_clock_in.json +25 -0
  86. bantamkit/assets/tools/shiftwork_clock_out.json +60 -0
  87. bantamkit/assets/tools/shiftwork_status.json +25 -0
  88. bantamkit/assets/tools/skill_audit.json +70 -0
  89. bantamkit/assets/tools/validate_json.json +31 -0
  90. bantamkit/assets.py +67 -0
  91. bantamkit/budget.py +114 -0
  92. bantamkit/client.py +329 -0
  93. bantamkit/contract.py +522 -0
  94. bantamkit/criticreplay.py +3241 -0
  95. bantamkit/critique.py +301 -0
  96. bantamkit/docread.py +1744 -0
  97. bantamkit/evalrun.py +2003 -0
  98. bantamkit/eventlog.py +282 -0
  99. bantamkit/filegraph.py +218 -0
  100. bantamkit/loopguard.py +101 -0
  101. bantamkit/mcpreport.py +763 -0
  102. bantamkit/mcpserver.py +1334 -0
  103. bantamkit/memory/__init__.py +28 -0
  104. bantamkit/memory/__main__.py +291 -0
  105. bantamkit/memory/component.py +569 -0
  106. bantamkit/memory/divergence.py +744 -0
  107. bantamkit/memory/layers.py +257 -0
  108. bantamkit/memory/store.py +940 -0
  109. bantamkit/pdfread.py +1402 -0
  110. bantamkit/profile.py +46 -0
  111. bantamkit/shiftwork.py +212 -0
  112. bantamkit/skillaudit.py +853 -0
  113. bantamkit/statusline.py +313 -0
  114. bantamkit/structured.py +125 -0
  115. bantamkit/textutil.py +30 -0
  116. bantamkit-0.27.0.dist-info/METADATA +207 -0
  117. bantamkit-0.27.0.dist-info/RECORD +119 -0
  118. bantamkit-0.27.0.dist-info/WHEEL +4 -0
  119. bantamkit-0.27.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,3241 @@
1
+ """Perturbation bar: score one critic over a family of meaning-preserving prompt edits.
2
+
3
+ RB-P14. A one-cell bar cannot tell a mechanism from a perturbation — deleting a single
4
+ semantically null byte from a rubric reproduced an entire pass signature. This module
5
+ replays the critic alone, at a pinned seed, over a small declared family of
6
+ meaning-preserving edits *to the rubric template*, and reports the pass rate with its
7
+ spread, so a rubric edit has to beat the noise band it lives in.
8
+
9
+ Layer: Measurement. It reads Contract assets (rubrics) and the frozen suite read-only,
10
+ calls Transport (`client.py`) and Core (`structured()`), and adds nothing to either.
11
+
12
+ Two constraints that are not optional (spec §6.1):
13
+
14
+ - **`CritiqueGate` is bypassed.** Its verdict memo keys on exact prompt bytes, so N
15
+ replays of one point would collapse into one model call and one repeated verdict —
16
+ silently destroying the spread this instrument exists to measure.
17
+ - **Requests go through `structured()`.** A hand-built `response_format` request on the
18
+ as-filed rubric scored 9 where the committed replay record has 5. Request
19
+ construction is load-bearing, which is why the reproduction gate comes first.
20
+
21
+ Not a whole-suite sweeper: its job is validating one change's attribution on a named
22
+ cell or a small task set.
23
+
24
+ **Guard 2 has two readings and this module reports both.** §3.3's shared-token guard
25
+ forbids a word "the point adds or removes" from appearing in the cell, and the spec says
26
+ that in two sentences which do not agree about what "the point adds or removes" means.
27
+ An independent re-derivation from the spec's Test sentence produced one table; this
28
+ module's original implementation produced another; the user's ruling (2026-08-12) is
29
+ that both are computed, both are named in the artifacts, and neither is declared wrong.
30
+ `moved_words` defines them; `guard_union` is what the run acts on when they differ.
31
+
32
+ **Where the guards live on the public API.** Guards 1, 3 and 4 are properties of a
33
+ point, so they run inside `apply_point` — the one public route from a `Point` to a
34
+ template — and a consumer who never calls `run()` still gets them. `check=False` is the
35
+ documented deliberate bypass. Guard 2 is a property of a (point, CELL) pair, so it
36
+ cannot ride along there.
37
+
38
+ **Start at `guarded_family`.** It is the one call from (variants, points, cells) to
39
+ `GuardedReplay` units, each carrying the exact `Rubric` to replay, the cell, the replay
40
+ count the bar uses, and guard 2's verdict on that pair under both readings. `unit.replay
41
+ (client)` issues it and stamps that verdict onto every `Verdict` it returns, so it puts
42
+ the taint on the object the consumer serializes, rather than in a second table they must
43
+ join. `run()` is those two calls and nothing else — there is no second derivation of the
44
+ family ON THE RUN PATH. (`materialize_manifest` derives it again for the `--manifest`
45
+ audit artifact; `_check_materialization` is what keeps the two from drifting.)
46
+ `apply_point`, `guard_table` and `replay_verdicts` are the primitives they are built
47
+ from, and they stay public because an offline audit needs them; but the guarded route is
48
+ now the SHORT one — two public calls against three, from the same starting object, since
49
+ `guarded_family` takes a bare `Rubric` as well as a `RubricVariant` — which is the whole
50
+ of RB-P23's fix.
51
+
52
+ RB-P23 is a defence-in-depth hole, not a soundness bug, and this does not make it
53
+ impossible: Python has no private functions, so `apply_point` -> hand-assembled `Rubric`
54
+ -> `replay_verdicts` still reaches the wire with a tainted pair and nothing recording it.
55
+ What changed is the length comparison. That residual is measured, not claimed, by
56
+ `test_the_unguarded_route_still_reaches_the_wire_and_is_the_longer_one`, which EXECUTES
57
+ both routes from one `Rubric` and counts the module calls each one makes.
58
+ """
59
+
60
+ from __future__ import annotations
61
+
62
+ import argparse
63
+ import difflib
64
+ import errno
65
+ import hashlib
66
+ import json
67
+ import os
68
+ import re
69
+ import subprocess
70
+ import sys
71
+ from collections.abc import Callable
72
+ from dataclasses import dataclass, field
73
+ from itertools import combinations
74
+ from pathlib import Path, PurePosixPath, PureWindowsPath
75
+
76
+ import yaml
77
+
78
+ from bantamkit.assets import assets_root
79
+ from bantamkit.client import BantamError, Message, ModelClient, OpenAICompatible
80
+ from bantamkit.critique import Rubric
81
+ from bantamkit.evalrun import TrackingClient
82
+ from bantamkit.structured import structured
83
+
84
+ __all__ = [
85
+ "ARTIFACT_WRITE_EXIT",
86
+ "GUARD_DECISION_RULE",
87
+ "GUARD_MODES",
88
+ "GUARD_VIOLATION_EXIT",
89
+ "IN_MEMORY_REF",
90
+ "READINGS",
91
+ "READING_RULES",
92
+ "REFUSAL_EXIT",
93
+ "RENDER_FAILURE_EXIT",
94
+ "USAGE_EXIT",
95
+ "Case",
96
+ "GuardedFamily",
97
+ "GuardedReplay",
98
+ "Manifest",
99
+ "PerturbationError",
100
+ "Point",
101
+ "ReplayRow",
102
+ "RubricVariant",
103
+ "RunResult",
104
+ "Verdict",
105
+ "apply_point",
106
+ "cell_guard_violations",
107
+ "exit_status",
108
+ "guard_table",
109
+ "guard_union",
110
+ "guarded_family",
111
+ "load_cases",
112
+ "load_manifest",
113
+ "main",
114
+ "materialize_manifest",
115
+ "moved_words",
116
+ "normalize_whitespace",
117
+ "parse_rubric_arg",
118
+ "payload_shas_recorded",
119
+ "point_admissibility_violations",
120
+ "render_prompt",
121
+ "replay_scores",
122
+ "replay_verdicts",
123
+ "rubric_arg_shape_problem",
124
+ "rubric_label_collision_problem",
125
+ "run",
126
+ "substitution_pair_moved_words",
127
+ "summarize",
128
+ "whole_text_moved_words",
129
+ ]
130
+
131
+ BAR = "perturbation"
132
+
133
+ # §3.3 step 3, guard 1. The schema keys, the JSON braces and the band digits are the
134
+ # rubric's contract with the wire; a point that moves one is a requirement edit.
135
+ FROZEN_LITERALS = ('"score"', '"feedback"', "9-10", "5-8", "0-4", "{{", "}}")
136
+
137
+ # §3.3 step 3, guard 3. "should not" for "do NOT" changes the force of a directive, so a
138
+ # paraphrase that moves any of these is a requirement edit wearing a paraphrase's clothes.
139
+ FROZEN_KEYWORDS = (
140
+ "ONLY",
141
+ "NOT",
142
+ "never",
143
+ "every",
144
+ "any",
145
+ "must",
146
+ "should",
147
+ "even when",
148
+ "instead",
149
+ )
150
+
151
+ # The point classes §3 admits. `identity` is a mandatory member, not a perturbation class.
152
+ CLASSES = ("identity", "whitespace", "order", "paraphrase")
153
+
154
+ # What a run does when a point violates §3.3's shared-token guard on a cell. See
155
+ # `guard_table` for why `warn` is the default and `error` is not.
156
+ GUARD_MODES = ("warn", "error")
157
+
158
+ # ---- the exit-status contract (§6.2, RB-P24) ----
159
+ #
160
+ # Five outcomes a CI job has to tell apart. Four of them are meanings this tool
161
+ # chooses, and TWO MEANINGS MAY NOT SHARE ONE NUMBER — which is exactly what was
162
+ # wrong: a run where guard 2 fired on eight cells exited 0, indistinguishable from a
163
+ # clean one, so `--guard error` (which refuses instead of measuring) was the only
164
+ # machine-readable verdict there was.
165
+ #
166
+ # 0 measured, and guard 2 fired on nothing that ran
167
+ # 1 DID NOT COMPLETE A MEASUREMENT THAT WAS ATTEMPTED — every `BantamError` path
168
+ # reached from inside `main`'s `try`, `--guard error` included, and every abort
169
+ # in the middle of one. The perturbation-bar spec §6/§11 rest on this family
170
+ # being non-zero. It does NOT promise that nothing was written: the JSONL sink
171
+ # flushes per row on purpose, so a run that dies mid-family (a refused
172
+ # connection on request 40 of 80) exits 1 with rows already on disk. 1 means the
173
+ # run owes you no conclusion, not that it left no trace. Read it as "fix the
174
+ # input or the environment and run it again", and treat any artifact from a 1 as
175
+ # partial. WHAT IS NO LONGER IN HERE (RB-P32): a command line that is malformed
176
+ # on its face. Those left for 2, because they can leave no artifact at all and
177
+ # "treat the artifacts as partial" is not advice a typo can act on.
178
+ # 2 THE COMMAND LINE ITSELF IS WRONG, and no run was attempted. Two sources, one
179
+ # number. The number is argparse's and is recorded here as a MEASURED one
180
+ # (`--not-a-flag` exits 2), pinned from a shell by
181
+ # `test_argparses_usage_status_is_measured_not_assumed`, so that the statuses this
182
+ # module does choose cannot silently collide with it. WHICH of this module's own
183
+ # rules report it IS this module's choice, and since RB-P32 the answer is: all of
184
+ # the argument-SHAPE ones and only those, every one of them routed through
185
+ # `parser.error` above `main`'s `try` (`--replays 0`, `--identity-replays 0`,
186
+ # `--rubric` with no `LABEL=`, `--rubric a=git:HEAD`, `--rubric a=X --rubric a=Y`).
187
+ # shape rules (exact set): --identity-replays --replays --rubric
188
+ # THAT LINE IS CHECKED AGAINST THE CODE, not against this paragraph (K4B/I7). The
189
+ # old check compared this block and the epilog for two substrings and joined them
190
+ # with `and`, so renaming `parser.error` here turned it green while the epilog kept
191
+ # a claim that was false. `test_the_usage_status_names_exactly_the_shape_rules_the_
192
+ # code_has` now DERIVES the set — every `parser.error` reachable above `main`'s
193
+ # `try`, and the `--flag` names its messages carry — and requires this roster and
194
+ # the epilog's to be exactly it, with
195
+ # `test_every_shape_rule_flag_is_the_usage_status_in_the_field` measuring each named
196
+ # flag to 2 and each unnamed module rule to 1 from a real shell. A rename changes
197
+ # nothing; adding a shape rule without a field case, or naming a flag here that has
198
+ # no shape rule, is red.
199
+ # WHERE THE LINE IS, AND WHY IT IS NOT "WHOSE RULE IS IT" (RB-P32). A 2 is an
200
+ # argv that is malformed ON ITS FACE: `rubric_arg_shape_problem` resolves no
201
+ # path, opens no file and runs no `git`, so the verdict is a function of the
202
+ # typed string alone. That is what licenses the strong claim a 2 makes — this
203
+ # command can never work on any machine, so nothing ran, nothing was written and
204
+ # re-running it unchanged is guaranteed to fail again. A 1 is an argv that is
205
+ # well formed and names something the world did not supply (`--rubric
206
+ # a=/tmp/gone.yaml`, an empty `--transcripts` directory): the same argv succeeds
207
+ # on the next run once the world changes, and there may be partial artifacts to
208
+ # handle. The line is drawn for the CI job that has to ACT on the number: on a 2
209
+ # a human edits the command line and there is nothing on disk to quarantine; on a
210
+ # 1 the artifacts are partial and the input or the environment is what to fix.
211
+ # Three candidate lines were tested against the measured matrix and all three
212
+ # failed as descriptions of the code as it stood: "2 is argparse's, 1 is ours" is
213
+ # circular (`--replays 0` is this module's rule and was already 2); "2 before
214
+ # parsing finishes, 1 after" is false (the `parser.error` call is after
215
+ # `parse_args` returns); "1 needs the world, 2 does not" was false in one
216
+ # direction (`--rubric /tmp/x.yaml` consults nothing and was 1) — and it is that
217
+ # third one that RB-P32 made TRUE by moving the behaviour, rather than by
218
+ # re-describing it.
219
+ # WHAT MOVED, and it is a behaviour change a CI consumer sees. Four cases went
220
+ # from 1 to 2: `--rubric SPEC` with no `LABEL=`, `--rubric =SPEC`, `--rubric
221
+ # LABEL=`, and `--rubric a=git:HEAD` (which did not even reach the handler — it
222
+ # raised an uncaught `ValueError` and gave a raw traceback with the
223
+ # interpreter's 1). Every other case in the matrix kept its number. Measured in a
224
+ # real shell before and after in
225
+ # docs/eval-data/2026-08-13-rbp32-argument-validation-matrix.md and
226
+ # -matrix-after.md.
227
+ # A FIFTH CASE MOVED IN K4B, and the sentence above was FALSE without it (K4's C2):
228
+ # `--rubric a=X --rubric a=Y`. Two labels that collide are decidable from the typed
229
+ # strings alone — no path, no file, no `git` — yet the rule lived in
230
+ # `guarded_family` inside the `try` and reported 1 on an argv the world had
231
+ # supplied everything for. It now goes through `rubric_label_collision_problem` and
232
+ # `parser.error`; `guarded_family` keeps its own copy for in-process callers, whose
233
+ # labels come from a bare `Rubric`'s `name` and are world-dependent, so the two
234
+ # checks agree on the verdict and differ on what they are entitled to look at. It
235
+ # also stopped a measured waste: `--rubric a=git:R:P --rubric a=git:R:P` spent TWO
236
+ # `git show` subprocesses before noticing, and now spends none. Before and after in
237
+ # docs/eval-data/2026-08-13-k4b-c2-duplicate-rubric-label.md.
238
+ # 3 measured, WITH violations. Every artifact is written — the JSONL, the summary,
239
+ # the printed table — and the status says the guard fired, never that the run
240
+ # failed. A violating cell (`nav-prod-port` is one) has to stay measurable.
241
+ # 4 measured, but AN ARTIFACT COULD NOT BE WRITTEN — today, exactly: the
242
+ # `--summary` file. The measurement completed and the table is still printed;
243
+ # what is missing is the file a later reader would diff. It is a separate
244
+ # number because it used to be an unhandled `OSError`, i.e. a 1, on a run that
245
+ # had already flushed every JSONL row — the refusal status on a run that
246
+ # measured (RB-P24 review, C1). It OUTRANKS 3: "every artifact is written" is
247
+ # the one thing 3 promises, and here it is false. `--violations-exit-zero`
248
+ # cannot suppress it — the hatch is an opt-out from 3 alone.
249
+ # 5 measured, but THE REPORT COULD NOT BE RENDERED: the run path's own write to
250
+ # stdout failed for a reason that is NOT the reader going away. fd 1 on a
251
+ # read-only fd (EBADF), fd 1 closed before the process started (`1>&-`, where
252
+ # CPython leaves `sys.stdout` as None and `print` is a silent no-op), a full
253
+ # device (ENOSPC), or a stdout whose CODEC cannot represent the table
254
+ # (`PYTHONIOENCODING=latin-1` or `=ascii`: the GUARD section always carries U+2014
255
+ # and U+00A7, so the write raises `UnicodeEncodeError` — K4B/C1, and until it was
256
+ # fixed that case escaped this arm and reported 1 on a run that measured, which is
257
+ # the RB-P24 defect the PR's own headline claimed to have closed).
258
+ # RB-P31, and it is the SAME shape as 4 one step further out:
259
+ # the measurement completed and every file that could be written is on disk,
260
+ # but the table a reader would have read is not anywhere. It OUTRANKS 4 for
261
+ # 4's own reason — each number here outranks the one below it because the
262
+ # lower one's promise is false about this run, and 4's promise, in its own
263
+ # sentence above, is "the table is still printed". `--violations-exit-zero`
264
+ # does not suppress it either.
265
+ # WHY NOT REUSE 4. Two meanings may not share one number (the rule this whole
266
+ # block exists to enforce), and these are two: on a 4 the answer IS in the
267
+ # log and the fix is a writable `--summary` path; on a 5 the answer is NOT in
268
+ # the log and the fix is the caller's stdout. Reusing 4 would have required
269
+ # deleting "the table is still printed" from a number CI jobs already read,
270
+ # which is a larger break of the contract than adding one.
271
+ # WHY NOT DOWNGRADE TO THE EARNED STATUS, the way EPIPE does. EPIPE means
272
+ # NOBODY WAS READING, so the table's absence costs no one anything and 0 is
273
+ # still true. Here the report was wanted and is gone; 0 would say "measured,
274
+ # clean" about a run whose report nobody got.
275
+ #
276
+ # A CI job writes against these directly: 0 clean, 1 fix the input and re-run (any
277
+ # artifacts are partial), 2 fix the command line, 3 the run happened and the GUARD
278
+ # section and the `guard_dropped` blocks have to be read before anything is credited,
279
+ # 4 the run happened but its summary is not on disk, 5 the run happened but its table
280
+ # was never delivered — read the artifacts, not the log. Branch on 0-5; 130 is SIGINT
281
+ # and 143 is SIGTERM.
282
+ #
283
+ # ---- RB-P27, and what replaced a sentence that was wrong (v0.19.0) ----
284
+ #
285
+ # This comment used to end "a stdout that goes away mid-table is the interpreter's
286
+ # number, not one of these — a CI job should branch on this range and treat everything
287
+ # else as *did not run to completion*". The first half was true and the second was
288
+ # false about the very case the first half named: that run HAD run to completion, every
289
+ # JSONL row and the summary were on disk to prove it, and it reported 120 anyway.
290
+ #
291
+ # COVERED SINCE v0.19.0: the READER GOING AWAY at the run path's own write to
292
+ # stdout — the table print, its flush, and the interpreter's shutdown flush behind them.
293
+ # THE STATE, NOT ONE OS'S SPELLING OF IT (job 31). Until then this was written as EPIPE
294
+ # and keyed on `BrokenPipeError`, which is what POSIX raises and what Windows does NOT:
295
+ # there the same dead pipe arrives as a plain `OSError` with `errno == EINVAL` and no
296
+ # `winerror`, so the arm was never entered and a clean run reported 5. The question is
297
+ # now asked once, by `_stdout_reader_is_gone`, and that function carries the reading.
298
+ # If the reader of stdout is gone there, the run reports the status it EARNED (0, 3 or 4)
299
+ # and never the interpreter's number. A lost stdout may DOWNGRADE to a number the run
300
+ # already had; it may not invent one. Read from a real shell's `$?` for all three earned
301
+ # values by `test_closed_pipe_*` in test_criticreplay.py.
302
+ #
303
+ # ---- RB-P31, and the half of that sentence that was a defect ----
304
+ #
305
+ # The sentence above used to end "and this is the whole of what is covered". It was, and
306
+ # the price was measured across a 45-cell matrix on 2026-08-13
307
+ # (docs/eval-data/2026-08-13-rbp31-render-failure-matrix.md): a stdout write that failed
308
+ # for ANY OTHER reason escaped `main` on a run that had MEASURED — JSONL rows and summary
309
+ # on disk, byte-identical to the same argv with a live reader — and the shell read 120
310
+ # below fd 1's `BufferedWriter` and REFUSAL_EXIT above it, and REFUSAL_EXIT at every size
311
+ # when fd 1 was closed outright. A run that measured reporting that it refused is the
312
+ # RB-P24 defect class, one line from where RB-P24 fixed it.
313
+ #
314
+ # COVERED NOW: the run path's own write to stdout failing for a reason other than a gone
315
+ # reader reports 5 (above), not the interpreter's number and never REFUSAL_EXIT. Three
316
+ # things make that arm narrow rather than a bare `except`:
317
+ # - `format_table(summary)` is evaluated OUTSIDE the try. A failure to BUILD the table
318
+ # is a bug in this module and propagates with its traceback; only the WRITE is inside
319
+ # the arm. Pinned by `test_a_non_pipe_failure_around_the_table_print_is_not_downgraded`,
320
+ # which raises from `format_table` and requires both raisers to reach the caller.
321
+ # - the arm catches `OSError` and `UnicodeEncodeError`, not `Exception`, and that pair
322
+ # is a LINE rather than a list: they are the two things about stdout THE CALLER
323
+ # OWNS — the descriptor, and the codec it was wrapped in. Neither is editable from
324
+ # inside this file and on both the measurement is complete. Anything else raised BY
325
+ # THE WRITE is a bug in this module and still propagates: pinned by
326
+ # `test_a_non_oserror_at_the_table_write_is_not_downgraded` (a `RuntimeError`) and by
327
+ # `test_a_non_unicode_valueerror_at_the_table_write_is_not_downgraded` (a bare
328
+ # `ValueError`, the sibling of the class that WAS added, so the widening is pinned
329
+ # exactly where it stops).
330
+ # K4B/C1 IS WHY THE SECOND CLASS IS THERE, and it is worth the sentence: the first
331
+ # version of this arm justified its narrowness on "a non-`OSError` at the write is a
332
+ # bug in THIS MODULE". That generalisation was argued against `RuntimeError` and
333
+ # never tested against the only realistic non-`OSError` a write can raise.
334
+ # `UnicodeEncodeError` at the write is a property of the caller's stdout in exactly
335
+ # the way `EBADF` is a property of the caller's fd, so it belongs on the same side of
336
+ # the line — and with the class missing, `PYTHONIOENCODING=latin-1` was enough to
337
+ # make a run that measured report REFUSAL_EXIT (field-measured at 3981efd: status 1,
338
+ # 320 rows and a summary byte-identical to the same argv on a live stdout that
339
+ # earned 3, docs/eval-data/2026-08-13-k4b-c1-stdout-encoding-matrix.md).
340
+ # - the closed-fd 1 case is READ, not caught: `sys.stdout is None` is a state CPython
341
+ # puts the interpreter in before `main` runs, and there is no exception to catch —
342
+ # `print` to a None stdout is a silent no-op and the table is lost with no raise at
343
+ # all. That cell is why "give the print an `except OSError` arm", RB-P31's own filed
344
+ # attack direction, would not have closed it.
345
+ #
346
+ # The buffering is not the axis of the FIX, but it is why the defect looked like two
347
+ # defects: whether the doomed bytes are still INSIDE THE PROCESS when the failure
348
+ # surfaces decides whether the interpreter's finalization flush re-fails and overwrites
349
+ # the status with 120. Both sides are handled by the same arm and both are pinned, at
350
+ # both sizes.
351
+ #
352
+ # TWO LAYERS DECIDE THAT, AND THIS COMMENT USED TO NAME ONLY THE LOWER ONE. CPython does
353
+ # give `sys.stdout` a `BufferedWriter` of `os.fstat(1).st_blksize` bytes — 4096 for a
354
+ # regular file, 16384 for a pipe, 65536 for `/dev/null` — but a text `print` reaches it
355
+ # through a `TextIOWrapper` that holds everything until its own 8192-unit chunk fires, so
356
+ # the point at which bytes actually leave is `max(io.DEFAULT_BUFFER_SIZE, st_blksize + 1)`
357
+ # and `st_blksize` alone decides nothing whenever it is below 8192. Measured 2026-08-22,
358
+ # macOS/APFS, CPython 3.13: with `st_blksize == 4096`, an 8191-byte text write leaves 0
359
+ # bytes on fd 1 before any flush and an 8192-byte one leaves 8192 — crossover 8192, not
360
+ # 4097. Behind a pipe (`st_blksize == 16384`) 16384 leaves 0 and 16385 leaves 16385,
361
+ # which is what shows `st_blksize` really is the `BufferedWriter` size and really is not
362
+ # the crossover. Nothing in this module reads either number; the file that did was the
363
+ # test file's own straddle guard, and it now measures the crossover instead.
364
+ #
365
+ # STILL OUTSIDE THE RANGE, measured 2026-08-13 rather than assumed, because "everything
366
+ # outside 0-4 means the run did not complete" is STILL not a true reading. This list is
367
+ # OPEN — it is what has been measured, not a proof that nothing else escapes:
368
+ # - `--help` with no reader on stdout leaves the range with a number THIS MODULE DOES
369
+ # NOT CHOOSE, and THE NUMBER IS NOT FIXED — do not branch on a particular one.
370
+ # argparse writes the epilog and exits before `main` reaches the handler, so the
371
+ # handler is not on that path at all; `argparse._print_message` then SWALLOWS the
372
+ # write error (`except (AttributeError, OSError): pass`, CPython 3.10+), so nothing
373
+ # propagates and what the shell reads is decided entirely by what the interpreter's
374
+ # shutdown flush finds in the `BufferedWriter` — the same buffer-size accident this
375
+ # comment names four paragraphs up, applied to this module's own help text. So the
376
+ # number turns on the SIZE OF THE HELP TEXT against a buffer this module does not
377
+ # set. This line used to say "exits 120", and it went false in CI with no behaviour
378
+ # of this module changed, because docstrings added by an unrelated fix grew the
379
+ # epilog past the boundary on one platform (RB-P35, 2026-08-14; both readings and
380
+ # their byte counts are in docs/eval.md, which is where a number that can go stale
381
+ # belongs). What is pinned here is the relation and not the number:
382
+ # `test_help_with_no_reader_on_stdout_is_still_the_interpreters_number` asserts this
383
+ # module reads what a bare interpreter doing exactly what argparse does reads.
384
+ # - a run that WRITES to stderr while stderr has no reader exits 120 — a refusal's
385
+ # `error: …`, the summary-write failure, the `--violations-exit-zero` note. The
386
+ # refusal's own 1 is erased exactly as the table's 3 used to be. A run that writes
387
+ # nothing to stderr is unaffected (nothing to flush; measured, still 3).
388
+ # Those two are pinned by nodes that go red if a later change covers them, so they cannot
389
+ # go stale silently: `test_help_with_no_reader_on_stdout_is_still_the_interpreters_
390
+ # number` and `test_a_refusal_whose_stderr_has_no_reader_leaves_the_range`. NOTHING pins
391
+ # the list's exhaustiveness, and it is NOT exhaustive. What RB-P31's fix does NOT reach,
392
+ # stated so the next reader does not have to re-derive it:
393
+ # - anything written to stdout OUTSIDE the run path's own table print. `--help` is the
394
+ # measured instance; argparse writes and exits before `main` gets there.
395
+ # - stderr, on BOTH of its axes. Every arm here reports on stderr, so a run whose
396
+ # stderr is gone loses the explanation and the finalization flush of fd 2 takes the
397
+ # status with it — and a stderr whose CODEC cannot encode what is written to it is
398
+ # the same hole one class over. This module's own status messages are ASCII for that
399
+ # reason (K4B/C1), which makes the 4 and 5 reports survive `PYTHONIOENCODING=ascii`;
400
+ # a `BantamError` whose message is not ASCII, on such a stderr, still leaves by
401
+ # traceback. It reports the number the refusal would have reported anyway, so it is a
402
+ # DELIVERY defect and not a status one, and it is filed rather than fixed:
403
+ # docs/eval-data/2026-08-13-k4b-c1-stdout-encoding-matrix.md.
404
+ # - fd 1 pointing at a DIRECTORY. CPython dies in `init_sys_streams` with
405
+ # `IsADirectoryError` before `main` exists, prints `Fatal Python error`, and the
406
+ # shell reads 1 (measured 2026-08-13, K4B/M3). Nothing in this module runs, so
407
+ # nothing here can choose that number; it is recorded so the next reader does not
408
+ # re-file it as a defect of this arm. Pinned by
409
+ # `test_fd_one_on_a_directory_never_reaches_this_module`.
410
+ # - ENOSPC on a real full device was NOT constructed in the field (macOS has no
411
+ # `/dev/full`); it is in the arm by class, and only simulated in-process. On Linux
412
+ # the cell is `... > /dev/full` and it is worth one line there.
413
+ #
414
+ # So the advice is: branch on 0-5, and read anything else as "this process did not
415
+ # choose its own status" — a signal, or a stream this handler does not cover — rather
416
+ # than as "the measurement did not happen". The artifacts may still be on disk, and
417
+ # under RB-P27's case (a) and RB-P31's whole matrix they demonstrably were.
418
+ REFUSAL_EXIT = 1
419
+ USAGE_EXIT = 2
420
+ GUARD_VIOLATION_EXIT = 3
421
+ ARTIFACT_WRITE_EXIT = 4
422
+ RENDER_FAILURE_EXIT = 5
423
+
424
+ # §3.3 guard 2 has two defensible readings of "a word the point adds or removes", and
425
+ # the spec supports each in a different sentence. The user's ruling (2026-08-12) is that
426
+ # both are computed and reported side by side and neither is called wrong; `guard_union`
427
+ # is the decision rule that has to act when they disagree. See `moved_words`.
428
+ READINGS = ("whole-text", "substitution-pair")
429
+
430
+ READING_RULES = {
431
+ "whole-text": (
432
+ "symmetric difference of the base and perturbed TEMPLATE word sets — §3.3's "
433
+ "operative Test sentence, and what an independent re-derivation from the spec "
434
+ "produced"
435
+ ),
436
+ "substitution-pair": (
437
+ "symmetric difference of the point's own `from`/`to` word sets — what §3.3 "
438
+ "step 4's substitution-pair form and the manifest's P2 justification imply"
439
+ ),
440
+ }
441
+
442
+ GUARD_DECISION_RULE = (
443
+ "union — a (point, cell) is tainted if EITHER reading flags it, because "
444
+ "under-detection is the direction this guard exists to prevent"
445
+ )
446
+
447
+ NO_NEWLINE = "\"
448
+
449
+ # §3.3 guard 2's "tokenize on word boundaries". A HYPHEN IS A WORD BOUNDARY: with it
450
+ # word-internal, `requests-per-minute` was one token, so the guard could not see
451
+ # `requests` inside it and reported clean on `recall-org-quota` — a false negative,
452
+ # the direction this guard exists to prevent, and true under both readings. The four
453
+ # compounds in the frozen prompts (`requests-per-minute`, `on-call`, `billing-svc`,
454
+ # `INV-42`) hid seven words between them. Pinned by
455
+ # `test_a_hyphen_does_not_hide_the_words_inside_a_compound`.
456
+ _WORD = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
457
+
458
+
459
+ class PerturbationError(BantamError):
460
+ """The instrument cannot report a number it would be able to defend."""
461
+
462
+
463
+ # ---- small helpers ----
464
+
465
+
466
+ def sha256_text(text: str) -> str:
467
+ return hashlib.sha256(text.encode()).hexdigest()
468
+
469
+
470
+ def normalize_whitespace(text: str) -> str:
471
+ """§3.1's admissibility predicate, one half of it: collapse runs and strip.
472
+
473
+ Two strings are in class W of each other iff their normalizations are
474
+ byte-identical. Mechanical and checkable by anyone; nothing else is class W.
475
+ """
476
+ return " ".join(text.split())
477
+
478
+
479
+ def _diff_lines(text: str) -> list[str]:
480
+ """Lines for a unified diff, with git's explicit missing-trailing-newline marker.
481
+
482
+ Without the marker a trailing-newline edit — the null control, the whole reason this
483
+ module exists — diffs as no change at all, because both sides have identical lines.
484
+ """
485
+ lines = text.splitlines()
486
+ if not text.endswith("\n"):
487
+ lines.append(NO_NEWLINE)
488
+ return lines
489
+
490
+
491
+ def unified_diff(base: str, new: str, from_label: str = "base", to_label: str = "point") -> str:
492
+ if base == new:
493
+ return ""
494
+ return "\n".join(
495
+ difflib.unified_diff(
496
+ _diff_lines(base),
497
+ _diff_lines(new),
498
+ fromfile=from_label,
499
+ tofile=to_label,
500
+ n=1,
501
+ lineterm="",
502
+ )
503
+ )
504
+
505
+
506
+ def _words(text: str) -> set[str]:
507
+ return set(_WORD.findall(text.lower()))
508
+
509
+
510
+ # §3.3 step 3, guard 4. A sentence ends at `.`/`!`/`?` followed by whitespace OR the end
511
+ # of the string — a NEWLINE is whitespace, which is the whole point: the template is
512
+ # hard-wrapped, so the predicate this replaces (`". " in anchor` or a trailing `.`) was
513
+ # inverted on real input. It raised on an anchor that is exactly one complete sentence,
514
+ # and passed an anchor spanning two sentences joined by a newline.
515
+ _SENTENCE_END = re.compile(r"[.!?](?=\s|$)")
516
+
517
+ # §3.3 step 3, guard 3, at WORD boundaries and case-sensitively (§3.6 X3: case here does
518
+ # illocutionary work). Substring counting reported `every` as moved when `itself` became
519
+ # `everything`.
520
+ _FROZEN_KEYWORD_RE = {
521
+ word: re.compile(rf"(?<![A-Za-z0-9_]){re.escape(word)}(?![A-Za-z0-9_])")
522
+ for word in FROZEN_KEYWORDS
523
+ }
524
+
525
+
526
+ def _spans_a_sentence_boundary(text: str) -> bool:
527
+ """True when `text` continues past a sentence terminator.
528
+
529
+ "One sentence per instance" admits a whole sentence — an instance that IS one
530
+ complete sentence is the easiest thing there is to check at a glance. What it
531
+ forbids is an instance that runs past a boundary into a second sentence.
532
+ """
533
+ stripped = text.strip()
534
+ return any(match.end() != len(stripped) for match in _SENTENCE_END.finditer(stripped))
535
+
536
+
537
+ def _keyword_positions(text: str) -> list[tuple[str, int]]:
538
+ """Every frozen keyword in `text`, with its ordinal position in the word sequence.
539
+
540
+ Counting can only see a keyword appear or vanish. `"Judge ONLY whether"` ->
541
+ `"Judge whether ONLY"` changes what `ONLY` scopes over while leaving every count
542
+ identical, and that is a requirement edit wearing a paraphrase's clothes — exactly
543
+ what guard 3 is for. Comparing positions across the instance catches it, and still
544
+ admits rewording the words a keyword governs (`"Do NOT deduct"` -> `"Do NOT
545
+ subtract"` leaves `NOT` at index 1).
546
+ """
547
+ words = _WORD.findall(text)
548
+ found: list[tuple[str, int]] = []
549
+ for keyword in FROZEN_KEYWORDS:
550
+ parts = keyword.split()
551
+ for index in range(len(words) - len(parts) + 1):
552
+ if words[index : index + len(parts)] == parts:
553
+ found.append((keyword, index))
554
+ return sorted(found)
555
+
556
+
557
+ # ---- the manifest (§3, §4.1) ----
558
+
559
+
560
+ @dataclass
561
+ class Point:
562
+ id: str
563
+ point_class: str # `class` in the manifest and in the JSONL row; not a Python name
564
+ rule: str
565
+ op: str
566
+ replace: list[dict] = field(default_factory=list)
567
+ swap: dict | None = None
568
+ justification: str | None = None
569
+ variants: dict = field(default_factory=dict)
570
+
571
+
572
+ @dataclass
573
+ class Manifest:
574
+ rubric: str
575
+ requirement_inventory: list[str]
576
+ points: list[Point]
577
+ materialized_variants: dict
578
+ sha256: str
579
+ path: Path
580
+
581
+
582
+ def default_manifest_path(rubric_name: str) -> Path:
583
+ """`assets/evals/perturbations/<rubric-name>.yaml`.
584
+
585
+ Measurement *input*, not a Contract asset: no product code path loads it, and
586
+ `test_layers.py`'s golden byte-identity guard over contract strings does not extend
587
+ to it. It lives under `assets/evals/` because that is already where measurement
588
+ input lives (`tasks/`, `fixtures/`), and a test asserts this module is its only
589
+ reader.
590
+ """
591
+ return assets_root() / "evals" / "perturbations" / f"{rubric_name}.yaml"
592
+
593
+
594
+ def load_manifest(path: Path | None = None, rubric_name: str | None = None) -> Manifest:
595
+ if path is None:
596
+ if rubric_name is None:
597
+ raise PerturbationError("load_manifest needs a path or a rubric name")
598
+ path = default_manifest_path(rubric_name)
599
+ path = Path(path)
600
+ if not path.is_file():
601
+ raise PerturbationError(f"perturbation manifest not found: {path}")
602
+ raw = path.read_text(encoding="utf-8")
603
+ data = yaml.safe_load(raw)
604
+ points = []
605
+ for entry in data["points"]:
606
+ if entry["class"] not in CLASSES:
607
+ raise PerturbationError(f"point '{entry['id']}' has unknown class {entry['class']!r}")
608
+ points.append(
609
+ Point(
610
+ id=entry["id"],
611
+ point_class=entry["class"],
612
+ rule=entry["rule"],
613
+ op=entry["op"],
614
+ replace=entry.get("replace") or [],
615
+ swap=entry.get("swap"),
616
+ justification=entry.get("justification"),
617
+ variants=entry.get("variants") or {},
618
+ )
619
+ )
620
+ ids = [p.id for p in points]
621
+ if len(set(ids)) != len(ids):
622
+ raise PerturbationError(f"duplicate point ids in {path}")
623
+ return Manifest(
624
+ rubric=data["rubric"],
625
+ requirement_inventory=list(data["requirement_inventory"]),
626
+ points=points,
627
+ materialized_variants=data.get("materialized_variants") or {},
628
+ sha256=sha256_text(raw),
629
+ path=path,
630
+ )
631
+
632
+
633
+ def apply_point(point: Point, template: str, *, check: bool = True) -> str | None:
634
+ """Apply one declared, text-anchored transformation to one template.
635
+
636
+ Returns the perturbed template, or `None` when the point's anchor is absent from
637
+ this variant (§4.1: `applicable: false`, which paired dropping then handles). Never
638
+ a silent no-op — a transformation that fires and changes nothing is an error, not a
639
+ point, and so is an anchor that matches in more places than it claims.
640
+
641
+ **Guards 1, 3 and 4 run here** (`check=True`, the default). They are properties of
642
+ the POINT, not of any cell, so they ride with the transformation itself rather than
643
+ living only inside `run()`. This is the public route from a `Point` to a template,
644
+ so a library consumer with a hand-rolled manifest gets them without knowing they
645
+ exist — the case `3420384` claimed to close for `--manifest` and left open here.
646
+
647
+ Guard 2 cannot ride along: it is a property of a (point, CELL) pair and this
648
+ function never sees a cell.
649
+
650
+ **A PRIMITIVE, not the route to a replay.** `guarded_family` is what a consumer
651
+ scoring a family should call — it is built from this function and returns templates
652
+ already paired with guard 2's verdict on each cell. Reaching a request from here
653
+ instead means assembling the `Rubric` by hand and getting no guard 2 at all, which is
654
+ RB-P23's residual: possible, and now the longer of the two routes — three public
655
+ calls (`apply_point`, `Rubric`, `replay_verdicts`) against the guarded route's two
656
+ (`guarded_family`, `unit.replay`), from the same starting `Rubric`.
657
+
658
+ `check=False` is the deliberate bypass, and it is deliberate on purpose: a guard
659
+ nobody can turn off is a guard people route around. `_variant_family` uses it so it
660
+ can raise a message naming the variant as well as the point.
661
+ """
662
+ new = _transform(point, template)
663
+ if new is None:
664
+ return None
665
+ if new == template and point.op != "identity":
666
+ raise PerturbationError(
667
+ f"point '{point.id}' is a no-op on a variant whose anchor it matched — "
668
+ "a transformation that changes nothing is an error, not a point"
669
+ )
670
+ if check:
671
+ problems = point_admissibility_violations(point, template, new)
672
+ if problems:
673
+ raise PerturbationError(
674
+ f"point '{point.id}' is inadmissible (spec §3.3 step 3): it "
675
+ f"{'; it '.join(problems)} — a point that is not meaning-preserving "
676
+ "measures a requirement edit, not a perturbation. Pass check=False to "
677
+ "apply it anyway."
678
+ )
679
+ return new
680
+
681
+
682
+ def _transform(point: Point, template: str) -> str | None:
683
+ if point.op == "identity":
684
+ return template
685
+ if point.op == "strip-trailing-newline":
686
+ return template[:-1] if template.endswith("\n") else None
687
+ if point.op == "append-trailing-newline":
688
+ return template + "\n"
689
+ if point.op == "replace":
690
+ new = template
691
+ for op in point.replace:
692
+ found, new = _replace_once(point.id, new, op)
693
+ if not found:
694
+ return None
695
+ return new
696
+ if point.op == "swap":
697
+ return _swap(point.id, template, point.swap or {})
698
+ raise PerturbationError(f"point '{point.id}' has unknown op {point.op!r}")
699
+
700
+
701
+ def _replace_once(point_id: str, template: str, op: dict) -> tuple[bool, str]:
702
+ anchor, replacement = op["from"], op["to"]
703
+ count = template.count(anchor)
704
+ if count == 0:
705
+ return False, template
706
+ if op.get("occurrences", 1) != "all" and count != 1:
707
+ raise PerturbationError(
708
+ f"point '{point_id}' anchor occurs {count} times but claims one — "
709
+ "an ambiguous anchor silently perturbs the wrong place"
710
+ )
711
+ return True, template.replace(anchor, replacement)
712
+
713
+
714
+ _SENTINEL = "\x00bantamkit-swap\x00"
715
+
716
+
717
+ def _swap(point_id: str, template: str, swap: dict) -> str | None:
718
+ a, b = swap["a"], swap["b"]
719
+ if a not in template or b not in template:
720
+ return None
721
+ for side in (a, b):
722
+ if template.count(side) != 1:
723
+ raise PerturbationError(
724
+ f"point '{point_id}' swap anchor occurs {template.count(side)} times but "
725
+ "claims one"
726
+ )
727
+ if _SENTINEL in template:
728
+ raise PerturbationError(f"point '{point_id}': template contains the swap sentinel")
729
+ return template.replace(a, _SENTINEL).replace(b, a).replace(_SENTINEL, b)
730
+
731
+
732
+ def whole_text_moved_words(base: str, perturbed: str) -> list[str]:
733
+ """Reading 1 of "the words this point moves": base vs perturbed TEMPLATE word sets.
734
+
735
+ This is §3.3's operative Test sentence read literally — "the symmetric difference of
736
+ the base and perturbed word sets must be disjoint from the task prompt's word set" —
737
+ and it is what an independent reviewer, forbidden from reading this module, derived
738
+ from the spec alone. A word the edit drops from one clause but that still occurs
739
+ elsewhere in the template has not moved out of the critic's input, so this reading
740
+ does not count it.
741
+ """
742
+ return sorted(_words(base) ^ _words(perturbed))
743
+
744
+
745
+ def substitution_pair_moved_words(point: Point) -> list[str]:
746
+ """Reading 2: the symmetric difference of the point's own `from`/`to` strings.
747
+
748
+ This is what §3.3 step 4 ("each P point is expressed as a literal `from` -> `to`
749
+ substitution pair") and the manifest's own P2 justification prose imply: the
750
+ instance is the pair, so the words the instance moves are the pair's. It is blind to
751
+ anything that is not a `replace` op, and to a substitution landing inside a word.
752
+ """
753
+ changed: set[str] = set()
754
+ for op in point.replace:
755
+ changed |= _words(op["from"]) ^ _words(op["to"])
756
+ return sorted(changed)
757
+
758
+
759
+ def moved_words(point: Point, base: str, perturbed: str) -> dict[str, list[str]]:
760
+ """Both readings of §3.3 guard 2's "added or removed word", by name.
761
+
762
+ The user's ruling (2026-08-12) after the two readings were found to disagree on
763
+ `P2-asks-requests`: **compute both, report both, declare neither wrong.** The spec
764
+ supports each in a different sentence and they answer different questions —
765
+ whole-text asks what changed in the bytes the critic reads, substitution-pair asks
766
+ what the author declared they were changing. Neither is a superset of the other in
767
+ general: substitution-pair sees a word the edit removes from one clause that
768
+ survives elsewhere; whole-text sees an edit that lands inside a word, and any point
769
+ whose op is not `replace`.
770
+ """
771
+ return {
772
+ "whole-text": whole_text_moved_words(base, perturbed),
773
+ "substitution-pair": substitution_pair_moved_words(point),
774
+ }
775
+
776
+
777
+ def guard_union(readings: dict[str, list[str]]) -> list[str]:
778
+ """The decision rule when the readings disagree: taint if EITHER flags (§3.3 g2).
779
+
780
+ Under-detection is the direction this guard exists to prevent — a tainted point that
781
+ reads clean is RB-P4's mechanism scoring itself — so the union is the conservative
782
+ choice, and the cost of a false taint is bounded: `pass_rate` still reports the full
783
+ family, only the *attribution* claim has to survive dropping the point.
784
+ """
785
+ out: set[str] = set()
786
+ for words in readings.values():
787
+ out |= set(words)
788
+ return sorted(out)
789
+
790
+
791
+ def shared_token_violations(
792
+ point: Point, text: str, base: str, perturbed: str | None = None
793
+ ) -> dict[str, list[str]]:
794
+ """§3.3 step 3, guard 2, against one text — both readings, keyed by name.
795
+
796
+ RB-P4's measured mechanism was literal matching against a token copied from the task
797
+ prompt, so a paraphrase that changes the shared-token surface is changing the
798
+ mechanism under test.
799
+
800
+ The primitive. It takes one text so the offline tables over all 22 frozen task
801
+ prompts can be pinned against it directly; `cell_guard_violations` is what the run
802
+ path calls, because the guard the spec writes is about the whole cell.
803
+ """
804
+ if perturbed is None:
805
+ perturbed = _transform(point, base)
806
+ if perturbed is None: # the anchor is absent: this point moves nothing here
807
+ perturbed = base
808
+ text_words = _words(text)
809
+ return {
810
+ reading: sorted(set(words) & text_words)
811
+ for reading, words in moved_words(point, base, perturbed).items()
812
+ }
813
+
814
+
815
+ def point_admissibility_violations(point: Point, base: str, perturbed: str) -> list[str]:
816
+ """§3.3 step 3, guards 1, 3 and 4: what makes a point inadmissible on ANY cell.
817
+
818
+ Unlike guard 2 these need no cell, so they cannot be drop-and-report: a point that
819
+ moves a band digit is not meaning-preserving anywhere, and a run has nothing to
820
+ salvage from it. `_variant_family` raises on them, before any request.
821
+
822
+ Guard 3's keyword list has lived in this module since the bar shipped and was read
823
+ only by an offline test over the three committed variants — the same
824
+ defined-but-uncalled shape as guard 2. Guards 1 and 4 had no definition here at all.
825
+
826
+ Guards 3 and 4 were both written from the spec with no measured defect behind them,
827
+ and once they could abort a run they aborted it on legitimate input. Guard 4 was
828
+ INVERTED on the real template: it flagged `". "` and a trailing `.`, so an anchor
829
+ that is exactly one complete sentence raised, while an anchor spanning two sentences
830
+ joined by a newline — which is how the hard-wrapped template joins them — passed.
831
+ Guard 3 counted substrings, so `itself` -> `everything` "moved" the keyword `every`,
832
+ and reordering `ONLY` inside the instance moved nothing. See `_spans_a_sentence_
833
+ boundary` and `_keyword_positions`.
834
+ """
835
+ problems = []
836
+ for literal in FROZEN_LITERALS:
837
+ if perturbed.count(literal) != base.count(literal):
838
+ problems.append(f"moves the frozen contract literal {literal!r}")
839
+ if point.point_class != "paraphrase":
840
+ return problems
841
+ for word in FROZEN_KEYWORDS:
842
+ pattern = _FROZEN_KEYWORD_RE[word]
843
+ if len(pattern.findall(perturbed)) != len(pattern.findall(base)):
844
+ problems.append(f"moves the frozen keyword {word!r}")
845
+ if point.op != "replace" or len(point.replace) != 1:
846
+ problems.append("is not one sentence expressed as one literal from -> to pair")
847
+ return problems
848
+ anchor, replacement = point.replace[0]["from"], point.replace[0]["to"]
849
+ if _keyword_positions(anchor) != _keyword_positions(replacement):
850
+ problems.append(
851
+ "moves a frozen keyword within the instance it edits, changing what the "
852
+ "directive scopes over"
853
+ )
854
+ for side, label in ((anchor, "from"), (replacement, "to")):
855
+ if _spans_a_sentence_boundary(side):
856
+ problems.append(f"spans more than one sentence (the {label} side)")
857
+ return problems
858
+
859
+
860
+ def cell_guard_violations(
861
+ point: Point, case: Case, base: str, perturbed: str | None = None
862
+ ) -> dict[str, list[str]]:
863
+ """The same guard against a whole cell: §3.3's `{task}` **or** `{output}`.
864
+
865
+ Checking `{task}` alone is how this guard was under-implemented; the spec names both
866
+ surfaces, and a run holds both. Measured on the committed M1 transcripts the
867
+ `{output}` half adds nothing today (zero extra violations over 20 cells), which is
868
+ the point: a guard is not allowed to be right only by luck.
869
+ """
870
+ task = shared_token_violations(point, case.prompt, base, perturbed)
871
+ output = shared_token_violations(point, case.output, base, perturbed)
872
+ return {reading: sorted(set(task[reading]) | set(output[reading])) for reading in READINGS}
873
+
874
+
875
+ def materialize_manifest(points: list[Point], templates: dict[str, str]) -> dict:
876
+ """Per point, per variant: applicability, the resulting template sha, and a diff.
877
+
878
+ The whole family is materialized so a reader audits the exact bytes without
879
+ re-running anything, and so a rule change shows up as a manifest diff.
880
+ """
881
+ out: dict[str, dict] = {}
882
+ for point in points:
883
+ out[point.id] = {}
884
+ for label, base in templates.items():
885
+ new = apply_point(point, base)
886
+ if new is None:
887
+ out[point.id][label] = {"applicable": False}
888
+ else:
889
+ out[point.id][label] = {
890
+ "applicable": True,
891
+ "sha256": sha256_text(new),
892
+ "diff": unified_diff(new=new, base=base, from_label=label, to_label=point.id),
893
+ }
894
+ return out
895
+
896
+
897
+ # ---- rubric variants (§6.2) ----
898
+
899
+
900
+ @dataclass
901
+ class RubricVariant:
902
+ label: str
903
+ spec: str
904
+ ref: str
905
+ rubric: Rubric
906
+ sha256: str
907
+ # RB-P17, L6 2026-08-14. The bytes of the MANIFEST a `derive:` ref points through,
908
+ # blank for every other form. The `derive:` ref is the one recorded ref with a
909
+ # WORKING-TREE segment in it: `git:<ref>:<path>` is immutable and a plain path form
910
+ # records `rubric_sha256`, but a derived variant has `rubric_sha256=""` and its
911
+ # manifest is an ordinary file that can be edited under the same path. Measured by
912
+ # L5: editing the op in place makes one recorded ref resolve to a different rubric
913
+ # (`d1f32ad2947b` -> `447e5be27613`), silently, with no file hash to fall back on.
914
+ # This column is the fall-back: it does not make the manifest immutable, it makes a
915
+ # SUBSTITUTION DETECTABLE, which is the most a record can do about a mutable input.
916
+ derive_manifest_sha256: str = ""
917
+
918
+ @property
919
+ def template_sha256(self) -> str:
920
+ """The sha of the RUBRIC the critic reads, as distinct from the FILE it came in.
921
+
922
+ RB-P17, measured 2026-08-14: the two committed `B-nonewline` runs record
923
+ `rubric_sha256` `59b0fe81ecf3…` and `29f299707fd6…` — two different values — for
924
+ ONE rubric, whose template hashes `d1f32ad2947b…` in both. The recorded column is
925
+ the file's bytes, so two files carrying the same `prompt` under different `name:`
926
+ or `threshold:` framing report as two different rubrics, and the field cannot
927
+ answer the one question provenance exists to answer: did these two runs judge
928
+ with the same rubric? This property answers it, and it is the value the frozen
929
+ manifest already records as `materialized_variants.<label>.base_sha256`, so a
930
+ derived variant's row ties back to a committed manifest entry by equality.
931
+
932
+ It is a SECOND column, not a redefinition of the first. `rubric_sha256` stays the
933
+ file's bytes — every committed row means that, and a field that changes meaning
934
+ under a fixed name is RB-P18's defect, which is the one being fixed here.
935
+ """
936
+ return sha256_text(self.rubric.prompt)
937
+
938
+
939
+ _DRIVE_ABSOLUTE_HEAD = re.compile(r"^[A-Za-z]:[\\/]")
940
+
941
+
942
+ def _names_a_machine(path: str) -> bool:
943
+ r"""Is `path` absolute on the platform it CAME FROM, rather than on the one reading it?
944
+
945
+ W5, MEASURED 2026-08-21 on macOS under CPython 3.12.13, which is why this is two
946
+ questions and not one. The single-flavour check this replaces asked only
947
+ `PurePosixPath(path).is_absolute()`, and:
948
+
949
+ PurePosixPath(r"C:\Users\x\m.yaml").is_absolute() -> False
950
+ PurePosixPath(r"\\server\share\m.yaml").is_absolute() -> False
951
+
952
+ so the rule whose entire purpose is to refuse a path that cannot work on another
953
+ machine was blind to exactly the class of absolute path most likely to have ARRIVED
954
+ from another machine. `PureWindowsPath` alone is no fix in the other direction:
955
+
956
+ PureWindowsPath("/private/tmp/s/m.yaml").is_absolute() -> False
957
+
958
+ because a Windows path is absolute only with BOTH a drive and a root. Neither flavour
959
+ is a superset of the other, so both are asked and either one is enough.
960
+
961
+ `windows.root` is asked BEYOND `windows.is_absolute()` for the drive-less rooted form
962
+ `\Users\x\m.yaml`, which both flavours call relative: it has a root and no drive, so
963
+ it names the top of whichever drive happens to be current — not this repository, and
964
+ not anything a second reader can obtain.
965
+
966
+ NOT INCLUDED, deliberately: the drive-RELATIVE form `C:foo`, which is absolute on no
967
+ platform — it names the working directory of drive `C:` and is a genuinely relative
968
+ path. It is out of a predicate called "absolute" by that predicate's own definition.
969
+ It is also unrepresentable in the `derive:` grammar for the colon reason documented
970
+ at `rubric_arg_shape_problem`, so no run can record one.
971
+
972
+ This asks the STRING and nothing else. It resolves no path and opens no file, so
973
+ `_not_repo_relative` stays a shape rule under RB-P32 and keeps reporting `USAGE_EXIT`.
974
+ """
975
+ if PurePosixPath(path).is_absolute():
976
+ return True
977
+ windows = PureWindowsPath(path)
978
+ return windows.is_absolute() or bool(windows.root)
979
+
980
+
981
+ def _not_repo_relative(path: str) -> str | None:
982
+ r"""Why `path` cannot name a file in THIS repository, or `None` if nothing does.
983
+
984
+ A function of the string and of nothing else — no path resolved, no file opened — so
985
+ it is an argument-SHAPE rule under RB-P32's definition and reports `USAGE_EXIT`. The
986
+ three refusals are the three ways a recorded path stops naming something a second
987
+ reader can obtain: an absolute path names this machine, a `..` segment names outside
988
+ the tree, and `~` names a home directory that is not theirs.
989
+
990
+ `pathlib` will not do this for you and that is the trap L5 caught in two resolvers at
991
+ once: `Path(repo) / "/private/tmp/x.yaml"` is `/private/tmp/x.yaml`, so a checker
992
+ written as `repo / ref` silently reads the absolute path and calls it resolved.
993
+
994
+ NOR WILL ONE FLAVOUR OF `pathlib` DO IT (W5). Both refusals below used to ask
995
+ `PurePosixPath` alone, i.e. they asked whether the string is absolute on the platform
996
+ that happens to be READING it. Measured at `97c14c7`: `\\server\share\m.yaml` was
997
+ ACCEPTED as a repo-relative manifest path — a path that names another machine by
998
+ name, which is the single shape this function exists to refuse. The `..` refusal had
999
+ the same one-separator blindness, so `assets\..\..\etc\m.yaml` was accepted whole:
1000
+ `PurePosixPath` splits on `/` only and sees one part, `PureWindowsPath` splits on both
1001
+ and sees the escape. Both flavours are asked for both refusals.
1002
+ """
1003
+ if path.startswith("~"):
1004
+ return "a `~` names a home directory, which is not this repository"
1005
+ if _names_a_machine(path):
1006
+ return "an absolute path names a machine, not a repository"
1007
+ if ".." in PurePosixPath(path).parts or ".." in PureWindowsPath(path).parts:
1008
+ return "a `..` segment leaves the repository"
1009
+ return None
1010
+
1011
+
1012
+ def _derive_manifest_refusal(manifest: str, problem: str) -> str:
1013
+ """The one wording of the manifest-segment refusal, for the two places that reach it.
1014
+
1015
+ Two call sites judge the same rule (before the split for a drive-lettered path, after
1016
+ it for every other shape) and a refusal whose text depended on which one fired would
1017
+ be two rules under one name. `N11` in `tools/pinharness/contract-ledger.json` anchors
1018
+ on the post-split site's three lines, which are unchanged.
1019
+ """
1020
+ return (
1021
+ f"--rubric derive spec wants a repo-relative manifest path, got "
1022
+ f"{manifest!r} — {problem}. A derived variant has no file of its own, so "
1023
+ "the only thing that makes its recorded ref resolvable is that every "
1024
+ "segment of it names something a second reader can obtain from this "
1025
+ "repository. An absolute path names this machine, and RB-P17 is the "
1026
+ "problem of a record that points at one."
1027
+ )
1028
+
1029
+
1030
+ def rubric_arg_shape_problem(arg: str) -> str | None:
1031
+ """What is malformed about one `--rubric` value ON ITS FACE, or `None` if nothing is.
1032
+
1033
+ THE WHOLE POINT IS WHAT THIS FUNCTION MAY NOT DO (RB-P32). It resolves no path,
1034
+ opens no file and runs no `git`: its answer is a function of the string the user
1035
+ typed and of nothing else. That is what makes it safe to run above `main`'s `try`
1036
+ and report through `parser.error`, i.e. as a `USAGE_EXIT` — a verdict of "this argv
1037
+ can never work on any machine", which is a claim only a check that consulted no
1038
+ machine is entitled to make. A rule that needs the filesystem (is the rubric
1039
+ there? is it a rubric?) belongs in `parse_rubric_arg` below and is a
1040
+ `REFUSAL_EXIT`, because the same argv works on the next run once the world changes.
1041
+
1042
+ Split out of `parse_rubric_arg` rather than duplicated, so the CLI and an
1043
+ in-process caller cannot drift on what "malformed" means.
1044
+ """
1045
+ label, sep, spec = arg.partition("=")
1046
+ if not sep or not label or not spec:
1047
+ return f"--rubric wants LABEL=SPEC, got {arg!r}"
1048
+ if spec.startswith("git:"):
1049
+ # `git:HEAD` used to reach `spec.split(":", 2)` below and raise an UNCAUGHT
1050
+ # `ValueError` out of `main` — a raw traceback and the interpreter's 1, which
1051
+ # from CI is indistinguishable by status from a refusal that reported itself
1052
+ # (RB-P32, matrix case C4). Empty segments are the same defect one step in:
1053
+ # `git::x` and `git:HEAD:` are malformed on their face, and letting them
1054
+ # through would have spent a `git show` to say so.
1055
+ parts = spec.split(":", 2)
1056
+ if len(parts) < 3 or not parts[1] or not parts[2]:
1057
+ return f"--rubric git spec wants git:<ref>:<path>, got {spec!r}"
1058
+ if spec.startswith("derive:"):
1059
+ # ORDERING (W5, 2026-08-21). For ONE shape the absolute-path refusal has to be
1060
+ # asked BEFORE the split, because the split destroys the evidence. This grammar
1061
+ # is colon-delimited and a drive letter's colon IS that delimiter, so
1062
+ # `derive:C:\Users\x\m.yaml:W1-trailing-newline:git:<ref>:<path>` splits into
1063
+ # manifest `'C'` — repo-relative, and accepted — with the rest of the path
1064
+ # shifted one segment along, and the refusal that then fires is the BASE rule's:
1065
+ # "wants a git: base, got 'W1-trailing-newline:git:…'". MEASURED at `97c14c7` on
1066
+ # macOS: refused, right exit, WRONG reason — and for a shape rule the reason is
1067
+ # the whole of what it delivers, since the caller's next move is to fix the argv.
1068
+ #
1069
+ # This does NOT make a Windows absolute path representable, and is not meant to.
1070
+ # It was refused before and it is refused here; the colon collision is real and
1071
+ # stays. What changes is that the refusal names the actual defect. The rejoin
1072
+ # below exists ONLY to name the offender in the message — this branch returns a
1073
+ # refusal or falls through to the ordinary path, and never yields a manifest.
1074
+ head = spec[len("derive:") :]
1075
+ if _DRIVE_ABSOLUTE_HEAD.match(head):
1076
+ offender = ":".join(head.split(":", 2)[:2])
1077
+ problem = _not_repo_relative(offender)
1078
+ if problem is not None:
1079
+ return _derive_manifest_refusal(offender, problem)
1080
+ parts = spec.split(":", 3)
1081
+ if len(parts) < 4 or not all(parts[1:]):
1082
+ return (
1083
+ "--rubric derive spec wants "
1084
+ f"derive:<manifest-path>:<rule-id>:git:<ref>:<path>, got {spec!r}"
1085
+ )
1086
+ manifest = parts[1]
1087
+ problem = _not_repo_relative(manifest)
1088
+ if problem is not None:
1089
+ # RB-P17, CORRECTED 2026-08-14 (L6). The base segment was refused a
1090
+ # working-tree path from the day this form shipped, on the argument that a
1091
+ # derived variant has no file of its own and therefore nothing but the
1092
+ # immutability of every segment makes its record resolvable. THE MANIFEST
1093
+ # SEGMENT GOT NO SUCH RULE, so `derive:/private/tmp/<session>/x.yaml:...`
1094
+ # was ACCEPTED and recorded verbatim in `ref` with `sha256=''` — the exact
1095
+ # absolute-scratchpad shape RB-P17 was filed about, inside the fix that
1096
+ # closes RB-P17. It is the same defect and it gets the same answer.
1097
+ return _derive_manifest_refusal(manifest, problem)
1098
+ base = parts[3]
1099
+ if not base.startswith("git:"):
1100
+ return (
1101
+ f"--rubric derive spec wants a git: base, got {base!r} — a derived variant "
1102
+ "has no file of its own, so the only thing that makes its recorded ref "
1103
+ "resolvable is that every segment of it is immutable. A working-tree path "
1104
+ "is not, and unlike the path form there is no file left to hash as a "
1105
+ "fallback. This is also what makes a derive-of-a-derive unrepresentable, "
1106
+ "so the load order can never cycle."
1107
+ )
1108
+ base_parts = base.split(":", 2)
1109
+ if len(base_parts) < 3 or not base_parts[1] or not base_parts[2]:
1110
+ return f"--rubric derive spec wants git:<ref>:<path> as its base, got {base!r}"
1111
+ return None
1112
+
1113
+
1114
+ def rubric_label_collision_problem(specs: list[str]) -> str | None:
1115
+ """What is malformed about a SET of `--rubric` values on its face, or `None`.
1116
+
1117
+ THE SAME RULE `guarded_family` ENFORCES, DECIDED ONE LAYER EARLIER AND FROM LESS
1118
+ (K4's C2). Two `--rubric` values that share a LABEL collide on every machine: a
1119
+ label is the text left of the first `=` in the string the user typed, so the
1120
+ collision is a function of the argv and of nothing else — no path resolved, no file
1121
+ opened, no `git` run. That makes it an argument-SHAPE rule under RB-P32's own
1122
+ definition, and until K4B it was the one shape rule that did not behave like one:
1123
+ it was refused by `guarded_family` INSIDE `main`'s `try` and reported
1124
+ `REFUSAL_EXIT`, which falsified, verbatim, the `# 2` block's "all of the
1125
+ argument-SHAPE ones and only those" and its "a `1` is an argv that names something
1126
+ the world did not supply" — here the world supplied everything. Measured at 3981efd
1127
+ from a real shell: status 1, 0 bytes on stdout
1128
+ (docs/eval-data/2026-08-13-k4b-c2-duplicate-rubric-label.md).
1129
+
1130
+ WHY THE `guarded_family` CHECK STAYS WHERE IT IS, rather than moving here. It is not
1131
+ this check on this input. An in-process caller may pass a bare `Rubric`, whose label
1132
+ is its `name` and therefore comes from a file on disk — world-dependent, undecidable
1133
+ from any argv, and a `PerturbationError` inside the run is the right answer for it.
1134
+ This one is decidable from the typed strings, so the CLI answers it before it spends
1135
+ anything, and the two checks agree on the verdict while disagreeing on what they are
1136
+ allowed to look at.
1137
+
1138
+ IT ALSO STOPS THE MODULE PAYING TO LEARN IT. `--rubric a=git:R:P --rubric a=git:R:P`
1139
+ spent TWO `git show` subprocesses in `parse_rubric_arg` before `guarded_family`
1140
+ noticed the duplicate (measured, same artifact; zero after). That is exactly the
1141
+ cost the `git:` shape check above exists to avoid, and it was being paid one flag
1142
+ over.
1143
+ """
1144
+ labels = [spec.partition("=")[0] for spec in specs]
1145
+ duplicates = sorted({label for label in labels if labels.count(label) > 1})
1146
+ if not duplicates:
1147
+ return None
1148
+ return (
1149
+ f"--rubric label {', '.join(repr(d) for d in duplicates)} given more than once; "
1150
+ "every per-label table (templates, dropped, the rubric cache, the summary) is "
1151
+ "keyed on the label, so two --rubric values sharing one would both be replayed "
1152
+ "with whichever rubric was built last. Give each --rubric a distinct LABEL=."
1153
+ )
1154
+
1155
+
1156
+ def parse_rubric_arg(arg: str) -> RubricVariant:
1157
+ """`LABEL=SPEC`, where SPEC is a path, `git:<ref>:<path>`, or a `derive:` rule.
1158
+
1159
+ The git form exists because the acceptance test needs rubrics that live only in
1160
+ history. `load_rubric` is name-only and has no path hook, so the parsed `Rubric` is
1161
+ passed around as an instance.
1162
+
1163
+ THE RECORDED REF IS THE WHOLE SPEC, NOT A PIECE OF IT (RB-P17, 2026-08-14). The git
1164
+ branch used to record `source = ref`, so a row said `d2f78b7` — a commit and not a
1165
+ file. Two rubrics in one commit were indistinguishable in the record, and
1166
+ `rubric_sha256` could not be re-derived from the row without knowing which path to
1167
+ ask `git show` for. `RubricVariant.spec` held the answer and never reached a row.
1168
+ Every branch now records the string a second reader can hand back to this function.
1169
+
1170
+ THE `derive:` FORM: `derive:<manifest-path>:<rule-id>:git:<ref>:<path>`.
1171
+
1172
+ A perturbed variant is a rule applied to a committed ref, and until now it was not
1173
+ expressible as one. `B-nonewline` — the null control the whole RB-P14 finding turns
1174
+ on — is `A-asfiled`'s template minus its single trailing newline, which is exactly
1175
+ the frozen manifest's `W1-trailing-newline` point; with no way to say that, it was
1176
+ materialized to a scratch file and two committed summaries record an absolute
1177
+ `/private/tmp` path as its provenance. Written in this form the null control is::
1178
+
1179
+ --rubric B-nonewline=derive:assets/evals/perturbations/task-completion.yaml\
1180
+ :W1-trailing-newline:git:d2f78b7:assets/rubrics/task-completion.yaml
1181
+
1182
+ WHY THE MANIFEST IS IN THE STRING, and not left implicit. The filing proposed
1183
+ `derive:<label>:<rule-id>` — `derive:A-asfiled:W1-trailing-newline`. That resolves
1184
+ only from the argv that also defined `A-asfiled`, and it names the manifest nowhere,
1185
+ so a row carrying it is a pointer into a process that has exited: the same failure as
1186
+ a pointer into a directory that has been cleaned, one indirection along. Every
1187
+ segment here is repo-relative or a git ref, so a reader who has ONLY this repository
1188
+ and one row can recover the exact bytes the critic read — read the manifest at that
1189
+ path, take the point with that id, `git show` that ref and path, apply. No argv, no
1190
+ machine, no `/private/tmp`. `manifest_sha256` is already on every row, so the reader
1191
+ can also tell whether the manifest they just read is the one the run used.
1192
+
1193
+ Ordering is manifest, then rule, then base, because the base is the only segment that
1194
+ is SUPPOSED to contain a `:` — three splits and the remainder is the base spec, with
1195
+ no escaping. CORRECTED (W5): "the only segment that contains a `:`" was a statement
1196
+ about the grammar mistaken for one about the input. A Windows drive letter puts a
1197
+ colon in the manifest segment, and the split then shifts every segment one along. The
1198
+ form stays unrepresentable — refs are repo-relative by design and a drive letter is
1199
+ not — but `rubric_arg_shape_problem` now judges that shape before the split, so it is
1200
+ refused as the absolute path it is rather than as a malformed base.
1201
+
1202
+ NO SILENT NO-OP, AND NO CYCLE. A rule whose anchor is absent from the base raises
1203
+ rather than returning the base unchanged: `W1-trailing-newline` on a template that
1204
+ has no trailing newline (`B-nonewline` itself — it is the rule's fixed point) names
1205
+ no variant, and a variant that is silently its own base is RB-P17's defect one level
1206
+ in. The base must be a `git:` spec, which makes a derive-of-a-derive unrepresentable
1207
+ on its face, so no load order can cycle and no resolution can recurse.
1208
+
1209
+ The CLI has already rejected a malformed `arg` through `parser.error` before this
1210
+ runs (RB-P32); the same check is re-raised here as a `PerturbationError` for
1211
+ in-process callers, so that every malformed `--rubric` raises ONE kind of error
1212
+ rather than some of them escaping as `ValueError`.
1213
+ """
1214
+ problem = rubric_arg_shape_problem(arg)
1215
+ if problem is not None:
1216
+ raise PerturbationError(problem)
1217
+ label, _, spec = arg.partition("=")
1218
+ if spec.startswith("derive:"):
1219
+ rubric, manifest_sha256 = _derive_rubric(spec)
1220
+ return RubricVariant(
1221
+ label=label, spec=spec, ref=spec, rubric=rubric, sha256="",
1222
+ derive_manifest_sha256=manifest_sha256,
1223
+ )
1224
+ if spec.startswith("git:"):
1225
+ _, ref, path = spec.split(":", 2)
1226
+ raw = _git_show(ref, path)
1227
+ else:
1228
+ file = Path(spec)
1229
+ if not file.is_file():
1230
+ raise PerturbationError(f"rubric not found: {spec}")
1231
+ raw = file.read_text(encoding="utf-8")
1232
+ return RubricVariant(
1233
+ label=label, spec=spec, ref=spec, rubric=_parse_rubric(raw, spec), sha256=sha256_text(raw)
1234
+ )
1235
+
1236
+
1237
+ def _derive_rubric(spec: str) -> tuple[Rubric, str]:
1238
+ """Resolve `derive:<manifest-path>:<rule-id>:git:<ref>:<path>` to `(rubric, manifest sha)`.
1239
+
1240
+ THE MANIFEST SHA COMES BACK WITH THE RUBRIC because the manifest is the one segment of
1241
+ this form that is MUTABLE (L5, 2026-08-14): `git:<ref>:<path>` is immutable, a plain
1242
+ path form records `rubric_sha256`, and a derived variant records neither. Editing the
1243
+ op in place makes the same recorded ref resolve to a different rubric, silently. The
1244
+ sha does not stop that; it makes it detectable, which is the most a record can do about
1245
+ an input the reader has to fetch. It rides on the row as `derive_manifest_sha256`.
1246
+
1247
+ `sha256` is BLANK on the variant this builds, following the `IN_MEMORY_REF` precedent
1248
+ exactly: the populated `rubric_sha256` column is the sha of a rubric FILE's bytes, a
1249
+ derived variant has no file, and hashing something else into that column would put two
1250
+ meanings under one name. `rubric_template_sha256` is the populated one here, and it is
1251
+ the value the manifest already records for this (point, variant) cell — so the row and
1252
+ the frozen manifest tie together by equality rather than by prose.
1253
+ """
1254
+ _, manifest_spec, rule_id, base_spec = spec.split(":", 3)
1255
+ manifest = load_manifest(Path(manifest_spec))
1256
+ manifest_sha256 = manifest.sha256
1257
+ points = [point for point in manifest.points if point.id == rule_id]
1258
+ if not points:
1259
+ raise PerturbationError(
1260
+ f"rule {rule_id!r} is not a point in {manifest_spec}: "
1261
+ f"have {', '.join(point.id for point in manifest.points)}"
1262
+ )
1263
+ _, ref, path = base_spec.split(":", 2)
1264
+ base = _parse_rubric(_git_show(ref, path), base_spec)
1265
+ perturbed = apply_point(points[0], base.prompt)
1266
+ if perturbed is None:
1267
+ raise PerturbationError(
1268
+ f"rule {rule_id!r} is inapplicable to {base_spec}: its anchor is absent from that "
1269
+ "template, so this spec names no variant. A rule that cannot fire would return "
1270
+ "the base rubric unchanged, and a variant silently equal to its own base is "
1271
+ "RB-P17's defect one level in — the record would name a perturbation that never "
1272
+ "happened. (This is what `W1-trailing-newline` does against a base that already "
1273
+ "has no trailing newline: it is that rule's fixed point.)"
1274
+ )
1275
+ return (
1276
+ Rubric(name=base.name, threshold=base.threshold, prompt=perturbed, schema=base.schema),
1277
+ manifest_sha256,
1278
+ )
1279
+
1280
+
1281
+ def _git_show(ref: str, path: str) -> str:
1282
+ try:
1283
+ return subprocess.run(
1284
+ ["git", "show", f"{ref}:{path}"],
1285
+ capture_output=True,
1286
+ text=True,
1287
+ encoding="utf-8",
1288
+ check=True,
1289
+ ).stdout
1290
+ except (OSError, subprocess.CalledProcessError) as e:
1291
+ raise PerturbationError(f"cannot read git:{ref}:{path}: {e}") from e
1292
+
1293
+
1294
+ def _parse_rubric(raw: str, where: str) -> Rubric:
1295
+ data = yaml.safe_load(raw)
1296
+ try:
1297
+ rubric = Rubric(
1298
+ name=data["name"],
1299
+ threshold=int(data["threshold"]),
1300
+ prompt=data["prompt"],
1301
+ schema=data["schema"],
1302
+ )
1303
+ except (KeyError, TypeError, ValueError) as e:
1304
+ raise PerturbationError(f"{where} is not a rubric: {e}") from e
1305
+ missing = [slot for slot in ("{task}", "{output}") if slot not in rubric.prompt]
1306
+ if missing:
1307
+ raise PerturbationError(f"{where} prompt missing placeholder(s): {', '.join(missing)}")
1308
+ return rubric
1309
+
1310
+
1311
+ # ---- cells, taken byte-exact from P8 transcripts (§6.2) ----
1312
+
1313
+
1314
+ @dataclass
1315
+ class Case:
1316
+ task: str
1317
+ repeat: int
1318
+ seed: int
1319
+ prompt: str # the frozen suite's task prompt, read-only
1320
+ output: str # the answer, byte-exact from the transcript
1321
+
1322
+
1323
+ def load_cases(
1324
+ transcripts_dir: Path, tasks: list[str] | None = None, tasks_dir: Path | None = None
1325
+ ) -> list[Case]:
1326
+ """One cell per (task, repeat), fed from P8 transcripts rather than hand-copied strings.
1327
+
1328
+ That is what makes "byte-identical answer" true by construction instead of by
1329
+ assertion. The task *prompt* is loaded read-only from the frozen suite; a
1330
+ transcript that carries no answer is skipped, and two transcripts that disagree
1331
+ about one cell are a hard error rather than a coin toss.
1332
+ """
1333
+ tasks_dir = Path(tasks_dir) if tasks_dir else assets_root() / "evals" / "tasks"
1334
+ wanted = set(tasks) if tasks else None
1335
+ cells: dict[tuple[str, int], tuple[Case, str]] = {}
1336
+ for path in sorted(Path(transcripts_dir).glob("*.json")):
1337
+ # K4B/M2, and it is RB-P32's `git:HEAD` shape one directory over: a `.json` in
1338
+ # here that is not a transcript used to raise an uncaught `KeyError: 'task'` (or
1339
+ # a `JSONDecodeError`) straight out of `main`, so the user got a raw traceback
1340
+ # and the interpreter's 1 where the refusal path would have given them
1341
+ # `error: ...` and the same 1. The NUMBER was never wrong - this directory is
1342
+ # world, so a 1 is right - only the delivery was, and "reported by traceback" is
1343
+ # not a way this module reports anything. Refusing rather than skipping is
1344
+ # deliberate: a silently skipped file is a cell missing from the run.
1345
+ try:
1346
+ data = json.loads(path.read_text(encoding="utf-8"))
1347
+ except ValueError as e:
1348
+ raise PerturbationError(f"{path.name} is not readable as JSON: {e}") from e
1349
+ if not isinstance(data, dict) or "task" not in data or "repeat" not in data:
1350
+ raise PerturbationError(
1351
+ f"{path.name} is in {transcripts_dir} but is not a transcript: a "
1352
+ "transcript is a JSON object recording at least `task` and `repeat`. "
1353
+ "Point --transcripts at a directory of P8 transcripts, or move this "
1354
+ "file out of it."
1355
+ )
1356
+ name = data["task"]
1357
+ if wanted is not None and name not in wanted:
1358
+ continue
1359
+ if data.get("output") is None:
1360
+ continue
1361
+ if data.get("seed") is None:
1362
+ raise PerturbationError(
1363
+ f"{path.name} records no seed; the bar reports seeded numbers only"
1364
+ )
1365
+ case = Case(
1366
+ task=name,
1367
+ repeat=int(data["repeat"]),
1368
+ seed=int(data["seed"]),
1369
+ prompt=_task_prompt(tasks_dir, name),
1370
+ output=data["output"],
1371
+ )
1372
+ key = (case.task, case.repeat)
1373
+ seen = cells.get(key)
1374
+ if seen is not None and (seen[0].prompt, seen[0].output) != (case.prompt, case.output):
1375
+ raise PerturbationError(
1376
+ f"transcripts {seen[1]} and {path.name} disagree about cell {key}: the "
1377
+ "{task}/{output} region must be identical for every variant of a cell"
1378
+ )
1379
+ if seen is None:
1380
+ cells[key] = (case, path.name)
1381
+ return [case for case, _ in cells.values()]
1382
+
1383
+
1384
+ def _task_prompt(tasks_dir: Path, name: str) -> str:
1385
+ path = Path(tasks_dir) / f"{name}.yaml"
1386
+ if not path.is_file():
1387
+ raise PerturbationError(f"task asset not found: {path}")
1388
+ return yaml.safe_load(path.read_text(encoding="utf-8"))["prompt"]
1389
+
1390
+
1391
+ def render_prompt(template: str, case: Case) -> str:
1392
+ """The critic's literal input: the template, then `{task}`/`{output}` interpolated.
1393
+
1394
+ Perturbation runs on the template only and interpolation happens afterwards from
1395
+ read-only sources, which is what makes the frozen-suite invariant mechanical: a
1396
+ perturbation cannot reach `{task}` or `{output}`.
1397
+ """
1398
+ return template.format(task=case.prompt, output=case.output)
1399
+
1400
+
1401
+ # ---- the replay primitive (§6.1, §8) ----
1402
+
1403
+
1404
+ @dataclass
1405
+ class Verdict:
1406
+ score: int
1407
+ feedback: str
1408
+ tokens_in: int
1409
+ tokens_out: int
1410
+ calls: int
1411
+ prompt_sha256: str
1412
+ payload_sha256: str
1413
+ # The same wire body key-sorted (RB-P18). Defaulted here and NOT on `ReplayRow`: a
1414
+ # `Verdict` is also built by callers that never went through `_PayloadSpy`, whereas a
1415
+ # row a run writes must state the column or fail to construct.
1416
+ payload_canonical_sha256: str = ""
1417
+ # §3.3 guard 2's verdict for the (point, cell) this replay came from. EMPTY when the
1418
+ # replay was issued through `replay_verdicts` directly, which holds a `Rubric` and a
1419
+ # `Case` and cannot re-derive which point produced the template — blank, not clean.
1420
+ # `GuardedReplay.replay` fills them, so the taint is on the object the consumer
1421
+ # serializes, rather than in a second table they must join.
1422
+ guard_violations: list[str] = field(default_factory=list)
1423
+ guard_readings: dict[str, list[str]] = field(default_factory=dict)
1424
+
1425
+
1426
+ # ---- what a payload sha NAMES, published beside the field (RB-P18) ----
1427
+
1428
+ #: The exact call behind each recorded payload column. Emitted into every summary a run
1429
+ #: writes, so the recipe travels with the artifact instead of living in a docstring the
1430
+ #: reader of a JSONL row will never open.
1431
+ PAYLOAD_SHA_RECIPES = {
1432
+ "payload_sha256": "sha256(json.dumps(payload, ensure_ascii=False))",
1433
+ "payload_canonical_sha256": (
1434
+ "sha256(json.dumps(payload, ensure_ascii=False, sort_keys=True))"
1435
+ ),
1436
+ }
1437
+
1438
+ #: WHERE A READER LOOKS. Emitted into every summary beside the recipes above.
1439
+ PAYLOAD_SHA_NOTE = (
1440
+ "`prompt_sha256` is the CROSS-RECORD IDENTITY: it hashes the rendered critic prompt "
1441
+ "and depends on no serialization choice, so two records that agree on it put the same "
1442
+ "text in front of the critic, whoever wrote them. The payload columns hash the whole "
1443
+ "wire body, which is strictly more than the prompt (model, seed, response_format) and "
1444
+ "therefore also strictly more fragile. `payload_sha256` is that body under THIS "
1445
+ "writer's key insertion order and is the value every committed row before 2026-08-14 "
1446
+ "carries; `payload_canonical_sha256` is the same bytes key-sorted, is independent of "
1447
+ "insertion order, and is the column two records may be compared on. Measured "
1448
+ "2026-08-14 on all six committed (variant, seed) cells: the canonical recipe "
1449
+ "reproduces the payload shas in "
1450
+ "docs/eval-data/2026-08-11-sa3-14b-nav-prod-port-critic-replay.json exactly, and the "
1451
+ "insertion-order recipe reproduces the perturbation-bar JSONL rows exactly, so the "
1452
+ "two frozen record families differ by the one keyword argument `sort_keys=True` and "
1453
+ "by nothing else. To interpret a row written before 2026-08-14, which carries only "
1454
+ "`payload_sha256` and names no recipe: re-run its cell and see which of the two "
1455
+ "columns the frozen value lands in."
1456
+ )
1457
+
1458
+
1459
+ def payload_shas_recorded(value: str | list[str]) -> tuple[str, ...]:
1460
+ """The payload shas a record's `payload_sha256` field states, whatever SHAPE it is in.
1461
+
1462
+ ONE NAME, TWO ARITIES — MEASURED 2026-08-14 over the whole committed record, not
1463
+ asserted. 1280 occurrences across 12 artifacts carry a `str`; 30 occurrences in
1464
+ `2026-08-11-sa3-14b-nav-prod-port-critic-replay.json` carry a `list`. A reader diffing
1465
+ the two families with `==` gets `False` from the TYPE before the hash is ever compared,
1466
+ which is RB-P18's own defect one level below the recipe.
1467
+
1468
+ THE LIST IS NOT A TYPO, AND FLATTENING IT WOULD DESTROY EVIDENCE. The two writers
1469
+ record different UNITS. A bar row is one replay, so its field is one sha. An SA3 entry
1470
+ is one CELL, and its field is the SET of distinct shas observed across that cell's
1471
+ processes — the cardinality is itself the claim, stated in that file's own
1472
+ `how_to_reproduce`: "every cell here shows exactly one payload sha across its
1473
+ processes, so any score spread is the server's, not the prompt's". Measured: all 30
1474
+ lists have length 1, so all 30 cells make that claim and none of them fails it. A
1475
+ reader who took `[0]` would silently discard a claim that a two-element list would
1476
+ have falsified.
1477
+
1478
+ So this returns a TUPLE and preserves cardinality: comparison between records is
1479
+ between sets of shas, never between a `str` and a `list`. What it does NOT do is
1480
+ rewrite anything — the committed bytes keep their shapes; this is the reader supplying
1481
+ a defined reading, exactly as `_rows_as_replay_rows` supplies an absent column.
1482
+
1483
+ IT IS IN `__all__`, AND THAT WAS A REPAIR (L5's M1, fixed 2026-08-14 by L7). It
1484
+ shipped outside the export list while `docs/eval.md` presented it as RB-P18's remedy
1485
+ for a reader who has the JSONL and not this tree — i.e. the whole audience of this
1486
+ function is out-of-tree, and the published surface did not name it. `guard_table` has
1487
+ carried `assert "guard_table" in criticreplay.__all__` since it shipped for the same
1488
+ reason; this one had nothing, so nothing would have gone red if it were dropped.
1489
+ Ledger `N14`.
1490
+ """
1491
+ if isinstance(value, str):
1492
+ return (value,)
1493
+ if isinstance(value, list) and all(isinstance(item, str) for item in value):
1494
+ return tuple(value)
1495
+ raise PerturbationError(
1496
+ f"a `payload_sha256` field is a sha string or a list of sha strings, got {value!r}"
1497
+ )
1498
+
1499
+
1500
+ def _payload_shas(
1501
+ model: str, seed: int | None, messages: list[Message], response_format
1502
+ ) -> tuple[str, str]:
1503
+ """The wire body's shas, assembled the way `OpenAICompatible.chat` assembles it.
1504
+
1505
+ Returns `(payload_sha256, payload_canonical_sha256)` — ONE dict, TWO serializations,
1506
+ and the pair is the whole of RB-P18's fix.
1507
+
1508
+ Recorded beside `prompt_sha256` because they answer different questions: the prompt
1509
+ sha proves which text the critic read, the payload shas prove the whole request —
1510
+ seed and `response_format` included — was the one intended. `e57f1a6` changes the
1511
+ rubric *schema*, so two variants can differ in what goes on the wire as
1512
+ `response_format` and not only in prose.
1513
+
1514
+ WHY `sort_keys` AND NOT SOME OTHER CANONICALISATION. RB-P18 was filed saying two
1515
+ recipes "serialize different dicts" under one field name. Re-measured on all six
1516
+ committed (variant, seed) cells: they do not. They serialize the IDENTICAL dict, and
1517
+ the entire cross-record incomparability is that the scratchpad writer passed
1518
+ `sort_keys=True` and this one did not. `sort_keys` is therefore not a canonicalisation
1519
+ chosen for taste — it is the one that (1) removes the measured difference, being the
1520
+ only key-order-independent form of the same call, and (2) is ALREADY A COMMITTED VALUE:
1521
+ the second column reproduces SA3's frozen `payload_sha256` bit for bit, so the fix
1522
+ reaches BACKWARD into the frozen record rather than only forward. Any other order-
1523
+ independent recipe (`ensure_ascii=True`, different `separators`) would be equally
1524
+ order-free and would match nothing that is already written down, leaving SA3's 30 rows
1525
+ exactly as uninterpretable as they are today.
1526
+
1527
+ WHY THE FIRST COLUMN IS UNTOUCHED, in name, recipe and value. 1280 committed
1528
+ occurrences mean the insertion-order recipe, and a field that changes meaning under a
1529
+ fixed name is RB-P18's own defect — the same argument that made
1530
+ `rubric_template_sha256` a second column beside `rubric_sha256` rather than a
1531
+ redefinition of it. This is also why the fix is NOT the one RB-P18 filed: "version the
1532
+ field name so two recipes cannot share one" would make permanent, in the schema, a
1533
+ difference that canonicalisation removes.
1534
+ """
1535
+ payload: dict = {"model": model, "messages": [m.to_wire() for m in messages]}
1536
+ if seed is not None:
1537
+ payload["seed"] = seed
1538
+ if response_format is not None:
1539
+ payload["response_format"] = response_format
1540
+ return (
1541
+ sha256_text(json.dumps(payload, ensure_ascii=False)),
1542
+ sha256_text(json.dumps(payload, ensure_ascii=False, sort_keys=True)),
1543
+ )
1544
+
1545
+
1546
+ def _payload_sha(model: str, seed: int | None, messages: list[Message], response_format) -> str:
1547
+ """The recorded `payload_sha256`, unchanged in name, in recipe and in value.
1548
+
1549
+ Kept as its own entry point because it is the recipe 1280 committed rows were written
1550
+ under and because `docs/eval-data/2026-08-13-rbp16-rbp17-rbp18-survey.py`, which is
1551
+ committed evidence and therefore never edited, imports it by this name.
1552
+ """
1553
+ return _payload_shas(model, seed, messages, response_format)[0]
1554
+
1555
+
1556
+ class _PayloadSpy:
1557
+ """Captures the first request `structured()` actually sends, changing nothing.
1558
+
1559
+ Reading the payload off the real call rather than rebuilding it keeps the recorded
1560
+ sha honest: a second spelling of the request would make "same request" unfalsifiable,
1561
+ which is exactly the failure §6.1 warns about.
1562
+ """
1563
+
1564
+ def __init__(self, inner, model: str, seed: int | None):
1565
+ self.inner = inner
1566
+ self.model = model
1567
+ self.seed = seed
1568
+ self.payload_sha256: str | None = None
1569
+ self.payload_canonical_sha256: str | None = None
1570
+
1571
+ @property
1572
+ def _response_format_unsupported(self) -> bool:
1573
+ return self.inner._response_format_unsupported
1574
+
1575
+ def chat(self, messages, tools=None, response_format=None):
1576
+ if self.payload_sha256 is None:
1577
+ # Both columns come off ONE observation of the request. Computing the
1578
+ # canonical form from a second reconstruction would make "same request"
1579
+ # unfalsifiable in exactly the way this class exists to prevent.
1580
+ self.payload_sha256, self.payload_canonical_sha256 = _payload_shas(
1581
+ self.model, self.seed, messages, response_format
1582
+ )
1583
+ return self.inner.chat(messages, tools, response_format=response_format)
1584
+
1585
+
1586
+ def replay_verdicts(
1587
+ client: ModelClient, rubric: Rubric, case: Case, replays: int = 1
1588
+ ) -> list[Verdict]:
1589
+ """Re-issue one pinned critic request `replays` times and keep every verdict.
1590
+
1591
+ `CritiqueGate` is deliberately not involved. Its memo keys on exact prompt bytes and
1592
+ would return one bought verdict N times, which is the one thing this instrument must
1593
+ never do. `structured()` is, because request construction is load-bearing.
1594
+
1595
+ **A PRIMITIVE, not the route to a family.** It holds a `Rubric` and a `Case` and
1596
+ cannot re-derive which point produced the template, so guard 2 cannot live here and
1597
+ threading a `Point` in would add a parameter this function does not use. The
1598
+ `Verdict`s it returns therefore carry EMPTY `guard_violations` — blank, not clean.
1599
+ Call `GuardedReplay.replay` from `guarded_family` instead: same request, same count,
1600
+ and the pair's verdict stamped on every one of them.
1601
+ """
1602
+ if replays < 1:
1603
+ raise PerturbationError(f"replays must be >= 1, got {replays}")
1604
+ if hasattr(client, "seed"):
1605
+ client.seed = case.seed
1606
+ prompt = render_prompt(rubric.prompt, case)
1607
+ prompt_sha = sha256_text(prompt)
1608
+ model = getattr(client, "model", "")
1609
+ seed = getattr(client, "seed", None)
1610
+ verdicts = []
1611
+ for _ in range(replays):
1612
+ tracking = TrackingClient(client)
1613
+ spy = _PayloadSpy(tracking, model, seed)
1614
+ data = structured(spy, prompt, rubric.schema)
1615
+ verdicts.append(
1616
+ Verdict(
1617
+ score=int(data["score"]),
1618
+ feedback=str(data.get("feedback") or ""),
1619
+ tokens_in=tracking.usage.prompt_tokens,
1620
+ tokens_out=tracking.usage.completion_tokens,
1621
+ calls=tracking.calls,
1622
+ prompt_sha256=prompt_sha,
1623
+ payload_sha256=spy.payload_sha256 or "",
1624
+ payload_canonical_sha256=spy.payload_canonical_sha256 or "",
1625
+ )
1626
+ )
1627
+ return verdicts
1628
+
1629
+
1630
+ def replay_scores(client: ModelClient, rubric: Rubric, case: Case, replays: int = 1) -> list[int]:
1631
+ """RB-P15's standing check, reduced to the number it asks for: N scores at one seed.
1632
+
1633
+ The `identity` point is one call to this. Nothing extra is needed to *compute* the
1634
+ within-cell replay spread — only the plumbing around it (§8).
1635
+ """
1636
+ return [v.score for v in replay_verdicts(client, rubric, case, replays)]
1637
+
1638
+
1639
+ # ---- the run (§6.3) ----
1640
+
1641
+ _ROW_KEYS = (
1642
+ "bar", "variant", "rubric_ref", "rubric_sha256", "rubric_template_sha256",
1643
+ "derive_manifest_sha256", "manifest_sha256", "task", "seed",
1644
+ "repeat", "model", "point", "class", "rule", "replay", "prompt_sha256", "payload_sha256",
1645
+ "payload_canonical_sha256",
1646
+ "score", "threshold", "passed", "feedback", "tokens_in", "tokens_out", "calls",
1647
+ "guard_violations", "guard_readings",
1648
+ )
1649
+
1650
+
1651
+ @dataclass
1652
+ class ReplayRow:
1653
+ bar: str
1654
+ variant: str
1655
+ rubric_ref: str
1656
+ rubric_sha256: str # the FILE's bytes, blank when the variant has no file
1657
+ rubric_template_sha256: str # the RUBRIC the critic read; see RubricVariant
1658
+ derive_manifest_sha256: str # the MANIFEST a derive: ref points through; see RubricVariant
1659
+ manifest_sha256: str
1660
+ task: str
1661
+ seed: int
1662
+ repeat: int
1663
+ model: str
1664
+ point: str
1665
+ point_class: str # emitted as `class`, which is not a Python identifier
1666
+ rule: str
1667
+ replay: int
1668
+ prompt_sha256: str
1669
+ payload_sha256: str # this writer's key INSERTION order; see PAYLOAD_SHA_RECIPES
1670
+ payload_canonical_sha256: str # the same body key-SORTED; the comparable column
1671
+ score: int
1672
+ threshold: int
1673
+ passed: bool
1674
+ feedback: str
1675
+ tokens_in: int
1676
+ tokens_out: int
1677
+ calls: int # wire calls behind this one row: 1, or more if `structured()` retried
1678
+ # §3.3 guard 2's verdict for THIS (point, cell): the shared words, or empty. The row
1679
+ # is the artifact a later reader has; a violation that lives only in prose is how
1680
+ # this one got under-reported twice. `guard_violations` is the decision the run acts
1681
+ # on — the union of the two readings — and `guard_readings` carries each reading
1682
+ # under its own name, because the readings disagree and neither is wrong.
1683
+ guard_violations: list[str] = field(default_factory=list)
1684
+ guard_readings: dict[str, list[str]] = field(default_factory=dict)
1685
+
1686
+ def row(self) -> dict:
1687
+ data = dict(vars(self))
1688
+ data["class"] = data.pop("point_class")
1689
+ return {key: data[key] for key in _ROW_KEYS}
1690
+
1691
+
1692
+ @dataclass
1693
+ class RunResult:
1694
+ rows: list[ReplayRow]
1695
+ dropped: dict[str, list[str]]
1696
+ selected: list[str]
1697
+ labels: list[str]
1698
+ threshold: int
1699
+ cells: list[Case]
1700
+ # (task, repeat) -> {point id: shared words}. Empty when every point is clean. This
1701
+ # is the UNION of the two readings: the set the decision rule acts on.
1702
+ guard: dict[tuple[str, int], dict[str, list[str]]] = field(default_factory=dict)
1703
+ # reading name -> the same table under that reading alone. Both ship, side by side.
1704
+ guard_readings: dict[str, dict[tuple[str, int], dict[str, list[str]]]] = field(
1705
+ default_factory=dict
1706
+ )
1707
+ guard_mode: str = "warn"
1708
+
1709
+
1710
+ @dataclass
1711
+ class GuardedReplay:
1712
+ """One (variant, point, cell): the exact request to replay, and guard 2's verdict on it.
1713
+
1714
+ The unit `guarded_family` hands back. It carries the perturbed `Rubric` ready for
1715
+ `replay_verdicts`, the cell, the replay count the bar uses for this point's class,
1716
+ and the shared-token verdict for this pair — the union the decision rule acts on, and
1717
+ each reading under its own name.
1718
+
1719
+ The verdict is on the object that carries the rubric ON PURPOSE. A shape that handed
1720
+ back templates and a separate guard table would close nothing: the consumer can
1721
+ ignore half of it and never know. Here the verdict is in hand before the rubric is.
1722
+ """
1723
+
1724
+ variant: RubricVariant
1725
+ point: Point
1726
+ case: Case
1727
+ rubric: Rubric
1728
+ replays: int
1729
+ guard_violations: list[str] = field(default_factory=list)
1730
+ guard_readings: dict[str, list[str]] = field(default_factory=dict)
1731
+
1732
+ @property
1733
+ def tainted(self) -> bool:
1734
+ """§3.3 guard 2 fired on this (point, cell) under at least one reading."""
1735
+ return bool(self.guard_violations)
1736
+
1737
+ def replay(self, client: ModelClient) -> list[Verdict]:
1738
+ """Replay this unit the number of times the bar uses, taint already stamped on.
1739
+
1740
+ `guarded_family` puts the verdict in the consumer's HAND; this puts it in their
1741
+ ARTIFACT. A consumer who calls `replay_verdicts` off the unit still has to choose
1742
+ to copy `guard_violations` into whatever they write, and a violation that lives
1743
+ anywhere except the artifact is the exact failure mode this line of work exists
1744
+ to stop. Every `Verdict` this returns carries the pair's verdict under both
1745
+ readings, so the default is recorded rather than dropped.
1746
+ """
1747
+ verdicts = replay_verdicts(client, self.rubric, self.case, self.replays)
1748
+ for verdict in verdicts:
1749
+ verdict.guard_violations = list(self.guard_violations)
1750
+ verdict.guard_readings = {
1751
+ reading: list(words) for reading, words in self.guard_readings.items()
1752
+ }
1753
+ return verdicts
1754
+
1755
+
1756
+ @dataclass
1757
+ class GuardedFamily:
1758
+ """Every replayable (variant, point, cell) of one bar, already guarded.
1759
+
1760
+ `units` is the only route from here to a request. The tables beside it are the same
1761
+ verdicts aggregated for the artifacts — `summarize` reads them — not a second way to
1762
+ get a rubric.
1763
+ """
1764
+
1765
+ units: list[GuardedReplay]
1766
+ dropped: dict[str, list[str]]
1767
+ selected: list[str]
1768
+ labels: list[str]
1769
+ threshold: int
1770
+ cells: list[Case]
1771
+ # (task, repeat) -> {point id: shared words}, the UNION of the two readings.
1772
+ guard: dict[tuple[str, int], dict[str, list[str]]] = field(default_factory=dict)
1773
+ guard_readings: dict[str, dict[tuple[str, int], dict[str, list[str]]]] = field(
1774
+ default_factory=dict
1775
+ )
1776
+ guard_mode: str = "warn"
1777
+
1778
+
1779
+ IN_MEMORY_REF = "<in-memory>"
1780
+
1781
+
1782
+ def _as_variant(entry: RubricVariant | Rubric) -> RubricVariant:
1783
+ """A bare `Rubric` is a variant with no file behind it, and the derived fields say so.
1784
+
1785
+ `guarded_family` takes either. Requiring a `RubricVariant` charged the guarded route a
1786
+ construction the unguarded route never charges — five fields including a `sha256` the
1787
+ consumer computes — so from an equal starting point (a `Rubric`, a `Manifest`, a
1788
+ `Case`) the two routes were the SAME length, and the length claim was true only for a
1789
+ consumer whose rubric is a file, which is the CLI and not the hand-rolling library
1790
+ consumer RB-P23 is about.
1791
+
1792
+ The derived fields are the honest ones, not invented ones:
1793
+
1794
+ - `label` is the rubric's own `name`. Two variants that share a label are refused in
1795
+ `guarded_family` rather than silently collapsing into one entry of the per-label
1796
+ tables.
1797
+ - `ref` is `<in-memory>`. There is no path and no git ref to record, and naming a file
1798
+ that was never read is worse than saying there is none.
1799
+ - `sha256` is EMPTY. The populated column is the sha of a rubric FILE's bytes
1800
+ (`parse_rubric_arg`), and an object has no file; hashing the prompt instead would put
1801
+ two different meanings under one column name. Blank, not wrong — and every row still
1802
+ carries `prompt_sha256`, which pins the exact text that went on the wire.
1803
+ """
1804
+ if isinstance(entry, RubricVariant):
1805
+ return entry
1806
+ if isinstance(entry, Rubric):
1807
+ return RubricVariant(
1808
+ label=entry.name,
1809
+ spec=IN_MEMORY_REF,
1810
+ ref=IN_MEMORY_REF,
1811
+ rubric=entry,
1812
+ sha256="",
1813
+ )
1814
+ raise PerturbationError(
1815
+ f"variants must be RubricVariant or Rubric, got {type(entry).__name__}"
1816
+ )
1817
+
1818
+
1819
+ def guarded_family(
1820
+ variants: list[RubricVariant | Rubric],
1821
+ manifest: Manifest,
1822
+ cases: list[Case],
1823
+ *,
1824
+ replays: int = 1,
1825
+ identity_replays: int = 5,
1826
+ identity_only: bool = False,
1827
+ guard: str = "warn",
1828
+ ) -> GuardedFamily:
1829
+ """(variants, points, cells) -> templates already paired with their guard verdicts.
1830
+
1831
+ **RB-P23's fix, and it is a length claim, not an impossibility claim.** Before this
1832
+ existed, the shortest route a consumer could assemble from the public API was
1833
+ `apply_point` -> hand-built `Rubric` -> `replay_verdicts`, which touches guard 2
1834
+ nowhere; getting the guard meant knowing that a SECOND call, `guard_table`, exists
1835
+ and joining its table to the (point, cell) key by hand. The unguarded route was the
1836
+ short one. Now the guarded route is: this call, then `unit.replay(client)`. The
1837
+ unguarded route still works — a consumer can always hand-roll around any API in a
1838
+ language without private functions — and that residual is measured by a test rather
1839
+ than argued away.
1840
+
1841
+ **`variants` takes `RubricVariant` OR a bare `Rubric`** (`_as_variant` normalizes,
1842
+ and documents what the derived `label`/`ref`/`sha256` mean). That is what makes the
1843
+ length claim true for the consumer RB-P23 is about rather than only for the CLI: a
1844
+ consumer holding a `Rubric` used to owe a `RubricVariant` construction the unguarded
1845
+ route never charged, which made the two routes the same length from an equal start.
1846
+
1847
+ Everything a run decides before it spends anything happens here, in the order it
1848
+ happened in `run()`: the mode and threshold preconditions, then guard 2 over every
1849
+ (point, cell) — which in `error` mode refuses HERE, so no unit is ever handed out —
1850
+ then per-variant family construction, which is where the `dropped` bookkeeping and
1851
+ guards 1/3/4, the collision check and the materialization check live.
1852
+
1853
+ **The materialization cost of the per-(variant, point, cell) shape.** `units` is
1854
+ cells x points x variants objects, where a templates-plus-table shape would be
1855
+ points x variants. The `Rubric` each unit carries is shared by reference across
1856
+ cells, so what is actually materialized per cell is one small dataclass holding
1857
+ references — pinned by
1858
+ `test_the_constructor_materializes_one_rubric_per_variant_point_not_per_cell`. That
1859
+ is the trade the filing asked to be chosen explicitly, and it is chosen: a consumer
1860
+ who has the rubric necessarily has the verdict.
1861
+
1862
+ `identity_only` (RB-P15's standing check) is a family SELECTION, not a special case:
1863
+ the identity point moves no words, so its units come back with empty verdicts and
1864
+ replay exactly as any other unit does. Nothing here refuses to score without a point.
1865
+ """
1866
+ if guard not in GUARD_MODES:
1867
+ raise PerturbationError(f"unknown guard mode {guard!r}; expected one of {GUARD_MODES}")
1868
+ if not variants:
1869
+ raise PerturbationError("nothing to replay: no rubric variants")
1870
+ if not cases:
1871
+ raise PerturbationError("nothing to replay: no cells")
1872
+ variants = [_as_variant(entry) for entry in variants]
1873
+ labels = [v.label for v in variants]
1874
+ duplicates = sorted({label for label in labels if labels.count(label) > 1})
1875
+ if duplicates:
1876
+ # K4B/C2. This is the WORLD-DEPENDENT half of the rule and it stays here: a bare
1877
+ # `Rubric` takes its label from its `name`, which came off a file on disk, so
1878
+ # whether two of them collide cannot be decided from any command line. The half
1879
+ # that CAN be — two `--rubric LABEL=SPEC` strings sharing the text left of the
1880
+ # first `=` — is `rubric_label_collision_problem`, checked above `main`'s `try`
1881
+ # and reported as `USAGE_EXIT`, so the CLI never reaches this raise and never
1882
+ # spends a `git show` to arrive at it. Same verdict, two entitlements.
1883
+ raise PerturbationError(
1884
+ f"two variants share the label {', '.join(repr(d) for d in duplicates)} — every "
1885
+ "per-label table here (templates, dropped, the rubric cache, the summary) is "
1886
+ "keyed on it, so both would be replayed with whichever rubric was built last. "
1887
+ "A bare Rubric takes its label from its name; pass RubricVariant with distinct "
1888
+ "labels to compare two rubrics of one name."
1889
+ )
1890
+ thresholds = {v.rubric.threshold for v in variants}
1891
+ if len(thresholds) != 1:
1892
+ raise PerturbationError(
1893
+ f"variants disagree on threshold ({sorted(thresholds)}); pass rates across two "
1894
+ "different decision points are not comparable"
1895
+ )
1896
+ threshold = thresholds.pop()
1897
+ points = [p for p in manifest.points if p.point_class == "identity" or not identity_only]
1898
+ full_guard = guard_table(
1899
+ points, cases, {v.label: v.rubric.prompt for v in variants}, mode=guard
1900
+ )
1901
+ union_guard = {
1902
+ cell: {pid: guard_union(hit) for pid, hit in hits.items()}
1903
+ for cell, hits in full_guard.items()
1904
+ }
1905
+ reading_guards = {
1906
+ reading: {
1907
+ cell: {pid: hit[reading] for pid, hit in hits.items() if hit[reading]}
1908
+ for cell, hits in full_guard.items()
1909
+ if any(hit[reading] for hit in hits.values())
1910
+ }
1911
+ for reading in READINGS
1912
+ }
1913
+
1914
+ templates: dict[str, dict[str, str]] = {}
1915
+ dropped: dict[str, list[str]] = {}
1916
+ for variant in variants:
1917
+ templates[variant.label], dropped[variant.label] = _variant_family(
1918
+ variant, points, manifest
1919
+ )
1920
+ # One Rubric per (variant, point), shared by reference across every cell.
1921
+ rubrics = {
1922
+ (variant.label, pid): Rubric(
1923
+ name=variant.rubric.name,
1924
+ threshold=threshold,
1925
+ prompt=template,
1926
+ schema=variant.rubric.schema,
1927
+ )
1928
+ for variant in variants
1929
+ for pid, template in templates[variant.label].items()
1930
+ }
1931
+
1932
+ units: list[GuardedReplay] = []
1933
+ for variant in variants:
1934
+ for case in cases:
1935
+ for point in points:
1936
+ rubric = rubrics.get((variant.label, point.id))
1937
+ if rubric is None: # §4.1: the anchor is absent from this variant
1938
+ continue
1939
+ cell = (case.task, case.repeat)
1940
+ units.append(
1941
+ GuardedReplay(
1942
+ variant=variant,
1943
+ point=point,
1944
+ case=case,
1945
+ rubric=rubric,
1946
+ replays=(
1947
+ identity_replays if point.point_class == "identity" else replays
1948
+ ),
1949
+ guard_violations=list(union_guard.get(cell, {}).get(point.id, [])),
1950
+ guard_readings={
1951
+ reading: list(
1952
+ full_guard.get(cell, {}).get(point.id, {}).get(reading, [])
1953
+ )
1954
+ for reading in READINGS
1955
+ },
1956
+ )
1957
+ )
1958
+ return GuardedFamily(
1959
+ units=units,
1960
+ dropped=dropped,
1961
+ selected=[p.id for p in points],
1962
+ labels=[v.label for v in variants],
1963
+ threshold=threshold,
1964
+ cells=list(cases),
1965
+ guard=union_guard,
1966
+ guard_readings=reading_guards,
1967
+ guard_mode=guard,
1968
+ )
1969
+
1970
+
1971
+ def run(
1972
+ client: ModelClient,
1973
+ variants: list[RubricVariant | Rubric],
1974
+ manifest: Manifest,
1975
+ cases: list[Case],
1976
+ *,
1977
+ replays: int = 1,
1978
+ identity_replays: int = 5,
1979
+ identity_only: bool = False,
1980
+ model: str = "",
1981
+ guard: str = "warn",
1982
+ on_row: Callable[[ReplayRow], None] | None = None,
1983
+ ) -> RunResult:
1984
+ """`guarded_family` plus `unit.replay` over what it returns. Nothing else.
1985
+
1986
+ ONE DERIVATION, NOT TWO THAT AGREE. The family, the guard tables and the replay
1987
+ counts are whatever the constructor said they are; this function re-derives none of
1988
+ them, so a consumer calling `guarded_family` gets the run's own answer rather than a
1989
+ parallel implementation that happens to agree with it. RB-P19's transferable finding
1990
+ is that a check inheriting the method of the thing it checks is not a check of it,
1991
+ and the same logic makes a parallel path worthless as corroboration.
1992
+ """
1993
+ family = guarded_family(
1994
+ variants,
1995
+ manifest,
1996
+ cases,
1997
+ replays=replays,
1998
+ identity_replays=identity_replays,
1999
+ identity_only=identity_only,
2000
+ guard=guard,
2001
+ )
2002
+ model = model or getattr(client, "model", "")
2003
+ rows: list[ReplayRow] = []
2004
+ for unit in family.units:
2005
+ for index, verdict in enumerate(unit.replay(client)):
2006
+ row = ReplayRow(
2007
+ bar=BAR,
2008
+ variant=unit.variant.label,
2009
+ rubric_ref=unit.variant.ref,
2010
+ rubric_sha256=unit.variant.sha256,
2011
+ rubric_template_sha256=unit.variant.template_sha256,
2012
+ derive_manifest_sha256=unit.variant.derive_manifest_sha256,
2013
+ manifest_sha256=manifest.sha256,
2014
+ task=unit.case.task,
2015
+ seed=unit.case.seed,
2016
+ repeat=unit.case.repeat,
2017
+ model=model,
2018
+ point=unit.point.id,
2019
+ point_class=unit.point.point_class,
2020
+ rule=unit.point.rule,
2021
+ replay=index,
2022
+ prompt_sha256=verdict.prompt_sha256,
2023
+ payload_sha256=verdict.payload_sha256,
2024
+ payload_canonical_sha256=verdict.payload_canonical_sha256,
2025
+ score=verdict.score,
2026
+ threshold=family.threshold,
2027
+ passed=verdict.score >= family.threshold,
2028
+ feedback=verdict.feedback,
2029
+ tokens_in=verdict.tokens_in,
2030
+ tokens_out=verdict.tokens_out,
2031
+ calls=verdict.calls,
2032
+ guard_violations=verdict.guard_violations,
2033
+ guard_readings=verdict.guard_readings,
2034
+ )
2035
+ rows.append(row)
2036
+ if on_row is not None:
2037
+ on_row(row)
2038
+ return RunResult(
2039
+ rows=rows,
2040
+ dropped=family.dropped,
2041
+ selected=family.selected,
2042
+ labels=family.labels,
2043
+ threshold=family.threshold,
2044
+ cells=family.cells,
2045
+ guard=family.guard,
2046
+ guard_readings=family.guard_readings,
2047
+ guard_mode=family.guard_mode,
2048
+ )
2049
+
2050
+
2051
+ def guard_table(
2052
+ points: list[Point], cases: list[Case], templates: dict[str, str], mode: str = "warn"
2053
+ ) -> dict[tuple[str, int], dict[str, dict[str, list[str]]]]:
2054
+ """§3.3 guard 2 for every (point, cell), decided before a single request goes out.
2055
+
2056
+ **A PRIMITIVE of `guarded_family`, and the offline audit route.** It stays public
2057
+ because a reader auditing which pairs are tainted wants the table alone, without
2058
+ materializing a family; but a consumer who is going to REPLAY should call
2059
+ `guarded_family`, which calls this and hands the verdicts back already attached to
2060
+ the rubrics they belong to. Guard 2 is a property of a (point, cell) pair, so unlike
2061
+ guards 1/3/4 it cannot ride inside `apply_point`, which never sees a cell.
2062
+
2063
+ `templates` maps variant label to that variant's BASE template. The whole-text
2064
+ reading is a function of the template, so it is computed per variant and unioned:
2065
+ a word that moves on one variant under comparison is a word that moves.
2066
+
2067
+ Returns `{(task, repeat): {point id: {reading: shared words}}}`, carrying only the
2068
+ (cell, point) pairs at least one reading flags. `guard_union` collapses a value to
2069
+ the decision the run acts on.
2070
+
2071
+ **`warn` is the default, and a hard error is the wrong default.** Eight of the
2072
+ twenty M1-screened cells violate this guard, `nav-prod-port` — the canonical cell of
2073
+ this whole line of work — among them, and two of the twelve committed anchor cells.
2074
+ A run that aborts on a violation makes those cells unrunnable, which is a regression
2075
+ dressed as a stricter guard: the status quo at least produced measurements.
2076
+
2077
+ What the status quo failed to do was make the violation impossible to miss, so that
2078
+ is what is enforced instead. The violating point still runs; every row it produces
2079
+ carries the shared words; the summary reports the family recomputed without it; and
2080
+ attribution is credited only if the separation survives dropping it (`_compare`).
2081
+ `error` is available for callers who want the strict reading, and it refuses here,
2082
+ before any spend.
2083
+ """
2084
+ if mode not in GUARD_MODES:
2085
+ raise PerturbationError(f"unknown guard mode {mode!r}; expected one of {GUARD_MODES}")
2086
+ table: dict[tuple[str, int], dict[str, dict[str, list[str]]]] = {}
2087
+ for case in cases:
2088
+ for point in points:
2089
+ readings: dict[str, set[str]] = {reading: set() for reading in READINGS}
2090
+ for base in templates.values():
2091
+ for reading, words in cell_guard_violations(point, case, base).items():
2092
+ readings[reading] |= set(words)
2093
+ hit = {reading: sorted(words) for reading, words in readings.items()}
2094
+ if guard_union(hit):
2095
+ table.setdefault((case.task, case.repeat), {})[point.id] = hit
2096
+ if table and mode == "error":
2097
+ named = "; ".join(
2098
+ f"{task} r{repeat}: {pid} shares {guard_union(hit)} ("
2099
+ + ", ".join(f"{reading}={hit[reading]}" for reading in READINGS)
2100
+ + ")"
2101
+ for (task, repeat), hits in sorted(table.items())
2102
+ for pid, hit in sorted(hits.items())
2103
+ )
2104
+ raise PerturbationError(
2105
+ f"shared-token guard (spec §3.3 step 3, guard 2) violated on {len(table)} cell(s) "
2106
+ f"— {named}. Decision rule: {GUARD_DECISION_RULE}. Re-run with the default guard "
2107
+ "mode to measure them anyway, with every violating row and the guard-dropped "
2108
+ "family recorded."
2109
+ )
2110
+ return table
2111
+
2112
+
2113
+ def _variant_family(
2114
+ variant: RubricVariant, points: list[Point], manifest: Manifest
2115
+ ) -> tuple[dict[str, str], list[str]]:
2116
+ family: dict[str, str] = {}
2117
+ dropped: list[str] = []
2118
+ seen: dict[str, str] = {}
2119
+ for point in points:
2120
+ # check=False so the message below can name the variant as well as the point;
2121
+ # the same guards run either way, and neither route can reach a request.
2122
+ template = apply_point(point, variant.rubric.prompt, check=False)
2123
+ if template is None:
2124
+ dropped.append(point.id)
2125
+ continue
2126
+ collision = seen.get(template)
2127
+ if collision is not None:
2128
+ raise PerturbationError(
2129
+ f"points '{collision}' and '{point.id}' produce the same template on variant "
2130
+ f"'{variant.label}' — a point that changed nothing measures nothing"
2131
+ )
2132
+ problems = point_admissibility_violations(point, variant.rubric.prompt, template)
2133
+ if problems:
2134
+ raise PerturbationError(
2135
+ f"point '{point.id}' is inadmissible on variant '{variant.label}' "
2136
+ f"(spec §3.3 step 3): it {'; it '.join(problems)} — a point that is not "
2137
+ "meaning-preserving measures a requirement edit, not a perturbation"
2138
+ )
2139
+ seen[template] = point.id
2140
+ family[point.id] = template
2141
+ _check_materialization(manifest, variant.label, point, template)
2142
+ return family, dropped
2143
+
2144
+
2145
+ def _check_materialization(manifest: Manifest, label: str, point: Point, template: str) -> None:
2146
+ """Where the manifest already materialized this label, the bytes must still match.
2147
+
2148
+ A manifest that has drifted from the rubric it describes would let a reader audit
2149
+ one family while the run measured another.
2150
+ """
2151
+ recorded = point.variants.get(label)
2152
+ if not recorded or not recorded.get("applicable"):
2153
+ return
2154
+ if recorded.get("sha256") != sha256_text(template):
2155
+ raise PerturbationError(
2156
+ f"point '{point.id}' on variant '{label}' does not match the manifest's "
2157
+ f"materialized sha — {manifest.path} has drifted from the rubric it describes"
2158
+ )
2159
+
2160
+
2161
+ # ---- the decision rule (§7) ----
2162
+
2163
+
2164
+ def summarize(result: RunResult, manifest_sha256: str) -> dict:
2165
+ threshold = result.threshold
2166
+ by_cell: dict[tuple[str, int], dict[str, dict[str, list[ReplayRow]]]] = {}
2167
+ for row in result.rows:
2168
+ cell = by_cell.setdefault((row.task, row.repeat), {})
2169
+ cell.setdefault(row.variant, {}).setdefault(row.point, []).append(row)
2170
+
2171
+ cells = []
2172
+ for case in result.cells:
2173
+ key = (case.task, case.repeat)
2174
+ per_variant = by_cell.get(key, {})
2175
+ violations = result.guard.get(key, {})
2176
+ readings = {
2177
+ reading: dict(result.guard_readings.get(reading, {}).get(key, {}))
2178
+ for reading in READINGS
2179
+ }
2180
+ block = {
2181
+ "task": case.task,
2182
+ "repeat": case.repeat,
2183
+ "seed": case.seed,
2184
+ "guard_violations": dict(violations),
2185
+ "guard_readings": readings,
2186
+ "variants": {
2187
+ label: _variant_stats(
2188
+ per_variant.get(label, {}),
2189
+ [p for p in result.selected if p not in result.dropped[label]],
2190
+ threshold,
2191
+ violations,
2192
+ readings,
2193
+ )
2194
+ for label in result.labels
2195
+ },
2196
+ "comparisons": [
2197
+ _compare(result, per_variant, a, b, threshold, key, violations)
2198
+ for a, b in combinations(result.labels, 2)
2199
+ ],
2200
+ }
2201
+ cells.append(block)
2202
+
2203
+ return {
2204
+ "bar": BAR,
2205
+ "threshold": threshold,
2206
+ "manifest_sha256": manifest_sha256,
2207
+ # RB-P16's third missing piece: a statement ACROSS cells. Report only — see
2208
+ # `_directional`, which records that the rule RB-P16 proposes has no instance in
2209
+ # the committed record and is therefore reported rather than enforced.
2210
+ "directional": _directional(cells),
2211
+ "guard": {
2212
+ "rule": "spec §3.3 step 3, guard 2 — no word a point adds or removes may "
2213
+ "appear in the cell's {task} or {output}. 'A word a point adds or removes' "
2214
+ "has two defensible readings and the spec supports each in a different "
2215
+ "sentence; both are computed and reported here and NEITHER is wrong.",
2216
+ "readings": dict(READING_RULES),
2217
+ "decision_rule": GUARD_DECISION_RULE,
2218
+ "mode": result.guard_mode,
2219
+ "violations": [
2220
+ {
2221
+ "task": task,
2222
+ "repeat": repeat,
2223
+ "point": pid,
2224
+ "words": words,
2225
+ "readings": {
2226
+ reading: result.guard_readings.get(reading, {})
2227
+ .get((task, repeat), {})
2228
+ .get(pid, [])
2229
+ for reading in READINGS
2230
+ },
2231
+ }
2232
+ for (task, repeat), hits in sorted(result.guard.items())
2233
+ for pid, words in sorted(hits.items())
2234
+ ],
2235
+ "by_reading": {
2236
+ reading: [
2237
+ {"task": task, "repeat": repeat, "point": pid, "words": words}
2238
+ for (task, repeat), hits in sorted(
2239
+ result.guard_readings.get(reading, {}).items()
2240
+ )
2241
+ for pid, words in sorted(hits.items())
2242
+ ]
2243
+ for reading in READINGS
2244
+ },
2245
+ },
2246
+ # `requests` is the row count — one row per (variant, cell, point, replay).
2247
+ # `wire_calls` is what actually went out: they differ exactly when `structured()`
2248
+ # retried, which would otherwise inflate `tokens_total` invisibly.
2249
+ "requests": len(result.rows),
2250
+ "wire_calls": sum(r.calls for r in result.rows),
2251
+ "tokens_total": sum(r.tokens_in + r.tokens_out for r in result.rows),
2252
+ # THE RECIPE TRAVELS WITH THE ARTIFACT (RB-P18). A reader holding this file and a
2253
+ # row file can say which serialization produced each payload column without
2254
+ # opening this repository, which is what "the recipe is published" has to mean for
2255
+ # evidence that outlives the tree it was written in. `cross_record_identity` is
2256
+ # here and not only in a commit message because it is the field a reader should be
2257
+ # diffing on in the first place.
2258
+ "payload_sha256_recipes": {
2259
+ "recipes": dict(PAYLOAD_SHA_RECIPES),
2260
+ "payload": "{model, messages, seed?, response_format?}, in that insertion order",
2261
+ "cross_record_identity": "prompt_sha256",
2262
+ "comparable_column": "payload_canonical_sha256",
2263
+ "note": PAYLOAD_SHA_NOTE,
2264
+ },
2265
+ "variants": [
2266
+ {
2267
+ "label": label,
2268
+ "rubric_ref": next(r.rubric_ref for r in result.rows if r.variant == label),
2269
+ "rubric_sha256": next(r.rubric_sha256 for r in result.rows if r.variant == label),
2270
+ "rubric_template_sha256": next(
2271
+ r.rubric_template_sha256 for r in result.rows if r.variant == label
2272
+ ),
2273
+ "derive_manifest_sha256": next(
2274
+ r.derive_manifest_sha256 for r in result.rows if r.variant == label
2275
+ ),
2276
+ "dropped_rules": result.dropped[label],
2277
+ }
2278
+ for label in result.labels
2279
+ if any(r.variant == label for r in result.rows)
2280
+ ],
2281
+ "cells": cells,
2282
+ }
2283
+
2284
+
2285
+ def _variant_stats(
2286
+ point_rows: dict[str, list[ReplayRow]],
2287
+ family: list[str],
2288
+ threshold: int,
2289
+ violations: dict[str, list[str]],
2290
+ readings: dict[str, dict[str, list[str]]],
2291
+ ) -> dict:
2292
+ """One (variant, cell) block, plus the same statistic with tainted points removed.
2293
+
2294
+ `pass_rate` stays the FULL family so a run remains comparable with every committed
2295
+ number, including the anchor set's `expect_pass_rate`. `guard_dropped` is the
2296
+ recomputation a reader would otherwise have to do by hand — and did, in prose, in
2297
+ `docs/eval.md`, because the artifacts could not carry it.
2298
+
2299
+ `guard_violations` is the union decision; `guard_readings` names each reading, so a
2300
+ reader who prefers one of them can recompute `guard_dropped` for it without a run.
2301
+ """
2302
+ stats = _family_stats(point_rows, family, threshold)
2303
+ hits = {pid: words for pid, words in violations.items() if pid in family}
2304
+ stats["guard_violations"] = hits
2305
+ stats["guard_readings"] = {
2306
+ reading: {pid: words for pid, words in hit.items() if pid in family}
2307
+ for reading, hit in readings.items()
2308
+ }
2309
+ stats["guard_dropped"] = (
2310
+ _family_stats(point_rows, [pid for pid in family if pid not in hits], threshold)
2311
+ if hits
2312
+ else None
2313
+ )
2314
+ return stats
2315
+
2316
+
2317
+ def _passing_points(
2318
+ point_rows: dict[str, list[ReplayRow]], family: list[str]
2319
+ ) -> list[str]:
2320
+ """The family points this variant passed. A point passes only if every replay does.
2321
+
2322
+ THE ONE DEFINITION. `_family_stats` counts this list and `_effect` intersects two of
2323
+ them, so the pass rate a cell reports and the point-level disagreement it reports
2324
+ cannot drift apart. RB-P19's transferable finding is that a second derivation which
2325
+ happens to agree corroborates nothing — so there is not a second one.
2326
+ """
2327
+ return [
2328
+ pid for pid in family if point_rows.get(pid) and all(r.passed for r in point_rows[pid])
2329
+ ]
2330
+
2331
+
2332
+ def _family_stats(
2333
+ point_rows: dict[str, list[ReplayRow]], family: list[str], threshold: int
2334
+ ) -> dict:
2335
+ """One (variant, cell) block. A point passes only if every one of its replays does."""
2336
+ scores = [row.score for pid in family for row in point_rows.get(pid, [])]
2337
+ passed = len(_passing_points(point_rows, family))
2338
+ identity = [row.score for row in point_rows.get("identity", [])]
2339
+ low, high = (min(scores), max(scores)) if scores else (0, 0)
2340
+ return {
2341
+ "pass_rate": f"{passed}/{len(family)}",
2342
+ "passed": passed,
2343
+ "family_size": len(family),
2344
+ "score_min": low,
2345
+ "score_max": high,
2346
+ "spread": high - low,
2347
+ "margin_zero": sum(
2348
+ 1
2349
+ for pid in family
2350
+ if any(abs(r.score - threshold) <= 1 for r in point_rows.get(pid, []))
2351
+ ),
2352
+ "identity_scores": identity,
2353
+ "identity_spread": (max(identity) - min(identity)) if identity else 0,
2354
+ # min and max straddling the threshold: the family cannot tell pass from fail.
2355
+ "fragile": bool(scores) and low < threshold <= high,
2356
+ }
2357
+
2358
+
2359
+ def _compare(
2360
+ result: RunResult,
2361
+ per_variant: dict[str, dict[str, list[ReplayRow]]],
2362
+ a: str,
2363
+ b: str,
2364
+ threshold: int,
2365
+ cell: tuple[str, int],
2366
+ violations: dict[str, list[str]],
2367
+ ) -> dict:
2368
+ """Paired dropping, then the separation rule. F is post-drop and reported explicitly.
2369
+
2370
+ Guard dropping is a second, cell-scoped drop on top of paired dropping, and it gates
2371
+ attribution: a separation carried by a point that shares a token with the cell is
2372
+ RB-P4's measured mechanism, not evidence about the edit. So `attributable` now
2373
+ requires the separation to survive removing the tainted points. It never removes a
2374
+ cell from the run — when everything is tainted, `guard_verdict` is `undefined` and
2375
+ nothing is credited, which is the honest answer, not an abort.
2376
+ """
2377
+ family = [
2378
+ pid
2379
+ for pid in result.selected
2380
+ if pid not in result.dropped[a] and pid not in result.dropped[b]
2381
+ ]
2382
+ dropped_rules = [
2383
+ {"rule": pid, "variant": label}
2384
+ for pid in result.selected
2385
+ if pid not in family
2386
+ for label in (a, b)
2387
+ if pid in result.dropped[label]
2388
+ ]
2389
+ rows_a, rows_b = per_variant.get(a, {}), per_variant.get(b, {})
2390
+ present_a = {pid for pid in family if rows_a.get(pid)}
2391
+ present_b = {pid for pid in family if rows_b.get(pid)}
2392
+ if present_a != present_b:
2393
+ raise PerturbationError(
2394
+ f"post-drop rule-id sets differ for '{a}' and '{b}' on cell {cell}: "
2395
+ f"{sorted(present_a ^ present_b)} — refusing to report across mismatched families"
2396
+ )
2397
+ stats_a = _family_stats(rows_a, family, threshold)
2398
+ stats_b = _family_stats(rows_b, family, threshold)
2399
+ verdict = _separation(stats_a, stats_b, len(family))
2400
+ guard_family = [pid for pid in family if pid not in violations]
2401
+ guard_verdict = _separation(
2402
+ _family_stats(rows_a, guard_family, threshold),
2403
+ _family_stats(rows_b, guard_family, threshold),
2404
+ len(guard_family),
2405
+ )
2406
+ fragile = [label for label, s in ((a, stats_a), (b, stats_b)) if s["fragile"]]
2407
+ return {
2408
+ "a": a,
2409
+ "b": b,
2410
+ "verdict": verdict,
2411
+ # RB-P16: the verdict is one of four words for a two-dimensional fact, so it
2412
+ # travels with its size, its direction and its point-level disagreement. Read
2413
+ # `_effect`'s docstring for why these fields and not a banding vocabulary.
2414
+ # It decides nothing — `attributable` below is untouched by it.
2415
+ "effect": _effect(a, b, rows_a, rows_b, family),
2416
+ "family_size": len(family),
2417
+ "dropped_rules": dropped_rules,
2418
+ "a_pass_rate": stats_a["pass_rate"],
2419
+ "b_pass_rate": stats_b["pass_rate"],
2420
+ "fragile": fragile,
2421
+ "guard_verdict": guard_verdict,
2422
+ "guard_effect": _effect(a, b, rows_a, rows_b, guard_family),
2423
+ "guard_family_size": len(guard_family),
2424
+ "guard_dropped_rules": [
2425
+ {"rule": pid, "reason": "shared-token", "words": violations[pid]}
2426
+ for pid in family
2427
+ if pid in violations
2428
+ ],
2429
+ # §7 rule 2: a fragile family voids attribution on this cell even if rule 1
2430
+ # fires. RB-P19 adds the third: so does a separation that only the
2431
+ # shared-token-violating points carried.
2432
+ "attributable": (
2433
+ verdict == "distinguishable" and guard_verdict == "distinguishable" and not fragile
2434
+ ),
2435
+ }
2436
+
2437
+
2438
+ def _separation(stats_a: dict, stats_b: dict, size: int) -> str:
2439
+ """§7 rule 1, on whichever family it is handed.
2440
+
2441
+ **Read the word beside its `effect` block, never alone.** This function returns four
2442
+ words for a two-dimensional fact and RB-P16 is the measured consequence:
2443
+
2444
+ - `inconclusive` covers everything that is neither `F/F`-versus-`0/F` nor a tie. Over
2445
+ the whole committed §7 record (12 comparisons, 2 runs, measured 2026-08-13) eleven
2446
+ cells wear it, spanning |Δ| 3/11 = 0.2727 to 5/6 = 0.8333 — a factor of 3.06.
2447
+ - `indistinguishable` is **equal pass COUNTS, not agreement.** The one committed cell
2448
+ that carries it (`B-nonewline` 7/11 vs `C-attempted` 7/11, r1) is a cell on which
2449
+ the two variants disagree on **2 of 11 points**: `B` passes `P2-asks-requests`,
2450
+ `C` passes `W2-double-trailing`. The word claims an identity the measurement does
2451
+ not support, and it is the only cell in that record where the disagreement is
2452
+ two-sided — on the other eleven the pass sets are nested, so Δ alone recovers it.
2453
+ A fresh run measured 2026-08-14 puts a second instance on the record: the shipped
2454
+ rubric versus its trailing-newline variant reads `indistinguishable` at 2/11 vs
2455
+ 2/11 on `nav-prod-port` r0 while the two disagree on **4 of 11 points**.
2456
+
2457
+ Neither word is renamed here. The defect is not the spelling: a rename would make the
2458
+ committed record incomparable while still asserting nothing about agreement. What
2459
+ fixes it is that `_compare` now ships `_effect` beside every verdict, so a reader gets
2460
+ the size, the direction and the point-level disagreement without re-deriving them.
2461
+ """
2462
+ if size == 0:
2463
+ return "undefined"
2464
+ if (stats_a["passed"] == size and stats_b["passed"] == 0) or (
2465
+ stats_b["passed"] == size and stats_a["passed"] == 0
2466
+ ):
2467
+ return "distinguishable"
2468
+ if stats_a["passed"] == stats_b["passed"]:
2469
+ return "indistinguishable"
2470
+ return "inconclusive"
2471
+
2472
+
2473
+ def _effect(
2474
+ a: str,
2475
+ b: str,
2476
+ rows_a: dict[str, list[ReplayRow]],
2477
+ rows_b: dict[str, list[ReplayRow]],
2478
+ family: list[str],
2479
+ ) -> dict:
2480
+ """The effect size that travels with the verdict (RB-P16). It DECIDES NOTHING.
2481
+
2482
+ ## Why this shape, argued against the measured band
2483
+
2484
+ The band this has to render is not hypothetical. Every §7 comparison this project has
2485
+ ever committed — 12, over 2 runs, surveyed 2026-08-13 in
2486
+ `docs/eval-data/2026-08-13-rbp16-rbp17-rbp18-survey.md` and re-derived from the rows
2487
+ again on 2026-08-14 — is:
2488
+
2489
+ |Δrate| 0.2727 0.2727 0.3333 0.3636 0.3636 0.4545 0.4545 0.6364 0.6364 0.6667 0.8333
2490
+ |Δn| 3 3 4 4 4 5 5 7 7 8 10
2491
+
2492
+ plus one exact tie. Eleven of the twelve read `inconclusive`. The worst — `A-asfiled`
2493
+ 1/12 vs `C-attempted` 11/12, ten of twelve points flipped, **one point short of
2494
+ `0/F` versus `F/F` on each side** — wears the same word as the mildest, 7/11 vs 10/11.
2495
+ So the requirement is exact: 0.2727 and 0.8333 must be tellable **at a glance**.
2496
+
2497
+ Four fields, each earning its place against that band:
2498
+
2499
+ - **`delta_passed` / `delta_rate`, signed `a - b`.** The sign convention is the
2500
+ committed survey's, so its signed columns and this block are directly comparable.
2501
+ `delta_rate` is the at-a-glance number: no two DISTINCT values in the band above
2502
+ collide at the 3 decimals `format_table` prints (0.273 / 0.333 / 0.364 / 0.455 /
2503
+ 0.636 / 0.667 / 0.833). `format_table` additionally renders |`delta_rate`| as a
2504
+ 10-cell bar, because the eye compares LENGTHS faster than it compares decimals and
2505
+ the whole complaint is that a reader could not tell these apart while skimming.
2506
+ - **`points_from_separation` = `F - |Δn|`.** Distance to the only thing §7 rule 1
2507
+ credits. This is derived from the rule itself and invents no threshold —
2508
+ deliberately: a banding vocabulary (`large`/`small`) would be a new grade with new
2509
+ cut points, and an instrument that grades evidence may not quietly re-grade its
2510
+ own. The worst committed cell reads 2; the mildest reads 8.
2511
+ - **`disagreeing_points` with `a_only` / `b_only`.** This is what makes the tie word
2512
+ honest and is the field `delta_*` cannot supply. At Δn = 0 the difference is 0 and
2513
+ the two families can still disagree — measured on the one committed
2514
+ `indistinguishable` cell, they disagree on 2 of 11 points. Reported by NAME so the
2515
+ claim is checkable point by point rather than re-derived by the reader.
2516
+
2517
+ ## What this deliberately does not do
2518
+
2519
+ It does not touch `attributable`, and no field of it is read by any decision. §7's
2520
+ rules 1-3 decide exactly what they decided before this function existed; moving
2521
+ attribution would move a committed finding by moving the ruler and needs its own
2522
+ argued case. Rule 1 has decided all 12 committed cells, so rules 2 and 3 have never
2523
+ been reached — a reporting change is the only lever here that touches every cell.
2524
+ """
2525
+ size = len(family)
2526
+ passed_a = set(_passing_points(rows_a, family))
2527
+ passed_b = set(_passing_points(rows_b, family))
2528
+ delta = len(passed_a) - len(passed_b)
2529
+ a_only = [pid for pid in family if pid in passed_a and pid not in passed_b]
2530
+ b_only = [pid for pid in family if pid in passed_b and pid not in passed_a]
2531
+ return {
2532
+ "delta_passed": delta,
2533
+ "delta_rate": round(delta / size, 4) if size else 0.0,
2534
+ "sign": (delta > 0) - (delta < 0),
2535
+ "leads": (a if delta > 0 else b) if delta else None,
2536
+ "points_from_separation": size - abs(delta) if size else 0,
2537
+ "disagreeing_points": len(a_only) + len(b_only),
2538
+ "a_only": a_only,
2539
+ "b_only": b_only,
2540
+ }
2541
+
2542
+
2543
+ def _directional(cells: list[dict]) -> list[dict]:
2544
+ """The cross-cell statement §7 never made: does one pair point one way on every cell?
2545
+
2546
+ **REPORT ONLY. Nothing here is read by `attributable` or by any verdict.** That is not
2547
+ caution, it is the measurement: RB-P16's filing proposes requiring the sign to agree
2548
+ across cells before an `inconclusive` may be called directional, and **no committed
2549
+ cell exercises that rule.** Surveyed over the entire committed §7 record on
2550
+ 2026-08-13 and re-derived here: every `inconclusive` cell has sign −1, there is no run
2551
+ in which two cells of one pair point in opposite directions, and the only non-negative
2552
+ sign anywhere is the exact tie that already reads `indistinguishable`. Shipping it as
2553
+ a gate would be shipping a rule with zero instances behind it, which would change
2554
+ nothing on any cell while looking like it had been tested. So it is shipped as a
2555
+ sentence a reader can check, and `conflicting` is the field that would go true first.
2556
+
2557
+ A tie ABSTAINS rather than breaking agreement: sign 0 is "this cell says nothing about
2558
+ direction", which is exactly what an equal pass count means. `directional` therefore
2559
+ requires agreement AND at least one cell that actually pointed.
2560
+ """
2561
+ by_pair: dict[tuple[str, str], list[dict]] = {}
2562
+ for cell in cells:
2563
+ for comparison in cell["comparisons"]:
2564
+ by_pair.setdefault((comparison["a"], comparison["b"]), []).append(
2565
+ {
2566
+ "task": cell["task"],
2567
+ "repeat": cell["repeat"],
2568
+ "verdict": comparison["verdict"],
2569
+ **{
2570
+ key: comparison["effect"][key]
2571
+ for key in ("sign", "delta_rate", "leads")
2572
+ },
2573
+ }
2574
+ )
2575
+ out = []
2576
+ for (a, b), seen in by_pair.items():
2577
+ signs = [c["sign"] for c in seen]
2578
+ nonzero = {s for s in signs if s}
2579
+ rates = [abs(c["delta_rate"]) for c in seen]
2580
+ out.append(
2581
+ {
2582
+ "a": a,
2583
+ "b": b,
2584
+ "cells": seen,
2585
+ "signs": signs,
2586
+ "ties": signs.count(0),
2587
+ "conflicting": len(nonzero) > 1,
2588
+ "directional": len(nonzero) == 1,
2589
+ "leads": (a if nonzero == {1} else b) if len(nonzero) == 1 else None,
2590
+ "abs_delta_rate_min": min(rates) if rates else 0.0,
2591
+ "abs_delta_rate_max": max(rates) if rates else 0.0,
2592
+ }
2593
+ )
2594
+ return out
2595
+
2596
+
2597
+ def _format_effect(effect: dict, size: int) -> str:
2598
+ """One line a skimming reader can rank without subtracting two fractions.
2599
+
2600
+ The bar is ten ASCII cells of |Δrate|, `#` filled. Against the committed band that
2601
+ is `[########--]` for the worst cell (0.8333) and `[###-------]` for the mildest
2602
+ (0.2727) — the difference RB-P16 says a reader cannot currently see. ASCII on
2603
+ purpose: RB-P31/K4B measured what this module does when stdout cannot encode a
2604
+ character, and a report line is not the place to find out again.
2605
+ """
2606
+ delta, rate = effect["delta_passed"], effect["delta_rate"]
2607
+ filled = round(abs(rate) * 10)
2608
+ bar = "#" * filled + "-" * (10 - filled)
2609
+ lead = f"leads {effect['leads']}" if effect["leads"] else "neither leads"
2610
+ # A tie renders " 0", never "+0": the column width is kept but no direction is
2611
+ # implied, because an equal pass count is exactly the absence of one.
2612
+ dtxt = f"{delta:+d}" if delta else " 0"
2613
+ rtxt = f"{rate:+.3f}" if delta else " 0.000"
2614
+ return (
2615
+ f" effect: d={dtxt}/{size} ({rtxt}) [{bar}] {lead}; "
2616
+ f"{effect['points_from_separation']} from separation; "
2617
+ f"{effect['disagreeing_points']}/{size} points disagree"
2618
+ )
2619
+
2620
+
2621
+ def format_table(summary: dict) -> str:
2622
+ lines = [
2623
+ "| variant | task | repeat | seed | pass | min | max | spread | margin0 "
2624
+ "| identity | fragile |",
2625
+ "|---|---|---|---|---|---|---|---|---|---|---|",
2626
+ ]
2627
+ for cell in summary["cells"]:
2628
+ for label, stats in cell["variants"].items():
2629
+ lines.append(
2630
+ f"| {label} | {cell['task']} | {cell['repeat']} | {cell['seed']} | "
2631
+ f"{stats['pass_rate']} | {stats['score_min']} | {stats['score_max']} | "
2632
+ f"{stats['spread']} | {stats['margin_zero']} | "
2633
+ f"{','.join(str(s) for s in stats['identity_scores'])} | {stats['fragile']} |"
2634
+ )
2635
+ guard = summary["guard"]
2636
+ if guard["violations"]:
2637
+ lines += [
2638
+ "",
2639
+ f"GUARD VIOLATIONS ({guard['mode']} mode) — spec §3.3 step 3, guard 2, "
2640
+ f"BOTH readings; decision: {guard['decision_rule']}",
2641
+ ]
2642
+ for hit in guard["violations"]:
2643
+ per_reading = "; ".join(
2644
+ f"{reading}: {', '.join(hit['readings'][reading]) or '(none)'}"
2645
+ for reading in READINGS
2646
+ )
2647
+ lines.append(
2648
+ f"- {hit['task']} r{hit['repeat']}: {hit['point']} shares "
2649
+ f"{', '.join(hit['words'])} with the cell [{per_reading}] — dropped from the "
2650
+ "guard-clean family, attribution void unless the separation survives it"
2651
+ )
2652
+ for cell in summary["cells"]:
2653
+ for label, stats in cell["variants"].items():
2654
+ if stats["guard_dropped"]:
2655
+ lines.append(
2656
+ f" {label} {cell['task']} r{cell['repeat']}: "
2657
+ f"{stats['pass_rate']} full -> "
2658
+ f"{stats['guard_dropped']['pass_rate']} guard-clean"
2659
+ )
2660
+ lines += ["", "Pairwise (post-drop family):"]
2661
+ for cell in summary["cells"]:
2662
+ for comparison in cell["comparisons"]:
2663
+ dropped = ", ".join(
2664
+ f"{d['rule']} (absent from {d['variant']})" for d in comparison["dropped_rules"]
2665
+ )
2666
+ lines.append(
2667
+ f"- {cell['task']} r{cell['repeat']}: {comparison['a']} "
2668
+ f"{comparison['a_pass_rate']} vs {comparison['b']} {comparison['b_pass_rate']} "
2669
+ f"-> {comparison['verdict']} (F={comparison['family_size']}"
2670
+ + (f"; dropped {dropped}" if dropped else "")
2671
+ + ")"
2672
+ + ("" if comparison["attributable"] or comparison["verdict"] != "distinguishable"
2673
+ else f" — attribution VOID: fragile {comparison['fragile']}")
2674
+ )
2675
+ # RB-P16. A SECOND line rather than a longer first one: the verdict line's
2676
+ # bytes are what every committed table and every downstream reader already
2677
+ # parses, and the effect is an addition to the report, not a reflow of it.
2678
+ lines.append(_format_effect(comparison["effect"], comparison["family_size"]))
2679
+ if summary.get("directional"):
2680
+ lines += [
2681
+ "",
2682
+ "Directional consistency (one pair across its cells) — REPORT ONLY, it decides "
2683
+ "nothing; §7 has no cross-cell rule and no committed cell exercises one:",
2684
+ ]
2685
+ for pair in summary["directional"]:
2686
+ signs = ",".join("+" if s > 0 else "-" if s < 0 else "0" for s in pair["signs"])
2687
+ if pair["conflicting"]:
2688
+ verdict = "CONFLICTING — cells of this pair point opposite ways"
2689
+ elif pair["directional"]:
2690
+ verdict = f"consistent toward {pair['leads']}"
2691
+ else:
2692
+ verdict = "no direction (every cell ties)"
2693
+ lines.append(
2694
+ f"- {pair['a']} vs {pair['b']}: signs {signs} -> {verdict}"
2695
+ + (f" ({pair['ties']} tie)" if pair["ties"] == 1 else "")
2696
+ + (f" ({pair['ties']} ties)" if pair["ties"] > 1 else "")
2697
+ + f"; |d| {pair['abs_delta_rate_min']:.3f}..{pair['abs_delta_rate_max']:.3f}"
2698
+ )
2699
+ lines += [
2700
+ "",
2701
+ f"requests: {summary['requests']} wire calls: {summary['wire_calls']} "
2702
+ f"tokens: {summary['tokens_total']} manifest: {summary['manifest_sha256'][:12]}",
2703
+ ]
2704
+ return "\n".join(lines)
2705
+
2706
+
2707
+ def exit_status(result: RunResult) -> int:
2708
+ """The status a run that MEASURED something leaves behind: 0, or `GUARD_VIOLATION_EXIT`.
2709
+
2710
+ **What counts, and it is the rows.** Guard 2's union is non-empty on a (point, cell)
2711
+ pair the run ACTUALLY REPLAYED. Every row carries that pair's verdict already
2712
+ (`GuardedReplay.replay` stamps it), so this reads the run's own answer rather than
2713
+ re-deriving one beside it — RB-P19's finding is that a second derivation which
2714
+ happens to agree corroborates nothing.
2715
+
2716
+ Three consequences, each deliberate:
2717
+
2718
+ - **A point dropped from every variant's family does not count.** `guard_table` runs
2719
+ over every selected point, before `_variant_family` drops the ones whose anchor is
2720
+ absent, and the substitution-pair reading is a function of the point's own
2721
+ `from`/`to` pair — so `result.guard` can flag a point that reached no request and
2722
+ entered no statistic. Statusing on that table would be a verdict about a pair that
2723
+ never ran. It is still in the summary, and the drop is still in `dropped_rules`.
2724
+ - **`--identity-only` is always 0.** The identity point moves no words and cannot
2725
+ violate; RB-P15's standing check keeps the status it has always had.
2726
+ - **A violation on a variant whose rows were all dropped does not count for the
2727
+ status, but a violation on any variant that DID replay the pair does.** The union
2728
+ is across variants exactly as `guard_table` computes it.
2729
+
2730
+ This is a statement about the GUARD FIRING, not about whether the conclusion survived
2731
+ dropping the point. Whether the separation holds on the guard-clean family is
2732
+ `_compare`'s `guard_verdict`/`attributable`, it is already in the summary, and it is
2733
+ not re-decided here: a run whose attribution survives its violations still fired the
2734
+ guard, and a reader who is told otherwise by an exit code learns the wrong thing.
2735
+ """
2736
+ return GUARD_VIOLATION_EXIT if any(row.guard_violations for row in result.rows) else 0
2737
+
2738
+
2739
+ # ---- CLI (§6.2) ----
2740
+
2741
+ _EXIT_CONTRACT = f"""exit status (RB-P24):
2742
+ 0 measured; guard 2 fired on nothing that ran
2743
+ {REFUSAL_EXIT} did not complete a measurement that was ATTEMPTED: the command line was
2744
+ well formed and something it named could not be used (a rubric that is not
2745
+ on disk, a directory with no answered transcripts), or --guard error fired
2746
+ on a violating cell, or the run aborted mid-family. Artifacts from a
2747
+ {REFUSAL_EXIT} are PARTIAL, not absent: the --json sink flushes per row so a killed
2748
+ run keeps what it got. The SAME argv can succeed on the next run - what it
2749
+ depends on lives outside the command line.
2750
+ {USAGE_EXIT} the command line itself is wrong and no run was attempted. Two sources,
2751
+ one number: argparse's own parsing (unknown flag, missing required,
2752
+ type=/choices=), and every argument-SHAPE validation this module makes -
2753
+ --replays 0, --identity-replays 0, --rubric with no LABEL=, --rubric
2754
+ a=git:HEAD, two --rubric values sharing one LABEL, a --rubric derive: spec
2755
+ whose base is not a git: spec (RB-P17 - which is also how a derive OF a
2756
+ derive is refused, so the load order is acyclic rather than checked) -
2757
+ each of them reported through parser.error before any file is opened. A
2758
+ derive: whose rule id the manifest does not have is NOT here: that needs
2759
+ the manifest read, so it is a {REFUSAL_EXIT}. Malformed ON ITS FACE means
2760
+ it can never work on any machine: nothing ran, nothing was written, and
2761
+ re-running the same argv is guaranteed to fail again, so a human edits the
2762
+ command.
2763
+ shape rules (exact set): --identity-replays --replays --rubric
2764
+ That roster is checked against the CODE - every parser.error reachable
2765
+ above main's try and the flags its messages name - and every flag on it is
2766
+ field-measured to {USAGE_EXIT} from a real shell, so it cannot drift from what this
2767
+ tool does. A rule that needs the WORLD is not on it and is a {REFUSAL_EXIT}:
2768
+ --rubric a=<a path that is not there> is the one to keep in mind, because
2769
+ the flag is on the roster and that case still is not.
2770
+ CHANGED IN RB-P32, and a CI job that branches on these sees it: the four
2771
+ malformed --rubric shapes used to report {REFUSAL_EXIT}, the status that also means
2772
+ "a measurement died halfway and its artifacts are partial". A fifth case
2773
+ followed it in K4B - two --rubric values sharing a LABEL, which collide on
2774
+ every machine and were nonetheless refused from inside the run. Measured
2775
+ before and after in docs/eval-data/2026-08-13-rbp32-argument-validation-*.md
2776
+ and docs/eval-data/2026-08-13-k4b-c2-duplicate-rubric-label.md.
2777
+ RB-P17 adds the derive: shapes above. 3 cases change number, measured in
2778
+ docs/eval-data/2026-08-14-rbp17-provenance-resolution.md: a derive of a
2779
+ derive moves 1 -> 2 (it is now wrong on its face, not a path that is not
2780
+ there), and the two runs that use the form at all move 1 -> 3 because
2781
+ before it there was no form and the argv could not run.
2782
+ {GUARD_VIOLATION_EXIT} measured, WITH guard-2 violations - every artifact is still written,
2783
+ and the GUARD section names each violating (point, cell)
2784
+ {ARTIFACT_WRITE_EXIT} measured, but an artifact could not be written (the --summary file).
2785
+ The table is still printed - unless the READER of stdout had already gone,
2786
+ in which case it went nowhere and nobody was owed it (a stdout that FAILED
2787
+ is {RENDER_FAILURE_EXIT}, below, never this). Outranks {GUARD_VIOLATION_EXIT};
2788
+ --violations-exit-zero does not suppress it.
2789
+ {RENDER_FAILURE_EXIT} measured, but THE REPORT COULD NOT BE RENDERED: the write of
2790
+ the table to stdout failed for a reason that is NOT the reader going away -
2791
+ fd 1 read-only (EBADF), fd 1 closed before the process started, a full
2792
+ device, or a stdout whose CODEC cannot represent the table
2793
+ (PYTHONIOENCODING=latin-1 or =ascii, where the GUARD section's U+2014 raises
2794
+ UnicodeEncodeError). The measurement is COMPLETE and every file that could
2795
+ be written is on disk, BYTE-IDENTICAL to what the same argv leaves with a
2796
+ live stdout; read those, not this run's log, because the table is not in it.
2797
+ The reason is on stderr and is ASCII, so it arrives as written on a
2798
+ stderr wrapped in the codec that just failed - stderr's own error handler
2799
+ is backslashreplace, so the status would survive a non-ASCII byte there
2800
+ but the sentence would not. Outranks
2801
+ {ARTIFACT_WRITE_EXIT}; --violations-exit-zero does not suppress it.
2802
+ Branch on 0-{RENDER_FAILURE_EXIT}. A stdout that fails at the table print
2803
+ no longer leaves the range, and the two arms report DIFFERENT numbers on
2804
+ purpose: if the reader is gone nothing was owed to anyone, so the run
2805
+ reports the status it EARNED (RB-P27); if the write fails for any other reason
2806
+ the report was wanted and is now lost, so the run
2807
+ reports {RENDER_FAILURE_EXIT} instead of claiming a clean measurement (RB-P31).
2808
+ Neither arm invents a status the run did not have.
2809
+ WHAT IS STILL OUTSIDE THE RANGE is the interpreter or a signal, and that list
2810
+ is OPEN rather than exhaustive. Measured so far: 130 SIGINT, 143 SIGTERM, a
2811
+ stderr lost while the run was writing to it, and a stdout lost OUTSIDE the run
2812
+ path (--help, which argparse writes and exits before main is reached) — whose
2813
+ number this module does not choose and which is NOT FIXED, so do not branch on
2814
+ a particular one: argparse swallows the write error, and what the shell reads is
2815
+ then decided by what the shutdown flush finds in the stdio buffer. Both 120 and
2816
+ 0 have been measured, on different platforms at different help lengths
2817
+ (RB-P35); the readings and their dates are in docs/eval.md.
2818
+ Read those as "this process did not choose its own status", NOT as "the
2819
+ measurement did not happen": the artifacts may still be on disk. ENOSPC on a
2820
+ real full device has NOT been measured in the field on this platform; it is
2821
+ handled by class, not by evidence. fd 1 pointing at a DIRECTORY is outside the
2822
+ range in the other direction: CPython dies in init_sys_streams before main
2823
+ exists and the shell reads 1, so nothing here chose that number either.
2824
+ NOTE for in-process callers (main is exported): once either arm has fired,
2825
+ this process's sys.stdout is replaced by a sink that RAISES the original
2826
+ failure on any further write, and fd 1 itself is left exactly as it was found.
2827
+ A caller that keeps writing after main gets an exception, never silence.
2828
+ """
2829
+
2830
+
2831
+ _EINVAL_MEANS_LOST_READER = os.name == "nt"
2832
+ """Does `EINVAL` from a write to stdout mean the READER of stdout is gone?
2833
+
2834
+ On Windows it does, and the reason is a MAPPING, not a state. The CRT's `_write()`
2835
+ calls `WriteFile`; when that fails it runs the Win32 error through `_dosmaperr`, whose
2836
+ table has no entry for `ERROR_BROKEN_PIPE` (109) or `ERROR_NO_DATA` (232) - the two
2837
+ errors a write to a pipe with no reader produces - so both reach Python as a plain
2838
+ `OSError` with `errno == EINVAL` and NO `winerror`, and the `except BrokenPipeError`
2839
+ clause this module used to key on was never entered.
2840
+
2841
+ MEASURED, windows-latest 3.11 and 3.12, CI run 32555258828 (job 31): a clean run whose
2842
+ stdout was a pipe with no reader reported `5` instead of the `0` it earned, with
2843
+ `error: measured, but the report could not be rendered on stdout: [Errno 22] Invalid
2844
+ argument` on stderr. That the prefix is `[Errno 22]` and not `[WinError ...]` is itself
2845
+ the evidence that no `winerror` came with it: the SAME log renders a `winerror`-carrying
2846
+ `OSError` two nodes later as `[WinError 183] Cannot create a file when that file already
2847
+ exists`. A `winerror` that DOES survive needs no clause here - CPython maps both 109 and
2848
+ 232 to `EPIPE` in `PC/errmap.h`, so such an `OSError` arrives already a
2849
+ `BrokenPipeError`.
2850
+
2851
+ WHY A PLATFORM CONSTANT AND NOT A BARE `errno` TEST, which is the narrower thing to do
2852
+ and is done here anyway. `EINVAL` is Windows' CATCH-ALL for Win32 errors its table does
2853
+ not name, so it carries this meaning only on the platform whose CRT put it there. On
2854
+ POSIX a write's `EINVAL` says nothing about a reader, and reading it as one would let
2855
+ this module report a clean `0` for a report that a waiting reader never got - the exact
2856
+ invention the arm below is forbidden to make (`a lost stdout may DOWNGRADE to a number
2857
+ the run already had; it may never invent one`).
2858
+
2859
+ So the platform FACT and the RULE are split, and the seam is here on purpose: the fact
2860
+ is this constant, which no non-Windows runner can exercise and which therefore measures
2861
+ nothing off Windows; the rule is `_stdout_reader_is_gone`, which
2862
+ `test_einval_is_a_lost_reader_only_where_the_c_runtime_says_so` exercises in BOTH
2863
+ directions on any platform. What is still NOT measured anywhere but a Windows runner is
2864
+ this line's own `os.name == "nt"`.
2865
+ """
2866
+
2867
+
2868
+ def _stdout_reader_is_gone(e: BaseException) -> bool:
2869
+ """Was `e`, raised by the run path's write to stdout, THE READER GOING AWAY?
2870
+
2871
+ The state, not one OS's spelling of it. Two arms hang off this answer and they
2872
+ report different numbers on purpose (see `_EXIT_CONTRACT`): a gone reader downgrades
2873
+ to the status the run EARNED, because nothing was owed to anyone; anything else is
2874
+ `RENDER_FAILURE_EXIT`, because the report was wanted and is lost.
2875
+
2876
+ It stays a PREDICATE over the errno rather than a wider `except`: `ENOSPC`, `EBADF`
2877
+ and `UnicodeEncodeError` must keep falling through to `RENDER_FAILURE_EXIT`, and they
2878
+ do - `test_an_enospc_failure_at_the_table_write_reports_the_render_failure_status`
2879
+ and `test_a_lost_stdout_raises_on_the_next_write_instead_of_swallowing_it` go red if
2880
+ this ever answers True for them.
2881
+ """
2882
+ if isinstance(e, BrokenPipeError):
2883
+ return True # POSIX EPIPE, and any Windows error CPython already mapped to it
2884
+ return _EINVAL_MEANS_LOST_READER and isinstance(e, OSError) and e.errno == errno.EINVAL
2885
+
2886
+
2887
+ class _LostStdout:
2888
+ """What `sys.stdout` becomes once the run path's write to it has failed.
2889
+
2890
+ ONE recovery for both arms, and it replaces the `os.dup2(devnull, 1)` that v0.19.0
2891
+ used. The job is exactly one thing: make CPython's finalization flush succeed. That
2892
+ flush runs AFTER `main` has returned and after `SystemExit` has chosen its number, it
2893
+ calls `sys.stdout.flush()` on whatever `sys.stdout` is bound to at that moment, and
2894
+ if it raises, the interpreter throws the chosen status away and exits 120. Below fd
2895
+ 1's `BufferedWriter` the doomed bytes are still in the buffer when we get here, so
2896
+ that re-failure is not hypothetical - it is what the whole 120 half of RB-P31's
2897
+ matrix is. Rebinding `sys.stdout` to this object gives the flush nothing to fail on.
2898
+
2899
+ Three properties the `dup2` recipe did not have, and each is a filed defect closed:
2900
+
2901
+ - **It needs no file descriptor.** `os.open(os.devnull)` inside the handler can
2902
+ itself fail, and RB-P31 filed that as its second route: the recovery raising
2903
+ `OSError` out of the arm lands the run on REFUSAL_EXIT, which is the very thing
2904
+ the arm exists to prevent. There is no `os.open` here, so the route is gone rather
2905
+ than caught. (K1 could not construct EMFILE from outside the process and said so;
2906
+ the answer to a route that cannot be measured is to remove it, not to argue it is
2907
+ rare.)
2908
+ - **It does not touch fd 1.** RB-P33: the `dup2` pointed this process's fd 1 at the
2909
+ null device permanently, so every later write - including a child process's, which
2910
+ inherits fd 1 - silently succeeded into nothing. fd 1 is left exactly as found.
2911
+ - **It raises rather than lies.** RB-P33's own words: silence is the one option not
2912
+ available. A caller that writes to `sys.stdout` after `main` returns gets the
2913
+ original failure re-raised, which is true: that stream really is broken.
2914
+
2915
+ `flush` and `close` are no-ops because the bytes are already lost and re-attempting
2916
+ the write is what this exists to stop.
2917
+ """
2918
+
2919
+ closed = False
2920
+
2921
+ def __init__(self, cause: BaseException) -> None:
2922
+ self.cause = cause
2923
+
2924
+ def write(self, _text: str) -> int:
2925
+ raise self.cause
2926
+
2927
+ def flush(self) -> None:
2928
+ return None
2929
+
2930
+ def close(self) -> None:
2931
+ return None
2932
+
2933
+ def writable(self) -> bool:
2934
+ return True
2935
+
2936
+ def readable(self) -> bool:
2937
+ return False
2938
+
2939
+ def seekable(self) -> bool:
2940
+ return False
2941
+
2942
+ def isatty(self) -> bool:
2943
+ return False
2944
+
2945
+
2946
+ def main(argv: list[str] | None = None) -> None:
2947
+ """The CLI. Exits through the contract in `_EXIT_CONTRACT`; see the module comment.
2948
+
2949
+ ONE SIDE EFFECT AN IN-PROCESS CALLER HAS TO KNOW ABOUT (RB-P33). If the write of the
2950
+ table to stdout fails - the reader gone, fd 1 read-only, a full device - this
2951
+ function rebinds `sys.stdout` to a `_LostStdout` and LEAVES it rebound, because
2952
+ CPython's finalization flush happens after this function returns and would otherwise
2953
+ re-fail on the same stream and replace the status with 120. `sys.__stdout__` and fd 1
2954
+ are untouched, and a later write to `sys.stdout` re-raises the original failure, so
2955
+ the loss stays visible to whoever writes next.
2956
+ """
2957
+ parser = argparse.ArgumentParser(
2958
+ prog="python -m bantamkit.criticreplay",
2959
+ description=(
2960
+ "Perturbation bar: replay one critic over a family of meaning-preserving\n"
2961
+ "edits to its own prompt and report the pass rate with its spread."
2962
+ ),
2963
+ epilog=_EXIT_CONTRACT,
2964
+ formatter_class=argparse.RawDescriptionHelpFormatter,
2965
+ )
2966
+ parser.add_argument(
2967
+ "--rubric",
2968
+ action="append",
2969
+ required=True,
2970
+ metavar="LABEL=SPEC",
2971
+ help=(
2972
+ "repeatable; SPEC is a path, git:<ref>:<path>, or "
2973
+ "derive:<manifest-path>:<rule-id>:git:<ref>:<path>"
2974
+ ),
2975
+ )
2976
+ parser.add_argument(
2977
+ "--manifest", type=Path, help="perturbation manifest (default: by rubric name)"
2978
+ )
2979
+ parser.add_argument(
2980
+ "--transcripts", type=Path, required=True, help="directory of P8 transcripts (the cells)"
2981
+ )
2982
+ parser.add_argument("--task", action="append", help="repeatable; restrict to these tasks")
2983
+ parser.add_argument("--base-url", required=True)
2984
+ parser.add_argument("--model", required=True)
2985
+ parser.add_argument("--timeout", type=float, default=60.0)
2986
+ parser.add_argument("--replays", type=int, default=1, help="replays per perturbation point")
2987
+ parser.add_argument("--identity-replays", type=int, default=5)
2988
+ parser.add_argument("--identity-only", action="store_true", help="RB-P15's standing check")
2989
+ parser.add_argument(
2990
+ "--guard",
2991
+ choices=GUARD_MODES,
2992
+ default="warn",
2993
+ help=(
2994
+ "what to do when a point shares a moved word with a cell (spec §3.3 guard 2, "
2995
+ "computed under BOTH readings and tainted if either flags): 'warn' measures it "
2996
+ "and records both readings on every row and in the summary (default); 'error' "
2997
+ "refuses before any request"
2998
+ ),
2999
+ )
3000
+ parser.add_argument("--json", type=Path, help="append one JSON line per replay to this file")
3001
+ parser.add_argument("--summary", type=Path, help="write the summary JSON here")
3002
+ parser.add_argument(
3003
+ "--violations-exit-zero",
3004
+ action="store_true",
3005
+ help=(
3006
+ f"exit 0 instead of {GUARD_VIOLATION_EXIT} when guard 2 fired on a replayed "
3007
+ "pair, for a procedure whose violations are EXPECTED and recorded (the anchor "
3008
+ "set's second pass is one). Changes nothing that is written, and prints what "
3009
+ "it suppressed on stderr"
3010
+ ),
3011
+ )
3012
+ args = parser.parse_args(argv)
3013
+ # EVERY ARGUMENT-SHAPE VALIDATION THIS MODULE MAKES, IN ONE PLACE, ABOVE THE `try`
3014
+ # (RB-P32). What belongs here is a rule that can be decided from the argv alone —
3015
+ # no path resolved, no file opened, no `git` run. Those exit through `parser.error`,
3016
+ # so a command line that is malformed on its face is always `USAGE_EXIT`: it can
3017
+ # never work on any machine, nothing ran, nothing was written, and a CI job's only
3018
+ # move is to edit the command. A rule that needs the world stays inside the `try`
3019
+ # below and is a `REFUSAL_EXIT`, because the same argv works once the world changes
3020
+ # and the artifacts of a half-finished run may be on disk.
3021
+ #
3022
+ # BEFORE RB-P32 the line was not this one: it was whichever function the author had
3023
+ # reached for. `--replays 0` was `parser.error` and therefore 2, while `--rubric`
3024
+ # with no `LABEL=` raised `PerturbationError` one statement lower and was 1 — the
3025
+ # same 1 a run that died on request 40 of 80 with 39 rows on disk reports. Four
3026
+ # cases moved from 1 to 2 and the module comment records them.
3027
+ if args.replays < 1 or args.identity_replays < 1:
3028
+ parser.error("--replays and --identity-replays must be >= 1")
3029
+ for spec in args.rubric:
3030
+ problem = rubric_arg_shape_problem(spec)
3031
+ if problem is not None:
3032
+ parser.error(problem)
3033
+ collision = rubric_label_collision_problem(args.rubric)
3034
+ if collision is not None:
3035
+ parser.error(collision)
3036
+
3037
+ jsonl = None
3038
+ try:
3039
+ variants = [parse_rubric_arg(spec) for spec in args.rubric]
3040
+ manifest = load_manifest(args.manifest, rubric_name=variants[0].rubric.name)
3041
+ cases = load_cases(args.transcripts, tasks=args.task)
3042
+ if not cases:
3043
+ raise PerturbationError(
3044
+ f"no transcripts with answers in {args.transcripts}"
3045
+ + (f" for task(s) {args.task}" if args.task else "")
3046
+ )
3047
+ client = OpenAICompatible(
3048
+ base_url=args.base_url, model=args.model, timeout=args.timeout
3049
+ )
3050
+ sink = None
3051
+ if args.json:
3052
+ args.json.parent.mkdir(parents=True, exist_ok=True)
3053
+ jsonl = args.json.open("a", encoding="utf-8")
3054
+
3055
+ def sink(row: ReplayRow) -> None:
3056
+ jsonl.write(json.dumps(row.row()) + "\n")
3057
+ jsonl.flush() # a killed run keeps its partials
3058
+
3059
+ result = run(
3060
+ client,
3061
+ variants,
3062
+ manifest,
3063
+ cases,
3064
+ replays=args.replays,
3065
+ identity_replays=args.identity_replays,
3066
+ identity_only=args.identity_only,
3067
+ model=args.model,
3068
+ guard=args.guard,
3069
+ on_row=sink,
3070
+ )
3071
+ summary = summarize(result, manifest.sha256)
3072
+ except BantamError as e:
3073
+ print(f"error: {e}", file=sys.stderr)
3074
+ raise SystemExit(1) from e
3075
+ finally:
3076
+ if jsonl is not None:
3077
+ jsonl.close()
3078
+
3079
+ # RB-P24 review, C1: this write is the one artifact produced AFTER the measurement,
3080
+ # and it used to sit outside every handler — so an unwritable `--summary` raised an
3081
+ # unhandled OSError and the process exited 1, the REFUSAL status, on a run that had
3082
+ # already flushed every JSONL row. A run that measured may not report that it
3083
+ # refused. The failure gets its own number and the table is still printed, because
3084
+ # the measurement happened and stdout is where it is readable.
3085
+ write_failure: OSError | None = None
3086
+ if args.summary:
3087
+ try:
3088
+ args.summary.parent.mkdir(parents=True, exist_ok=True)
3089
+ args.summary.write_text(json.dumps(summary, indent=2) + "\n", encoding="utf-8")
3090
+ except OSError as e:
3091
+ write_failure = e
3092
+ print(
3093
+ f"error: measured, but the summary could not be written to "
3094
+ f"{args.summary}: {e}. The measurement itself went to stdout IF STDOUT "
3095
+ "TOOK IT, which this line cannot promise: a stdout that failed is "
3096
+ f"reported on its own line below and the status becomes "
3097
+ f"{RENDER_FAILURE_EXIT}, and a stdout whose reader was already gone is "
3098
+ "not reported at all - the table went nowhere and nobody was waiting "
3099
+ "for it. The JSONL rows, if --json was passed, are on disk either way.",
3100
+ file=sys.stderr,
3101
+ )
3102
+
3103
+ # RB-P31. `format_table` is OUTSIDE the try BY DESIGN. A failure to BUILD the table
3104
+ # is a bug in this module, and a bug may not be laundered into a contract status: it
3105
+ # propagates with its traceback. Only the WRITE is inside an arm. Pinned by
3106
+ # `test_a_non_pipe_failure_around_the_table_print_is_not_downgraded`, which raises
3107
+ # from `format_table` and requires both raisers to reach the caller — move this call
3108
+ # back inside the try and its OSError case goes red.
3109
+ table = format_table(summary)
3110
+ render_failure: str | None = None
3111
+ if sys.stdout is None:
3112
+ # fd 1 was not open when this process started (`prog 1>&-`). CPython leaves
3113
+ # `sys.stdout` as None in that case, `print` to a None stdout is a SILENT NO-OP,
3114
+ # and the table is lost with no exception raised anywhere — which is why this is
3115
+ # READ as a state rather than caught as an error, and why RB-P31's own filed
3116
+ # attack direction ("give the print an `except OSError` arm") would have left
3117
+ # this cell exactly where it was: at REFUSAL_EXIT, at every table size, on a run
3118
+ # with every artifact on disk. Measured 2026-08-13, 9 of 9 cells.
3119
+ render_failure = "fd 1 was not open when this process started"
3120
+ else:
3121
+ try:
3122
+ print(table)
3123
+ sys.stdout.flush()
3124
+ except (OSError, UnicodeEncodeError) as e:
3125
+ # ONE arm, then the question that decides which number this run reports:
3126
+ # WAS THE READER GONE. It used to be two `except` clauses, `BrokenPipeError`
3127
+ # and everything else, which spelled the question as `EPIPE` — one OS's word
3128
+ # for the state rather than the state. The caught set is unchanged
3129
+ # (`BrokenPipeError` is an `OSError`); only the test moved from the clause to
3130
+ # `_stdout_reader_is_gone`, where a platform that spells it differently can be
3131
+ # added without widening what this arm catches.
3132
+ if _stdout_reader_is_gone(e):
3133
+ # RB-P27 lever (2). The reader of stdout is gone (`... | head -1` once `head`
3134
+ # has exited), so the table cannot be delivered — and NOBODY WAS WAITING FOR
3135
+ # IT. Its absence costs no reader anything, so the run reports the status it
3136
+ # EARNED below rather than the interpreter's 120: a lost stdout may DOWNGRADE
3137
+ # to a number the run already had; it may never invent one. This is the ONLY
3138
+ # arm that downgrades, and "nobody was reading" is the whole of the reason.
3139
+ #
3140
+ # The explicit `flush()` is load-bearing: `print` writes into a buffer, and
3141
+ # without it the EPIPE would surface at interpreter shutdown instead of here,
3142
+ # where it can be handled.
3143
+ sys.stdout = _LostStdout(e)
3144
+ else:
3145
+ # RB-P31, and the line K4B had to draw again because the first one was one
3146
+ # class too narrow. The write failed and the reader was NOT gone: fd 1 is
3147
+ # read-only (EBADF), or the device is full (ENOSPC), or the fd is otherwise
3148
+ # unusable — or stdout's CODEC cannot represent the table
3149
+ # (`PYTHONIOENCODING=latin-1` or `=ascii`, where the GUARD section's U+2014
3150
+ # and U+00A7 raise `UnicodeEncodeError`). stderr is live in every measured
3151
+ # cell of this class, so this is a report that could not be RENDERED, not a
3152
+ # reader that walked away — the difference the two arms exist to keep apart.
3153
+ # The run measured, so it does not report REFUSAL_EXIT; the report is gone,
3154
+ # so it does not report the earned status either. It reports
3155
+ # RENDER_FAILURE_EXIT, and says why on stderr.
3156
+ #
3157
+ # WHERE THE LINE IS NOW, AND WHY IT IS NOT `except Exception`. The two
3158
+ # classes here are the two things about stdout that THE CALLER OWNS and this
3159
+ # module cannot fix: the descriptor (`OSError`) and the codec that
3160
+ # descriptor was wrapped in (`UnicodeEncodeError`). The shell chose fd 1; the
3161
+ # environment and the locale chose the encoding; neither is editable from
3162
+ # inside this file, and on both the measurement is complete and only the
3163
+ # delivery is lost. Everything else a write can raise is still a bug in this
3164
+ # module and still propagates with its traceback — a `RuntimeError`, a
3165
+ # `TypeError`, an `AttributeError`, and any `ValueError` that is not a
3166
+ # `UnicodeEncodeError` (`I/O operation on closed file` is the realistic one,
3167
+ # and it is reachable only from an in-process caller who closed
3168
+ # `sys.stdout`: a shell cannot hand a fresh process a stdout that is closed
3169
+ # at the Python-object level — `1>&-` gives `sys.stdout is None`, which is
3170
+ # the branch above). Pinned on that side by
3171
+ # `test_a_non_oserror_at_the_table_write_is_not_downgraded`, which raises a
3172
+ # `RuntimeError` from the write, and by
3173
+ # `test_a_non_unicode_valueerror_at_the_table_write_is_not_downgraded`,
3174
+ # which raises a bare `ValueError` — the sibling class of the one now
3175
+ # caught, so the widening is pinned exactly where it stops.
3176
+ #
3177
+ # WHAT THIS DOES NOT REACH: an encoding failure on STDERR. Every arm here
3178
+ # reports on stderr, so the messages this module writes there are ASCII on
3179
+ # purpose (below); a `BantamError` whose own message is not, on a stderr
3180
+ # that cannot encode it, still leaves by traceback with the same number the
3181
+ # refusal would have had. Measured in
3182
+ # docs/eval-data/2026-08-13-k4b-c1-stdout-encoding-matrix.md.
3183
+ render_failure = str(e)
3184
+ sys.stdout = _LostStdout(e)
3185
+ if render_failure is not None:
3186
+ # ASCII ONLY, and the reason is NOT the one it looks like (K4B, and this is a
3187
+ # claim K4B made, measured, and had to correct). One of the failures this line
3188
+ # reports is stdout's CODEC refusing the table, and stderr is wrapped in the SAME
3189
+ # codec — so the obvious story is that an em dash here would fail to encode and
3190
+ # turn a reported 5 back into an unreported traceback. IT WOULD NOT: CPython
3191
+ # gives `sys.stderr` the `backslashreplace` error handler and keeps it there even
3192
+ # under `PYTHONIOENCODING=ascii:strict` (measured 2026-08-13, both codecs), so a
3193
+ # non-ASCII character here is ESCAPED, never raised. The status survives.
3194
+ # What does not survive is the SENTENCE: it arrives as `COMPLETE — the`,
3195
+ # i.e. mangled in the one report whose whole job is to tell a reader where the
3196
+ # measurement went. So this is a legibility rule, not a survival one, and it is
3197
+ # pinned that way — the encoding nodes compare the delivered text against this
3198
+ # literal rather than merely checking that the bytes are ASCII, because
3199
+ # `backslashreplace` output IS ASCII and would pass that check.
3200
+ print(
3201
+ f"error: measured, but the report could not be rendered on stdout: "
3202
+ f"{render_failure}. The measurement is COMPLETE: the JSONL rows, if --json "
3203
+ "was passed, and the summary file, if --summary was passed and could be "
3204
+ "written, are on disk and are the same bytes a run with a live stdout would "
3205
+ f"have left. The table is not in this run's log. Exit status "
3206
+ f"{RENDER_FAILURE_EXIT}.",
3207
+ file=sys.stderr,
3208
+ )
3209
+
3210
+ # RB-P24. LAST, after every artifact that could be written exists: the JSONL (whose
3211
+ # sink already flushed per row), the summary and the table. Non-zero here means
3212
+ # "measured, and the guard fired" or "measured, and a file could not be written" or
3213
+ # "measured, and the report could not be rendered" — never "nothing happened".
3214
+ # Nothing that is written depends on this.
3215
+ status = exit_status(result)
3216
+ if status and args.violations_exit_zero:
3217
+ print(
3218
+ f"note: guard 2 fired on a replayed pair; suppressing status {status} because "
3219
+ "--violations-exit-zero was passed. The violations are in the GUARD section "
3220
+ "above and in the artifacts.",
3221
+ file=sys.stderr,
3222
+ )
3223
+ status = 0
3224
+ if write_failure is not None:
3225
+ # Outranks the guard status and survives the hatch: 3 promises "every artifact
3226
+ # is written", and the hatch is an opt-out from 3 alone.
3227
+ status = ARTIFACT_WRITE_EXIT
3228
+ if render_failure is not None:
3229
+ # Outranks 4 for 4's own reason, one step further out. Each number in this ladder
3230
+ # outranks the one below because the lower one's own promise is false about this
3231
+ # run: 3 promises "every artifact is written", and 4 promises "the table is still
3232
+ # printed". Here neither is true, and the one a reader is worse off not knowing
3233
+ # is that the report never reached them. Survives the hatch for the same reason 4
3234
+ # does — the hatch is an opt-out from 3 alone.
3235
+ status = RENDER_FAILURE_EXIT
3236
+ if status:
3237
+ raise SystemExit(status)
3238
+
3239
+
3240
+ if __name__ == "__main__":
3241
+ main()