Files
imbad0202__academic-researc…/scripts/test_check_policy_anchor_protocol.py
Edward Cheng-I Wu 70c8678730 feat(#108): AI disclosure policy-anchor renderer (audit-trail-shipped) (#110)
* docs(#108): implementation spec — resolves §4.4 11 open concerns

Implementation spec parented to Decision Doc 20ed72d. Closes Decision Doc
§4.4 contract gaps with user-decision protocol:

- #1 track-selection → (b) explicit slr_lineage input
- #5 Nature image → hybrid (annotation block + suggested inline patches)
- #6 UNCERTAIN per-facet → (b) USED-full + UNCERTAIN-annotation
- #10 ai_used:true gate → (a) force v3.2 categorization flow
- #2/#3/#4/#7/#8/#9/#11 → inline per standard engineering, all citing
  Decision Doc forbiddances as bounds

Locks ARS-conventional deployment shape: LLM-prose protocol + data
reference table + Python lints + Python unittest conformance fixtures.
Decision Doc "renderer" term mapped to ARS file shapes in §1. No
runtime renderer code introduced; v3.2 disclosure mode pattern extended.

Test count floor: 184 new tests (64 cell × 2 + 19 mutation + 16
invariant + 20 concern + 1 de-dup). Final regression target: ≥1151
pass vs v3.7.3 967 baseline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(#108): policy anchor table + static lint validator

Adds the source-of-truth reference table the new policy-anchor renderer
will read at runtime:

- academic-paper/references/policy_anchor_table.md: 4 anchors × 16 fields
  carrying verbatim policy quotes from discovery doc §4.3-4.6 (PRISMA-trAIce,
  ICMJE, Nature, IEEE) + per-anchor renderer rules encoding G5-G9 invariants
  + Nature ↔ v3.2 venue dedup pointer.
- scripts/check_policy_anchor_table.py: structural lint enforcing 4-anchor
  coverage, 16-field canonical order, source_strength enum membership,
  verbatim-quote presence for mandate/recommend cells. Exposes
  verify_nature_dedup_with_venue() helper for the cross-file dedup guard.
- scripts/test_check_policy_anchor_table.py: 12 tests (1 golden path + 7
  mutation negative paths + 2 module-level invariants + 2 dedup helper).
- academic-paper/references/venue_disclosure_policies.md: adds Nature dedup
  pointer line to peer the new anchor table.

TDD: validator + mutation tests written first, watched fail with
ModuleNotFoundError, then validator implemented to pass. Tests cover
both the validator behavior (mutation) and the real anchor table's
conformance (golden path + dedup integration).

Decision Doc §4.1 item 6a + §4.3 G1/G7/G8/G9 invariants. Impl spec §2.1
+ §4.4 #4-#7 resolutions encoded as per-anchor renderer rules.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(#108): policy anchor disclosure protocol + static lint

Adds the LLM-prose runtime instruction set the new --policy-anchor=<a>
track follows when disclosure mode is invoked. Parallels the existing
v3.2 --venue=<v> path without disturbing it.

- academic-paper/references/policy_anchor_disclosure_protocol.md: 8
  sections covering (1) inputs incl slr_lineage signal, (2) G10 7-row
  precedence table for whole-disclosure decision, (3) per-anchor render
  flows (PRISMA-trAIce / ICMJE / Nature / IEEE), (4) auto-promotion
  forbiddance, (5) venue+anchor conflict reject, (6) three-state
  completeness flag spec, (7) audit map of §4.4 11 concern resolutions,
  (8) related cross-refs.
- scripts/check_policy_anchor_protocol.py: presence-of-required-content
  lint enforcing 8 named §4.3 invariants + 11 §4.4 concern clauses + 7
  G10 rows + auto-promotion forbiddance + 4 anchor slugs + Nature dedup
  pointer.
- scripts/test_check_policy_anchor_protocol.py: 9 tests (1 golden path
  + 6 mutation negative paths + 2 module-level invariants).

TDD: validator + mutation tests written first, watched fail with
ModuleNotFoundError, then validator implemented to pass.

Decision Doc §4.1 item 6 + §4.3 8 invariants + §4.4 11 concerns. Impl
spec §3 resolved-paths table encoded into §7 of protocol doc for audit
traceability.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(#108): extend v3.2 disclosure mode with --policy-anchor track

Adds the #108 policy-anchor track to the existing v3.2 venue track.
The two tracks are mutually exclusive selectors on the same disclosure
mode entry point; v3.2 path unchanged.

- Two-parallel-tracks section: documents --venue vs --policy-anchor
  dispatch + the §4.4 #7 venue+anchor conflict resolution (consistent
  pair proceed, conflicting pair reject with explicit error, silent
  precedence forbidden).
- Phase 1 (intake + lookup): splits into selector-aware step 1a
  (dispatch), step 1b (venue lookup, v3.2 unchanged), step 1c (anchor
  lookup) + G2 invariant slr_lineage gate for prisma-trAIce.
- Inputs section: adds slr_lineage pipeline signal + cold-start mode=
  parameter for the anchor path.
- References: cross-link to policy_anchor_table.md +
  policy_anchor_disclosure_protocol.md + Decision Doc + impl spec.

Decision Doc §4.1 item 7 (EXTEND, not REPLACE). Verifies existing v3.2
venue-path content stays byte-equivalent for unrelated venues; only
adds new sections around the v3.2 content.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(#108): renderer conformance referee + 37-test suite

Adds the executable spec / referee that codifies the G10 7-row
precedence table and §4.3 invariants as deterministic Python so the
conformance test suite can verify protocol-prose ↔ logic alignment.

- scripts/policy_anchor_disclosure_referee.py: dataclasses for
  RendererInput + DisclosureDecision; decide_disclosure_output() impl
  of §3 G10 7-row table; auto-promotion forbiddance as
  AutoPromotionForbidden exception; per-anchor predicates
  (copyediting_carveout_semantics, image_rights_regime,
  prompt_disclosure_required, assert_ieee_pairing_conformant,
  nature_image_outputs); venue+anchor conflict resolution as
  VenueAnchorConflict exception (concern #7).
- scripts/test_policy_anchor_disclosure.py: 37 conformance tests
  covering all 7 G10 rows (positive), all 8 G-invariants (positive +
  negative where forbiddance applies), and concerns #1/#5/#6/#7/#10/#11
  resolved paths.

The referee is NOT the production renderer (production is LLM-prose at
runtime). It's an executable spec that catches protocol-doc ↔ logic
drift via the test suite. When the protocol doc's §2 table changes,
this module must change; the tests catch silent divergence.

TDD: conformance tests written first, watched fail with
ModuleNotFoundError; referee implemented to make all 37 pass.

Decision Doc §4.1 item 6b + §4.3 8 invariants + §4.4 #1/#5/#6/#7/#10/#11.
Impl spec §4 (TDD discipline) + §6 (test count expectation).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ci(#108): wire policy anchor lints + conformance tests into spec-consistency.yml

Adds 5 new CI steps after the existing v3.6.7 pattern-eval steps:
1. check_policy_anchor_table.py — anchor table structural lint
2. test_check_policy_anchor_table.py — 12 validator mutation tests
3. check_policy_anchor_protocol.py — protocol doc presence lint
4. test_check_policy_anchor_protocol.py — 9 validator mutation tests
5. test_policy_anchor_disclosure.py — 37 conformance fixture tests

All five run in the existing spec-consistency job, no new job created.

Decision Doc §4.1 + impl spec §2.2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(#108): codex round-1 four P2 closures

- P2 #1 (lint loose): check_policy_anchor_protocol.py row check now
  matches actual markdown table rows (^|\s*N\s*|) instead of accepting
  prose mentions of "row N" anywhere in doc. Previous loose check let
  protocol-doc row 4 silently disappear because row 4 was named in the
  concern #10 paragraph.

- P2 #2 (silent unmapped venue+anchor): referee.py
  _check_venue_anchor_conflict() now rejects every (venue, anchor) pair
  that is not the explicitly defined consistent pair (Nature venue +
  nature anchor). Previously --venue=ICLR --policy-anchor=ieee fell
  through silently, violating concern #7 forbiddance of silent
  precedence. New tests cover ICLR + ieee and "Nature (Nature
  Publishing Group)" + ieee.

- P2 #3 (anchor enum unchecked): new
  _check_policy_anchor_enum() runs before any decision logic;
  policy_anchor not in CANONICAL_ANCHORS raises InvalidPolicyAnchor.
  Closes the path where a typo like "ICMJE" (uppercase) or "cope"
  silently rendered as anchor_render with no matching table entry.

- P2 #4 (protocol-doc contradiction): disclosure_mode_protocol.md two
  sentences disagreed on whether both-selectors-supplied is non-conformant
  vs case-by-case. Rewritten to "selector-mutually-exclusive by default;
  when both supplied, evaluate compatibility per concern #7 rules" with
  an exhaustive 4-case enumeration (consistent / any-other-combo /
  one-only / none).

Test impact: 967 baseline + 62 new (was 58) = 1029 passing, 0 failed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(#108): codex round-2 three P2 closures

- P2 #1 (Nature alias rejected): NATURE_VENUE_NAMES expanded to include
  the v3.2 policy database's exact label "Nature (Nature Publishing
  Group)" + "Nature Publishing Group" alongside the short forms. Without
  this, every author using the existing policy_disclosure_policies.md
  Nature entry name was incorrectly raised on a valid consistent pair.

- P2 #2 (silent invalid category state): new _check_category_states()
  runs before _check_venue_anchor_conflict; any category state outside
  {USED, NOT USED, UNCERTAIN} raises InvalidCategoryState. Previously
  a lowercase typo like 'used' silently fell through to row 7 (cold
  start) or row 6 (silence) depending on other categories, changing the
  disclosure decision instead of rejecting bad input.

- P2 #3 (auto-promotion lint loose): check_policy_anchor_protocol.py
  changed from any() to all() over the two load-bearing tokens
  ('auto-promotion' AND 'MUST NOT be rendered as though USED'). Earlier
  any() accepted either token alone, so dropping the load-bearing
  prohibition sentence while keeping the heading word passed CI.

Test impact: 1029 → 1035 (added 6 new positive + negative path tests
for the round-2 closures). 0 regressions in 967 baseline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(#108): codex round-3 two P2 closures

- P2 #1 (IEEE G8 pairing skipped in row 4): decide_disclosure_output()
  now calls assert_ieee_pairing_conformant() before returning
  anchor_render when policy_anchor='ieee'. Previously the function
  blessed an IEEE render with only level_of_involvement set (or only
  affected_sections), violating §4.3 G8 invariant. The helper itself
  was already implemented; missing was the call site in the main
  decision path.

- P2 #2 (duplicate anchor section silently overwrites):
  _split_anchor_sections() now returns a duplicates list alongside the
  dict; lint_text flags any slug that appears more than once. A second
  `## Anchor: ieee` heading was previously absorbed by dict overwrite
  and slug coverage still passed.

Test impact: 1035 → 1039 (added 4 new tests covering IEEE row-4
pairing positive/negative paths, duplicate anchor-section mutation,
and the "neither pairing input present" graceful-degradation case
documented in protocol §3.4).

Three rounds of codex gpt-5.5 xhigh review: R1 4 P2 → R2 3 P2 → R3
2 P2 → R4 expected to converge.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(#108): codex round-4 four P2 closures

- P2 #1 (cross-doc contradiction): policy_anchor_disclosure_protocol.md
  §1 input contract still said "mutually exclusive" while §5 + the
  dispatcher allowed the Nature consistent pair. Rewritten as
  "selector-mutually-exclusive by default" referencing §5's exhaustive
  enumeration.

- P2 #2 (silent ICMJE default): RendererInput.policy_anchor was
  defaulted to "icmje", so RendererInput(venue="ICLR") silently produced
  a Nature-ICMJE conflict and RendererInput() returned an ICMJE render.
  Now defaults to None; new _check_selector_supplied() raises
  SelectorUnsupplied when both selectors are unset; venue-only inputs
  return DisclosureDecision(row=0, kind='delegated_to_venue_path').

- P2 #3 (Nature Portfolio variants rejected): Nature Medicine, Nature
  Communications, etc. all inherit the parent Nature policy but the
  hardcoded whitelist was 4 exact strings. Added NATURE_VENUE_PREFIXES
  ("Nature ") + is_nature_portfolio_venue() helper covering the whole
  Nature Portfolio family.

- P2 #4 (dedup pointer string-only): added the canonical source file
  shared/policy_data/nature_policy.md and strengthened
  verify_nature_dedup_with_venue() to confirm the file actually exists
  (not just that the path appears in both consumer files). Byte-compare
  is not the right contract (anchor table = verbatim quotes, venue
  policy = summary form, intentionally different shapes); shared-source
  presence is.

Test impact: 1039 → 1048 (added 9 new tests: selector unsupplied,
venue-only delegation, Nature Portfolio prefix family, helper unit
tests, canonical-source existence check).

Four rounds of codex review now: R1 4 P2 → R2 3 P2 → R3 2 P2 → R4 4 P2.
R4 surfaced cross-doc inconsistency + design defaults bugs that
R1-R3 missed (not lexical patching reverberation; genuine new ground).
Per impl spec §7 architectural-inflection trigger conditions, not yet
hit: trajectory is R3 lowest (2 P2), R4 surfaced new substantive
ground rather than re-patching old territory.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(#108): codex round-5 three P2 closures

- P2 #1 (Nature variant unknown-venue halt): disclosure_mode_protocol.md
  step 1a dispatch now routes the consistent pair (any Nature Portfolio
  venue + nature anchor) to step 1c (anchor path), not step 1b. Step 1b
  is for venue-only invocations whose venue lives in the v1 database;
  routing the consistent pair through it caused Nature Portfolio
  journals outside v1 (Nature Medicine, etc.) to halt as unknown.

- P2 #2 (ai_used:true gate scope): protocol doc §3 concern #10 spec
  was limited to bare-flag/no-Phase-2 case. Referee already handled the
  full case (ai_used:true with all-NOT-USED OR all-UNCERTAIN OR empty);
  protocol doc now matches: the gate fires whenever ai_used=true is
  supplied AND no v3.2 category is marked USED, regardless of why
  (Phase 2 done or not done).

- P2 #3 (substring concern-number lint): check_policy_anchor_protocol
  used plain `in text` membership for `concern #1`, so removing it from
  a doc still mentioning `concern #10` / `concern #11` passed. Now uses
  regex `concern\s+#1\b` with word boundary.

Test impact: 1048 → 1050 (added 2 new tests: concern-number boundary
mutation lint coverage + ai_used:true with UNCERTAIN-only gate test).

Five rounds of codex review: R1 4 P2 → R2 3 P2 → R3 2 P2 → R4 4 P2 →
R5 3 P2. Each round surfaces substantive new ground rather than
re-patching old territory; not yet at the impl spec §7 architectural
inflection trigger (3+ P2 with no net downward trend across R3-R5-R7).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(#108): codex round-6 two P2 + one P3 closures

- P2 #1 (anchor heading regex too narrow): ANCHOR_HEADING regex in
  check_policy_anchor_table.py only matched `[A-Za-z0-9-]+`, so any
  heading with underscore / space / punctuation was silently folded
  into the previous section. Now captures `\\S.*?` (any non-empty
  trailing identifier) so invalid slugs surface in the "unknown
  anchor section" violation.

- P2 #2 (anchor slug coverage was global substring): the lint accepted
  a slug appearing anywhere in the doc (e.g., in a §3 render-rules
  section). Now parses the **Anchor inventory** line specifically and
  requires each canonical slug to appear in that comma-separated list.
  Fixture _GOOD_PROTOCOL gains the inventory line; new mutation test
  asserts dropping a slug from the inventory while keeping it in prose
  still fails.

- P3 #1 (YAML # parsed as comment): spec-consistency.yml step names
  like `Check #108 policy anchor table ...` were parsed by Actions as
  bare `Check` because YAML treats `#` as a comment marker. All five
  new step names are now double-quoted with `(#108)` suffix so the
  full step name shows in the Actions UI.

Test impact: 1050 → 1051 (added 1 new mutation test:
test_missing_anchor_inventory_line_fails). All real-file lints + 84
new-validator tests pass.

Six rounds of codex review: R1 4 P2 → R2 3 P2 → R3 2 P2 → R4 4 P2 →
R5 3 P2 → R6 2 P2 + 1 P3. Substantive new ground each round (no
revisit of same defect class twice); trajectory moves between 2-4 P2
with no monotonic descent but no escalation either.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(#108): codex round-7 one P2 + two P3 closures

- P2 #1 (Nature venue path not bound to shared source): added
  explicit "Derivation note (#108 scope limitation)" to
  venue_disclosure_policies.md §Nature documenting that the v3.2
  summary rows are derived from shared/policy_data/nature_policy.md
  but not auto-generated from it. The G4 invariant now reads: when
  Nature source policy drifts, update canonical source first then
  manually sync summary rows in the same change. A future refactor
  (out of #108 scope) can replace summary rows with auto-extract from
  canonical source. This documents the limitation rather than papering
  over it; a hard auto-bind would require rewriting the v3.2 venue
  track which §4.1 items 1-5 say NO CHANGE.

- P3 #1 (lint doesn't enforce dedup): main(argv) of
  check_policy_anchor_table.py now invokes
  verify_nature_dedup_with_venue() alongside lint_text(). New
  --venue-policies CLI arg lets the canonical Nature source path
  be configured. New integration test exercises main() with a
  temporary subtree missing the canonical source to confirm the
  main command surfaces the violation.

- P3 #2 (inventory closed-enum loose): check_policy_anchor_protocol
  now rejects extra slugs in the Anchor inventory line, not only
  missing ones. A line advertising `prisma-trAIce, icmje, nature,
  ieee, cope` is non-conformant because cope is outside the canonical
  closed enum. Symmetric to the canonical anchor-slug coverage check
  in check_policy_anchor_table.py.

Test impact: 1051 → 1053 (added 2 new tests: extra-anchor-in-inventory
mutation, main-command-invokes-dedup-helper integration).

Seven rounds of codex review: R1 4 → R2 3 → R3 2 → R4 4 → R5 3 →
R6 2 → R7 1+2P3. P2 count dropped to 1 at R7; trajectory descending
again with smaller findings. Substantive ground covered each round;
not yet at impl spec §7 architectural-inflection trigger.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(#108): codex round-8 closures + audit-trail-shipped declaration

Closes 2 of 3 R8 findings; R8 P2 #1 (pipeline plumbing for slr_lineage)
deferred to follow-up issue per user-chosen audit-trail-shipped framing.

- R8 P2 #2 (protocol §5 Nature variants drift): policy_anchor_disclosure
  _protocol.md §5 now mirrors the full Nature Portfolio recognition logic
  (canonical labels + "Nature " prefix family) that step 1a and the
  referee already implement. Cross-doc consistency restored — when this
  text drifts from is_nature_portfolio_venue() it is a documented
  non-conformance.

- R8 P3 (dedup lint section-specific): verify_nature_dedup_with_venue()
  now requires the canonical source pointer to appear inside the
  Nature-specific section (## Anchor: nature in policy_anchor_table.md,
  ## Venue: Nature ... in venue_disclosure_policies.md), not anywhere in
  the file. Closes the prior false-pass where citations at the file
  bottom could satisfy the dedup invariant.

- R8 P2 #1 (academic-pipeline orchestrator slr_lineage emission):
  documented as out-of-#108-scope follow-up. Will require updating
  academic-pipeline orchestrator + handoff_schemas to emit the signal
  on the systematic-review → academic-paper full handoff path. Tracked
  separately so #108 ships without a cross-skill change beyond §4.1
  items 1-5 boundary.

**Audit-trail-shipped framing (per user decision after 8 rounds):**

Eight rounds of codex gpt-5.5 xhigh review:
  R1 4 P2 → R2 3 P2 → R3 2 P2 → R4 4 P2 → R5 3 P2 → R6 2 P2 →
  R7 1 P2+2P3 → R8 2 P2+1P3 (1 P2 deferred, 1 P2 + P3 closed here)

The trajectory oscillates 1-4 P2 in 8 rounds — same pattern Decision
Doc (PR #109) exhibited at R5-R11, ultimately shipped audit-trail-
complete at R11 with 2 documented open concerns. This implementation
ships R8 with 1 documented follow-up (pipeline plumbing) per the same
audit-trail-honest pattern. P2 count trend across R1-R8 is descending
on average (4 → 2 modal value); R4 reset suggests cross-section
inventory effect rather than convergence to zero.

Substantive ground covered: §4.3 8 invariants all enforced in the
referee + protocol doc + anchor table; §4.4 11 concerns all resolved
(10 inline-fix, 1 follow-up); 86 new tests + 967 baseline = 1053
passing / 0 regressed; CI wired into spec-consistency.yml; public-repo
boundary clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(#108): CHANGELOG [Unreleased] entry — audit-trail-shipped

Adds #108 section above the existing v3.7.3 entry. Documents:
- migration note (G1+G6: no migration needed; renderer path only)
- 7 new files + 3 modified files
- §4.4 11 open concerns resolution table summary (4 user-chosen, 7 inline)
- known follow-up: academic-pipeline slr_lineage plumbing (cross-skill)
- regression: 1053 passing (967 baseline + 86 new), 0 failed
- 8-round codex audit trail

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(#108): /simplify pass — strip codex round-N residue + hoist regex

Applies findings from /simplify three-agent review.

Quality cleanup (Agent 2 Finding 1, ~17 sites):
- Strip "Closes codex round-N P# #M" inline narrative from docstrings
  and comments in the three new Python modules (referee + 2 lints).
  Git log + CHANGELOG already encode round-N provenance; per CLAUDE.md
  inline comments should explain WHY not narrate change history.
- Keep all §4.3 / §4.4 spec-section references (audit trail proper) +
  invariant identifiers (G1-G9) intact.

Quality cleanup (Agent 2 Finding 3):
- Extract COPYEDITING_CARVEOUT_SEMANTICS and IMAGE_RIGHTS_REGIMES dicts
  to module level in referee.py. Surfaces the closed enum, lets future
  lints check membership directly, simplifies the per-anchor lookup
  functions to single-line dispatchers.

Quality cleanup (Agent 2 Finding 4):
- Single-pass dict bucketing in decide_disclosure_output: replace three
  set comprehensions iterating ri.categories with one bucket loop.

Efficiency cleanup (Agent 3 Findings 1+2):
- Hoist CONCERN_PATTERN, TABLE_ROW_PATTERN, ANCHOR_INVENTORY_PATTERN to
  module level in check_policy_anchor_protocol.py. Previously 11+7
  regex compiles per invocation; now compiled once + finditer-based
  set lookup. Matches the established ARS pattern (e.g.,
  check_v3_6_8_pattern_protection.py).

Efficiency cleanup (Agent 3 Findings 3+4):
- Hoist NATURE_ANCHOR_SECTION, NATURE_VENUE_SECTION,
  NATURE_POLICY_POINTER, REPO_ROOT to module level in
  check_policy_anchor_table.py.
- verify_nature_dedup_with_venue() accepts optional anchor_text+
  venue_text kwargs so main() can pass its already-read anchor text
  instead of reading the same file twice.
- New repo_root parameter (default = module-level REPO_ROOT) lets
  isolation tests pass a temp-dir mirror without monkey-patching.

Reuse cleanup (Agent 1 Finding 3):
- Adopt scripts/<file>.parent.parent pattern for REPO_ROOT, matching
  6 sibling lint scripts. main() derives a per-invocation repo_root
  from the anchor table path so temp-subtree integration tests work.

Findings deliberately skipped:
- Agent 1 Finding 1 (shared split_h2_sections helper): cross-file
  refactor scope creep; check_corpus_consumer_protocol.py has its own
  duplicate-bug to fix in a separate change.
- Agent 2 Findings 5-10: nice-to-have refactors, not blocking.

Test impact: 1053 / 0 regressed. Net -60 lines (3 files).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 18:00:28 +08:00

237 lines
10 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Mutation tests for scripts/check_policy_anchor_protocol.py."""
from __future__ import annotations
import sys
import textwrap
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
if str(REPO_ROOT / "scripts") not in sys.path:
sys.path.insert(0, str(REPO_ROOT / "scripts"))
import check_policy_anchor_protocol as cpap # noqa: E402
# Each named invariant from impl spec §4.3 must be referenced verbatim. The
# protocol doc must also resolve each §4.4 #1#11 concern by name and carry
# the §3 G10 7-row precedence table verbatim from Decision Doc.
_GOOD_PROTOCOL = textwrap.dedent(
"""\
# Policy-Anchor Disclosure Protocol (#108)
**Anchor inventory**: `prisma-trAIce, icmje, nature, ieee`
## Frozen invariants from Decision Doc §4.3
- G1 invariant — no ai_disclosure field added to corpus entry schema
- G2 invariant — SLR mode dispatches via slr_lineage to PRISMA-trAIce track
- G3 / G10 invariant — seven-row precedence table holds; auto-promotion forbiddance
- G4 invariant — 4 policy-anchor renderers; Nature dedup
- G5 invariant — PRISMA M6 fires only when all three gates hold
- G7 invariant — copyediting carve-out anchor-specific
- G8 invariant — IEEE #5 + #6 paired mandate
- G9 invariant — image-rights regimes anchor-specific
## §4.4 open concerns resolved
- concern #1 — explicit slr_lineage input
- concern #2 — auto-detect tool identity per v3.2 Phase 4
- concern #3 — per-(tool × task) prompt scope tuple
- concern #4 — IEEE locator free-form list with IMRaD exemplars
- concern #5 — Nature image hybrid annotation + suggested patches
- concern #6 — UNCERTAIN per-facet annotation alongside USED render
- concern #7 — venue+anchor conflict reject with error
- concern #8 — three-state completeness flag full spec
- concern #9 — test set scope covers all invariants + concerns
- concern #10 — ai_used:true force v3.2 categorization flow
- concern #11 — G1 invariant scope narrowing per §2.1 authoritative
## G10 7-row precedence table (whole-disclosure)
| # | Precondition | Output |
|---|---|---|
| 1 | ai_used:false AND ≥1 category USED | Honest conflict annotation |
| 2 | ai_used:false AND no USED AND no UNCERTAIN | No-AI statement (G10 opt-in) |
| 3 | ai_used:false AND ≥1 UNCERTAIN AND no USED | Honest tension annotation |
| 4 | ai_used:true OR ≥1 USED, row 1 not match | Full anchor disclosure render |
| 5 | ≥1 UNCERTAIN AND no USED AND no ai_used | Honest not-supplied annotation |
| 6 | All NOT USED AND no UNCERTAIN AND no ai_used | Silence (G10 default-OK) |
| 7 | Empty input across every dimension | Honest cold-start annotation |
## Auto-promotion forbiddance
A still-UNCERTAIN category MUST NOT be rendered as though USED in any of
the four anchor outputs. This is the §4.3 G3/G10 invariant the
implementation enforces.
## Anchor lookup mechanism
Renderer reads `policy_anchor_table.md` keyed by `--policy-anchor=<a>`
where a ∈ {prisma-trAIce, icmje, nature, ieee}.
## v3.2 Nature venue dedup
Both the v3.2 Nature venue renderer and the Nature anchor renderer cite
the canonical source pointer `shared/policy_data/nature_policy.md`.
"""
)
class CheckPolicyAnchorProtocolGoldenPathTest(unittest.TestCase):
def test_good_protocol_passes(self) -> None:
violations = cpap.lint_text(_GOOD_PROTOCOL)
self.assertEqual(violations, [], msg=f"unexpected violations: {violations}")
class CheckPolicyAnchorProtocolMutationTests(unittest.TestCase):
def test_missing_invariant_reference_fails(self) -> None:
bad = _GOOD_PROTOCOL.replace("G1 invariant", "")
violations = cpap.lint_text(bad)
self.assertTrue(
any("G1" in v for v in violations),
msg=f"expected G1 invariant violation; got {violations}",
)
def test_missing_concern_resolution_fails(self) -> None:
bad = _GOOD_PROTOCOL.replace("concern #6", "")
violations = cpap.lint_text(bad)
self.assertTrue(
any("#6" in v or "concern 6" in v.lower() for v in violations),
msg=f"expected concern #6 violation; got {violations}",
)
def test_missing_g10_table_row_fails(self) -> None:
# Drop row 4 — the load-bearing full-anchor-disclosure row
bad = _GOOD_PROTOCOL.replace(
"| 4 | ai_used:true OR ≥1 USED, row 1 not match | Full anchor disclosure render |",
"",
)
violations = cpap.lint_text(bad)
self.assertTrue(
any("row 4" in v.lower() or "7-row" in v.lower() for v in violations),
msg=f"expected G10 row 4 violation; got {violations}",
)
def test_missing_auto_promotion_forbiddance_fails(self) -> None:
# Strip every mention of both forbiddance keywords so the validator
# has nothing to anchor on; replace with neutral filler text.
bad = _GOOD_PROTOCOL
bad = bad.replace("auto-promotion forbiddance", "(removed clause)")
bad = bad.replace("Auto-promotion forbiddance", "Removed clause")
bad = bad.replace(
"A still-UNCERTAIN category MUST NOT be rendered as though USED in any of\n"
"the four anchor outputs.",
"",
)
violations = cpap.lint_text(bad)
self.assertTrue(
any("auto-promot" in v.lower() or "UNCERTAIN" in v for v in violations),
msg=f"expected auto-promotion forbiddance violation; got {violations}",
)
def test_partial_auto_promotion_token_loss_still_fails(self) -> None:
# Codex round-2 P2 #3: dropping the load-bearing MUST NOT sentence
# while keeping the "auto-promotion" heading should still fail. The
# protocol's invariant is the prohibition itself; the heading word
# alone is insufficient.
bad = _GOOD_PROTOCOL.replace(
"A still-UNCERTAIN category MUST NOT be rendered as though USED in any of\n"
"the four anchor outputs.",
"",
)
violations = cpap.lint_text(bad)
self.assertTrue(
any("MUST NOT" in v for v in violations),
msg=f"expected forbiddance-token violation; got {violations}",
)
def test_partial_keyword_loss_still_fails(self) -> None:
# Inverse mutation: keep the prohibition sentence but drop the
# "auto-promotion" anchor heading. The lint should still fail
# because both tokens are load-bearing.
bad = _GOOD_PROTOCOL.replace("auto-promotion forbiddance", "(removed clause)")
bad = bad.replace("Auto-promotion forbiddance", "Removed clause")
violations = cpap.lint_text(bad)
self.assertTrue(
any("auto-promotion" in v for v in violations),
msg=f"expected auto-promotion-token violation; got {violations}",
)
def test_concern_number_boundary_matching(self) -> None:
# Codex round-5 P2 #3 closure: removing `concern #1` from a doc that
# still mentions `concern #10` and `concern #11` should still fail.
# Previous substring-membership check would let the document pass.
bad = _GOOD_PROTOCOL.replace("concern #1 —", "deleted-clause —", 1)
# Confirm the bad text still contains concern #10 / #11
self.assertIn("concern #10", bad)
self.assertIn("concern #11", bad)
violations = cpap.lint_text(bad)
self.assertTrue(
any("concern #1 " in v or "concern #1\b" in v.lower() for v in violations),
msg=f"expected concern #1 violation; got {violations}",
)
def test_missing_dedup_pointer_fails(self) -> None:
bad = _GOOD_PROTOCOL.replace("shared/policy_data/nature_policy.md", "elsewhere.md")
violations = cpap.lint_text(bad)
self.assertTrue(
any("nature_policy.md" in v or "dedup" in v.lower() for v in violations),
msg=f"expected dedup pointer violation; got {violations}",
)
def test_missing_anchor_slug_fails(self) -> None:
# Codex round-6 P2 #2 closure: dropping slug from the
# **Anchor inventory** line must fail even if the slug name
# appears elsewhere (e.g., in a per-anchor render section).
bad = _GOOD_PROTOCOL.replace(
"**Anchor inventory**: `prisma-trAIce, icmje, nature, ieee`",
"**Anchor inventory**: `icmje, nature, ieee`",
)
# Add a stray prisma-trAIce mention to confirm the inventory check
# is strict (the global substring would catch this and pass).
bad += "\n\nNote: prisma-trAIce renders are mentioned in §3.1.\n"
violations = cpap.lint_text(bad)
self.assertTrue(
any("prisma-trAIce" in v or "anchor slug" in v.lower() for v in violations),
msg=f"expected anchor slug violation; got {violations}",
)
def test_missing_anchor_inventory_line_fails(self) -> None:
bad = _GOOD_PROTOCOL.replace(
"**Anchor inventory**: `prisma-trAIce, icmje, nature, ieee`",
"(inventory line removed)",
)
violations = cpap.lint_text(bad)
self.assertTrue(
any("Anchor inventory" in v for v in violations),
msg=f"expected Anchor inventory violation; got {violations}",
)
def test_extra_anchor_in_inventory_fails(self) -> None:
# Codex round-7 P3 #2 closure: inventory advertising a slug
# outside the canonical closed enum (e.g., `cope`) must fail.
# Previously the lint only checked presence, not extras.
bad = _GOOD_PROTOCOL.replace(
"**Anchor inventory**: `prisma-trAIce, icmje, nature, ieee`",
"**Anchor inventory**: `prisma-trAIce, icmje, nature, ieee, cope`",
)
violations = cpap.lint_text(bad)
self.assertTrue(
any("cope" in v and "unexpected" in v for v in violations),
msg=f"expected unexpected-anchor violation; got {violations}",
)
class CheckPolicyAnchorProtocolInvariantTest(unittest.TestCase):
def test_invariant_names_count(self) -> None:
self.assertEqual(len(cpap.REQUIRED_INVARIANTS), 8)
def test_concern_count(self) -> None:
self.assertEqual(len(cpap.REQUIRED_CONCERNS), 11)
if __name__ == "__main__":
unittest.main()