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.
- bantamkit/__init__.py +32 -0
- bantamkit/agent.py +458 -0
- bantamkit/assets/contracts/default.yaml +90 -0
- bantamkit/assets/evals/devteam/manifest.yaml +351 -0
- bantamkit/assets/evals/devteam/repo/HISTORY.md +18 -0
- bantamkit/assets/evals/devteam/repo/README.md +12 -0
- bantamkit/assets/evals/devteam/repo/docs/architecture.md +17 -0
- bantamkit/assets/evals/devteam/repo/docs/runbook.md +10 -0
- bantamkit/assets/evals/devteam/repo/issues/142-settlement-timeout.md +23 -0
- bantamkit/assets/evals/devteam/repo/patches/0009-retry-budget.patch +38 -0
- bantamkit/assets/evals/devteam/repo/src/ledger/__init__.py +3 -0
- bantamkit/assets/evals/devteam/repo/src/ledger/config.py +35 -0
- bantamkit/assets/evals/devteam/repo/src/ledger/errors.py +13 -0
- bantamkit/assets/evals/devteam/repo/src/ledger/posting.py +12 -0
- bantamkit/assets/evals/devteam/repo/src/ledger/registry.py +7 -0
- bantamkit/assets/evals/devteam/repo/src/ledger/report.py +9 -0
- bantamkit/assets/evals/devteam/repo/src/ledger/retry.py +17 -0
- bantamkit/assets/evals/devteam/repo/src/ledger/settle.py +16 -0
- bantamkit/assets/evals/devteam/repo/src/ledger/validate.py +14 -0
- bantamkit/assets/evals/devteam/repo/tests/test_posting.py +13 -0
- bantamkit/assets/evals/devteam/repo/tests/test_settle.py +9 -0
- bantamkit/assets/evals/devteam/tasks/dt-error-contract.yaml +186 -0
- bantamkit/assets/evals/devteam/tasks/dt-handler-map.yaml +183 -0
- bantamkit/assets/evals/devteam/tasks/dt-patch-before-after.yaml +182 -0
- bantamkit/assets/evals/devteam/tasks/dt-retry-attempts.yaml +181 -0
- bantamkit/assets/evals/devteam/tasks/dt-settlement-config.yaml +185 -0
- bantamkit/assets/evals/devteam/tasks/dt-symbol-home.yaml +181 -0
- bantamkit/assets/evals/devteam/tasks/dt-trace-blame.yaml +182 -0
- bantamkit/assets/evals/devteam/tasks/dt-unread-key.yaml +180 -0
- bantamkit/assets/evals/document/tasks/doc-large-in-137.yaml +38 -0
- bantamkit/assets/evals/document/tasks/doc-large-in-359.yaml +44 -0
- bantamkit/assets/evals/document/tasks/doc-large-in-372.yaml +38 -0
- bantamkit/assets/evals/document/tasks/doc-large-out-11764.yaml +37 -0
- bantamkit/assets/evals/document/tasks/doc-large-out-4137.yaml +37 -0
- bantamkit/assets/evals/document/tasks/doc-large-out-8022.yaml +37 -0
- bantamkit/assets/evals/document/tasks/doc-small-137.yaml +37 -0
- bantamkit/assets/evals/document/tasks/doc-small-261.yaml +37 -0
- bantamkit/assets/evals/document/tasks/doc-small-388.yaml +37 -0
- bantamkit/assets/evals/fixtures/.gitkeep +0 -0
- bantamkit/assets/evals/fixtures/catalog.json +6 -0
- bantamkit/assets/evals/perturbations/task-completion.yaml +576 -0
- bantamkit/assets/evals/tasks/.gitkeep +0 -0
- bantamkit/assets/evals/tasks/extract-contact.yaml +14 -0
- bantamkit/assets/evals/tasks/extract-invoice.yaml +14 -0
- bantamkit/assets/evals/tasks/extract-order.yaml +15 -0
- bantamkit/assets/evals/tasks/extract-schedule.yaml +14 -0
- bantamkit/assets/evals/tasks/extract-versions.yaml +17 -0
- bantamkit/assets/evals/tasks/nav-prod-port.yaml +84 -0
- bantamkit/assets/evals/tasks/nav-release-bundle.yaml +87 -0
- bantamkit/assets/evals/tasks/recall-audit-retention.yaml +17 -0
- bantamkit/assets/evals/tasks/recall-cache-ttl.yaml +13 -0
- bantamkit/assets/evals/tasks/recall-db-port.yaml +17 -0
- bantamkit/assets/evals/tasks/recall-deploy.yaml +13 -0
- bantamkit/assets/evals/tasks/recall-env-endpoint.yaml +18 -0
- bantamkit/assets/evals/tasks/recall-oncall-rotation.yaml +21 -0
- bantamkit/assets/evals/tasks/recall-oncall.yaml +13 -0
- bantamkit/assets/evals/tasks/recall-org-quota.yaml +18 -0
- bantamkit/assets/evals/tasks/recall-owner.yaml +13 -0
- bantamkit/assets/evals/tasks/shop-basket-total.yaml +10 -0
- bantamkit/assets/evals/tasks/shop-cheapest.yaml +9 -0
- bantamkit/assets/evals/tasks/shop-compare.yaml +9 -0
- bantamkit/assets/evals/tasks/shop-gadget-value.yaml +9 -0
- bantamkit/assets/evals/tasks/shop-stock-total.yaml +9 -0
- bantamkit/assets/evals/tasks/shop-total.yaml +9 -0
- bantamkit/assets/profiles/default.yaml +31 -0
- bantamkit/assets/profiles/patient.yaml +31 -0
- bantamkit/assets/rubrics/.gitkeep +0 -0
- bantamkit/assets/rubrics/code-quality.yaml +20 -0
- bantamkit/assets/rubrics/grounded-completion.yaml +37 -0
- bantamkit/assets/rubrics/task-completion.yaml +28 -0
- bantamkit/assets/schemas/shiftwork-checkpoint.json +188 -0
- bantamkit/assets/skills/.gitkeep +0 -0
- bantamkit/assets/skills/file-graph.md +7 -0
- bantamkit/assets/skills/memory.md +35 -0
- bantamkit/assets/tools/.gitkeep +0 -0
- bantamkit/assets/tools/bantamkit_read.json +48 -0
- bantamkit/assets/tools/bantamkit_status.json +25 -0
- bantamkit/assets/tools/build_identity.json +17 -0
- bantamkit/assets/tools/document_list.json +12 -0
- bantamkit/assets/tools/document_read.json +31 -0
- bantamkit/assets/tools/file_graph.json +12 -0
- bantamkit/assets/tools/memory_compact.json +31 -0
- bantamkit/assets/tools/memory_recall.json +38 -0
- bantamkit/assets/tools/memory_save.json +61 -0
- bantamkit/assets/tools/shiftwork_clock_in.json +25 -0
- bantamkit/assets/tools/shiftwork_clock_out.json +60 -0
- bantamkit/assets/tools/shiftwork_status.json +25 -0
- bantamkit/assets/tools/skill_audit.json +70 -0
- bantamkit/assets/tools/validate_json.json +31 -0
- bantamkit/assets.py +67 -0
- bantamkit/budget.py +114 -0
- bantamkit/client.py +329 -0
- bantamkit/contract.py +522 -0
- bantamkit/criticreplay.py +3241 -0
- bantamkit/critique.py +301 -0
- bantamkit/docread.py +1744 -0
- bantamkit/evalrun.py +2003 -0
- bantamkit/eventlog.py +282 -0
- bantamkit/filegraph.py +218 -0
- bantamkit/loopguard.py +101 -0
- bantamkit/mcpreport.py +763 -0
- bantamkit/mcpserver.py +1334 -0
- bantamkit/memory/__init__.py +28 -0
- bantamkit/memory/__main__.py +291 -0
- bantamkit/memory/component.py +569 -0
- bantamkit/memory/divergence.py +744 -0
- bantamkit/memory/layers.py +257 -0
- bantamkit/memory/store.py +940 -0
- bantamkit/pdfread.py +1402 -0
- bantamkit/profile.py +46 -0
- bantamkit/shiftwork.py +212 -0
- bantamkit/skillaudit.py +853 -0
- bantamkit/statusline.py +313 -0
- bantamkit/structured.py +125 -0
- bantamkit/textutil.py +30 -0
- bantamkit-0.27.0.dist-info/METADATA +207 -0
- bantamkit-0.27.0.dist-info/RECORD +119 -0
- bantamkit-0.27.0.dist-info/WHEEL +4 -0
- 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()
|