mirror of
https://github.com/Imbad0202/academic-research-skills.git
synced 2026-09-14 13:51:17 +08:00
a1999ec721
* 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 at6501946— 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's268747d. 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>
638 lines
27 KiB
Python
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())
|