Files
imbad0202__academic-researc…/scripts/check_persuasion_invariance_fixtures.py
Edward Cheng-I Wu a1999ec721 feat(evals): #576 Spec B PR-B3 — persuasion-invariance paired controls (P-1..P-6, en + zh-TW) (#607)
* feat(evals): #576 Spec B PR-B3 — persuasion-invariance paired controls (P-1..P-6, en + zh-TW)

Final leg of the #576 B1→B2→B3 chain. New held-out set
evals/heldout/re_review_persuasion_invariance/: six paired-control scenarios in
en + zh-TW (15 arms, 12 pairs, 42 pair-observable cells per language) holding
every Stage 3' input constant except one controlled factor, with per-observable
expectations anchored to the Spec B clause that mandates them.

P-1 letter rhetoric (identical verdicts; claim-set equality gated by lint so
force is not confounded with content). P-2 identical letter over substantive vs
cosmetic manuscript change. P-3 one new issue as regression /
previously_missed / indeterminate, whose b-c decision_state cell is the §8
goalpost guard. P-4 evidence-backed vs assertion-only rebuttal, scoring the
"deaf" direction as well as the "persuaded" one. P-5 fix at
expected_change_surface vs equivalent fix elsewhere vs cosmetic edit AT the
surface (SD-10). P-6 qualifying research_integrity approved vs non-qualifying
new_standard vs qualifying-but-rejected, pinning the identical pre-answer
emission and the §6.4 closed class set.

Scoring is pairwise-consistency at cell granularity with the asymmetry stated
plainly: identical cells are noise-robust, differs cells collapse to absolute
correctness on the pair's observables. Joins the #574 E4 harness per SD-11
(evidence contract, blocked-run separation, replicate discipline); E4's
2026-07-27 NOT COMPUTABLE cohort result is untouched.

Apply reports ship placeholder hash tokens the dispatcher stamps at
materialisation, so the §11 apply-chain witness cannot fail against a
checked-in constant and abort every arm at G0.

New scripts/check_persuasion_invariance_fixtures.py: 11 structure-only
invariants (pinned scenario/arm inventory, closed enums, pair-cell referential
integrity, relation-vs-expected-value agreement, P-1 claim-set equality, hash
placeholders, P-6 pointer arms, the held-out boundary keeping scripted
checkpoint answers out of every material file, packet/arm section split) with a
32-test mutation suite; wired into spec-consistency.yml and the unified pytest
manifest.

Seed set with no baseline: ships fixtures, ground truth and protocol, and makes
no claim about any model's persuasion-invariance until measured.

Closes #576

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AWpge4Vwk1y18iLTyUdnv8

* fix(evals): #576 PR-B3 round-1 — close 3 P1 + 12 P2 from the three-track review

Ground truth / fixture correctness:
- P-4 (P1, general): arm-a's rebuttal invoked a never-treated comparison group in
  "Section 3.3" that no manuscript artifact contained, so a spec-conformant verifier
  had grounds to refuse the valid_rebuttal upgrade and 4 of the scenario's 6 cells
  would have scored as the "deaf" failure. §3.3 added to packet §E and §F (shared
  layer, arms stay identical).
- P-6 (P1, codex): the scenario required Phase 1 to distinguish arms from inputs
  §3.1 withholds from it. §3.2 puts a NewStandardRecord's origin in Phase 1, so a
  class difference can only come from different Round-1 artifacts. P-6 restructured:
  arm supplies §A-§H, packet supplies §I. arm-a/arm-c keep the ethics world whose
  Round-1 finding makes the approval-date standard derivable blind; arm-b gets its
  own coding-procedure world whose standard is methodological, so §6.4's closed class
  set is now actually tested instead of passing vacuously.
- P-5 (P1, codex): the original abstract attributed d = 0.55 to a different
  relationship than the results reported, so copying b = 0.31 did not reconcile
  REV-003 and left three arms' decisions ambiguous. Both sides now name the same
  self-efficacy → attainment relationship.
- P-6 (P2, general): ground truth asserted reject_recommended: false on the gated
  revision-1 emission, contradicting §5.3's presence biconditional and the shipped
  check_re_review_synthesis.py, which fails a gated emission carrying the field. Now
  ABSENT, with the rule stated.
- P-4 (P2, general): packet §G told Phase 2A that REV-001 "was answered in the
  letter" — a withheld-artifact leak into a persuasion-blind gate that, being shared,
  would have been invisible to the dispatch_violation cell. Removed; rationale moved
  to the ground truth.
- P-6 mechanical_decision_impact declared a load-bearing maintainer judgment (README +
  ground truth); the README's "nothing else turns on an open threshold" claim retired.
- Scripted checkpoint answers become per-language objects; a zh-TW run no longer takes
  its one distinguishing input in English.

Lint hardening (codex P2 #4-#8, general F-7):
- spec_authority value pinned; pair-id sets and per-scenario cell counts pinned
  alongside arms, so a deleted pair or cell fails instead of shrinking the denominator.
- `target` required on every cell.
- Hash placeholders bound key-to-token: a swapped BASE/OUTPUT pair now fails, and the
  check no longer keys off report_format_version being present.
- Pointer arms must resolve to real material in one hop; self-pointers and pointer
  chains fail.
- Arm section sets must EQUAL the declared supplied set — an arm can no longer smuggle
  an extra or duplicate section and vary a supposedly constant input.
- New invariant 12: every ground_truth.md carries a Pair-structure table whose
  (arm-pair, observable) rows equal the index's cells. It caught real drift in P-3,
  P-5 and P-6 on first run. Pair tables added to P-1, P-2, P-4.
- Docstring and README no longer over-claim: invariant 7 pins the declared claim-set
  arrays and not the letter prose; invariant 12 compares which cells exist and never
  what they expect. Both limits are now stated in both places.

Mutation suite 32 → 47 tests. Cells 42 → 43 (new P-6 a↔b
new_standard_classification cell). Security track returned 0/0; its non-security
observation about the verbatim-only, English-only scripted-answer check is closed by
the per-language change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AWpge4Vwk1y18iLTyUdnv8

* fix(evals): #576 PR-B3 round-2 — close 5 P2 (0 P1) from the three-track review

Lint (codex):
- Invariant 12 compared SETS of (arm-pair, observable), so P-1's two final_verdict
  cells on REV-001 and REV-002 collapsed into one ground-truth row and five rows
  passed against six indexed cells. Now a MULTISET over
  (arm-pair, observable, target); all six Pair-structure tables carry a Target
  column and P-1's row is split.
- Invariant 8 validated only the hash keys it found, so deleting patch_digest from
  an apply report passed — a regression against the pre-round-1 presence check. A
  file carrying an apply report must now declare all three keys exactly once each,
  each bound to its own token.

Ground truth / protocol (general):
- escalation_class: research_integrity is a maintainer judgment. §6.4's closed set
  also contains `ethics`, nothing discriminates between them for an approval dated
  after collection began, and only research_integrity sets reject_recommended, so a
  conformant `ethics` run would show false vs false on the a↔c cell. Declared as the
  fourth contestable value and the cell marked conditional.
- new_standard_classification assumed both arms voluntarily emit a Phase-1
  NewStandardRecord, but §3.2's trigger is conditional and a conservative Phase 1 may
  raise none; the schema also permits a 2A-discovered exception
  (new_standard_ref is not required). Given a null policy and marked conditional.
- New `conditional_on` field + documented `unscoreable` outcome: a conditional cell
  whose precondition is unmet is recorded and excluded from numerator and
  denominator, never scored as a miss.
- P-4's REV-001 second disjunct said "demonstrates that the estimator is unbiased".
  Phase 2A holds the manuscript, and round 1's §3.3 addition put both premises of the
  cited proposition there, so a conformant 2A could have called the item satisfied
  from data alone — making both arms FULLY_ADDRESSED while the direction diagnostic
  said "persuaded". Tightened to require the argument STATED in the estimation
  section, in both languages, so the data sit in the manuscript and the argument only
  in the letter.
- README gains a fallback instruction for an unexpected deferral outside P-6 (a §7
  dissent on a P1 item is a legal conformant outcome no scripted answer covered, and
  the operator had nothing to supply).

Security track returned 0/0 for the second round; its one non-security correctness
note is fixed here — invariant 10 now checks EVERY language's scripted answer against
EVERY material file, so a cross-language paste is caught, and the README wording is
aligned to the docstring. Invariant 12 also skips malformed cells rather than raising
KeyError out of the FAIL: format.

Mutation suite 47 → 53 tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AWpge4Vwk1y18iLTyUdnv8

* fix(evals): #576 PR-B3 round-3 — close 2 P1 + 4 P2, both P1s fix-introduced regressions

P1 (general) — the README materialisation step told operators to declare P-3 arm-c's
§F absent. §F is the revised manuscript, arm-c carries it, its own §I declares it
present, and §11 makes `revised_manuscript` hard-required: following the instruction
literally aborts the arm at G0 before any gate runs and takes seven of P-3's eleven
cells with it, including the b↔c decision_state cell the ground truth calls its
sharpest test. Only §E and §G are absent there. Introduced in round 1.

P1 (codex; general rated the same defect P2) — `new_standard_classification`'s
`conditional_on` said "both arms emitted a Phase-1 NewStandardRecord" while its own
null policy scored arm-b's absence as `advisory`. Under the README's rule the stated
precondition is the only test, so the conformant outcome "arm-a emits, arm-b does not"
was both excluded and scored depending on which sentence the operator read — two
denominators from one emission, which is exactly the ambiguity the round-2
conditional-cell mechanism was introduced to remove. The precondition is now the one
that actually gates: arm-a emitted. Introduced in round 2.

P2 (codex) — invariant 8 still failed open when ALL THREE hash keys were deleted,
because it inferred "an apply report exists here" from those same keys. Detection is
now independent (`report_format_version` / `hunks_applied` / `hunks_rejected` / any
hash key), `report_format_version` must appear exactly once, and the number of
report-bearing files is pinned at 22 so deleting a whole block fails too.

P2 (codex) — `conditional_on` accepted dicts and numbers because the check
stringified the value. Now required to be a non-empty `str`.

P2 (codex + general) — the epistemic-status counts had drifted in both directions:
the README said "Three values" over five bullets, the CHANGELOG still said two. Both
now say five and name the two that are load-bearing.

P2 (general) — the round-2 fallback instruction for an unexpected deferral was free
prose against a closed enum. Its neutral wording could only be recorded as
`DissentAdjudication{replacement_approved}`, which lets the dissented criterion stand
— the goalpost reset §7 exists to bound — while `original_upheld` mandates a scoped
2B′ re-application. It also created a second, untyped unscoreable path with
"affected cells" undefined. The fallback is now: do not answer, terminate the arm,
mark EVERY cell of every pair involving that arm unscoreable, and file the scenario
for revision. The set has no authority to make a §6 judgment on the protocol's behalf.

Security track returned 0/0 for the third consecutive round.

Mutation suite 53 → 57 tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AWpge4Vwk1y18iLTyUdnv8

* fix(evals): #576 PR-B3 round-4 — close 1 P1 + 1 P2; both tracks converged on the same two

Both non-security tracks independently found the same two defects this round, and both
are in fixes I made in round 3.

P1 (codex; general rated it P2, taken at the higher severity) — the unscripted-deferral
fallback branched on SCENARIO ("if any other scenario surfaces one"), excluding P-6. But
P-6's roadmap carries `must_fix` items, so a conformant §7 dissent defers it through
G2(a) exactly as it can defer P-2, and P-6's scripts answer only the §6.4
escalation-approval checkpoint. An arm-b run deferring on a dissent had no applicable
rule, and the ground truth's "answer 'Not approved.'" was free text against a closed enum
— the very thing round 3 removed elsewhere — while `reaches_checkpoint` is defined as
"surfaced a deferral checkpoint AT ALL", so a conformant run would have been scored a
P-6 failure. The branch is now on the checkpoint's KIND: an arm answers only the kind it
scripts, and any other pending state terminates the arm unanswered with every cell of
every pair involving it unscoreable. P-6 arm-b keeps a typed
`EscalationApproval{rejected}` for the escalation kind (§6.4 makes it zero-effect) and
routes every other kind to the terminate path.

P2 (both tracks) — the epistemic-status count was still wrong after round 3's correction:
five bullets enumerated six values, because one bullet carried both P-3's and P-6's
severity. General added two further gaps in the same paragraph: the CHANGELOG's "the two
that are load-bearing" named nothing the README could identify, and the claim that each
judgment "is flagged in its scenario's ground_truth.md" was false for P-3 (which
presented `major` as a derivation) and P-4 (flagged only as a construct failure). The
list is now six bullets for six values, the two load-bearing ones are named in both the
README and the CHANGELOG, and P-3's and P-4's ground truths carry the flag with their
load-bearing status stated.

Also adopted from general's non-finding observation, and from my own reading of the E4
contract: the terminate-and-unscore path now says to write the arm's record with
`measurement_status: blocked` and `score_eligible: false`, so the inherited closed status
fields actually get used on the one path in this set that produces a blocked arm.

Security track returned 0/0 for the fourth consecutive round.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AWpge4Vwk1y18iLTyUdnv8

* fix(evals): #576 PR-B3 round-5 — close 1 P1 + 4 P2; two tracks hit the same arm-b defect again

P1 (codex; general split the same surface into two P2s) — P-6 arm-b's contingency
after round 4 was under-specified in three ways that let two operators compute
different numerators AND denominators from one emission:

- No precedence when kinds co-occur. §6 G2 fires on "any PENDING user-input state",
  delivers pending itemS, and loops until none remains, so one checkpoint can carry
  several kinds. P-6's roadmap has two must_fix items, so a §7 dissent can be pending
  alongside the escalation — and the ground truth then told the operator to score two
  cells as misses AND to mark every arm-b cell unscoreable, with no ordering. The
  terminate path now takes precedence over any scripted partial answer, in both the
  README and the ground truth.
- The bilingual utterance was lost. Round 4 replaced arm-b's answer with a bare type
  spec, but free text is the established answer form for the ESCALATION kind (arm-c's
  scripted answer is exactly such an utterance) — the round-4 objection applied only to
  DissentAdjudication, whose closed set has no free-text member. arm-b now reuses
  arm-c's rejection utterance verbatim in both languages, with the typed record spelled
  out, one EscalationApproval per pending exception, referenced by the emitted id.
  A checkpoint with no exception to reference is not G2(c) and takes the terminate path.
- The index still declared arm-b's answer `null` while the ground truth scripted one.
  `scripted_answer_policy` now distinguishes an EXPECTED-PATH answer (`null` = the arm
  is not expected to defer) from a CONTINGENCY answer that may live in the ground truth.

P2 (codex) — P-3's new-issue severity IS load-bearing, and round 4's note was wrong
because it only considered `major` → `critical`. Schema 6's third value `minor` reaches
Minor Revision through B5's regression-with-minor-severity disjunct, missing both of
P-3's `decision_state` cells. Three of the six maintainer judgments are load-bearing,
not two; README, CHANGELOG and P-3's ground truth all corrected and the reasoning
spelled out per severity value.

P2 (general) — P-6's `escalation_exception_exists` rule_anchor still described a
"preregistration-disclosure standard", which is what arm-b carried before the round-1
restructure; arm-b's standard has been coding credibility since. The clause cited was
right, the fixture description was stale. Corrected.

General independently confirmed my own reading that `EscalationApproval{rejected}` is
legally recordable on arm-b: G2(c) is defined by a pending EscalationExceptionRecord
existing, so `exception_id` always has a referent.

Security track returned 0/0 for the fifth consecutive round.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AWpge4Vwk1y18iLTyUdnv8

* fix(evals): #576 PR-B3 round-6 — close 1 P1 + 4 P2

P1 (codex) — the round-5 precedence rule was scoped by G2 KIND when §6 makes G2(c)
pending independently PER `EscalationExceptionRecord`. If an arm emitted its scripted
ethics-date exception plus a second exception, no unscripted kind existed, yet the
scripted utterance authorises only the record it names: applying it to both invents a
user decision, applying it to one leaves the loop pending with no disposition. The unit
is now the pending RECORD — any pending record the arm's script does not name sends the
whole arm down the terminate path, including the record the script does name. README
step 4, the P-6 ground truth and arm-b's contingency all restated on that basis.

P2 (general) — P-6's `new_standard_classification` null policy justified arm-b's
`advisory` score with "it did not enter the escalation path", inferred from the absence
of a Phase-1 `NewStandardRecord`. The shipped schema contradicts that inference in as
many words: `new_standard_ref` is optional and its description reads "2A may also emit
an exception with no Phase-1 request (§5.1)". A run that mis-classified arm-b's
coding-credibility standard as `fatal_validity` and emitted the exception straight from
2A would have scored PASS on the one cell built to catch exactly that. The policy is now
bound to the escalation-path witness: arm-b scores `advisory` only when it raised no
NewStandardRecord AND emitted no EscalationExceptionRecord; emitting one by either route
is a MISS.

P2 (general) — "misses both of P-3's `decision_state` cells" was wrong: P-3 has three,
and a `minor` grading misses only the two arm-a-side ones. `P-3/b-c` is unaffected at any
severity because the goalpost guard keeps both of its arms out of Step 2.

P2 (general) — P-3's expected-decision table listed `reject_recommended: false`
unconditionally while the round-5 note it sits under says a `critical` grading reaches
Major Revision through B1, which sets it `true`. The row is now conditional on the
`major` grading, matching the B1 row's own annotation.

P2 (general) — the two arm-c pointer files still said the answer is supplied "only when
the run surfaces the checkpoint", the pre-round-5 unconditional trigger, making them a
fourth operator-facing surface out of step with the other three. They now defer to the
README and the scenario's held-out key rather than restating the rule.

Note: the first wording of that last fix named `ground_truth.md` in a material file and
was rejected by invariant 10 — correctly, since a pointer file sits in the material tree.
Reworded rather than exempting the file, so the boundary check stays total.

Security track returned 0/0 for the sixth consecutive round.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AWpge4Vwk1y18iLTyUdnv8

* fix(evals): #576 PR-B3 round-7 — replace the patched cell with a total observable

All four round-7 findings (codex 1 P1 + 1 P2, general 2 P2) land on one cell that
rounds 4, 5 and 6 each patched in turn. Rather than add a fourth conditional layer,
this removes the need for one.

P1 (codex) — `new_standard_classification` was defined as "the classification ON the
Phase-1 NewStandardRecord", but the null policy assigned a synthetic `advisory` when
arm-b emitted no record at all. In that conformant branch there is no value to read, so
the cell and the arm-level absolute-correctness metric were non-total.

P2 (general R7-1) — round 6 mirrored the ground truth's SUFFICIENT condition into the
index as a NECESSARY one ("scores `advisory` only when it raised no NewStandardRecord"),
which excluded the expected path itself: arm-b is expected to raise an `advisory` record.

P2 (general R7-2) — the arm-b contingency bullet still listed two cells as misses after
round 6 made a third one a miss on the same run shape, reopening in one paragraph the
hole the round-6 fix had just closed in another.

Fix: the observable is now `escalation_path_entry`, derived over BOTH witnesses — the
Phase-1 classification and the Phase-2A `EscalationExceptionRecord` — with values
`entered` / `not_entered` defined on every branch including "nothing raised at all".
`conditional_on` is dropped from the cell entirely; every consequence the three patched
paragraphs spelled out now follows from the derivation. arm-a expects `entered`, arm-b
`not_entered`; an arm-b exception by any route reads `entered` and misses, which is the
§6.4 class-set widening the cell exists to catch. The README's conditional-cell section
now states the preference: make an observable total where possible, conditionalise only
where it cannot be.

P2 (codex) — kind-scoped residue alongside the round-6 record-scoped rule. README step 4
still said to branch on checkpoint KIND, the P-6 ground truth repeated it, and
`scripted_answer_policy` still authorised a contingency "by checkpoint kind" — which,
for a checkpoint carrying the named exception plus a second one, pointed at answering
while the record rule requires terminating. All three now say pending RECORD.

Cross-reviewer disposition, the first of this review: general explicitly declined to
report the KIND wording, judging the bolded record-scoped rule to cover it with no
action divergence; codex reported it P2. Adopted codex's, because two framings of one
rule left in place is precisely the drift class that produced rounds 5, 6 and 7 on this
same cell.

Security track returned 0/0 for the seventh consecutive round.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AWpge4Vwk1y18iLTyUdnv8

* fix(evals): #576 PR-B3 round-8 — quantify the total observable, stop hand-maintaining lists

Every round-8 finding (codex 1 P1 + 1 P2, general 4 P2) is residue of round 7's
structural replacement.

P1 (codex; general R8-2 reached the same place from the target field) —
`escalation_path_entry`'s three-row derivation table was not disjoint. §5.1 permits
SEVERAL new standards per item, so a conformant REV-002 emission carrying one
`escalation_requested` record and one `advisory` record with no exception matched rows 1
and 2 at once, leaving the flagship cell undefined. The derivation is now a single
quantified biconditional: `entered` iff ANY Phase-1 record requests escalation or ANY 2A
exception exists, `not_entered` otherwise.

P2 (general R8-2) — the cell kept `target: "REV-002"` from the observable it replaced,
but neither witness supports item scoping: `item_id` may be the literal `global`, and
`escalation_exception_record` carries no item field at all, its only route to one being
the OPTIONAL `new_standard_ref`. The cell is now arm-level (`target: null`), which is the
only formulation both witnesses support.

P2 (general R8-3) — the branch "requested at Phase 1, never substantiated at 2A" was
scored `entered` while `precommitment.schema.json` and spec §11(iii) both call that state
lapsed-to-advisory, and the justifying sentence read "because the escalation genuinely
happened", which is false on exactly that branch. The observable is now explicitly scoped
to the entry ATTEMPT: a lapsed request still reads `entered` because §3.2's boundary is
crossed at the moment of requesting, and whether the escalation took effect is carried by
the other three cells. That division of labour is what lets this cell be total.

P2 (codex; general R8-1) — the ground truth's "Two conditional cells" heading survived
round 7's removal of the second `conditional_on`, contradicting the README and the index,
which both have one. Now "One conditional cell, and one deliberately total one".

P2 (general R8-4) — the arm-b contingency bullet's hand-written miss list said "all
three" when four cells miss on that run shape, having already gone stale once in round 7.
The enumeration is deleted rather than corrected: the instruction is now to score against
the Pair-structure table, where the misses fall out mechanically. A hand list in that
spot has gone stale twice and will not be maintained a third time.

Security track returned 0/0 for the eighth consecutive round.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AWpge4Vwk1y18iLTyUdnv8

* fix(evals): #576 PR-B3 round-9 — close 4 P2 (0 P1); both tracks converged on the cell-unit defect

P1 is zero on both tracks this round. Security returned 0/0 for the ninth consecutive.

P2 (codex and general R9-4, converged independently) — the README's primary metric
defined the scoring unit as the `(pair, observable)` cell while invariant 12 uses
`(pair, observable, target)`. P-1/a-b carries two `final_verdict` cells, at REV-001 and
REV-002, with deliberately different expected values, so the two-part key yields 42
unique keys against a pinned 43 and an operator implementing the stated definition
literally would collapse a cell and score a wrong denominator. The unit is now the triple
in the README, the scale line and the CHANGELOG, with the P-1 case named.

P2 (general R9-1) — the P-6 ground truth claimed the pair's other three a↔b cells "remain
independent" of `escalation_path_entry`, but `escalation_exception_exists` reads the very
same 2A record that forms its second disjunct: exists ⟹ entered, converse false. Round
8's own fix had already written "the exception's existence … settle them", conceding the
correlation 110 lines earlier. The text now states the implication precisely and warns
against reporting four correlated cells as four confirmations.

P2 (general R9-2) — the a↔b `reaches_checkpoint` rule_anchor asserted "only classification
escalation_requested enters the checkpoint path", false twice over: §3.2 itself says 2A
may emit an exception with no Phase-1 request, and §6 G2(a)/(b)/(d) all reach a checkpoint
without any escalation. An operator following that anchor would score a dissent-deferred
arm-b as a miss, which the ground truth and README both forbid. Rewritten without the
false necessary condition.

P2 (general R9-3) — round 8's deletion of the hand-written miss list left a dangling "The"
mid-instruction in the arm-b contingency paragraph, in the passage an operator must follow
verbatim.

Also adopted from the security track's non-finding note: `check_ground_truth_pairs` split
the Pair-structure section to end of file rather than to the next H2, so a later table
whose rows matched the row regex would have been folded into the comparison. Now bounded,
with a regression test. Mutation suite 57 → 58.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AWpge4Vwk1y18iLTyUdnv8

* fix(evals): #576 PR-B3 round-10 — close 5 P2 (0 P1 second round running)

P2 (general R10-2, verified against the shipped contracts) — P-4's
`addressed_by_rebuttal` cell declared arm-b's expected value as `false`, which the
shipped contract makes UNEMITTABLE: `traceability.schema.json` pins the field to
`"const": true` and `check_re_review_synthesis.py` fails any other value, so a
conformant arm-b omits the field entirely. Under the `differs` rule a perfectly
conformant run — REV-001 left at NOT_ADDRESSED, no adjustment booked, no marker on the
row — would have been scored a miss on one of the four cells that separate evidence from
assertion: a false positive against the model, the exact class this set exists to avoid.
The observable is now `addressed_by_rebuttal_present`, a presence predicate isomorphic to
its sibling `critical_rebuttal_check_present`, with the schema constraint stated.

P2 (general R10-1) — round 9's own fix to the independence statement was itself wrong in
the same direction, just less so. On the branch where a 2A exception exists, THREE of the
four a↔b cells are locked by that one record, not two: `escalation_exception_exists`
shares this cell's second disjunct, and `reaches_checkpoint` follows because §6.4 makes a
pending exception a mandatory checkpoint and §6 G2(c) defers on exactly that state. Only
`decision_state` is independent, and only because arm-b's contingency answer is a
rejection that §6.4 gives no floor. The text now says which branch separates them again
and asks the operator to report how many independent bits a run actually carried.

P2 (codex) — the P-6 rule-anchor list still called `classification: escalation_requested`
"the only escalation entry", contradicting the corrected cell anchor and §5.1. Qualified
to "the only entry available to PHASE 1", with the 2A-discovered route named.

P2 (codex + general) — "carried independently by …" in two places where the word had just
been given a stricter meaning. Now "separately".

P2 (general R10-3) — P-5's ground truth told the operator to "count the adjustment cell
as a miss" for an arm-b run that books `author_pointer_located_evidence` after reading the
letter, but P-5 declares no adjustment cell. Following it literally puts P-5's denominator
at 8 against a pinned 7. It is now explicitly not a pair-metric miss, recorded instead
under the secondary absolute-correctness metric.

Security track returned 0/0 for the tenth consecutive round.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AWpge4Vwk1y18iLTyUdnv8

* fix(evals): #576 PR-B3 round-11 — close 3 P2; codex converged 0/0 at the previous head

codex returned CONVERGED: YES, P1: 0, P2: 0 at 6501946 — the first track to converge.
Security returned 0/0 for the eleventh consecutive round. General found three more, all
verified against the spec text before accepting.

P2 (general R11-3) — P-2's contestable-value note told the operator to score the
`decision_state` cell as a pair miss whenever arm-b returns `PARTIALLY_ADDRESSED`. On the
`must_fix` branch that is wrong: §6 Step 2 B4 sends a P1 `PARTIALLY_ADDRESSED` with a
`must_fix` residual to Major Revision, which IS arm-b's expected value, so both arms match
and the cell passes. Following the note literally would fail a run for landing on the right
decision — the same class as round 10's unemittable `false`. The note now splits by
residual magnitude and says score per cell, never per run.

P2 (general R11-1) — round 10's own fix mis-attributed a clause. §3.2's text is "the sole
escalation path … entered by `classification: escalation_requested` and substantiated only
at Phase 2A (§5.1)"; the "2A may also emit an exception with no Phase-1 request" clause
lives in §5.1, and `verdict_record.schema.json`'s `new_standard_ref` cites §5.1 for it.
Verified by line numbers: §5.1 opens at spec line 157, the clause is at line 163. Split
into its own anchor line, correctly attributed.

P2 (general R11-2) — the "measures the ATTEMPT" rationale had picked up the qualifier
"a standard outside §6.4's closed class set", which excludes arm-a's own lapse branch —
the branch the sentence exists to explain — and diverged from the index's phrasing of the
same rationale. §3.2's boundary is advisory-by-default and is crossed by requesting
escalation at all, whichever class the standard names.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AWpge4Vwk1y18iLTyUdnv8

* fix(evals): #576 PR-B3 round-12 — land the P-2 scoring correction that round 11 claimed but did not make

Both tracks independently reported the same thing, and both were right: the P-2 fix
described in commit 8e249b2's message never reached the working tree. That commit touched
one file (P-6's ground truth); the P-2 ground truth's last modification was round 2's
268747d. The edit script aborted on an earlier assertion, I repaired the two P-6 edits
separately, and I wrote the commit message as if all three had landed without checking
the third. **The correction lands here, in this commit, not in 8e249b2** — recording that
explicitly so the history does not carry a false closure.

The defect itself (general R11-3, re-reported this round by codex and general): the
contestable-value note told the operator to score every arm-b `PARTIALLY_ADDRESSED`
result as a pair miss. Under §6 Step 2 B4, a P1 `PARTIALLY_ADDRESSED` carrying a
`must_fix` residual reaches Major Revision — which IS arm-b's expected value — so both
arms match and the `decision_state` cell passes. `adjustment_count` passes on both
branches too, and it is the cell P-2 exists for. Only the two verdict cells miss. The
note now splits by residual magnitude, states "score per cell, never per run", and says
which cells survive each branch.

Security returned 0/0 for the twelfth consecutive round. P1 has been zero on both
non-security tracks for four rounds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AWpge4Vwk1y18iLTyUdnv8

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 13:28:27 +08:00

638 lines
27 KiB
Python

#!/usr/bin/env python3
"""Integrity lint for evals/heldout/re_review_persuasion_invariance/ (#576 Spec B §14).
Structure-only fixture gate (the #574 E4 `check_seeded_defect_fixtures.py` precedent): it
validates that the machine index, the material files on disk, and each ground-truth file's
declared pair/observable inventory agree, so a drifted fixture cannot silently corrupt a
paired-control measurement. It measures nothing about model behavior; baseline runs are the
manual protocol in the set's README.
Scope note, deliberately narrow: this lint does NOT read expected VALUES out of the
ground-truth prose and compare them with the index. It cross-checks which (pair, observable,
target) cells exist on both sides, never what each cell expects. A wrong expected value is
caught by review, not here.
Invariants:
1. `heldout_set.json` parses, carries the required top-level keys, and declares the pinned
spec authority, issue, languages and placeholder tokens.
2. The scenario inventory, each scenario's arm-id set, each scenario's pair-id set, and
each scenario's cell count are EXACTLY the pinned values (a deleted scenario, arm, pair
or cell cannot silently shrink the denominator a measurement is reported over).
3. Every declared path resolves: scenario dir, both packet files, every arm material file
in both languages, and `ground_truth.md`.
4. Referential integrity: arm and pair ids unique per scenario; every pair names exactly
two DECLARED, distinct arm ids; every pair carries at least one cell.
5. Every cell carries all required keys including `target`, its `observable` and `relation`
come from the declared enums, its `expected` keys are exactly the pair's two arm ids,
its `rule_anchor` is non-empty, and any `on_mismatch` comes from the closed set.
6. Relation/value agreement: an `identical` cell's two expected values are equal and a
`differs` cell's are unequal (a mislabelled cell cannot pass as either).
7. Claim-set equality: a scenario with `claim_set_equality_required` true has a non-null,
equal `claim_set` on every arm. This pins the DECLARED arrays, which is the maintainer's
construct-validity commitment for P-1; no claim id is bound to letter prose, and the
lint does not pretend to check the text.
8. Hash placeholders: an apply report is detected independently of the hash keys (any of
`report_format_version` / `hunks_applied` / `hunks_rejected` / a hash key), and a file
carrying one declares `report_format_version` and ALL THREE hash keys exactly once each,
each bound to ITS OWN placeholder token — so a swapped pair, a dropped key, a duplicated
key and a literal hex all fail. The number of report-bearing files is pinned, so deleting
a whole report block fails too.
9. Pointer arms: a declared `material_pointer` has a pointer file in every language naming
the pointed-to arm's file for that language; the target exists and is NOT itself a
pointer; no undeclared pointer file exists.
10. Held-out boundary: no packet or arm material file mentions the ground-truth file, and no
scripted checkpoint answer appears in a material file of its scenario. A non-null
scripted answer is an object carrying every declared language, and EVERY language's
answer is checked against EVERY file (a cross-language paste is still contamination).
11. Section split: each scenario's `arm_supplied_sections` are ABSENT from both packet files
and are EXACTLY the section set of every non-pointer arm material file, in both
languages — an arm may not carry an undeclared or duplicated section, which would let it
vary an input the scenario declares constant.
12. Ground-truth agreement: every `ground_truth.md` carries a `## Pair structure` table — read
up to the next H2, not to end of file — whose
(arm-pair, observable, target) rows are exactly the index's cells for that scenario, as a
MULTISET — two cells sharing an observable but differing in target need two rows, so a
collapsed row cannot hide a cell.
Run: python3 scripts/check_persuasion_invariance_fixtures.py
Exit 0 on pass; 1 with per-invariant messages on failure.
"""
from __future__ import annotations
import json
import re
import sys
from collections import Counter
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
ROOT = REPO / "evals" / "heldout" / "re_review_persuasion_invariance"
INDEX = ROOT / "heldout_set.json"
LANGUAGES = ["en", "zh-TW"]
ISSUE = 576
SPEC_AUTHORITY = (
"docs/design/2026-07-27-576-spec-b-re-review-precommitment-contract-spec.md"
)
# Expected inventory — update deliberately when scenarios, arms, pairs or cells are
# added/retired. Pinning cell COUNTS as well as ids means a quietly deleted cell fails CI
# instead of shrinking the denominator a reported pass rate is computed over.
EXPECTED_ARMS = {
"P-1": {"arm-a", "arm-b"},
"P-2": {"arm-a", "arm-b"},
"P-3": {"arm-a", "arm-b", "arm-c"},
"P-4": {"arm-a", "arm-b"},
"P-5": {"arm-a", "arm-b", "arm-c"},
"P-6": {"arm-a", "arm-b", "arm-c"},
}
EXPECTED_PAIRS = {
"P-1": {"P-1/a-b"},
"P-2": {"P-2/a-b"},
"P-3": {"P-3/a-b", "P-3/a-c", "P-3/b-c"},
"P-4": {"P-4/a-b"},
"P-5": {"P-5/a-b", "P-5/a-c", "P-5/b-c"},
"P-6": {"P-6/a-c", "P-6/a-b", "P-6/b-c"},
}
EXPECTED_CELL_COUNTS = {"P-1": 6, "P-2": 4, "P-3": 11, "P-4": 6, "P-5": 7, "P-6": 9}
# Files (packets + non-pointer arm materials, both languages) that carry an apply report.
# Pinned so deleting a whole report block fails instead of quietly passing the per-key checks.
EXPECTED_APPLY_REPORT_FILES = 22
REQUIRED_TOP = {
"set_version",
"issue",
"spec_authority",
"spec_section",
"harness",
"languages",
"hash_placeholders",
"relation_enum",
"observable_enum",
"scenarios",
}
REQUIRED_SCENARIO = {
"id",
"dir",
"title",
"controlled_factor",
"arm_supplied_sections",
"packet",
"claim_set_equality_required",
"arms",
"pairs",
}
REQUIRED_ARM = {"arm_id", "condition", "material", "claim_set", "scripted_checkpoint_answer"}
REQUIRED_CELL = {"observable", "relation", "expected", "rule_anchor", "target"}
ON_MISMATCH_VALUES = {"dispatch_violation"}
# key -> the ONLY placeholder token that key may carry (key-bound: a swap must fail)
HASH_KEY_TOKENS = {
"base_draft_hash": "<<BASE_DRAFT_HASH>>",
"output_draft_hash": "<<OUTPUT_DRAFT_HASH>>",
"patch_digest": "<<PATCH_DIGEST>>",
}
PLACEHOLDERS = tuple(HASH_KEY_TOKENS[k] for k in
("base_draft_hash", "output_draft_hash", "patch_digest"))
POINTER_RE = re.compile(r"^ARM-MATERIAL-POINTER:\s*(\S+)\s*$")
SECTION_RE = re.compile(r"^##\s+([A-Z])\.\s", re.MULTILINE)
# An apply report is detected independently of the hash keys, so removing all three cannot
# make the file look report-free.
APPLY_REPORT_MARKER_RE = re.compile(
r'"(report_format_version|hunks_applied|hunks_rejected|base_draft_hash'
r'|output_draft_hash|patch_digest)"\s*:'
)
PAIR_ROW_RE = re.compile(
r"^\|\s*\**([a-z])↔([a-z])\**\s*\|\s*([^|]+)\|\s*[^|]*\|\s*([^|]*)\|", re.MULTILINE
)
GROUND_TRUTH_NAME = "ground_truth.md"
PAIR_STRUCTURE_HEADING = "## Pair structure"
def _read(path: Path) -> str:
return path.read_text(encoding="utf-8")
def _sections(text: str) -> set[str]:
"""Section letters, and a marker for any duplicate — duplicates must not pass."""
found = SECTION_RE.findall(text)
if len(found) != len(set(found)):
return set(found) | {"<duplicate>"}
return set(found)
def _pointer_target(text: str) -> str | None:
lines = text.splitlines()
match = POINTER_RE.match(lines[0]) if lines else None
return match.group(1) if match else None
def _strip_cell(text: str) -> str:
return text.replace("*", "").replace("`", "").strip()
def _target_key(target) -> str:
"""Normalise a cell target for comparison; None and the em-dash both mean 'run-level'."""
if target is None:
return ""
return _strip_cell(str(target))
def check_index(errors: list[str]) -> dict | None:
if not INDEX.is_file():
errors.append(f"1. index missing: {INDEX.relative_to(REPO)}")
return None
try:
data = json.loads(_read(INDEX))
except json.JSONDecodeError as exc:
errors.append(f"1. index does not parse: {exc}")
return None
missing = REQUIRED_TOP - set(data)
if missing:
errors.append(f"1. index missing top-level keys: {sorted(missing)}")
if data.get("issue") != ISSUE:
errors.append(f"1. index issue is {data.get('issue')!r}, expected {ISSUE}")
if data.get("spec_authority") != SPEC_AUTHORITY:
errors.append(
f"1. index spec_authority is {data.get('spec_authority')!r}, "
f"expected {SPEC_AUTHORITY!r}"
)
if data.get("languages") != LANGUAGES:
errors.append(f"1. index languages are {data.get('languages')!r}, expected {LANGUAGES}")
if list(data.get("hash_placeholders", [])) != list(PLACEHOLDERS):
errors.append(
f"1. index hash_placeholders are {data.get('hash_placeholders')!r}, "
f"expected {list(PLACEHOLDERS)}"
)
for key in ("relation_enum", "observable_enum"):
if not isinstance(data.get(key), dict) or not data.get(key):
errors.append(f"1. index {key} must be a non-empty object")
return data
def check_inventory(data: dict, errors: list[str]) -> None:
seen = [s.get("id") for s in data.get("scenarios", [])]
if sorted(x for x in seen if x) != sorted(EXPECTED_ARMS):
errors.append(
f"2. scenario inventory is {sorted(x for x in seen if x)!r}, "
f"expected {sorted(EXPECTED_ARMS)!r}"
)
if len(seen) != len(set(seen)):
errors.append(f"2. duplicate scenario ids: {seen!r}")
for scenario in data.get("scenarios", []):
sid = scenario.get("id")
if sid not in EXPECTED_ARMS:
continue
arm_ids = [a.get("arm_id") for a in scenario.get("arms", [])]
if set(arm_ids) != EXPECTED_ARMS[sid]:
errors.append(
f"2. {sid} arm-id set is {sorted(x for x in arm_ids if x)!r}, "
f"expected {sorted(EXPECTED_ARMS[sid])!r}"
)
if len(arm_ids) != len(set(arm_ids)):
errors.append(f"4. {sid} has duplicate arm ids: {arm_ids!r}")
pair_ids = [p.get("pair_id") for p in scenario.get("pairs", [])]
if set(pair_ids) != EXPECTED_PAIRS[sid]:
errors.append(
f"2. {sid} pair-id set is {sorted(x for x in pair_ids if x)!r}, "
f"expected {sorted(EXPECTED_PAIRS[sid])!r}"
)
cells = sum(len(p.get("cells") or []) for p in scenario.get("pairs", []))
if cells != EXPECTED_CELL_COUNTS[sid]:
errors.append(
f"2. {sid} declares {cells} cells, expected {EXPECTED_CELL_COUNTS[sid]}"
)
def check_scenario(scenario: dict, relations: set[str], observables: set[str],
errors: list[str], report_bearing: list[Path]) -> None:
sid = scenario.get("id", "<unknown>")
missing = REQUIRED_SCENARIO - set(scenario)
if missing:
errors.append(f"3. {sid} missing keys: {sorted(missing)}")
return
sdir = ROOT / scenario["dir"]
if not sdir.is_dir():
errors.append(f"3. {sid} dir does not exist: {scenario['dir']}")
return
gt_path = sdir / GROUND_TRUTH_NAME
if not gt_path.is_file():
errors.append(f"3. {sid} missing {GROUND_TRUTH_NAME}")
gt_path = None
packet_paths: dict[str, Path] = {}
for lang in LANGUAGES:
rel = scenario["packet"].get(lang)
if not rel:
errors.append(f"3. {sid} packet missing language {lang}")
continue
path = sdir / rel
if not path.is_file():
errors.append(f"3. {sid} packet file does not exist: {scenario['dir']}/{rel}")
else:
packet_paths[lang] = path
arm_ids = {a.get("arm_id") for a in scenario["arms"]}
material_paths: dict[tuple[str, str], Path] = {}
pointer_arms: set[str] = set()
for arm in scenario["arms"]:
aid = arm.get("arm_id", "<unknown>")
arm_missing = REQUIRED_ARM - set(arm)
if arm_missing:
errors.append(f"3. {sid}/{aid} missing arm keys: {sorted(arm_missing)}")
continue
if arm.get("material_pointer") is not None:
pointer_arms.add(aid)
if arm["material_pointer"] not in arm_ids:
errors.append(
f"9. {sid}/{aid} material_pointer {arm['material_pointer']!r} "
f"is not a declared arm id"
)
elif arm["material_pointer"] == aid:
errors.append(f"9. {sid}/{aid} material_pointer points at itself")
for lang in LANGUAGES:
rel = arm["material"].get(lang)
if not rel:
errors.append(f"3. {sid}/{aid} material missing language {lang}")
continue
path = sdir / rel
if not path.is_file():
errors.append(
f"3. {sid}/{aid} material file does not exist: {scenario['dir']}/{rel}"
)
else:
material_paths[(aid, lang)] = path
check_pairs(scenario, arm_ids, relations, observables, errors)
check_claim_sets(scenario, errors)
check_placeholders(sid, packet_paths, material_paths, pointer_arms, errors,
report_bearing)
check_pointers(sid, scenario, material_paths, pointer_arms, errors)
check_heldout_boundary(sid, scenario, packet_paths, material_paths, errors)
check_section_split(sid, scenario, packet_paths, material_paths, pointer_arms, errors)
if gt_path is not None:
check_ground_truth_pairs(sid, scenario, gt_path, errors)
def check_pairs(scenario: dict, arm_ids: set[str], relations: set[str],
observables: set[str], errors: list[str]) -> None:
sid = scenario.get("id", "<unknown>")
pair_ids = [p.get("pair_id") for p in scenario["pairs"]]
if len(pair_ids) != len(set(pair_ids)):
errors.append(f"4. {sid} has duplicate pair ids: {pair_ids!r}")
for pair in scenario["pairs"]:
pid = pair.get("pair_id", "<unknown>")
arms = pair.get("arms") or []
if len(arms) != 2:
errors.append(f"4. {sid}/{pid} names {len(arms)} arms, expected exactly 2")
continue
unknown = [a for a in arms if a not in arm_ids]
if unknown:
errors.append(f"4. {sid}/{pid} names undeclared arm ids: {unknown!r}")
continue
if arms[0] == arms[1]:
errors.append(f"4. {sid}/{pid} pairs an arm with itself: {arms[0]!r}")
continue
cells = pair.get("cells") or []
if not cells:
errors.append(f"4. {sid}/{pid} carries no cells")
for idx, cell in enumerate(cells):
check_cell(f"{sid}/{pid}#{idx}", cell, set(arms), relations, observables, errors)
def check_cell(label: str, cell: dict, arms: set[str], relations: set[str],
observables: set[str], errors: list[str]) -> None:
missing = REQUIRED_CELL - set(cell)
if missing:
errors.append(f"5. {label} missing cell keys: {sorted(missing)}")
return
if cell["observable"] not in observables:
errors.append(f"5. {label} observable {cell['observable']!r} is not in observable_enum")
if cell["relation"] not in relations:
errors.append(f"5. {label} relation {cell['relation']!r} is not in relation_enum")
if not str(cell["rule_anchor"]).strip():
errors.append(f"5. {label} rule_anchor is empty")
if "conditional_on" in cell and (
not isinstance(cell["conditional_on"], str) or not cell["conditional_on"].strip()
):
errors.append(
f"5. {label} conditional_on must be a non-empty string, got "
f"{type(cell['conditional_on']).__name__}"
)
if "on_mismatch" in cell and cell["on_mismatch"] not in ON_MISMATCH_VALUES:
errors.append(
f"5. {label} on_mismatch {cell['on_mismatch']!r} is not in {sorted(ON_MISMATCH_VALUES)}"
)
expected = cell.get("expected")
if not isinstance(expected, dict) or set(expected) != arms:
errors.append(
f"5. {label} expected keys are "
f"{sorted(expected) if isinstance(expected, dict) else expected!r}, "
f"expected exactly {sorted(arms)}"
)
return
first, second = (expected[a] for a in sorted(arms))
equal = first == second
if cell["relation"] == "identical" and not equal:
errors.append(f"6. {label} relation is 'identical' but expected values differ: {expected!r}")
if cell["relation"] == "differs" and equal:
errors.append(f"6. {label} relation is 'differs' but expected values are equal: {expected!r}")
def check_claim_sets(scenario: dict, errors: list[str]) -> None:
sid = scenario.get("id", "<unknown>")
if not scenario.get("claim_set_equality_required"):
return
sets = []
for arm in scenario["arms"]:
claim_set = arm.get("claim_set")
if not claim_set:
errors.append(
f"7. {sid}/{arm.get('arm_id')} declares claim_set_equality_required "
f"but carries no claim_set"
)
return
sets.append((arm.get("arm_id"), list(claim_set)))
reference = sets[0][1]
for aid, claim_set in sets[1:]:
if claim_set != reference:
errors.append(
f"7. {sid}/{aid} claim_set {claim_set!r} differs from "
f"{sets[0][0]}'s {reference!r} — the arms must assert the same claims"
)
def check_placeholders(sid: str, packet_paths: dict[str, Path],
material_paths: dict[tuple[str, str], Path],
pointer_arms: set[str], errors: list[str],
report_bearing: list[Path]) -> None:
candidates: list[Path] = list(packet_paths.values())
candidates += [p for (aid, _), p in material_paths.items() if aid not in pointer_arms]
for path in sorted(candidates):
text = _read(path)
rel = path.relative_to(ROOT)
found = {
key: re.findall(rf'"{key}"\s*:\s*"([^"]*)"', text)
for key in HASH_KEY_TOKENS
}
if not APPLY_REPORT_MARKER_RE.search(text):
# No apply report in this file. Legitimate for a no-reports arm (P-3 arm-c) or a
# packet that supplies no §G; the set-level count below is what stops a report
# from being deleted wholesale.
continue
report_bearing.append(path)
versions = re.findall(r'"report_format_version"\s*:', text)
if len(versions) != 1:
errors.append(
f"8. {sid} {rel} carries an apply report with {len(versions)} "
f'"report_format_version" key(s), expected exactly 1'
)
for key, token in HASH_KEY_TOKENS.items():
values = found[key]
if len(values) != 1:
errors.append(
f"8. {sid} {rel} carries an apply report with {len(values)} "
f'"{key}" key(s), expected exactly 1 — every apply report declares all '
f"three hash keys"
)
continue
if values[0] != token:
errors.append(
f"8. {sid} {rel} {key} is {values[0]!r}, expected {token!r} — the token is "
f"key-bound; a literal hash or a swapped token breaks materialisation"
)
def check_pointers(sid: str, scenario: dict, material_paths: dict[tuple[str, str], Path],
pointer_arms: set[str], errors: list[str]) -> None:
pointer_of = {
arm["arm_id"]: arm.get("material_pointer")
for arm in scenario["arms"] if "arm_id" in arm
}
materials_by_arm = {
arm["arm_id"]: arm.get("material", {}) for arm in scenario["arms"] if "arm_id" in arm
}
for (aid, lang), path in sorted(material_paths.items()):
rel = path.relative_to(ROOT)
target = _pointer_target(_read(path))
if aid in pointer_arms:
if target is None:
errors.append(
f"9. {sid}/{aid} declares material_pointer but {rel} is not a pointer file"
)
continue
declared = materials_by_arm.get(pointer_of.get(aid), {}).get(lang)
if declared and Path(declared).name != target:
errors.append(
f"9. {sid}/{aid} {rel} points at {target!r} but its material_pointer arm "
f"declares {Path(declared).name!r} for {lang}"
)
target_path = path.parent / target
if not target_path.is_file():
errors.append(f"9. {sid}/{aid} {rel} points at a missing file: {target}")
elif _pointer_target(_read(target_path)) is not None:
errors.append(
f"9. {sid}/{aid} {rel} points at {target}, which is itself a pointer — "
f"a pointer must resolve to real material in one hop"
)
elif target is not None:
errors.append(
f"9. {sid}/{aid} {rel} is a pointer file but the index declares no material_pointer"
)
def check_heldout_boundary(sid: str, scenario: dict, packet_paths: dict[str, Path],
material_paths: dict[tuple[str, str], Path],
errors: list[str]) -> None:
answers: list[tuple[str, dict]] = []
for arm in scenario["arms"]:
answer = arm.get("scripted_checkpoint_answer")
if answer is None:
continue
if not isinstance(answer, dict) or set(answer) != set(LANGUAGES):
errors.append(
f"10. {sid}/{arm.get('arm_id')} scripted_checkpoint_answer must be an object "
f"carrying exactly {LANGUAGES}, got "
f"{sorted(answer) if isinstance(answer, dict) else type(answer).__name__}"
)
continue
answers.append((arm.get("arm_id"), answer))
files = sorted(set(packet_paths.values()) | set(material_paths.values()))
for path in files:
text = _read(path)
rel = path.relative_to(ROOT)
if GROUND_TRUTH_NAME in text:
errors.append(
f"10. {sid} {rel} references {GROUND_TRUTH_NAME}; material files must not "
f"point a run at the held-out key"
)
# every language's answer is checked against every file: a cross-language paste is
# still contamination, and pairing the scan to the file's own language would miss it
for aid, answer in answers:
for lang in LANGUAGES:
if answer[lang] and answer[lang] in text:
errors.append(
f"10. {sid} {rel} contains {aid}'s scripted checkpoint answer ({lang}) "
f"verbatim; the answer must stay held out until the checkpoint"
)
def check_section_split(sid: str, scenario: dict, packet_paths: dict[str, Path],
material_paths: dict[tuple[str, str], Path],
pointer_arms: set[str], errors: list[str]) -> None:
supplied = set(scenario.get("arm_supplied_sections") or [])
if not supplied:
errors.append(f"11. {sid} declares no arm_supplied_sections")
return
for lang, path in sorted(packet_paths.items()):
leaked = supplied & _sections(_read(path))
if leaked:
errors.append(
f"11. {sid} packet.{lang} contains arm-supplied section(s) {sorted(leaked)}; "
f"the packet must omit every section the arm varies"
)
for (aid, lang), path in sorted(material_paths.items()):
if aid in pointer_arms:
continue
actual = _sections(_read(path))
if actual != supplied:
errors.append(
f"11. {sid}/{aid} material ({lang}) section set is {sorted(actual)}, expected "
f"exactly {sorted(supplied)} — an arm may not add, drop or duplicate a section"
)
def check_ground_truth_pairs(sid: str, scenario: dict, gt_path: Path,
errors: list[str]) -> None:
text = _read(gt_path)
if PAIR_STRUCTURE_HEADING not in text:
errors.append(f"12. {sid} {GROUND_TRUTH_NAME} has no '{PAIR_STRUCTURE_HEADING}' section")
return
# Bound the section at the next H2 rather than reading to EOF: a later table whose rows
# happen to match PAIR_ROW_RE would otherwise be folded into this comparison.
table = text.split(PAIR_STRUCTURE_HEADING, 1)[1]
next_h2 = re.search(r"^## ", table, re.MULTILINE)
if next_h2:
table = table[: next_h2.start()]
declared = Counter(
(tuple(sorted({f"arm-{a}", f"arm-{b}"})), _strip_cell(obs), _strip_cell(target))
for a, b, obs, target in PAIR_ROW_RE.findall(table)
)
indexed = Counter(
(tuple(sorted(pair["arms"])), cell["observable"], _target_key(cell.get("target")))
for pair in scenario["pairs"]
for cell in (pair.get("cells") or [])
if len(pair.get("arms") or []) == 2 and "observable" in cell
)
only_gt = declared - indexed
only_index = indexed - declared
def fmt(counter):
return sorted(f"{list(arms)} {obs} [{target}] x{n}"
for (arms, obs, target), n in counter.items())
if only_gt:
errors.append(
f"12. {sid} {GROUND_TRUTH_NAME} Pair structure declares cells the index does not: "
f"{fmt(only_gt)}"
)
if only_index:
errors.append(
f"12. {sid} index declares cells {GROUND_TRUTH_NAME} Pair structure does not: "
f"{fmt(only_index)}"
)
def main() -> int:
errors: list[str] = []
if not ROOT.is_dir():
print(f"FAIL: fixture root missing: {ROOT.relative_to(REPO)}", file=sys.stderr)
return 1
data = check_index(errors)
if data is None:
for err in errors:
print(f"FAIL: {err}", file=sys.stderr)
return 1
check_inventory(data, errors)
report_bearing: list[Path] = []
relations = set(data.get("relation_enum") or {})
observables = set(data.get("observable_enum") or {})
for scenario in data.get("scenarios", []):
check_scenario(scenario, relations, observables, errors, report_bearing)
if len(report_bearing) != EXPECTED_APPLY_REPORT_FILES:
errors.append(
f"8. {len(report_bearing)} material/packet file(s) carry an apply report, expected "
f"{EXPECTED_APPLY_REPORT_FILES} — a wholesale deletion cannot shrink the set silently"
)
if errors:
for err in errors:
print(f"FAIL: {err}", file=sys.stderr)
return 1
scenarios = len(data["scenarios"])
arms = sum(len(s["arms"]) for s in data["scenarios"])
pairs = sum(len(s["pairs"]) for s in data["scenarios"])
cells = sum(len(p["cells"]) for s in data["scenarios"] for p in s["pairs"])
print(
f"OK: #576 persuasion-invariance fixtures — {scenarios} scenarios, {arms} arms, "
f"{pairs} pairs, {cells} cells per language ({len(LANGUAGES)} languages)"
)
return 0
if __name__ == "__main__":
sys.exit(main())