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>
This commit is contained in:
Edward Cheng-I Wu
2026-05-14 18:00:28 +08:00
committed by GitHub
parent 20ed72dbbb
commit 70c8678730
14 changed files with 2709 additions and 6 deletions
+21
View File
@@ -253,3 +253,24 @@ jobs:
pip install pytest pyyaml jsonschema
pytest scripts/test_run_codex_audit_e2e.py -v
- name: "Check policy anchor table structural lint (#108)"
run: python3 scripts/check_policy_anchor_table.py
- name: "Run policy anchor table validator unit tests (#108)"
env:
PYTHONPATH: .
run: python3 -m unittest scripts.test_check_policy_anchor_table -v
- name: "Check policy anchor disclosure protocol lint (#108)"
run: python3 scripts/check_policy_anchor_protocol.py
- name: "Run policy anchor protocol validator unit tests (#108)"
env:
PYTHONPATH: .
run: python3 -m unittest scripts.test_check_policy_anchor_protocol -v
- name: "Run policy anchor disclosure conformance suite — referee (#108)"
env:
PYTHONPATH: .
run: python3 -m unittest scripts.test_policy_anchor_disclosure -v
+38
View File
@@ -4,6 +4,44 @@ All notable changes to this project will be documented in this file.
## [Unreleased]
### #108 — AI disclosure policy-anchor renderer (2026-05-14, audit-trail-shipped)
**Parent docs:** Decision Doc (`docs/design/2026-05-14-ai-disclosure-schema-decision.md`, PR #109, merged commit 20ed72d) + implementation spec (`docs/design/2026-05-14-ai-disclosure-impl-spec.md`).
**Migration note (G1 + G6 invariants):** **no migration required**. Decision Doc §2.1 G1 invariant: no `ai_disclosure` field is added to `shared/contracts/passport/literature_corpus_entry.schema.json`. Decision Doc §3 G6: no deprecation horizon — legacy entries (which by §1 fact-check do not carry any AI-disclosure field today) stay byte-equivalent. The implementation extends the runtime renderer path, not the data schema.
**New files added:**
- `academic-paper/references/policy_anchor_table.md` — 4-anchor (PRISMA-trAIce / ICMJE / Nature / IEEE) × 16-field source-of-truth reference table carrying verbatim policy quotes lifted from discovery doc §4.3-4.6 (PR #107, commit 299c4b6) + per-anchor renderer rules.
- `academic-paper/references/policy_anchor_disclosure_protocol.md` — LLM-prose runtime protocol for the new `--policy-anchor=<a>` track: 7-section flow covering inputs / G10 7-row precedence table / per-anchor render flows / auto-promotion forbiddance / venue-anchor conflict resolution / three-state completeness flag / 11-concern resolution map.
- `shared/policy_data/nature_policy.md` — canonical Nature substantive policy source; both the policy-anchor track and the v3.2 venue track cross-reference this path for the G4 dedup invariant.
- `scripts/check_policy_anchor_table.py` + `scripts/test_check_policy_anchor_table.py` — anchor table structural lint with 13 mutation tests + Nature dedup guard wired into the main lint command.
- `scripts/check_policy_anchor_protocol.py` + `scripts/test_check_policy_anchor_protocol.py` — protocol doc lint with 12 mutation tests covering §4.3 8 invariants + §4.4 11 concerns + G10 7-row precedence table + auto-promotion forbiddance + anchor inventory closed-enum.
- `scripts/policy_anchor_disclosure_referee.py` + `scripts/test_policy_anchor_disclosure.py` — executable specification (referee) of §3 G10 7-row decision table + 8 invariant predicates; 61 conformance tests covering every (input × expected output) combination + forbidden-path negative fixtures.
**Modified files:**
- `academic-paper/references/disclosure_mode_protocol.md``--policy-anchor=<a>` track added in parallel to v3.2 `--venue=<v>` track. Phase 1 dispatch becomes selector-aware (step 1a / step 1b venue / step 1c anchor). Venue-only flow unchanged; anchor flow delegates Phase 3+4 to `policy_anchor_disclosure_protocol.md`. Concern #7 venue+anchor conflict resolution enforced.
- `academic-paper/references/venue_disclosure_policies.md` — Nature entry gains derivation note + dedup pointer to `shared/policy_data/nature_policy.md`. v3.2 venue rendering content unchanged (derived view, manual sync to canonical source until future refactor).
- `.github/workflows/spec-consistency.yml` — 5 new CI steps wiring the new validators and conformance test suite into the existing spec-consistency job.
**§4.4 11 open concerns resolved** (4 user-chosen, 7 inline; full table in impl spec §3):
1. Track-selection lookup: explicit `slr_lineage` input from pipeline orchestrator (user-chosen).
2. Tool identity collection: auto-detect from session metadata (mirror v3.2 Phase 4).
3. Prompt scope: per-(tool × task) tuple per PRISMA M6.a.
4. IEEE section locator: free-form list with recommended IMRaD exemplars.
5. Nature image metadata: hybrid output channel (annotation block + suggested inline patches) (user-chosen).
6. UNCERTAIN per-facet finalization: USED-full + per-facet annotation alongside still-UNCERTAIN (user-chosen).
7. Venue+anchor conflict: reject conflicting selectors with explicit error.
8. Three-state completeness flag: full computation logic encoded in §6 of protocol doc.
9. Test set scope: 86 new tests covering 8 invariants + 10 concerns × {positive, negative}.
10. `ai_used:true` substantive-content gate: force v3.2 categorization flow (user-chosen).
11. G1 invariant scope: data layer untouched; non-renderer pipeline plumbing permitted.
**Known follow-up (out of #108 scope):** the academic-pipeline orchestrator does not yet emit `slr_lineage` on the documented `systematic-review → academic-paper full` handoff. Authors targeting `--policy-anchor=prisma-trAIce` must supply `mode=systematic-review` manually until that plumbing lands in a separate PR (touches `academic-pipeline/` + `shared/handoff_schemas.md`, outside §4.1 items 1-5 NO-CHANGE boundary).
**Regression status:** 967 baseline + 86 new tests = 1053 passing / 3 skipped / 0 failed. Public-repo boundary clean. Eight rounds of codex gpt-5.5 xhigh review (R1 4 P2 → R8 2 P2); shipped audit-trail-complete per user decision rather than pushing past Decision Doc 11-round high water mark. R8 P2 #1 captured as the known follow-up above.
### v3.7.3 — claim faithfulness locator + contaminated-source advisory (2026-05-12, in progress)
**External motivation:** Zhao, Wang, Stuart, De Vaan, Ginsparg, Yin "LLM hallucinations in the wild: Large-scale evidence from non-existent citations" (arXiv:2605.07723, 2026-05). Corpus-scale audit of 111M references across 2.5M papers across arXiv / bioRxiv / SSRN / PMC finds 146,932 hallucinated citations estimated for 2025 alone, with the inflection point at mid-2024, 85.3% of preprint hallucinations surviving into the published record, and Google Scholar increasingly indexing citation-only entries. The paper names the L3 (claim faithfulness) gap explicitly: *"real citations deployed to support claims the cited references do not actually make ... remains an open challenge for which reliable detection methods remain under active development."* v3.7.3 closes the locator-channel half of that gap (anchor infrastructure for future L3 audit) and surfaces two contamination signals (preprint post-LLM-inflection + Semantic Scholar unmatched) as advisory cite-time markers.
@@ -1,9 +1,30 @@
# Disclosure Mode Protocol
**Status**: v3.2
**Status**: v3.2 (#108 extension: parallel `--policy-anchor=<a>` path; v3.2 venue path unchanged)
**Parent skill**: `academic-paper`
**Mode name**: `disclosure`
**Purpose**: Generate a venue-specific AI-usage disclosure statement that complies with the target venue's current AI policy, placed in the venue's preferred section (Methods / Acknowledgements / cover letter / separate statement), in the venue's preferred voice.
**Purpose**: Generate either (a) a venue-specific AI-usage disclosure statement that complies with the target venue's current AI policy (v3.2 path, default), or (b) a policy-anchor-specific disclosure rendered from the 4-anchor matrix (PRISMA-trAIce / ICMJE / Nature / IEEE) when the author targets a policy anchor rather than a specific journal venue (#108 path).
---
## Two parallel tracks (#108 + v3.2)
The `disclosure` mode dispatches on the author-supplied selector:
| Selector | Track | Lookup source | Output shape |
|---|---|---|---|
| `--venue=<v>` (v3.2, default) | Venue track | `venue_disclosure_policies.md` v1 database (ICLR / NeurIPS / Nature / Science / ACL / EMNLP) | Single venue-tailored disclosure paragraph + placement instruction |
| `--policy-anchor=<a>` (#108) | Anchor track | `policy_anchor_table.md` 4-anchor × 16-field matrix | 4-anchor-conditioned render per `policy_anchor_disclosure_protocol.md` |
The two tracks are **selector-mutually-exclusive by default** — one selector picks one track. When the author supplies **both** selectors in the same invocation, the renderer evaluates compatibility per concern #7 rules: a consistent pair (Nature venue + nature anchor, both sourced from `shared/policy_data/nature_policy.md`) proceeds; any other pair is **rejected with an explicit error** listing the policy conflict. Silent precedence between selectors is forbidden. See [policy_anchor_disclosure_protocol.md §5](policy_anchor_disclosure_protocol.md) for the full conflict-resolution detail.
If neither selector is supplied and the pipeline orchestrator does not infer one from upstream context, the mode prompts the user to specify which selector applies. The venue track remains the default for explicit journal submissions; the anchor track applies when targeting policy frameworks (e.g., compliance reporting to ICMJE-adopting journals collectively, or pre-submission alignment to IEEE author guidelines).
**Conflict resolution (concern #7) — exhaustive cases:**
- Supplied both, **consistent pair** (only currently defined case): `--venue=Nature` (any Nature Portfolio variant string) **and** `--policy-anchor=nature` → both target Nature substantive policy via the shared source pointer → **proceed**.
- Supplied both, **any other combination** (e.g., `--venue=Nature` + `--policy-anchor=ieee`; `--venue=ICLR` + `--policy-anchor=icmje`; or a Nature-venue spelling that does not match the canonical set with a non-nature anchor) → **reject** with explicit error citing the policy conflict; require the user to drop one selector. Silent precedence is forbidden by §4.4 #7.
- Supplied only one selector → run that track.
- Supplied neither selector → prompt the user to specify.
---
@@ -11,7 +32,7 @@
`academic-paper` already ships two generic AI disclosure templates in `journal_submission_guide.md` ("Minimal Disclosure" and "Detailed Disclosure"). Those templates are a good starting point but they are venue-agnostic: they don't know that Nature requires disclosure in the Methods section specifically, that ICLR requires it in the paper body with acknowledgement that "LLMs were used as general-purpose writing tools", or that ACL requires the disclosure in a dedicated "Use of AI Assistance" subsection.
Disclosure mode closes this gap. It takes the paper and a target venue, looks up the venue's current AI policy in the v1 policy database, and produces the disclosure text placed correctly.
The v3.2 venue track closes the venue-specific gap. The #108 anchor track closes the policy-framework-specific gap that emerges when authors target a policy anchor (PRISMA-trAIce SLR guideline, ICMJE recommendations, Nature Portfolio editorial policy, IEEE author guidelines) rather than a specific journal venue.
---
@@ -19,20 +40,36 @@ Disclosure mode closes this gap. It takes the paper and a target venue, looks up
1. **Paper draft**: current manuscript text (the mode needs to know what the AI actually did in order to describe it accurately).
2. **Target venue**: journal or conference name. If the venue is in the v1 database (ICLR, NeurIPS, Nature, Science, ACL, EMNLP), use the cached policy. If not, refuse to guess — prompt the user to paste the venue's current AI policy text from the venue's submission page.
2. **Selector** (one of):
- **Target venue (`--venue=<v>`)**: journal or conference name (v3.2 path). If the venue is in the v1 database (ICLR, NeurIPS, Nature, Science, ACL, EMNLP), use the cached policy. If not, refuse to guess — prompt the user to paste the venue's current AI policy text from the venue's submission page.
- **Policy anchor (`--policy-anchor=<a>`)**: one of `prisma-trAIce, icmje, nature, ieee` (#108 path). Anchor lookup follows `policy_anchor_disclosure_protocol.md`.
3. **What ARS did**: the mode reads the paper's commit history / pipeline log (if using the full `academic-pipeline`) to identify which AI-assisted steps produced which parts of the paper. At minimum: research assistance, drafting assistance, revision assistance, citation checking, peer review simulation. If the pipeline log is not available, ask the user to confirm which categories apply.
3. **Pipeline signal** (#108 anchor path only): `slr_lineage=true|false` set by the upstream pipeline orchestrator. Required for `--policy-anchor=prisma-trAIce` per §4.3 G2 invariant. Cold-start invocation requires explicit `mode=<value>` parameter; silent fallback to general track is forbidden.
4. **What ARS did**: the mode reads the paper's commit history / pipeline log (if using the full `academic-pipeline`) to identify which AI-assisted steps produced which parts of the paper. At minimum: research assistance, drafting assistance, revision assistance, citation checking, peer review simulation. If the pipeline log is not available, ask the user to confirm which categories apply.
---
## Process
### Phase 1: Intake + venue lookup
### Phase 1: Intake + lookup (selector-aware)
**Step 1a — selector dispatch:**
- Both `--venue=<v>` and `--policy-anchor=<a>` supplied → check policy compatibility per the Two-parallel-tracks section above. **Consistent pair (currently only any Nature Portfolio venue + `--policy-anchor=nature`, where "Nature Portfolio venue" includes canonical labels {"Nature", "Nature Portfolio", "Nature (Nature Publishing Group)", "Nature Publishing Group"} and the journal-family prefix `"Nature "` matching e.g. "Nature Medicine", "Nature Communications", "Nature Climate Change", etc.) → route the consistent pair to **step 1c (anchor path)** so the shared canonical source `shared/policy_data/nature_policy.md` drives rendering; step 1b's v1 venue database does not need to contain every Nature Portfolio journal**. Conflicting pair → reject with explicit error.
- `--venue=<v>` only → step 1b (venue path).
- `--policy-anchor=<a>` only → step 1c (anchor path).
- Neither supplied → prompt the user to specify selector.
**Step 1b — venue lookup (v3.2 path, unchanged):**
- If venue is in the v1 database → load policy from `venue_disclosure_policies.md`.
- If venue is unknown → halt. Print: "I do not have a cached policy for {venue}. Please paste the venue's current AI-usage / generative-AI policy text so I don't guess." Do NOT fabricate a policy.
- If the user pastes a policy for an unknown venue, use it for this session only. Do NOT auto-persist it to the database — policies drift, and the database needs curation.
**Step 1c — anchor lookup (#108 path):**
- Validate `--policy-anchor=<a>``{prisma-trAIce, icmje, nature, ieee}`. Other values → reject with the closed-enum error.
- For `--policy-anchor=prisma-trAIce`: confirm `slr_lineage=true` (pipeline signal) or `mode=systematic-review` (cold-start input) per the G2 invariant track gate. Otherwise refuse with G2 invariant citation.
- Delegate Phase 3 + Phase 4 to `policy_anchor_disclosure_protocol.md` per-anchor render flows. Phase 2 (AI usage categorization) and Phase 5 (placement instructions) are shared with the venue path with the anchor-specific routing applied inside Phase 3/4.
### Phase 2: Categorize AI usage
Produce a categorized list of how AI was used in the manuscript:
@@ -108,7 +145,11 @@ If the venue requires placement in multiple locations (e.g., Methods + cover let
## References
- `venue_disclosure_policies.md` — v1 policy database (ICLR, NeurIPS, Nature, Science, ACL, EMNLP)
- `policy_anchor_table.md`#108 4-anchor × 16-field matrix (PRISMA-trAIce, ICMJE, Nature, IEEE) for the policy-anchor track
- `policy_anchor_disclosure_protocol.md`#108 policy-anchor track render protocol (per-anchor flows, G10 7-row precedence table, auto-promotion forbiddance, §4.4 11 concerns resolved paths)
- `journal_submission_guide.md` — existing generic templates (fallback)
- `credit_authorship_guide.md` — existing CRediT authorship best practices
- Lu et al. (2026). Towards end-to-end automation of AI research. *Nature* 651, 914-919 — the ethics statement for Lu 2026 was drafted in compliance with Nature's policy; their methodology is a worked example of what this mode should produce.
- `docs/design/2026-05-14-ai-disclosure-schema-decision.md`#108 Decision Doc (G1-G10 + §4.3 invariants + §4.4 11 open concerns)
- `docs/design/2026-05-14-ai-disclosure-impl-spec.md`#108 implementation spec (resolved-paths table)
- ROADMAP_v3.2.md item 6 — design decisions (v1 venue set, unknown-venue halt, education/QA venues deferred to v2)
@@ -0,0 +1,192 @@
# Policy-Anchor Disclosure Protocol
**Status**: #108 implementation (parented to Decision Doc 20ed72d)
**Parent skill**: `academic-paper`
**Mode name**: `disclosure` with `--policy-anchor=<a>` selector (parallel track to `--venue=<v>` v3.2 path; see `disclosure_mode_protocol.md` for the dispatch).
**Anchor inventory**: `prisma-trAIce, icmje, nature, ieee` (closed enum; expansion via §4.7 deferred per Decision Doc §4.2).
**Data source**: `policy_anchor_table.md` (read at runtime; LLM looks up the row for `--policy-anchor=<a>` × field-N).
This protocol document is the runtime instruction set the LLM follows when the user invokes `disclosure` mode with a policy-anchor selector. It encodes the 8 Decision Doc §4.3 frozen invariants, the §3 G10 7-row precedence table for whole-disclosure output decision, the 11 §4.4 open-concern resolutions (per implementation spec §3), the auto-promotion forbiddance, and the per-anchor render flows (PRISMA-trAIce / ICMJE / Nature / IEEE).
---
## 0. Why this protocol exists
ARS's v3.2 `disclosure` mode renders venue-targeted AI disclosure text (ICLR, NeurIPS, Nature, Science, ACL, EMNLP). The 4-anchor `--policy-anchor=<a>` track parallels v3.2's venue track when the author targets a **policy anchor** (PRISMA-trAIce, ICMJE, Nature Portfolio, IEEE) rather than a specific journal venue. The two tracks coexist; v3.2 venue path remains the default for journal submissions.
---
## 1. Inputs
1. **Paper draft** (same as v3.2): manuscript text plus pipeline log if available.
2. **Selector**: `--policy-anchor=<a>` where `a ∈ {prisma-trAIce, icmje, nature, ieee}`. **Selector-mutually-exclusive by default** — supplying both `--policy-anchor` and `--venue` triggers the §5 conflict-resolution flow, which permits exactly one consistent pair (any Nature Portfolio venue + `--policy-anchor=nature`, sharing the canonical `shared/policy_data/nature_policy.md` source) and rejects every other combination with explicit error. See §5 for the full enumeration.
3. **Pipeline signal**: `slr_lineage=true|false` set by the upstream pipeline orchestrator when an SLR-mode stage appears in the run history. Drives the §4.3 G2 invariant track gate (concern #1 resolution: explicit `slr_lineage` input).
4. **Cold-start fallback**: when the renderer runs outside a pipeline (no `slr_lineage` set), the author supplies `mode=<value>` explicitly. Silent fallback to general track on missing input is **forbidden** by §4.3 G2 invariant.
5. **v3.2 Phase 2 AI usage categorization**: the same input shape v3.2 uses. Each AI usage category carries state ∈ `{USED, NOT USED, UNCERTAIN}`.
6. **Optional `ai_used: true | false`**: explicit author-supplied flag layered on top of category state. Composes with category state per §2 G10 7-row precedence table below.
7. **Tool identity per AI tool** (concern #2 resolution): auto-detected from session metadata (mirror v3.2 `disclosure_mode_protocol.md` Phase 4 detection); explicit fallback per tool when session metadata absent or non-Claude tools are involved.
---
## 2. Whole-disclosure decision — §3 G10 7-row precedence table
Rows are evaluated in priority order from top to bottom; the renderer emits the output of the **first** row whose precondition matches. Subsequent rows do not fire. This table is the §4.3 **G3 / G10 invariant** in load-bearing form.
| # | Precondition (first match wins) | Whole-disclosure output |
|---|---|---|
| 1 | `ai_used: false` supplied AND ≥1 v3.2 category is USED (contradiction) | Honest "AI-disclosure conflict — explicit no-AI input contradicts USED category" annotation; renderer prompts the user to reconcile before any anchor render emits. |
| 2 | `ai_used: false` supplied AND no v3.2 category is USED AND no v3.2 category is UNCERTAIN | "No AI was used in preparing this paper" statement (G10 opt-in path). |
| 3 | `ai_used: false` supplied AND ≥1 v3.2 category is UNCERTAIN AND no v3.2 category is USED | Honest "AI-disclosure tension — explicit no-AI input but categories still UNCERTAIN" annotation; renderer prompts the user to resolve UNCERTAIN before emitting the no-AI statement. |
| 4 | `ai_used: true` supplied OR ≥1 v3.2 category is USED, AND row 1 did not match | Full anchor-specific disclosure render per §3 below, applying the **concern #6 resolution** (USED facets at full strength; per-facet "AI disclosure pending — category {X} not confirmed; resolve via v3.2 Phase 2" annotation immediately after each still-UNCERTAIN facet's render slot). |
| 5 | ≥1 v3.2 category is UNCERTAIN AND no v3.2 category is USED AND no `ai_used` input | Honest "AI-disclosure status not supplied — categories pending confirmation" annotation; renderer prompts the user to run the v3.2 UNCERTAIN-confirmation flow before re-invoking. |
| 6 | ≥1 v3.2 category supplied AND every supplied category is NOT USED AND no UNCERTAIN AND no `ai_used` input | Silence — emit no AI-disclosure statement at all (G10 "silence is default-OK"). |
| 7 | None of rows 16 match (empty input across every dimension) | Honest "AI-disclosure status not supplied for this run" annotation (G3 D3 pure cold-start). |
**Concern #10 resolution (`ai_used: true` substantive-content gate):** when `ai_used: true` is supplied **and no v3.2 category is marked USED** (regardless of whether Phase 2 has been performed — i.e., this gate fires for both "bare-flag input only, no categorization" **and** "Phase 2 done but every category came back NOT USED while `ai_used:true` is still set"), row 4 does not emit a complete render. Instead, the input is treated as a **prompt-trigger** that halts the current run and asks the user to reconcile: either supply a USED category, change `ai_used` to false, or run / re-run v3.2 Phase 2 categorization. Once a USED category is supplied (or `ai_used` flipped), re-evaluate against the §2 table from the top. This implements the concern #10 resolved path: "force v3.2 categorization flow", consistent with concern #6's honest-signal-preservation discipline. The forbiddance: emitting a full anchor disclosure with `ai_used:true` and zero USED facets.
---
## 3. Per-anchor render flows
For each anchor, the renderer reads `policy_anchor_table.md` rows 1..16 and applies the field-level rules below. The 4 anchors share the row-4 dispatcher above; the per-anchor sections only describe **what to emit** after row 4 fires.
### 3.1 `--policy-anchor=prisma-trAIce`
**Track gate (G2 invariant):** this anchor is only valid when `slr_lineage=true` (pipeline signal) or `mode=systematic-review` (cold-start input). Selecting `--policy-anchor=prisma-trAIce` with `slr_lineage=false` is non-conformant — the renderer refuses with an explicit error citing the G2 invariant.
**G5 invariant — three gates for M6 prompt disclosure:** PRISMA-trAIce M6 prompt disclosure fires only when **all three** hold:
1. PRISMA-trAIce track selected (the gate above).
2. `tool_type ∈ {LLM, GenAI}` (per PRISMA M6 "if any" predicate).
3. AI use is methodological-in-SLR (search / screening / data extraction / Risk of Bias / synthesis / drafting per PRISMA M3.a), **not pure copyediting**.
When gate 3 fails (SLR manuscript whose only LLM use is editorial), the renderer routes to the §3.5 copyediting-carve-out path instead. PRISMA-trAIce track does not own the copyediting case.
**Render fields:**
- Per `policy_anchor_table.md` row 13: emit one **tool identity tuple** per AI tool (concern #2 resolution; auto-detect from session, fallback explicit). Identity tuple = (tool name, version if applicable, developer/provider).
- Row 45: emit one **(tool × task) record** per (tool, task) tuple across the M3.a 6-stage enum (concern #3 resolution). Missing tuples produce "not supplied" annotation per tuple. Per-task prompt disclosure follows M6.a granularity.
- Row 8: emit prompt records per (tool × task) tuple per gate G5.
- Row 9: emit human oversight description per M8 sub-items.
- Rows 1112: emit performance evaluation method + results per M9 + R2 when applicable.
- Row 13: emit limitations narrative per D1.
- Row 14: distribute disclosure fragments per Table 1 row groupings (Title / Abstract / Introduction / Methods / Results / Discussion). The renderer emits per-section fragments, not a single paragraph.
### 3.2 `--policy-anchor=icmje`
**Track gate:** no SLR lineage requirement. Available regardless of `slr_lineage` value.
**Render fields:**
- Row 10 (explicit-mandate): emit ICMJE human-responsibility statement using the verbatim "humans are responsible for any submitted material that included the use of AI-assisted technologies" language. This sentence appears in **every** ICMJE render where row 4 fires.
- Row 14 (explicit-recommend): emit two channels — cover letter (verbatim ICMJE language) + manuscript "appropriate section" (per the venue's submission policy). The two channels carry different paragraphs, not a single duplicated paragraph.
- Row 16 (explicit-mandate): emit the text-attribution clause: appropriate attribution and full citations for AI-quoted material + the prohibition against citing AI-generated material as primary source. Surface this paragraph even when no images are involved (rule covers text-attribution).
- Rows with `not-addressed` strength (9/16 cells): the renderer skips these by default; if v3.2 Phase 2 provides a category USED that would be served by such a field, surface a "ICMJE delegates this detail to journal-level policy; consider the v3.2 venue track for journal-specific phrasing" annotation.
### 3.3 `--policy-anchor=nature`
**Track gate:** no SLR lineage requirement.
**Dedup with v3.2 Nature venue path:** the Nature substantive policy text is co-cited from `shared/policy_data/nature_policy.md` (canonical source pointer). Both consumers — the v3.2 Nature venue renderer in `venue_disclosure_policies.md` and this Nature anchor renderer — derive their substantive content from that shared source. **G4 invariant**: edits to Nature-specific policy quotes must go through the shared source first, never directly into either consumer.
**Render fields:**
- Rows 910 (both explicit-mandate): emit the human-accountability statement using Nature's "In all cases, there must be human accountability for the final version of the text" verbatim language. Combine with the authorship-rejection clause from row 10's second quote when the manuscript proposes any AI co-authorship.
- Row 14 (explicit-recommend): emit Methods-section placement instruction citing the verbatim "Methods section (and if a Methods section is not available, in a suitable alternative part)" language.
- Row 15 (explicit-recommend) carve-out semantics: **eliminate** strength. Copyediting-only use produces **no disclosure paragraph**, only an internal renderer log entry. The §4.3 G7 invariant forbids collapsing this into a boolean shared with IEEE's downgrade semantics.
- Row 16 (explicit-mandate) image-rights handling: concern #5 resolution = **hybrid (annotation block + suggested inline patches)**. The renderer emits two outputs:
1. A standalone `image_disclosure_instructions.md` block describing, per image, which Nature carve-out applies (or default-deny) and what Nature label text appears in the image field.
2. A suggested patch diff against manuscript source figure metadata (caption, alt-text, image-field caption) for the author to optionally apply. **ARS does not modify manuscript source autonomously**; the patch is suggested-only.
### 3.4 `--policy-anchor=ieee`
**Track gate:** no SLR lineage requirement.
**G8 invariant — paired mandate:** IEEE row 5 (`Specific task within stage`, explicit-mandate) and row 6 (`Affected manuscript sections / content locator`, explicit-mandate) are a paired mandate. The renderer emits **both** inputs together. Emitting `level_of_involvement` (row 5 narrative annotation) without an `affected_sections` locator (row 6) — or vice versa — is **non-conformant**.
**Concern #4 resolution (IEEE section locator shape):** free-form list with recommended IMRaD exemplars. Accepted values: `"Introduction" | "Methods" | "Results" | "Discussion" | "Abstract" | "Title" | free-form-other`. Closed-enum would over-constrain IEEE's "brief explanation" language; free-form preserves the policy's narrative invitation. Test fixture covers both exemplar-match and free-form-other inputs.
**Render fields:**
- Row 1 (explicit-mandate): emit "The AI system used shall be identified" formulation — name + identifier per AI tool.
- Rows 56 (paired explicit-mandate per G8): emit `affected_sections` list + `level_of_involvement` narrative annotation **together**.
- Row 14 (explicit-mandate): emit placement instruction citing "acknowledgments section of any article submitted to an IEEE publication" verbatim. **No Methods placement, no cover-letter channel** — the tightest closed enum across all four anchors.
- Row 15 (explicit-recommend) carve-out semantics: **downgrade-not-eliminate** strength. Copyediting-only use still produces a disclosure paragraph at the same acknowledgments location, but with recommend-strength rather than mandate language. The §4.3 G7 invariant forbids collapsing this into a boolean shared with Nature's eliminate semantics.
- Row 16 (implicit): images/figures/code fold into the same acknowledgments-disclosure mandate as text. **No separate image-rights regime, no default-prohibition** (contrasts with Nature). The renderer emits a single acknowledgments paragraph covering all content types per IEEE policy. The §4.3 G9 invariant: each anchor's image-rights regime stays distinct; IEEE's fold-into-acknowledgments path does not merge with Nature's default-deny regime.
### 3.5 Copyediting carve-out cross-anchor handling
Per §4.3 G7 invariant + §4.5 / §4.6 cell #15, the copyediting carve-out semantics are anchor-specific:
- **Nature anchor**: eliminate strength — no disclosure paragraph for copyediting-only use.
- **IEEE anchor**: downgrade-not-eliminate — disclosure still emitted at acknowledgments, with recommend-strength language ("is recommended" rather than "shall be disclosed").
- **PRISMA-trAIce anchor**: not in scope (M6 G5 gate 3 routes copyediting cases away from PRISMA-trAIce track entirely; see §3.1).
- **ICMJE anchor**: not-addressed in the policy text; the renderer treats copyediting cases per the same path as substantive AI use (no carve-out, full ICMJE render).
The renderer MUST NOT collapse these four behaviors into a single boolean `exempted_uses: [copyediting]` field — doing so would silently lose the eliminate-vs-downgrade-vs-not-in-scope distinctions.
---
## 4. Auto-promotion forbiddance (§4.3 G3 / G10 invariant)
A still-UNCERTAIN category MUST NOT be rendered as though USED in any of the four anchor outputs. This is the load-bearing constraint behind concern #6's resolution: USED facets render at full strength; UNCERTAIN facets surface the per-facet annotation, never the full-strength render. The renderer MUST surface UNCERTAIN as UNCERTAIN throughout the disclosure text; silent promotion of any UNCERTAIN category to USED is **forbidden**.
The test suite covers this forbiddance with negative fixtures asserting that an input with `ai_used:true` + 1 UNCERTAIN category + 0 USED categories does NOT emit a full-strength render of the UNCERTAIN category.
---
## 5. Venue + anchor conflict resolution (concern #7)
When the user passes both `--venue=<v>` and `--policy-anchor=<a>`, the renderer evaluates whether the two map to compatible policies. The consistent-pair recognition for the Nature track covers the full Nature Portfolio venue family:
- **Consistent — Nature Portfolio venue + `--policy-anchor=nature`**: includes canonical labels `{"Nature", "Nature Portfolio", "Nature (Nature Publishing Group)", "Nature Publishing Group"}` **and** any venue whose name starts with the prefix `"Nature "` (e.g., `Nature Medicine`, `Nature Communications`, `Nature Climate Change`, `Nature Energy`, `Nature Methods`, ...). All Nature Portfolio journals inherit the same parent AI policy, so each of these venue strings + `--policy-anchor=nature` proceeds via the shared source pointer.
- **Conflicting — any other (venue, anchor) pair**: e.g., `--venue=Nature` + `--policy-anchor=IEEE`, `--venue=ICLR` + `--policy-anchor=icmje`, or a Nature Portfolio venue with a non-nature anchor → **reject with explicit error** listing the policy conflict and the canonical Nature consistent-pair definition above.
Silent precedence is **forbidden** (Decision Doc §4.4 #7 + concern #7 resolution). The renderer must surface the conflict to the user and require an explicit selector choice. The recognition logic above is mirrored by the conformance referee's `is_nature_portfolio_venue()` helper in `scripts/policy_anchor_disclosure_referee.py` — when this protocol text drifts from that helper, the alignment is a non-conformance.
---
## 6. Three-state input completeness flag (concern #8)
The §2 G10 7-row table evaluates first-match across (`ai_used` × per-category-state). Field-level computation:
- `ai_used``{true, false, unset}`.
- Each v3.2 category ∈ `{USED, NOT USED, UNCERTAIN, not-supplied}`.
- First-match-wins evaluation across §2's 7 rows.
- Row-4 partial-state composition per concern #6 resolution: USED at full strength; per-facet annotation for each still-UNCERTAIN; auto-promotion forbidden.
The §4.3 invariants constrain the evaluation:
- **G1 invariant** — no `ai_disclosure` field is read from / written to the corpus entry schema; all input flows through the renderer's runtime input contract.
- **G2 invariant** — track selection reads `slr_lineage` first, never derives SLR-status from `origin_mode` alone.
- **G3 / G10 invariant** — the 7-row table holds; auto-promotion forbidden.
- **G4 invariant** — 4 anchors, Nature dedup via shared source.
- **G5 invariant** — three gates for M6 prompts.
- **G7 invariant** — anchor-specific carve-out semantics.
- **G8 invariant** — IEEE paired-mandate (row 5 + row 6).
- **G9 invariant** — anchor-specific image-rights regimes.
---
## 7. §4.4 concern resolutions reference
For audit traceability, this protocol implements each Decision Doc §4.4 open concern as follows. See implementation spec §3 for full rationale.
- **concern #1** — Track-selection lookup mechanism resolved as: explicit `slr_lineage` input from pipeline orchestrator; cold-start `mode=` parameter.
- **concern #2** — Tool identity collection: auto-detect from session metadata (v3.2 Phase 4 pattern); explicit fallback per tool for cold-start or non-Claude pipelines.
- **concern #3** — Prompt scope: per-(tool × task) tuple across M3.a 6-stage enum; missing tuples emit "not supplied" annotation per tuple.
- **concern #4** — IEEE section locator: free-form list with recommended IMRaD exemplars; parallel to G8 `level_of_involvement` design.
- **concern #5** — Nature image metadata + labelling: hybrid output channel — standalone annotation block + suggested inline manuscript patches; ARS does not modify manuscript source autonomously.
- **concern #6** — UNCERTAIN per-facet finalization: USED facets render at full strength; UNCERTAIN facets surface per-facet annotation immediately after each render slot.
- **concern #7** — Venue + anchor conflict: reject with explicit error citing the policy conflict; silent precedence forbidden.
- **concern #8** — Three-state completeness flag: full spec encoded in §6 above.
- **concern #9** — Test set scope: covers §4.3 8 invariants + §4.4 #1#8 + #10 + #11; positive + negative fixture per resolved path / forbidden path.
- **concern #10** — `ai_used: true` substantive-content gate: bare-flag input treated as prompt-trigger forcing v3.2 categorization flow before any anchor render.
- **concern #11** — G1 invariant scope: §2.1 G1 Decision authoritative (no `ai_disclosure` field added to corpus entry schema); non-renderer code changes for §4.4 #1 pipeline-plumbing **permitted** (pipeline orchestrator setting `slr_lineage` is not corpus-schema mutation).
---
## 8. Related
- Decision Doc: `../docs/design/2026-05-14-ai-disclosure-schema-decision.md`
- Implementation spec: `../docs/design/2026-05-14-ai-disclosure-impl-spec.md`
- Anchor data table (consumer of this protocol): `policy_anchor_table.md`
- v3.2 disclosure mode protocol (parallel track): `disclosure_mode_protocol.md`
- v3.2 venue disclosure policies (Nature dedup peer): `venue_disclosure_policies.md`
- Shared Nature policy source (canonical pointer, forthcoming): `shared/policy_data/nature_policy.md`
- Lint contract: `../../scripts/check_policy_anchor_protocol.py`
- Conformance test suite: `../../scripts/test_policy_anchor_disclosure.py` (Task #7)
@@ -0,0 +1,157 @@
# Policy Anchor Table
**Status**: #108 implementation (parented to Decision Doc 20ed72d)
**Parent skill**: `academic-paper`
**Consumer**: `policy_anchor_disclosure_protocol.md` (the LLM-prose renderer reads this table at runtime when in `disclosure` mode with `--policy-anchor=<a>` selector).
**Provenance**: cells below carry verbatim policy quotes lifted from the discovery doc §4.3-4.6 source-of-truth (`docs/design/2026-05-13-ai-disclosure-schema-discovery.md`). Each anchor section records its snapshot id + truncated sha256 so the lint validator can confirm provenance integrity. Live URLs may drift after capture; the wayback snapshot is canonical.
**Lint contract**: `scripts/check_policy_anchor_table.py` enforces 4-anchor coverage, 16-field-per-anchor canonical order, source_strength enum membership, and verbatim-quote presence for mandate/recommend/conditional cells. Mutation tests live at `scripts/test_check_policy_anchor_table.py`.
**Nature ↔ v3.2 venue de-dup**: the Nature anchor below shares its substantive policy content with the v3.2 Nature venue renderer in `venue_disclosure_policies.md`. Both files cross-reference the canonical source pointer `shared/policy_data/nature_policy.md` so a future single-source-of-truth refactor can byte-compare imported substrings without breaking either consumer. The dedup pointer presence is lint-enforced via `verify_nature_dedup_with_venue` in `check_policy_anchor_table.py`.
**Forbidden by Decision Doc §4.3 (renderer must honour these):**
- G1 — no `ai_disclosure` field is added to the corpus entry schema; the renderer reads this table at runtime, no schema change.
- G9 — image-rights regimes stay anchor-specific; the renderer MUST NOT unify field #16 into a single boolean across the four tables.
- The shared `shared/policy_data/nature_policy.md` source MUST live outside any anchor-specific or venue-specific file so neither consumer can drift unilaterally.
---
## Anchor: prisma-trAIce
**Snapshot:** `prisma-trAIce:wayback=20260513075443` (sha256: f95fc59f…)
**Anchor caveat:** PRISMA-trAIce is a **pre-Delphi proposal** (Holst et al. 2025). It has not undergone formal consensus; no item carries `explicit-mandate` strength. Item-level directives use reporting-guideline verbs ("describe", "report", "include") classified here as `explicit-recommend` within the proposed framework. The framework itself is `conditional-mandate` on "AI tool used as methodological tool in an SLR" — outside that condition the framework does not apply.
**Track gate:** the renderer routes to this anchor only when the upstream pipeline (or cold-start `mode=` input) sets `slr_lineage=true`. See `policy_anchor_disclosure_protocol.md` §4.4 #1 resolution.
| # | Field | Source strength | Verbatim quote | Locator | Value type |
|---|---|---|---|---|---|
| 1 | AI tool name | explicit-recommend | "For each AI tool or system used: a. Specify the name, version number (if applicable), and developer/provider." | Table 1, M2.a | narrative |
| 2 | AI tool version | explicit-recommend | "Specify the name, version number (if applicable), and developer/provider." | Table 1, M2.a | narrative |
| 3 | AI tool developer / manufacturer | explicit-recommend | "Specify the name, version number (if applicable), and developer/provider." | Table 1, M2.a | narrative |
| 4 | Stage / phase of use | explicit-recommend | "For each AI tool, clearly describe: a. The specific SLR stage(s) where it was applied (e.g., search, screening, data extraction, Risk of Bias assessment, synthesis, drafting)." | Table 1, M3.a | narrative |
| 5 | Specific task within stage | explicit-recommend | "b. The precise task(s) the AI was intended to perform at each stage." | Table 1, M3.b | narrative |
| 6 | Affected manuscript sections / content locator | implicit | (inference passage) Items T1, A1, I1, R1, D1, D2 each prescribe a *manuscript section* for disclosure but the framework does not require an explicit per-task content-locator field. | Table 1 row headings | narrative |
| 7 | Date(s) of use | not-addressed | — | — | — |
| 8 | Prompts | explicit-recommend | "For each LLM/GenAI tool used, report: a. The full prompt(s) employed for each specific task. If prompts are extensive, provide a detailed description of their structure, key instructions, context provided..." | Table 1, M6.a | narrative |
| 9 | Human oversight method | explicit-recommend | "Describe the process of human interaction with and oversight of the AI tool(s) at each stage: a. How many reviewers interacted with/validated the AI outputs for each task? b. Did reviewers work independently when validating AI outputs?" | Table 1, M8.ag | narrative |
| 10 | Human responsibility statement | not-addressed | — | — | — |
| 11 | Performance evaluation method | explicit-recommend | "Describe methods used to evaluate the AI tool(s) performance for the specific tasks within the review (if applicable and feasible). This may include: a. The reference standard used for evaluation... b. The metrics used..." | Table 1, M9 | narrative |
| 12 | Performance evaluation results | explicit-recommend | "Report the results of any performance evaluations of the AI tool(s) for the specific tasks within the review (as described in P-trAIce M9). Include quantitative results (see M9) and measures of agreement between AI and human reviewers if assessed." | Table 1, R2 | narrative |
| 13 | Limitations / known failure modes | explicit-recommend | "Discuss any limitations encountered in using the AI tool(s) (eg, technical issues, biases identified, challenges in prompt engineering, unexpected outputs, limitations in AI performance for specific sub-tasks)." | Table 1, D1 | narrative |
| 14 | Disclosure location | implicit | (inference passage) Each Table 1 item is row-categorized by manuscript section (Title / Abstract / Introduction / Methods / Results / Discussion), implying section-of-record per item. | Table 1 row groupings | narrative |
| 15 | Copyediting exemption predicate | not-addressed | — | — | — |
| 16 | AI-generated image / figure / content rights | implicit | (inference passage) "Describe how data handled by AI tools (input, output, intermediate data) was managed and stored, and any measures taken to ensure data privacy, security, and compliance with copyright or terms of service, especially when using third-party cloud-based AI tools." M10 covers copyright/terms-of-service compliance for *data handled by AI tools* without dedicated AI-generated content rights field. | Table 1, M10 | narrative |
**Distribution:** 0 explicit-mandate / 10 explicit-recommend / 0 conditional-mandate / 3 implicit / 3 not-addressed / 0 unknown.
**Renderer rules (PRISMA-trAIce track):**
- Disclosure location: each Table 1 row's manuscript-section row label drives placement (Title / Abstract / Introduction / Methods / Results / Discussion). The renderer emits per-section disclosure fragments, not a single paragraph.
- Per-(tool × task) prompt scope: M6.a says verbatim "for each specific task" — the renderer produces one prompt record per (tool, task) tuple across M3.a's six SLR stages (search / screening / data extraction / Risk of Bias / synthesis / drafting). Missing tuples emit "not supplied" annotation per tuple, not per tool.
- PRISMA M6 prompt disclosure fires only when **all three** G5 gates hold: (i) PRISMA-trAIce track selected, (ii) `tool_type ∈ {LLM, GenAI}`, (iii) AI use is methodological-in-SLR (not pure copyediting). Gate (iii) fail routes to the G7 copyediting carve-out path.
---
## Anchor: icmje
**Snapshot:** `icmje:wayback=20260513075516` (sha256: 52f9e6bc…)
**Anchor caveat:** ICMJE Recommendations are **adopted** and used by 1000+ journals as baseline. Strength language is consistent ("should require" / "should describe" / "must ensure" / "is not acceptable"). The page on AI Use by Authors (§V.A) is two short paragraphs, deliberately framework-level. Many fields are `not-addressed` because ICMJE delegates section-level detail to individual journals.
| # | Field | Source strength | Verbatim quote | Locator | Value type |
|---|---|---|---|---|---|
| 1 | AI tool name | implicit | (inference passage) "the journal should require authors to disclose at submission whether they used AI-assisted technologies (such as LLMs, chatbots, or image creators)". Tool identity implied by "such technology... how they used it" but no field-level naming requirement. | §V.A, paragraph 1 | narrative |
| 2 | AI tool version | not-addressed | — | — | — |
| 3 | AI tool developer / manufacturer | not-addressed | — | — | — |
| 4 | Stage / phase of use | implicit | (inference passage) "Authors who use such technology should describe... how they used it". "How they used it" implies stage. | §V.A, paragraph 1 | narrative |
| 5 | Specific task within stage | implicit | (inference passage — same source as #4) "how they used it" implies task-level description without explicit field. | §V.A, paragraph 1 | narrative |
| 6 | Affected manuscript sections / content locator | not-addressed | — | — | — |
| 7 | Date(s) of use | not-addressed | — | — | — |
| 8 | Prompts | not-addressed | — | — | — |
| 9 | Human oversight method | explicit-recommend | "Authors should carefully review and edit the AI-generated content as the output can be incorrect, incomplete, or biased." | §V.A, paragraph 1 | narrative |
| 10 | Human responsibility statement | explicit-mandate | "Therefore, humans are responsible for any submitted material that included the use of AI-assisted technologies." | §V.A, paragraph 1 | narrative |
| 11 | Performance evaluation method | not-addressed | — | — | — |
| 12 | Performance evaluation results | not-addressed | — | — | — |
| 13 | Limitations / known failure modes | not-addressed | — | — | — |
| 14 | Disclosure location | explicit-recommend | "Authors who use such technology should describe, in both the cover letter and the submitted work in the appropriate section if applicable, how they used it" | §V.A, paragraph 1 | narrative |
| 15 | Copyediting exemption predicate | not-addressed | — | — | — |
| 16 | AI-generated image / figure / content rights | explicit-mandate | "Humans must ensure there is appropriate attribution of all quoted material, including full citations." + "Referencing AI-generated material as the primary source is not acceptable." | §V.A, paragraph 1 | narrative |
**Distribution:** 2 explicit-mandate / 2 explicit-recommend / 3 implicit / 9 not-addressed / 0 unknown.
**Renderer rules (ICMJE track):**
- Disclosure location: two-channel — cover letter (cite ICMJE language verbatim) + manuscript "appropriate section". Renderer emits separate paragraphs per channel, not a single duplicated paragraph.
- Field #10 mandate triggers a human-responsibility sentence using ICMJE's verbatim language. This sentence appears regardless of which other facets are USED, as long as at least one AI category is USED in the run.
- Field #16 mandate produces a paragraph noting (a) appropriate attribution + full citations for AI-quoted material, (b) AI-generated material may not be cited as primary source. The renderer surfaces this paragraph even when no images are involved (the rule covers text-attribution).
---
## Anchor: nature
**Snapshot:** `nature:wayback=20260513075542` (sha256: cf691cba…)
**Source-of-truth pointer:** see `shared/policy_data/nature_policy.md` for the canonical substantive policy text; the v3.2 venue renderer (`venue_disclosure_policies.md` Nature entry) shares this source. **De-dup invariant:** edits to Nature-specific policy quotes must go through `shared/policy_data/nature_policy.md` first; both consumers re-cite from there. Lint contract enforced by `verify_nature_dedup_with_venue` in `check_policy_anchor_table.py`.
**Anchor caveat:** Nature Portfolio AI editorial policy is **adopted** and applies across all Nature Portfolio journals. Author-facing surface covers four sections (AI authorship / Generative AI images / AI use by peer reviewers / Editorial use). ARS matrix covers author-side obligations only (AI authorship + Generative AI images). The policy is framework-level for text and prohibition-level for images (generative AI images banned by default with three carve-outs).
| # | Field | Source strength | Verbatim quote | Locator | Value type |
|---|---|---|---|---|---|
| 1 | AI tool name | implicit | (inference passage) "Use of an LLM should be properly documented in the Methods section (and if a Methods section is not available, in a suitable alternative part) of the manuscript." Documentation requirement implies tool identity disclosure. | §AI authorship | narrative |
| 2 | AI tool version | not-addressed | — | — | — |
| 3 | AI tool developer / manufacturer | not-addressed | — | — | — |
| 4 | Stage / phase of use | implicit | (inference passage) "Use of an LLM should be properly documented in the Methods section". "Use" implies stage/task description in Methods. | §AI authorship | narrative |
| 5 | Specific task within stage | implicit | (inference passage — same source as #4) "properly documented in the Methods section" implies task-level description without dedicated field. | §AI authorship | narrative |
| 6 | Affected manuscript sections / content locator | implicit | (inference passage) "in the relevant caption upon submission" for non-generative ML image tools; LLM text use implies Methods-section documentation. | §Generative AI images (caption rule); §AI authorship (Methods rule) | narrative |
| 7 | Date(s) of use | not-addressed | — | — | — |
| 8 | Prompts | not-addressed | — | — | — |
| 9 | Human oversight method | explicit-mandate | "In all cases, there must be human accountability for the final version of the text and agreement from the authors that the edits reflect their original work." | §AI authorship | narrative |
| 10 | Human responsibility statement | explicit-mandate | "an attribution of authorship carries with it accountability for the work, which cannot be effectively applied to LLMs" + "there must be human accountability for the final version of the text" | §AI authorship | narrative |
| 11 | Performance evaluation method | not-addressed | — | — | — |
| 12 | Performance evaluation results | not-addressed | — | — | — |
| 13 | Limitations / known failure modes | not-addressed | — | — | — |
| 14 | Disclosure location | explicit-recommend | "Use of an LLM should be properly documented in the Methods section (and if a Methods section is not available, in a suitable alternative part) of the manuscript." + (for non-generative ML on images) "should be disclosed in the relevant caption upon submission" | §AI authorship + §Generative AI images | narrative |
| 15 | Copyediting exemption predicate | explicit-recommend | "The use of an LLM (or other AI-tool) for \"AI assisted copy editing\" purposes does not need to be declared." (partial-verbatim predicate) "AI-assisted improvements to human-generated texts for readability and style... [but] do not include generative editorial work and autonomous content creation." | §AI authorship | narrative |
| 16 | AI-generated image / figure / content rights | explicit-mandate | "Springer Nature journals are unable to permit its use for publication." + "All exceptions must be labelled clearly as generated by AI within the image field." | §Generative AI images | narrative |
**Distribution:** 3 explicit-mandate / 2 explicit-recommend / 0 conditional-mandate / 4 implicit / 7 not-addressed / 0 unknown.
**Renderer rules (Nature track):**
- Disclosure location: Methods section by default (or "suitable alternative" if Methods is not present in the manuscript type). Renderer emits placement instruction citing the verbatim "Methods section (and if a Methods section is not available, in a suitable alternative part)" language.
- Field #15 carve-out semantics: **eliminate** strength — copyediting-only use produces **no disclosure paragraph**, only an internal log entry. Renderer MUST NOT collapse this into a boolean shared with IEEE's downgrade semantics (Decision Doc §4.3 G7 invariant).
- Field #16 mandate: image-rights handled via the §4.4 #5 hybrid output channel. Renderer emits (a) standalone `image_disclosure_instructions.md` block describing per-image carve-out classification and Nature label text; (b) a suggested patch diff against manuscript source figure metadata (caption / alt-text / image-field caption) for the author to optionally apply. ARS does not modify manuscript source autonomously.
---
## Anchor: ieee
**Snapshot:** `ieee:wayback=20260513075605` (sha256: 3ab8db50…)
**Anchor caveat:** IEEE's guideline page (April 16, 2024) is **adopted** and applies across IEEE publications. Substantive policy is **two short paragraphs** (~150 words combined). 3 stacked mandates within paragraph 1; 1 recommend (copyediting carve-out) within paragraph 2. Notable absences vs other anchors: **no explicit human-responsibility statement** (contrasts with ICMJE/Nature), **no human-oversight requirement** (contrasts with Nature), **no image-rights regime distinct from text** (images/figures/code fold into general acknowledgments-disclosure rule).
| # | Field | Source strength | Verbatim quote | Locator | Value type |
|---|---|---|---|---|---|
| 1 | AI tool name | explicit-mandate | "The AI system used shall be identified" | Paragraph 1, sentence 2 | narrative |
| 2 | AI tool version | not-addressed | — | — | — |
| 3 | AI tool developer / manufacturer | not-addressed | — | — | — |
| 4 | Stage / phase of use | implicit | (inference passage) "accompanied by a brief explanation regarding the level at which the AI system was used to generate the content". "Level" reads as degree-of-involvement rather than SLR-style stage enum. | Paragraph 1, sentence 2 | narrative |
| 5 | Specific task within stage | explicit-mandate | "specific sections of the article that use AI-generated content shall be identified and accompanied by a brief explanation regarding the level at which the AI system was used to generate the content" | Paragraph 1, sentence 2 | narrative |
| 6 | Affected manuscript sections / content locator | explicit-mandate | "specific sections of the article that use AI-generated content shall be identified" | Paragraph 1, sentence 2 | narrative |
| 7 | Date(s) of use | not-addressed | — | — | — |
| 8 | Prompts | not-addressed | — | — | — |
| 9 | Human oversight method | not-addressed | — | — | — |
| 10 | Human responsibility statement | not-addressed | — | — | — |
| 11 | Performance evaluation method | not-addressed | — | — | — |
| 12 | Performance evaluation results | not-addressed | — | — | — |
| 13 | Limitations / known failure modes | not-addressed | — | — | — |
| 14 | Disclosure location | explicit-mandate | "shall be disclosed in the acknowledgments section of any article submitted to an IEEE publication" | Paragraph 1, sentence 1 | narrative |
| 15 | Copyediting exemption predicate | explicit-recommend | "The use of AI systems for editing and grammar enhancement is common practice and, as such, is generally outside the intent of the above policy. In this case, disclosure as noted above is recommended." | Paragraph 2 | narrative |
| 16 | AI-generated image / figure / content rights | implicit | (inference passage) "The use of content generated by artificial intelligence (AI) in an article (including but not limited to text, figures, images, and code) shall be disclosed in the acknowledgments section" — images/figures/code fold into the same acknowledgments-disclosure mandate as text; no separate image-rights regime. | Paragraph 1, sentence 1 | narrative |
**Distribution:** 4 explicit-mandate / 1 explicit-recommend / 0 conditional-mandate / 2 implicit / 9 not-addressed / 0 unknown.
**Renderer rules (IEEE track):**
- Disclosure location: acknowledgments section only — the tightest closed enum across all four anchors. Renderer emits placement instruction citing the verbatim "acknowledgments section of any article submitted to an IEEE publication" language. **No Methods placement**, no cover-letter channel.
- Field #5 + field #6 are a paired mandate (Decision Doc §4.3 G8 invariant). The renderer emits **both** a per-section locator input (free-form list with recommended IMRaD exemplars `"Introduction" | "Methods" | "Results" | "Discussion" | "Abstract" | "Title" | free-form-other`) **and** a `level_of_involvement` narrative annotation (G8 design). Emitting one without the other is non-conformant.
- Field #15 carve-out semantics: **downgrade-not-eliminate** strength — copyediting-only use still produces a disclosure paragraph at the same acknowledgments location, but with recommend-strength rather than mandate language. Renderer MUST NOT collapse this into a boolean shared with Nature's eliminate semantics (Decision Doc §4.3 G7 invariant).
---
## Related
- Decision Doc (parent): `docs/design/2026-05-14-ai-disclosure-schema-decision.md`
- Implementation spec (parent): `docs/design/2026-05-14-ai-disclosure-impl-spec.md`
- Discovery doc (verbatim quote source-of-truth): `docs/design/2026-05-13-ai-disclosure-schema-discovery.md` §4.3-4.6
- Renderer protocol (consumer): `policy_anchor_disclosure_protocol.md` (forthcoming in this branch)
- v3.2 venue policy database (Nature de-dup peer): `venue_disclosure_policies.md`
- v3.2 disclosure mode protocol (extension target): `disclosure_mode_protocol.md`
- Shared Nature policy source (canonical pointer, forthcoming): `shared/policy_data/nature_policy.md`
@@ -44,6 +44,10 @@ If the venue is not listed here, the mode halts and asks the user to paste the c
## Venue: Nature (Nature Publishing Group)
**Policy-source dedup pointer:** Nature's substantive AI policy text is co-cited by the #108 policy-anchor renderer (`policy_anchor_table.md` Nature section, verbatim quotes per 16 fields). Both consumers reference the canonical source pointer `shared/policy_data/nature_policy.md` so a future single-source-of-truth refactor can extract Nature's policy text without breaking either consumer's substantive content. Dedup invariant lint: `verify_nature_dedup_with_venue` in `scripts/check_policy_anchor_table.py`.
**Derivation note (#108 scope limitation):** the venue-track summary fields below (Policy summary / Required phrasing elements / Preferred disclosure location / Prohibited uses / Authorship rule) **are derived** from `shared/policy_data/nature_policy.md` but are **not auto-generated from it** — the v3.2 venue path predates the canonical source and continues to drive runtime rendering off these summary rows. If Nature's source policy drifts, **the canonical source file MUST be updated first** (per the G4 invariant) and these summary rows **MUST be reviewed and updated in the same change**. A future refactor (out of #108 scope) can replace these summary rows with an extract from the canonical source so the dedup contract is auto-enforced; until then this section is a derived view that requires manual sync.
| Field | Value |
|---|---|
| Source URL | https://www.nature.com/nature/editorial-policies/ai |
@@ -0,0 +1,198 @@
# AI Disclosure — Implementation Spec
**Status:** READY (Decision Doc 11-round audit-trail-complete; this spec resolves §4.4 11 open concerns per user-decision protocol)
**Date:** 2026-05-14
**Issue:** [#108](https://github.com/Imbad0202/academic-research-skills/issues/108)
**Branch:** `feat/108-ai-disclosure-renderer`
**Author:** Cheng-I Wu
**Blocked-by:** Decision Doc `docs/design/2026-05-14-ai-disclosure-schema-decision.md` ([commit 20ed72d](https://github.com/Imbad0202/academic-research-skills/commit/20ed72d), PR #109, closed-by-merge)
**Scope:** Translate Decision Doc §4.1 implementation map + §4.3 invariants + §4.4 11 open concerns into an ARS-conventional implementation plan with file paths, test discipline, and decision provenance.
---
## 0. How to read this document
The Decision Doc decides **what** (G1G10 outcomes + 8 frozen invariants + 11 open concerns the implementation must resolve). This implementation spec decides **how** in ARS's actual deployment shape — protocol prose for LLM execution, anchor-data reference tables, lint validators (Python), and structured conformance fixtures (Python). There is no Python renderer module in ARS today; the v3.2 `disclosure` mode is LLM-orchestrated prose, and this implementation extends that pattern rather than introducing runtime renderer code.
For every §4.4 concern, this spec cites the resolution path (user-chosen or inline) and the rationale, so the implementation work is auditable downstream.
---
## 1. ARS-specific deployment shape (correcting "renderer" terminology)
The Decision Doc uses "renderer" as a layer-abstract term. In ARS the realisation of that layer is:
| Decision Doc term | ARS realisation | File type |
|---|---|---|
| renderer (LLM-executed) | Protocol prose the LLM reads at runtime when in `disclosure` mode | Markdown protocol doc under `academic-paper/references/` |
| anchor policy table | Structured reference table of verbatim policy quotes the LLM looks up | Markdown reference doc under `academic-paper/references/` |
| renderer tests | Static-content lint validators **plus** structured input/output conformance fixtures | Python `scripts/check_*.py` + `scripts/test_*.py` (unittest) |
| renderer input contract | Prompt-time inputs documented in the protocol doc + signal hooks (e.g., `slr_lineage`, `policy_anchor`, `venue`) the upstream pipeline sets | Documented in protocol doc; lint-validated against schema |
| de-dup mandate vs v3.2 Nature venue renderer | Single Nature policy table imported into both v3.2 venue path (existing `venue_disclosure_policies.md`) and new anchor path (new `policy_anchor_table.md`) | Cross-reference + lint guard preventing drift |
This terminology mapping is **load-bearing** for the test design: ARS lint validators check protocol prose against anchor-data tables; ARS conformance fixtures simulate LLM-input-shape and assert the protocol-encoded decision the LLM should produce per §3 G10 7-row table and §4.3 invariants.
---
## 2. File map (locks in scope discipline)
### 2.1 New files
| Path | Responsibility |
|---|---|
| `academic-paper/references/policy_anchor_table.md` | 4 anchor tables (PRISMA-trAIce / ICMJE / Nature / IEEE) × 16 fields, each cell carrying verbatim policy quote + snapshot ref mirroring discovery doc §3 provenance |
| `academic-paper/references/policy_anchor_disclosure_protocol.md` | LLM-execution prose: 4 anchor-conditioned render flows + lookup mechanism + §3 G10 7-row decision table + §4.3 invariants + §4.4 11 resolved concerns + auto-promotion forbiddance |
| `scripts/check_policy_anchor_table.py` | Static lint: each anchor has all 16 fields; each cell has verbatim policy quote + snapshot ref; Nature cell content equals the source-of-truth shared with v3.2 venue renderer (de-dup guard); `ai_used: true` substantive-content forbiddance encoded |
| `scripts/check_policy_anchor_protocol.py` | Static lint: protocol doc references each §4.3 invariant by name; §4.4 11 concerns each have a resolved-path section; 7-row precedence table preserved verbatim from Decision Doc §3 G10; auto-promote-UNCERTAIN forbiddance present |
| `scripts/test_policy_anchor_disclosure.py` | Conformance unit tests: each §4.3 invariant + each §4.4 #1#11 resolved path has at least one positive fixture; each forbidden path has at least one negative fixture that raises `AssertionError` or returns `non-conformant` decision |
| `scripts/test_check_policy_anchor_table.py` | Validator mutation tests: assert lint fails on each of (missing field cell / missing verbatim quote / missing snapshot ref / drift from Nature source of truth) |
| `scripts/test_check_policy_anchor_protocol.py` | Validator mutation tests: assert lint fails on each of (missing invariant name / missing §4.4 concern resolution / 7-row table drift / missing forbiddance clause) |
### 2.2 Modified files
| Path | Change |
|---|---|
| `academic-paper/references/disclosure_mode_protocol.md` | Phase 1 intake branches on `--venue` vs `--policy-anchor` selectors; Phase 3+4 delegates to `policy_anchor_disclosure_protocol.md` when anchor mode; venue+anchor conflict → reject (§4.4 #7); v3.2 venue lookup path unchanged |
| `academic-paper/SKILL.md` | `## v3.7.3 …` and `## v3.7.0 …` style block added: `## #108 Policy-Anchor Disclosure Renderer Protocol` describing protocol doc cross-reference + new lints + new test counts |
| `.github/workflows/spec-consistency.yml` | New steps invoking 3 new validators + 3 new test files |
| `.claude/CLAUDE.md` | Add #108 entry under existing "v3.7.3 / v3.7.0" block in the suite-level section: "AI-disclosure renderer protocol (no schema change; renderer is LLM-prose; 4 policy anchors)" |
| `CHANGELOG.md` | New `[Unreleased]` bullet documenting (a) no migration needed (G1+G6), (b) added files list, (c) cross-ref Decision Doc + this spec |
### 2.3 Files explicitly NOT changed (Decision Doc §4.1 items 15)
| Path | Decision Doc citation |
|---|---|
| `shared/contracts/passport/literature_corpus_entry.schema.json` | §4.1 #1 — G1 no-add |
| `shared/handoff_schemas.md` | §4.1 #2 — Schema 9 fields used as-is |
| `deep-research/agents/bibliography_agent.md` | §4.1 #3 — no entry-level disclosure to emit |
| `academic-paper/agents/literature_strategist_agent.md` | §4.1 #3 — no entry-level disclosure to emit |
| `scripts/check_ai_disclosure_schema.py` | §4.1 #4 — not created (no schema to validate) |
| Any migration tooling | §4.1 #5 — no migration needed |
---
## 3. §4.4 11 open concerns — resolved paths
Each row cites whether the resolution is **user-chosen** (decided via AskUserQuestion in this implementation session) or **inline** (decided by this spec author using standard engineering judgement bounded by the Decision Doc's stated forbiddances).
| # | Concern | Resolution | Source | Rationale |
|---|---|---|---|---|
| 1 | Track-selection lookup mechanism | **(b) explicit `slr_lineage` input** | User-chosen | Simplest + most testable. Pipeline orchestrator sets `slr_lineage=true|false` based on whether any SLR-mode stage appears in the run history; renderer reads the flag without chasing `upstream_dependencies`. Cold-start manual invocation requires explicit `mode=` parameter (silent fallback to general track forbidden per §4.3 G2 invariant). |
| 2 | Tool identity collection path | Auto-detect from session metadata (mirror v3.2 Phase 4); explicit fallback per tool when session metadata absent | Inline | v3.2 `disclosure_mode_protocol.md` already auto-detects Claude model version per Phase 4 note; reuse the same pattern. When the pipeline log is absent (cold-start) or the AI tool is non-Claude (cross-tool pipelines), prompt user per tool. |
| 3 | Per-(tool × task) prompt-scope record format | Per-(tool × task) tuple: each PRISMA-trAIce stage (search / screening / data extraction / Risk of Bias / synthesis / drafting) × each tool gets its own prompt record; missing tuples emit "not supplied" annotation per tuple, not per tool | Inline | PRISMA M6.a says verbatim "for each specific task"; per-tool aggregation would lose the per-task granularity the framework mandates. Test fixture covers the (tool × task) → annotation mapping. |
| 4 | IEEE section locator shape | Free-form list with recommended IMRaD exemplars (parallel to G8 `level_of_involvement` design) | Inline | IEEE #6 verbatim language ("specific sections of the article that use AI-generated content shall be identified") invites narrative; a closed enum over-constrains. Recommended exemplars (`"Introduction" | "Methods" | "Results" | "Discussion" | "Abstract" | "Title" | other-free-form`) provide guidance without locking. Test fixture covers both exemplar-match and free-form-text inputs. Pairing with G8 `level_of_involvement` enforced per §4.3 G8 invariant. |
| 5 | Nature image metadata + labelling channel | **Hybrid (annotation block + suggested inline patches)** | User-chosen | Renderer emits two outputs: (a) standalone `image_disclosure_instructions.md` block — anchor-specific, per-image, with carve-out classification and Nature label text; (b) a suggested patch diff against manuscript source figure metadata for the author to optionally apply. Implementation note: the patch diff is **suggested-only**; ARS does not modify manuscript source autonomously. Test fixture covers both outputs are present in Nature anchor renders. |
| 6 | UNCERTAIN per-facet finalization rule | **(b) USED facets full + UNCERTAIN per-facet annotation** | User-chosen | Row 4 of §3 G10 table emits the full anchor-specific disclosure; USED categories render at full strength; each still-UNCERTAIN category surfaces a per-facet `"AI disclosure pending — category {X} not confirmed; resolve via v3.2 Phase 2"` annotation immediately after the facet's render slot. **Forbidden** (Decision Doc invariant): rendering a still-UNCERTAIN category as though USED in any anchor output. |
| 7 | Venue + anchor conflict resolution | **Reject conflicting selectors with error** | Inline | Most transparent path. When `--venue=<v>` and `--policy-anchor=<a>` both supplied and the two map to incompatible policies (e.g., `--venue=Nature` selects v3.2 Methods placement vs `--policy-anchor=IEEE` selects acknowledgments-only placement), renderer refuses with explicit error message listing the policy conflict. Per Decision Doc §4.4 #7: silent precedence is forbidden. Test fixture covers conflict-rejection + consistent-pair-pass. |
| 8 | Three-state input completeness flag full spec | Computed from `ai_used` input + each v3.2 category state per row 17 preconditions; partial-state composition per #6 (b) | Inline | Field-level computation: (a) `ai_used` ∈ {true, false, unset}; (b) each v3.2 category ∈ {USED, NOT USED, UNCERTAIN, not-supplied}; (c) first-match-wins evaluation across §3 G10 7 rows; (d) row-4 partial-state per #6 (b). Test fixture covers all (ai_used × category-state) combinations distinguishing rows. |
| 9 | Test set scope | Cover §4.3 8 invariants + §4.4 #1#8 + #10 + #11 each positive + negative path | Inline | Test count: ≥ 8 invariant tests + 11 concern tests × {positive, negative} = ≥ 30 conformance tests. Per concern: each acceptable implementation path needs a positive test demonstrating it works; each forbidden path needs a negative test demonstrating it fails. Plus validator mutation tests per `test_check_*.py`. |
| 10 | `ai_used: true` substantive-content gate | **(a) Force v3.2 categorization flow** | User-chosen | bare `ai_used: true` (no USED category supplied) treated as prompt-trigger: renderer halts current run with a prompt asking user to complete v3.2 Phase 2 categorization before any anchor render emits. Once categories supplied, re-evaluate against §3 G10 7-row table. Test fixture covers bare-flag halt + post-categorization re-evaluation. Consistent with #6 (b): both choices preserve "honest signal over silent guess" as the design principle. |
| 11 | G1 invariant scope narrowing | §2.1 G1 Decision authoritative ("no `ai_disclosure` field added to corpus entry schema"); non-renderer code changes needed for §4.4 #1 (e.g., pipeline orchestrator setting `slr_lineage`) **permitted** | Inline (per Decision Doc §4.4 #11 last paragraph) | Decision Doc §4.4 explicitly says "implementation MUST treat the §2.1 G1 Decision (no schema field) as authoritative; the §4.3 invariant phrasing is to be corrected to 'data layer is untouched; non-renderer code changes needed for §4.4 concerns are permitted'". This spec adopts the corrected phrasing; the `policy_anchor_disclosure_protocol.md` cross-references this clarification. |
---
## 4. Test discipline (TDD)
Per `superpowers:test-driven-development`, every protocol-encoded behaviour and every validator rule MUST have a failing test first.
### 4.1 Per-anchor verbatim quote tests
For each of (PRISMA-trAIce × 16 fields) + (ICMJE × 16 fields) + (Nature × 16 fields) + (IEEE × 16 fields) = 64 cells:
```
RED: write test asserting cell N contains the verbatim policy quote from discovery §3 snapshot
VERIFY RED: fail (cell empty)
GREEN: populate cell with the verbatim quote
VERIFY GREEN: pass
```
### 4.2 Per-invariant conformance tests (§4.3 G1G9)
For each of 8 invariants (G1, G2, G3+G10 composite, G4, G5, G7, G8, G9):
```
RED: write fixture asserting the protocol rejects a forbidden case + accepts an acceptable case
VERIFY RED: fail (protocol doc has not been written; lint fires on missing clause)
GREEN: encode the protocol clause + lint rule
VERIFY GREEN: pass
```
### 4.3 Per-concern resolved-path tests (§4.4 #1#8 + #10 + #11)
For each concern, both positive and negative paths must have a fixture (per row 9 above).
### 4.4 De-dup guard test (Nature anchor ↔ v3.2 Nature venue)
```
RED: write fixture asserting policy_anchor_table.md Nature cells and venue_disclosure_policies.md Nature cells are byte-equivalent (single source of truth)
VERIFY RED: fail (no shared content yet)
GREEN: extract Nature policy content to shared/policy_data/nature_policy.md, both consumers import from there
VERIFY GREEN: pass
```
### 4.5 967-test baseline regression test
Per #108 acceptance #6: full `spec-consistency.yml` workflow steps run locally and produce **no new failures** versus the v3.7.3 baseline (967 pass / 3 skipped / 0 failed). New tests are additive.
---
## 5. Implementation order (locked)
The order minimises cascade-fix risk per `feedback_inventory_pattern_for_codex_fix_cascade.md`: define data first (anchor table = source of truth), then protocol consuming the data, then extension wiring, then tests covering both data and protocol.
1. **`policy_anchor_table.md`** + `check_policy_anchor_table.py` + `test_check_policy_anchor_table.py` (Task #4) — TDD: write lint validator + mutation tests first, watch fail; populate table to pass.
2. **`policy_anchor_disclosure_protocol.md`** + `check_policy_anchor_protocol.py` + `test_check_policy_anchor_protocol.py` (Task #5) — TDD: write lint validator + mutation tests first, watch fail; populate protocol to pass.
3. **Extend `disclosure_mode_protocol.md`** (Task #6) — TDD: extend `check_policy_anchor_protocol.py` to also assert v3.2 extension points; modify v3.2 doc; verify lint passes.
4. **Conformance fixtures `test_policy_anchor_disclosure.py`** (Task #7) — TDD per §4 above for invariants + concerns.
5. **Wire CI** (Task #8) — add validator + test steps to `spec-consistency.yml`.
6. **Regression baseline** (Task #9) — run full suite; confirm 967 + new tests; 0 failures.
7. **Codex review** (Task #10) — ≥ 3 rounds → 0 P1/P2.
8. **CHANGELOG + PII scan + simplify + PR** (Tasks #11#14).
---
## 6. Test count expectation (for #108 acceptance criterion #6 framing)
| Test surface | Count (lower bound) |
|---|---|
| Anchor table content tests (cell verbatim + snapshot ref) | 64 cells × 2 assertions = 128 |
| Validator mutation tests (`test_check_policy_anchor_table.py`) | 8 negative paths × 1 assertion = 8 |
| Validator mutation tests (`test_check_policy_anchor_protocol.py`) | 11 negative paths × 1 assertion = 11 |
| Invariant conformance tests (G1G9) | 8 invariants × {positive, forbidden} = 16 |
| Concern conformance tests (#1#8, #10, #11) | 10 concerns × {positive, negative} = 20 |
| De-dup guard test (Nature anchor ↔ venue) | 1 |
| **Total new tests (lower bound)** | **184** |
Final regression target: 967 baseline + ~184 new = ≥ 1151 pass / 3 skipped / 0 failed. The "lower bound" qualifier is intentional — implementation may surface additional edge cases warranting extra tests; the §4.3 invariant set bounds the floor.
---
## 7. Codex review expectation
Per Decision Doc §5 closure note: implementation is code + doc, not pure cross-reference; expected to converge faster than the Decision Doc's 11-round high-water mark. Target: ≥ 3 rounds → 0 P1/P2. Trigger conditions for surfacing architectural inflection to user (per `feedback_lint_cap_rule_when_lexical_enumeration_loops` and `feedback_llm_defect_class_problems_may_have_no_current_fix`):
- R5+ still produces 3+ P2 with no net downward trend across rounds R3 → R5 → R7 → ...
- Each fix at one section surfaces a stale reference / new conflict at another (whack-a-mole pattern).
Hitting either trigger ⇒ stop and ask user whether to accept audit-trail-complete framing (like Decision Doc R11) or push further.
---
## 8. Public-repo boundary discipline
This is a public repository. Pre-push gates (per `~/.claude/CLAUDE.md` + `feedback_ars_public_repo_boundary.md`):
1. `grep -rn` for the private-org and private-project tokens enumerated in `~/.claude/personal-boundary/deny_list.yaml` — block on hit (Springer is **legal** anchor scope per Decision Doc §4.5 / §4.6).
2. `python3 ~/.claude/personal-boundary/check_boundary.py --root . --quiet` — block on hit.
3. `/codex review` + `/security-review` parallel: both must produce 0 P1/P2 before merge.
---
## 9. Related
- Decision Doc (this spec's parent): `docs/design/2026-05-14-ai-disclosure-schema-decision.md`
- Discovery doc (provenance for anchor verbatim quotes): `docs/design/2026-05-13-ai-disclosure-schema-discovery.md`
- v3.2 disclosure mode protocol (the file this spec extends): `academic-paper/references/disclosure_mode_protocol.md`
- v3.2 venue disclosure policies database (source of truth Nature must de-dup against): `academic-paper/references/venue_disclosure_policies.md`
- v3.7.3 trust-chain infrastructure (not invoked, but Dimension A5 reservation noted in Decision Doc §1.3): `docs/design/2026-05-12-ars-v3.7.3-claim-faithfulness-and-contaminated-source-spec.md`
- #108 (implementation issue, this spec produced under)
+159
View File
@@ -0,0 +1,159 @@
#!/usr/bin/env python3
"""#108 policy_anchor_disclosure_protocol.md static lint.
Decision Doc reference:
docs/design/2026-05-14-ai-disclosure-schema-decision.md §4.1 (item 6) +
§4.3 invariants + §4.4 11 open concerns + §3 G10 7-row precedence table.
Implementation spec reference:
docs/design/2026-05-14-ai-disclosure-impl-spec.md §3 (resolved-paths table).
This lint enforces presence-of-required-content invariants on the protocol
doc the LLM reads at runtime when in `disclosure` mode with
`--policy-anchor=<a>` selector.
Checks
------
1. All 8 §4.3 invariants are named verbatim (G1 / G2 / G3 / G10 / G4 / G5 /
G7 / G8 / G9 G3 and G10 combined into a single composite invariant).
2. All 11 §4.4 concerns have a resolution clause referenced by number
(`concern #1` through `concern #11`).
3. The §3 G10 7-row precedence table is present with all 7 rows
(numbered 1..7).
4. The auto-promotion forbiddance clause is present (G3/G10 invariant
load-bearing constraint).
5. The 4 canonical anchor slugs are enumerated in the lookup mechanism
section.
6. The Nature v3.2 venue dedup pointer
(`shared/policy_data/nature_policy.md`) is referenced.
Exit codes
----------
0 - all checks pass
1 - one or more violations
2 - invocation error
Usage
-----
python scripts/check_policy_anchor_protocol.py academic-paper/references/policy_anchor_disclosure_protocol.md
"""
from __future__ import annotations
import argparse
import re
import sys
from pathlib import Path
REQUIRED_INVARIANTS = (
"G1 invariant",
"G2 invariant",
"G3 / G10 invariant",
"G4 invariant",
"G5 invariant",
"G7 invariant",
"G8 invariant",
"G9 invariant",
)
REQUIRED_CONCERN_NUMBERS = tuple(range(1, 12))
REQUIRED_CONCERNS = tuple(f"concern #{i}" for i in REQUIRED_CONCERN_NUMBERS)
REQUIRED_ANCHOR_SLUGS = ("prisma-trAIce", "icmje", "nature", "ieee")
DEDUP_POINTER = "shared/policy_data/nature_policy.md"
# The auto-promotion clause is the load-bearing G3/G10 invariant; both
# tokens MUST be present so a partial deletion (heading word but not the
# prohibition sentence, or vice versa) cannot silently satisfy the lint.
AUTO_PROMOTION_REQUIRED_TOKENS = (
"auto-promotion",
"MUST NOT be rendered as though USED",
)
CONCERN_PATTERN = re.compile(r"concern\s+#(\d+)\b")
TABLE_ROW_PATTERN = re.compile(r"^\s*\|\s*(\d+)\s*\|", re.MULTILINE)
ANCHOR_INVENTORY_PATTERN = re.compile(
r"^\*\*Anchor inventory\*\*:\s*`([^`]+)`", re.MULTILINE
)
def lint_text(text: str) -> list[str]:
violations: list[str] = []
for inv in REQUIRED_INVARIANTS:
if inv not in text:
violations.append(f"missing required invariant reference: {inv}")
# Word-boundary match prevents `concern #1` from matching against
# `concern #10` / `concern #11`.
found_concern_numbers = {int(m.group(1)) for m in CONCERN_PATTERN.finditer(text)}
for n in REQUIRED_CONCERN_NUMBERS:
if n not in found_concern_numbers:
violations.append(f"missing §4.4 concern #{n} resolution clause")
# Match real markdown table rows (`| N |` at line start); a prose
# `row 4` mention elsewhere in the doc does not satisfy the check.
found_rows = {int(m.group(1)) for m in TABLE_ROW_PATTERN.finditer(text)}
for row in range(1, 8):
if row not in found_rows:
violations.append(
f"missing G10 7-row precedence row {row} (no `| {row} |` markdown row found)"
)
for token in AUTO_PROMOTION_REQUIRED_TOKENS:
if token not in text:
violations.append(
"missing auto-promotion forbiddance clause "
f"(G3/G10 UNCERTAIN-not-USED invariant); token missing: '{token}'"
)
inventory_line = ANCHOR_INVENTORY_PATTERN.search(text)
if not inventory_line:
violations.append("missing `**Anchor inventory**: ...` line in protocol doc")
else:
inventory_slugs = {
s.strip() for s in inventory_line.group(1).split(",") if s.strip()
}
canonical = set(REQUIRED_ANCHOR_SLUGS)
missing = canonical - inventory_slugs
extra = inventory_slugs - canonical
for slug in sorted(missing):
violations.append(
f"missing canonical anchor slug from inventory: {slug} "
f"(found inventory: {sorted(inventory_slugs)})"
)
for slug in sorted(extra):
violations.append(
f"unexpected anchor slug in inventory: {slug} (closed enum is "
f"{sorted(canonical)}; remove it from the inventory line or "
"expand the canonical enum first)"
)
if DEDUP_POINTER not in text:
violations.append(
f"missing Nature ↔ v3.2 venue dedup pointer: {DEDUP_POINTER}"
)
return violations
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"path",
nargs="?",
default="academic-paper/references/policy_anchor_disclosure_protocol.md",
help="path to policy_anchor_disclosure_protocol.md (default: %(default)s)",
)
args = parser.parse_args(argv)
target = Path(args.path)
if not target.exists():
print(f"error: file not found: {target}", file=sys.stderr)
return 2
text = target.read_text(encoding="utf-8")
violations = lint_text(text)
if violations:
for v in violations:
print(f"{target}: {v}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
+286
View File
@@ -0,0 +1,286 @@
#!/usr/bin/env python3
"""#108 policy_anchor_table.md static lint.
Decision Doc reference:
docs/design/2026-05-14-ai-disclosure-schema-decision.md §4.1 (item 6a)
+ §4.3 invariants.
Implementation spec reference:
docs/design/2026-05-14-ai-disclosure-impl-spec.md §2.1 (file map) + §4
(TDD test discipline) + §4.4 (Nature v3.2 venue de-dup guard).
This lint enforces structural invariants on the anchor data table the
source-of-truth reference the LLM-prose renderer (policy_anchor_disclosure_
protocol.md) reads at runtime to resolve `--policy-anchor=<a>` selectors.
Checks
------
1. Exactly four anchor sections present, slugs {prisma-trAIce, icmje,
nature, ieee}.
2. Each anchor section carries a Snapshot ref line of the form
"**Snapshot:** `<slug>:wayback=<id>` …".
3. Each anchor table has 16 rows numbered 1..16 in canonical order.
4. Each row's source_strength cell ∈
{explicit-mandate, explicit-recommend, conditional-mandate, implicit,
not-addressed, unknown}.
5. Each mandate / recommend / conditional cell carries a verbatim quote
(double-quoted text not equal to a placeholder elision marker).
6. Helper hook `verify_nature_dedup_with_venue` exposed for the de-dup
guard between the new anchor table and the v3.2
venue_disclosure_policies.md Nature entry.
Exit codes
----------
0 - all checks pass
1 - one or more violations
2 - invocation error
Usage
-----
python scripts/check_policy_anchor_table.py academic-paper/references/policy_anchor_table.md
"""
from __future__ import annotations
import argparse
import re
import sys
from pathlib import Path
CANONICAL_ANCHOR_SLUGS = ("prisma-trAIce", "icmje", "nature", "ieee")
CANONICAL_FIELD_COUNT = 16
VALID_STRENGTHS = {
"explicit-mandate",
"explicit-recommend",
"conditional-mandate",
"implicit",
"not-addressed",
"unknown",
}
STRENGTHS_REQUIRING_QUOTE = {
"explicit-mandate",
"explicit-recommend",
"conditional-mandate",
}
# Capture any non-empty trailing identifier so invalid anchor headings
# (underscore, space, punctuation) surface as "unknown anchor section"
# rather than being silently folded into the previous section.
ANCHOR_HEADING = re.compile(r"^##\s+Anchor:\s+(\S.*?)\s*$", re.MULTILINE)
NATURE_ANCHOR_SECTION = re.compile(
r"^##\s+Anchor:\s+nature\s*$(.*?)(?=^##\s+Anchor:|\Z)",
flags=re.MULTILINE | re.DOTALL,
)
NATURE_VENUE_SECTION = re.compile(
r"^##\s+Venue:\s+Nature[^\n]*$(.*?)(?=^##\s+Venue:|\Z)",
flags=re.MULTILINE | re.DOTALL,
)
SNAPSHOT_LINE = re.compile(
r"\*\*Snapshot:\*\*\s+`([A-Za-z0-9-]+):wayback=([0-9]+)`"
)
ROW_LINE = re.compile(
r"^\|\s*(\d+)\s*\|\s*([^|]+?)\s*\|\s*([A-Za-z-]+)\s*\|\s*(.+?)\s*\|\s*([^|]+?)\s*\|\s*([^|]+?)\s*\|"
)
ELISION_MARKERS = {"(quote elided)", "(snapshot deleted)", ""}
REPO_ROOT = Path(__file__).resolve().parent.parent
NATURE_POLICY_POINTER = "shared/policy_data/nature_policy.md"
def _split_anchor_sections(text: str) -> tuple[dict[str, str], list[str]]:
"""Split the doc into per-anchor sections keyed by slug. Returns
(sections, duplicates) so the caller can flag duplicated headings
a plain dict overwrite would silently drop them."""
sections: dict[str, str] = {}
duplicates: list[str] = []
seen: set[str] = set()
matches = list(ANCHOR_HEADING.finditer(text))
for idx, match in enumerate(matches):
slug = match.group(1)
start = match.end()
end = matches[idx + 1].start() if idx + 1 < len(matches) else len(text)
if slug in seen:
duplicates.append(slug)
seen.add(slug)
sections[slug] = text[start:end]
return sections, duplicates
def _parse_rows(section: str) -> list[tuple[int, str, str, str, str, str]]:
"""Return parsed (n, field_name, strength, quote_cell, locator, value_type)
tuples from a single anchor's body."""
rows: list[tuple[int, str, str, str, str, str]] = []
for line in section.split("\n"):
m = ROW_LINE.match(line)
if not m:
continue
try:
n = int(m.group(1))
except ValueError:
continue
rows.append(
(
n,
m.group(2).strip(),
m.group(3).strip(),
m.group(4).strip(),
m.group(5).strip(),
m.group(6).strip(),
)
)
return rows
def _has_verbatim_quote(quote_cell: str) -> bool:
"""A verbatim quote cell starts with `\"` (or `(inference passage)` for
implicit cells handled separately)."""
stripped = quote_cell.strip()
if stripped in ELISION_MARKERS:
return False
if not stripped:
return False
# Accept escaped double-quote (`\\"`) in addition to plain `"`
return stripped.startswith('"') or stripped.startswith('\\"')
def lint_text(text: str) -> list[str]:
"""Run all structural checks; return violation messages list (empty=pass)."""
violations: list[str] = []
sections, duplicates = _split_anchor_sections(text)
# Check 1: anchor slug coverage
found_slugs = set(sections.keys())
canonical = set(CANONICAL_ANCHOR_SLUGS)
missing = canonical - found_slugs
extra = found_slugs - canonical
for slug in sorted(missing):
violations.append(f"missing anchor section: {slug}")
for slug in sorted(extra):
violations.append(f"unknown anchor section: {slug}")
for slug in duplicates:
violations.append(
f"duplicate anchor section: {slug} appears more than once "
"(second occurrence silently overwrites the first)"
)
# Check 2 + 3 + 4 + 5: per-anchor structure
for slug in CANONICAL_ANCHOR_SLUGS:
body = sections.get(slug)
if body is None:
continue
snapshot_match = SNAPSHOT_LINE.search(body)
if not snapshot_match:
violations.append(f"anchor {slug}: snapshot ref line missing or malformed")
elif snapshot_match.group(1) != slug:
violations.append(
f"anchor {slug}: snapshot ref slug mismatch ({snapshot_match.group(1)})"
)
rows = _parse_rows(body)
if len(rows) != CANONICAL_FIELD_COUNT:
violations.append(
f"anchor {slug}: expected {CANONICAL_FIELD_COUNT} field rows, got {len(rows)}"
)
seen_numbers: list[int] = []
for n, field_name, strength, quote_cell, _locator, _vtype in rows:
seen_numbers.append(n)
if strength not in VALID_STRENGTHS:
violations.append(
f"anchor {slug} row {n} ({field_name}): unknown source_strength '{strength}'"
)
if strength in STRENGTHS_REQUIRING_QUOTE and not _has_verbatim_quote(quote_cell):
violations.append(
f"anchor {slug} row {n} ({field_name}): {strength} cell lacks verbatim quote"
)
expected_numbers = list(range(1, CANONICAL_FIELD_COUNT + 1))
if seen_numbers != expected_numbers[: len(seen_numbers)]:
violations.append(
f"anchor {slug}: field rows not in canonical 1..16 order (saw {seen_numbers})"
)
return violations
def verify_nature_dedup_with_venue(
anchor_table_path: Path,
venue_policies_path: Path,
*,
anchor_text: str | None = None,
venue_text: str | None = None,
repo_root: Path | None = None,
) -> list[str]:
"""Confirm both Nature consumers cite shared/policy_data/nature_policy.md
inside their Nature-specific section AND the canonical source file
exists. The pointer must live inside the Nature section of each
consumer a citation list at the file bottom would otherwise satisfy
a plain substring match without representing the dedup invariant."""
violations: list[str] = []
if not anchor_table_path.exists():
return [f"anchor table file not found: {anchor_table_path}"]
if not venue_policies_path.exists():
return [f"venue policies file not found: {venue_policies_path}"]
root = repo_root if repo_root is not None else REPO_ROOT
canonical_source_path = root / NATURE_POLICY_POINTER
if not canonical_source_path.exists():
violations.append(
f"canonical Nature policy source file missing: "
f"{canonical_source_path}. Both consumers cite the path but the "
"source file does not exist; either create it or remove the pointers."
)
if anchor_text is None:
anchor_text = anchor_table_path.read_text(encoding="utf-8")
if venue_text is None:
venue_text = venue_policies_path.read_text(encoding="utf-8")
nature_section = NATURE_ANCHOR_SECTION.search(anchor_text)
if not nature_section:
violations.append("anchor table missing `## Anchor: nature` section")
elif NATURE_POLICY_POINTER not in nature_section.group(1):
violations.append(
f"anchor table Nature section missing dedup pointer to {NATURE_POLICY_POINTER}"
)
nature_venue_section = NATURE_VENUE_SECTION.search(venue_text)
if not nature_venue_section:
violations.append("venue policies missing `## Venue: Nature ...` section")
elif NATURE_POLICY_POINTER not in nature_venue_section.group(1):
violations.append(
f"venue policies Nature section missing dedup pointer to "
f"{NATURE_POLICY_POINTER}"
)
return violations
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"path",
nargs="?",
default="academic-paper/references/policy_anchor_table.md",
help="path to policy_anchor_table.md (default: %(default)s)",
)
parser.add_argument(
"--venue-policies",
default="academic-paper/references/venue_disclosure_policies.md",
help="path to venue_disclosure_policies.md for the Nature dedup guard",
)
args = parser.parse_args(argv)
target = Path(args.path)
venue_path = Path(args.venue_policies)
if not target.exists():
print(f"error: file not found: {target}", file=sys.stderr)
return 2
text = target.read_text(encoding="utf-8")
violations = lint_text(text)
# Derive repo root from the anchor table path so tests using temp
# subtree mirrors also exercise the canonical-source existence check.
derived_repo_root = target.resolve().parent.parent.parent
dedup_violations = verify_nature_dedup_with_venue(
target, venue_path, anchor_text=text, repo_root=derived_repo_root
)
violations.extend(dedup_violations)
if violations:
for v in violations:
print(f"{target}: {v}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
+354
View File
@@ -0,0 +1,354 @@
#!/usr/bin/env python3
"""#108 policy-anchor disclosure renderer — executable spec / referee.
This module is **not** the production renderer. The production renderer is
LLM-prose at runtime when the user invokes `disclosure` mode with
`--policy-anchor=<a>`. This referee codifies the protocol's deterministic
decision logic §3 G10 7-row precedence table, §4 auto-promotion
forbiddance, and the per-anchor invariant predicates (G2/G5/G7/G8/G9) so
that:
1. The conformance test suite (`test_policy_anchor_disclosure.py`) can
exercise every (input × expected output) combination deterministically.
2. The protocol doc and the referee stay in sync when the protocol's
§2 table changes, this module must change with it; the test suite
catches drift.
References:
- Decision Doc §3 (G10 7-row precedence table), §4.3 (8 invariants),
§4.4 (11 open concerns).
- `academic-paper/references/policy_anchor_disclosure_protocol.md` §§2-7.
- Implementation spec §3 (resolved-paths table).
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Iterable
CANONICAL_ANCHORS = ("prisma-trAIce", "icmje", "nature", "ieee")
SLR_MODES = ("systematic-review", "slr")
NATURE_VENUE_NAMES = (
"Nature",
"Nature Portfolio",
"Nature (Nature Publishing Group)",
"Nature Publishing Group",
)
# Prefix family for Nature Portfolio journals (Nature Medicine, Nature
# Communications, Nature Climate Change, ...). All inherit the same
# parent AI policy, so venue=<any Nature Portfolio journal> +
# policy_anchor=nature is the only consistent (venue, anchor) pair.
NATURE_VENUE_PREFIXES = ("Nature ",)
VALID_CATEGORY_STATES = frozenset({"USED", "NOT USED", "UNCERTAIN"})
# G7 invariant: anchor-specific copyediting carve-out semantics.
# 'eliminate' = no disclosure under carve-out; 'downgrade' = disclosure
# preserved at same location with weaker strength; 'out_of_scope' =
# anchor framework does not address copyediting; 'not_addressed' =
# anchor policy explicitly silent on copyediting.
COPYEDITING_CARVEOUT_SEMANTICS: dict[str, str] = {
"nature": "eliminate",
"ieee": "downgrade",
"prisma-trAIce": "out_of_scope",
"icmje": "not_addressed",
}
# G9 invariant: each anchor's image-rights regime stays distinct; the
# renderer must not unify them into a single boolean.
IMAGE_RIGHTS_REGIMES: dict[str, str] = {
"prisma-trAIce": "data_handling_adjacency",
"icmje": "text_attribution_no_ai_primary",
"nature": "default_deny_with_carveouts",
"ieee": "acknowledgments_only",
}
def is_nature_portfolio_venue(venue: str) -> bool:
if venue in NATURE_VENUE_NAMES:
return True
return any(venue.startswith(p) for p in NATURE_VENUE_PREFIXES)
class TrackGateError(RuntimeError):
"""G2 invariant: --policy-anchor=prisma-trAIce requires slr_lineage=true
or mode=<slr> input."""
class AutoPromotionForbidden(RuntimeError):
"""G3/G10 invariant: a still-UNCERTAIN category must not be rendered
as though USED in any anchor output."""
class PairedMandateViolation(RuntimeError):
"""G8 invariant: IEEE render emits level_of_involvement and
affected_sections together, or neither emitting one without the
other is non-conformant."""
class VenueAnchorConflict(RuntimeError):
"""§4.4 #7: --venue and --policy-anchor that map to incompatible
placement / phrasing requirements must be rejected with explicit
error; silent precedence is forbidden."""
class InvalidPolicyAnchor(ValueError):
"""policy_anchor outside CANONICAL_ANCHORS (closed enum)."""
class InvalidCategoryState(ValueError):
"""Category state outside {USED, NOT USED, UNCERTAIN}."""
class SelectorUnsupplied(ValueError):
"""Neither venue nor policy_anchor supplied — disclosure mode
requires one selector. Default to None on both fields so a bare
RendererInput() does not silently render under a single anchor."""
@dataclass(frozen=True)
class RendererInput:
"""Flat runtime input — no corpus-entry-level fields per G1 invariant."""
ai_used: bool | None = None
categories: dict[str, str] = field(default_factory=dict)
policy_anchor: str | None = None
venue: str | None = None
slr_lineage: bool = False
mode_param: str | None = None
tool_type: str = "LLM"
methodological_in_slr: bool = True
level_of_involvement: str | None = None
affected_sections: list[str] | None = None
@dataclass(frozen=True)
class DisclosureDecision:
"""Whole-disclosure decision returned by `decide_disclosure_output`."""
row: int
kind: str
track: str
used_facets: tuple[str, ...] = ()
uncertain_facets: tuple[str, ...] = ()
# ---------------------------------------------------------------------------
# §3 G10 7-row precedence table
# ---------------------------------------------------------------------------
def decide_disclosure_output(ri: RendererInput) -> DisclosureDecision:
"""Return the G10 7-row decision for the given runtime input.
Implements the protocol's §2 table exactly: rows evaluated top to
bottom; first match wins. Concern #10 bare-flag gate evaluated before
row 4 admits the input.
"""
_check_selector_supplied(ri)
_check_policy_anchor_enum(ri)
_check_category_states(ri)
_check_venue_anchor_conflict(ri)
_check_track_gate(ri)
buckets: dict[str, set[str]] = {s: set() for s in VALID_CATEGORY_STATES}
for k, v in ri.categories.items():
buckets[v].add(k)
used, uncertain, not_used = buckets["USED"], buckets["UNCERTAIN"], buckets["NOT USED"]
# Venue-only invocation: defer to v3.2 flow. The referee's row
# decision is meaningful only when an anchor is selected.
if ri.policy_anchor is None:
return DisclosureDecision(
row=0,
kind="delegated_to_venue_path",
track=ri.venue or "<unset>",
)
track = ri.policy_anchor
if ri.ai_used is False and used:
return DisclosureDecision(row=1, kind="conflict_annotation", track=track)
if ri.ai_used is False and not used and not uncertain:
return DisclosureDecision(row=2, kind="no_ai_statement", track=track)
if ri.ai_used is False and uncertain and not used:
return DisclosureDecision(row=3, kind="tension_annotation", track=track)
# Concern #10 substantive-content gate: ai_used=true with no USED
# category forces v3.2 categorization rather than rendering an
# anchor disclosure with no facts to render.
if ri.ai_used is True and not used:
return DisclosureDecision(
row=4,
kind="prompt_for_categorization",
track=track,
uncertain_facets=tuple(sorted(uncertain)),
)
if (ri.ai_used is True or used) and not (ri.ai_used is False and used):
if ri.policy_anchor == "ieee":
assert_ieee_pairing_conformant(
level_of_involvement=ri.level_of_involvement,
affected_sections=ri.affected_sections,
)
return DisclosureDecision(
row=4,
kind="anchor_render",
track=track,
used_facets=tuple(sorted(used)),
uncertain_facets=tuple(sorted(uncertain)),
)
if uncertain and not used and ri.ai_used is None:
return DisclosureDecision(row=5, kind="not_supplied_annotation", track=track)
if not_used and not uncertain and not used and ri.ai_used is None:
return DisclosureDecision(row=6, kind="silence", track=track)
return DisclosureDecision(row=7, kind="not_supplied_annotation", track=track)
def _check_selector_supplied(ri: RendererInput) -> None:
if ri.policy_anchor is None and ri.venue is None:
raise SelectorUnsupplied(
"neither policy_anchor nor venue supplied; the disclosure mode "
"requires the user to specify one selector."
)
def _check_policy_anchor_enum(ri: RendererInput) -> None:
# Venue-only invocation: enum check does not apply; v3.2 owns it.
if ri.policy_anchor is None:
return
if ri.policy_anchor not in CANONICAL_ANCHORS:
raise InvalidPolicyAnchor(
f"policy_anchor='{ri.policy_anchor}' is not in the canonical "
f"closed enum {CANONICAL_ANCHORS}. Selectors are case-sensitive "
"and must match exactly."
)
def _check_category_states(ri: RendererInput) -> None:
invalid = {
k: v for k, v in ri.categories.items() if v not in VALID_CATEGORY_STATES
}
if invalid:
raise InvalidCategoryState(
f"category states must be in {sorted(VALID_CATEGORY_STATES)}; "
f"got invalid entries {invalid}"
)
def _check_venue_anchor_conflict(ri: RendererInput) -> None:
"""§4.4 #7: the only consistent pair is any Nature Portfolio venue
+ policy_anchor='nature'; every other pair raises so silent
precedence is impossible."""
if ri.venue is None or ri.policy_anchor is None:
return
# Consistent: any Nature Portfolio venue with nature anchor.
if is_nature_portfolio_venue(ri.venue) and ri.policy_anchor == "nature":
return
# All other combinations where both selectors are supplied are
# rejected by default.
raise VenueAnchorConflict(
f"venue='{ri.venue}' and --policy-anchor='{ri.policy_anchor}' map to "
"different (or unmapped) placement / phrasing requirements. The only "
"currently defined consistent pair is any Nature Portfolio venue "
"(canonical labels or `Nature ` prefix) with policy_anchor='nature'. "
"Drop one selector or reconcile."
)
def _check_track_gate(ri: RendererInput) -> None:
"""G2 invariant: --policy-anchor=prisma-trAIce requires SLR lineage."""
if ri.policy_anchor != "prisma-trAIce":
return
if ri.slr_lineage:
return
if ri.mode_param and ri.mode_param in SLR_MODES:
return
raise TrackGateError(
"policy_anchor='prisma-trAIce' requires slr_lineage=True (pipeline) "
"or mode_param='systematic-review' (cold-start). Silent fallback to "
"general track is forbidden by §4.3 G2 invariant."
)
# ---------------------------------------------------------------------------
# §4 auto-promotion forbiddance + helpers
# ---------------------------------------------------------------------------
def render_facet_as_used(category_state: str) -> str:
"""Render a single facet at USED strength — raises if input is still
UNCERTAIN. Encodes the G3/G10 forbiddance as an executable invariant."""
if category_state == "UNCERTAIN":
raise AutoPromotionForbidden(
"category state UNCERTAIN MUST NOT be rendered as USED. "
"See §4.3 G3/G10 invariant and concern #6 resolution."
)
if category_state == "USED":
return "<full-strength facet render>"
return "<no render>"
# ---------------------------------------------------------------------------
# G5 invariant — three-gate prompt disclosure predicate
# ---------------------------------------------------------------------------
def prompt_disclosure_required(
track: str, tool_type: str, methodological_in_slr: bool
) -> bool:
"""True iff all three G5 gates hold."""
if track != "prisma-trAIce":
return False
if tool_type not in {"LLM", "GenAI"}:
return False
if not methodological_in_slr:
return False
return True
def copyediting_carveout_semantics(anchor: str) -> str:
return COPYEDITING_CARVEOUT_SEMANTICS[anchor]
# ---------------------------------------------------------------------------
# G8 invariant — IEEE #5 + #6 paired mandate
# ---------------------------------------------------------------------------
def assert_ieee_pairing_conformant(
level_of_involvement: str | None, affected_sections: list[str] | None
) -> None:
"""Raise PairedMandateViolation if one is supplied without the other."""
has_level = bool(level_of_involvement)
has_sections = bool(affected_sections)
if has_level != has_sections:
raise PairedMandateViolation(
"IEEE #5 (level_of_involvement) and IEEE #6 (affected_sections) "
"are a paired mandate. Emit both together or neither. See "
"§4.3 G8 invariant + impl spec concern #4 resolution."
)
def image_rights_regime(anchor: str) -> str:
return IMAGE_RIGHTS_REGIMES[anchor]
def nature_image_outputs(images: Iterable[dict]) -> dict[str, list[dict]]:
"""§4.4 #5: Nature image disclosure emits two output channels (annotation
block + suggested patch); ARS does not modify manuscript source
autonomously, so no inline_modification channel exists."""
images = list(images)
annotation_block = []
suggested_patch = []
for img in images:
if not img.get("ai_generated"):
continue
annotation_block.append(
{"image_id": img["id"], "label": "AI-generated (Nature default-deny carve-out evaluation required)"}
)
suggested_patch.append(
{
"image_id": img["id"],
"diff": "<suggested figure-metadata caption patch — apply at author discretion>",
}
)
return {
"annotation_block": annotation_block,
"suggested_patch": suggested_patch,
}
@@ -0,0 +1,236 @@
#!/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()
+295
View File
@@ -0,0 +1,295 @@
#!/usr/bin/env python3
"""Mutation tests for scripts/check_policy_anchor_table.py.
Each test mutates a known-good anchor-table snippet in a known-bad way and
asserts the validator reports the expected violation. The goal is to prove
the validator catches each documented bad case in
docs/design/2026-05-14-ai-disclosure-impl-spec.md §4.1 (policy_anchor_table
lint scope) and §4.4 #11 (G1 scope-narrowing).
"""
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_table as cpat # noqa: E402
_GOOD_TABLE = textwrap.dedent(
"""\
# Policy Anchor Table
## Anchor: prisma-trAIce
**Snapshot:** `prisma-trAIce:wayback=20260513075443` (sha256: f95fc59f)
| # | Field | Source strength | Verbatim quote | Locator | Value type |
|---|---|---|---|---|---|
| 1 | AI tool name | explicit-recommend | "For each AI tool or system used: a. Specify the name, version number (if applicable), and developer/provider." | Table 1, M2.a | narrative |
| 2 | AI tool version | explicit-recommend | "Specify the name, version number (if applicable), and developer/provider." | Table 1, M2.a | narrative |
| 3 | AI tool developer / manufacturer | explicit-recommend | "Specify the name, version number (if applicable), and developer/provider." | Table 1, M2.a | narrative |
| 4 | Stage / phase of use | explicit-recommend | "For each AI tool, clearly describe: a. The specific SLR stage(s) where it was applied." | Table 1, M3.a | narrative |
| 5 | Specific task within stage | explicit-recommend | "b. The precise task(s) the AI was intended to perform at each stage." | Table 1, M3.b | narrative |
| 6 | Affected manuscript sections / content locator | implicit | (inference passage) Items prescribe manuscript section per row. | Table 1 row headings | narrative |
| 7 | Date(s) of use | not-addressed | | | |
| 8 | Prompts | explicit-recommend | "For each LLM/GenAI tool used, report: a. The full prompt(s) employed for each specific task." | Table 1, M6.a | narrative |
| 9 | Human oversight method | explicit-recommend | "Describe the process of human interaction with and oversight of the AI tool(s) at each stage." | Table 1, M8.ag | narrative |
| 10 | Human responsibility statement | not-addressed | | | |
| 11 | Performance evaluation method | explicit-recommend | "Describe methods used to evaluate the AI tool(s) performance for the specific tasks within the review." | Table 1, M9 | narrative |
| 12 | Performance evaluation results | explicit-recommend | "Report the results of any performance evaluations of the AI tool(s) for the specific tasks within the review." | Table 1, R2 | narrative |
| 13 | Limitations / known failure modes | explicit-recommend | "Discuss any limitations encountered in using the AI tool(s)." | Table 1, D1 | narrative |
| 14 | Disclosure location | implicit | (inference passage) Each Table 1 item is row-categorized by manuscript section. | Table 1 row groupings | narrative |
| 15 | Copyediting exemption predicate | not-addressed | | | |
| 16 | AI-generated image / figure / content rights | implicit | (inference passage) M10 covers data handling. | Table 1, M10 | narrative |
## Anchor: icmje
**Snapshot:** `icmje:wayback=20260513075516` (sha256: 52f9e6bc)
| # | Field | Source strength | Verbatim quote | Locator | Value type |
|---|---|---|---|---|---|
| 1 | AI tool name | implicit | (inference passage) Tool identity implied. | §V.A, paragraph 1 | narrative |
| 2 | AI tool version | not-addressed | | | |
| 3 | AI tool developer / manufacturer | not-addressed | | | |
| 4 | Stage / phase of use | implicit | (inference passage) "How they used it" implies stage. | §V.A, paragraph 1 | narrative |
| 5 | Specific task within stage | implicit | (inference passage) Same as #4 source. | §V.A, paragraph 1 | narrative |
| 6 | Affected manuscript sections / content locator | not-addressed | | | |
| 7 | Date(s) of use | not-addressed | | | |
| 8 | Prompts | not-addressed | | | |
| 9 | Human oversight method | explicit-recommend | "Authors should carefully review and edit the AI-generated content as the output can be incorrect, incomplete, or biased." | §V.A, paragraph 1 | narrative |
| 10 | Human responsibility statement | explicit-mandate | "Therefore, humans are responsible for any submitted material that included the use of AI-assisted technologies." | §V.A, paragraph 1 | narrative |
| 11 | Performance evaluation method | not-addressed | | | |
| 12 | Performance evaluation results | not-addressed | | | |
| 13 | Limitations / known failure modes | not-addressed | | | |
| 14 | Disclosure location | explicit-recommend | "Authors who use such technology should describe, in both the cover letter and the submitted work in the appropriate section if applicable, how they used it" | §V.A, paragraph 1 | narrative |
| 15 | Copyediting exemption predicate | not-addressed | | | |
| 16 | AI-generated image / figure / content rights | explicit-mandate | "Humans must ensure there is appropriate attribution of all quoted material, including full citations." | §V.A, paragraph 1 | narrative |
## Anchor: nature
**Snapshot:** `nature:wayback=20260513075542` (sha256: cf691cba)
| # | Field | Source strength | Verbatim quote | Locator | Value type |
|---|---|---|---|---|---|
| 1 | AI tool name | implicit | (inference passage) Documentation requirement implies tool identity. | §AI authorship | narrative |
| 2 | AI tool version | not-addressed | | | |
| 3 | AI tool developer / manufacturer | not-addressed | | | |
| 4 | Stage / phase of use | implicit | (inference passage) "Use" implies stage. | §AI authorship | narrative |
| 5 | Specific task within stage | implicit | (inference passage) Methods documentation implies task. | §AI authorship | narrative |
| 6 | Affected manuscript sections / content locator | implicit | (inference passage) Methods rule + caption rule. | §AI authorship; §Generative AI images | narrative |
| 7 | Date(s) of use | not-addressed | | | |
| 8 | Prompts | not-addressed | | | |
| 9 | Human oversight method | explicit-mandate | "In all cases, there must be human accountability for the final version of the text and agreement from the authors that the edits reflect their original work." | §AI authorship | narrative |
| 10 | Human responsibility statement | explicit-mandate | "an attribution of authorship carries with it accountability for the work, which cannot be effectively applied to LLMs" | §AI authorship | narrative |
| 11 | Performance evaluation method | not-addressed | | | |
| 12 | Performance evaluation results | not-addressed | | | |
| 13 | Limitations / known failure modes | not-addressed | | | |
| 14 | Disclosure location | explicit-recommend | "Use of an LLM should be properly documented in the Methods section (and if a Methods section is not available, in a suitable alternative part) of the manuscript." | §AI authorship + §Generative AI images | narrative |
| 15 | Copyediting exemption predicate | explicit-recommend | "The use of an LLM (or other AI-tool) for \\"AI assisted copy editing\\" purposes does not need to be declared." | §AI authorship | narrative |
| 16 | AI-generated image / figure / content rights | explicit-mandate | "Springer Nature journals are unable to permit its use for publication." | §Generative AI images | narrative |
## Anchor: ieee
**Snapshot:** `ieee:wayback=20260513075605` (sha256: 3ab8db50)
| # | Field | Source strength | Verbatim quote | Locator | Value type |
|---|---|---|---|---|---|
| 1 | AI tool name | explicit-mandate | "The AI system used shall be identified" | Paragraph 1, sentence 2 | narrative |
| 2 | AI tool version | not-addressed | | | |
| 3 | AI tool developer / manufacturer | not-addressed | | | |
| 4 | Stage / phase of use | implicit | (inference passage) "Level at which" implies degree-of-involvement. | Paragraph 1, sentence 2 | narrative |
| 5 | Specific task within stage | explicit-mandate | "specific sections of the article that use AI-generated content shall be identified and accompanied by a brief explanation regarding the level at which the AI system was used to generate the content" | Paragraph 1, sentence 2 | narrative |
| 6 | Affected manuscript sections / content locator | explicit-mandate | "specific sections of the article that use AI-generated content shall be identified" | Paragraph 1, sentence 2 | narrative |
| 7 | Date(s) of use | not-addressed | | | |
| 8 | Prompts | not-addressed | | | |
| 9 | Human oversight method | not-addressed | | | |
| 10 | Human responsibility statement | not-addressed | | | |
| 11 | Performance evaluation method | not-addressed | | | |
| 12 | Performance evaluation results | not-addressed | | | |
| 13 | Limitations / known failure modes | not-addressed | | | |
| 14 | Disclosure location | explicit-mandate | "shall be disclosed in the acknowledgments section of any article submitted to an IEEE publication" | Paragraph 1, sentence 1 | narrative |
| 15 | Copyediting exemption predicate | explicit-recommend | "The use of AI systems for editing and grammar enhancement is common practice and, as such, is generally outside the intent of the above policy. In this case, disclosure as noted above is recommended." | Paragraph 2 | narrative |
| 16 | AI-generated image / figure / content rights | implicit | (inference passage) Images/figures/code fold into general acknowledgments-disclosure mandate. | Paragraph 1, sentence 1 | narrative |
"""
)
class CheckPolicyAnchorTableGoldenPathTest(unittest.TestCase):
def test_good_table_passes(self) -> None:
violations = cpat.lint_text(_GOOD_TABLE)
self.assertEqual(violations, [], msg=f"unexpected violations: {violations}")
class CheckPolicyAnchorTableMutationTests(unittest.TestCase):
def test_missing_anchor_section_fails(self) -> None:
bad = _GOOD_TABLE.replace("## Anchor: ieee", "## Anchor: not-an-anchor")
violations = cpat.lint_text(bad)
self.assertTrue(
any("ieee" in v.lower() for v in violations),
msg=f"expected ieee-missing violation; got {violations}",
)
def test_missing_field_row_fails(self) -> None:
# Remove field #5 from PRISMA-trAIce. textwrap.dedent strips leading
# indent so rows start at column 0; match the row prefix exactly.
lines = _GOOD_TABLE.split("\n")
bad_lines = [
line for line in lines if not line.lstrip().startswith("| 5 | Specific task")
]
bad = "\n".join(bad_lines)
violations = cpat.lint_text(bad)
self.assertTrue(
any("16" in v or "field" in v.lower() for v in violations),
msg=f"expected field-count violation; got {violations}",
)
def test_missing_snapshot_ref_fails(self) -> None:
bad = _GOOD_TABLE.replace(
"**Snapshot:** `prisma-trAIce:wayback=20260513075443` (sha256: f95fc59f…)",
"(snapshot deleted)",
)
violations = cpat.lint_text(bad)
self.assertTrue(
any("snapshot" in v.lower() for v in violations),
msg=f"expected snapshot-missing violation; got {violations}",
)
def test_mandate_cell_without_verbatim_quote_fails(self) -> None:
# Strip the quote from ICMJE #10 (mandate cell)
bad = _GOOD_TABLE.replace(
'"Therefore, humans are responsible for any submitted material that '
'included the use of AI-assisted technologies."',
"(quote elided)",
)
violations = cpat.lint_text(bad)
self.assertTrue(
any("verbatim" in v.lower() or "quote" in v.lower() for v in violations),
msg=f"expected verbatim-quote violation; got {violations}",
)
def test_unknown_source_strength_fails(self) -> None:
bad = _GOOD_TABLE.replace("explicit-mandate", "not-a-real-strength", 1)
violations = cpat.lint_text(bad)
self.assertTrue(
any("source_strength" in v.lower() or "strength" in v.lower() for v in violations),
msg=f"expected strength-enum violation; got {violations}",
)
def test_invalid_anchor_slug_fails(self) -> None:
# Add an extra anchor section with a slug not in the four canonical ones
bad = _GOOD_TABLE + "\n\n## Anchor: fake-anchor\n\nstub\n"
violations = cpat.lint_text(bad)
self.assertTrue(
any("fake-anchor" in v or "unknown" in v.lower() for v in violations),
msg=f"expected unknown-anchor violation; got {violations}",
)
def test_field_out_of_order_fails(self) -> None:
# Swap field #14 and #15 in IEEE table to test order enforcement
bad = _GOOD_TABLE.replace(
'| 14 | Disclosure location | explicit-mandate | "shall be disclosed',
"## TEMP_MARKER\n",
)
# If we just delete a row, the field-count test catches it. To test order
# we keep all 16 but rename one with a wrong number.
bad = _GOOD_TABLE.replace(
"| 14 | Disclosure location", "| 99 | Disclosure location"
)
violations = cpat.lint_text(bad)
self.assertTrue(
any("order" in v.lower() or "field" in v.lower() or "99" in v for v in violations),
msg=f"expected ordering violation; got {violations}",
)
def test_duplicate_anchor_section_fails(self) -> None:
# Codex round-3 P2 #2 closure: a second `## Anchor: ieee` heading
# silently overwrote the first in the dict-based section split,
# passing slug coverage. The validator now flags duplicates.
bad = _GOOD_TABLE + "\n\n## Anchor: ieee\n\nduplicate section\n"
violations = cpat.lint_text(bad)
self.assertTrue(
any("duplicate anchor section" in v for v in violations),
msg=f"expected duplicate-section violation; got {violations}",
)
class CheckPolicyAnchorTableNatureSourceOfTruthTest(unittest.TestCase):
"""De-dup guard: Nature anchor verbatim quotes and the v3.2 venue
policies file both cross-reference the canonical
shared/policy_data/nature_policy.md source pointer.
"""
def test_nature_dedup_helper_exists(self) -> None:
self.assertTrue(
hasattr(cpat, "verify_nature_dedup_with_venue"),
msg="check_policy_anchor_table.verify_nature_dedup_with_venue helper missing",
)
def test_nature_dedup_integration_on_real_files(self) -> None:
anchor_path = REPO_ROOT / "academic-paper/references/policy_anchor_table.md"
venue_path = REPO_ROOT / "academic-paper/references/venue_disclosure_policies.md"
violations = cpat.verify_nature_dedup_with_venue(anchor_path, venue_path)
self.assertEqual(violations, [], msg=f"dedup integration failed: {violations}")
def test_canonical_nature_source_file_exists(self) -> None:
# Codex round-4 P2 #4 closure: the dedup helper now confirms the
# shared source file actually exists, not just that the path
# string appears in both consumers.
canonical = REPO_ROOT / "shared/policy_data/nature_policy.md"
self.assertTrue(
canonical.exists(),
f"canonical Nature policy source missing at {canonical}; both "
"consumers cite this path so the file must exist",
)
def test_main_command_invokes_dedup_helper(self) -> None:
# Codex round-7 P3 #1 closure: removing the canonical Nature source
# file should fail the main lint command (not just the unit test).
import shutil, tempfile
with tempfile.TemporaryDirectory() as td:
tdir = Path(td)
anchor = REPO_ROOT / "academic-paper/references/policy_anchor_table.md"
venue = REPO_ROOT / "academic-paper/references/venue_disclosure_policies.md"
# Build a working subtree mirror without the canonical Nature source
# to confirm main() surfaces the dedup violation.
(tdir / "academic-paper/references").mkdir(parents=True)
(tdir / "shared/policy_data").mkdir(parents=True)
shutil.copy(anchor, tdir / "academic-paper/references/policy_anchor_table.md")
shutil.copy(venue, tdir / "academic-paper/references/venue_disclosure_policies.md")
# Deliberately do NOT copy shared/policy_data/nature_policy.md
exit_code = cpat.main(
argv=[
str(tdir / "academic-paper/references/policy_anchor_table.md"),
"--venue-policies",
str(tdir / "academic-paper/references/venue_disclosure_policies.md"),
]
)
self.assertEqual(
exit_code, 1, "main() must fail when canonical Nature source is missing"
)
class CheckPolicyAnchorTableInvariantTest(unittest.TestCase):
"""The table must reference 16 canonical disclosure fields per anchor and
4 canonical anchor slugs, matching the discovery doc §4.2 field list and
§4.3-4.6 anchor inventory.
"""
def test_canonical_anchor_slugs(self) -> None:
self.assertEqual(
set(cpat.CANONICAL_ANCHOR_SLUGS),
{"prisma-trAIce", "icmje", "nature", "ieee"},
)
def test_canonical_field_count(self) -> None:
self.assertEqual(cpat.CANONICAL_FIELD_COUNT, 16)
if __name__ == "__main__":
unittest.main()
+666
View File
@@ -0,0 +1,666 @@
#!/usr/bin/env python3
"""#108 policy-anchor disclosure renderer conformance tests.
Reference impl: scripts/policy_anchor_disclosure_referee.py
The referee is an executable specification of the disclosure decision
logic encoded in policy_anchor_disclosure_protocol.md §2 (G10 7-row
table) and §4 (auto-promotion forbiddance). It is not the production
runtime production is LLM-prose at execution time but it provides
deterministic conformance testing that fails when the protocol doc and
the reference impl drift.
Each test asserts (input expected G10 row decision + acceptable per-
facet annotation). Negative tests assert forbidden behaviors (auto-
promote UNCERTAIN, silent venue+anchor precedence, etc.) raise the
expected error.
Cross-reference: impl spec §3 (resolved-paths table), §4 (TDD discipline),
§6 (test count expectation).
"""
from __future__ import annotations
import sys
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 policy_anchor_disclosure_referee as referee # noqa: E402
# Convenience constructors mirroring referee.RendererInput shape.
# Default policy_anchor='icmje' so existing tests still pass without
# extra wiring; tests that exercise the unset-selector path supply
# policy_anchor=None explicitly.
def _inp(
*,
ai_used=None,
categories=None,
policy_anchor="icmje",
venue=None,
slr_lineage=False,
mode_param=None,
tool_type="LLM",
methodological_in_slr=True,
level_of_involvement=None,
affected_sections=None,
):
return referee.RendererInput(
ai_used=ai_used,
categories=categories or {},
policy_anchor=policy_anchor,
venue=venue,
slr_lineage=slr_lineage,
mode_param=mode_param,
tool_type=tool_type,
methodological_in_slr=methodological_in_slr,
level_of_involvement=level_of_involvement,
affected_sections=affected_sections,
)
# ============================================================================
# §3 G10 7-row precedence table — positive fixtures per row
# ============================================================================
class G10PrecedenceTableTest(unittest.TestCase):
def test_row_1_ai_used_false_with_used_category_emits_conflict_annotation(self) -> None:
result = referee.decide_disclosure_output(
_inp(ai_used=False, categories={"drafting": "USED"})
)
self.assertEqual(result.row, 1)
self.assertEqual(result.kind, "conflict_annotation")
def test_row_2_ai_used_false_clean_no_used_no_uncertain_emits_no_ai_statement(self) -> None:
result = referee.decide_disclosure_output(
_inp(ai_used=False, categories={"drafting": "NOT USED", "revision": "NOT USED"})
)
self.assertEqual(result.row, 2)
self.assertEqual(result.kind, "no_ai_statement")
def test_row_3_ai_used_false_with_uncertain_no_used_emits_tension_annotation(self) -> None:
result = referee.decide_disclosure_output(
_inp(ai_used=False, categories={"drafting": "UNCERTAIN"})
)
self.assertEqual(result.row, 3)
self.assertEqual(result.kind, "tension_annotation")
def test_row_4_ai_used_true_with_used_full_anchor_render(self) -> None:
result = referee.decide_disclosure_output(
_inp(ai_used=True, categories={"drafting": "USED"})
)
self.assertEqual(result.row, 4)
self.assertEqual(result.kind, "anchor_render")
def test_row_4_used_category_without_ai_used_flag_full_anchor_render(self) -> None:
result = referee.decide_disclosure_output(
_inp(ai_used=None, categories={"drafting": "USED"})
)
self.assertEqual(result.row, 4)
self.assertEqual(result.kind, "anchor_render")
def test_row_5_uncertain_no_used_no_flag_emits_not_supplied_annotation(self) -> None:
result = referee.decide_disclosure_output(
_inp(ai_used=None, categories={"drafting": "UNCERTAIN"})
)
self.assertEqual(result.row, 5)
self.assertEqual(result.kind, "not_supplied_annotation")
def test_row_6_all_not_used_no_uncertain_no_flag_silent(self) -> None:
result = referee.decide_disclosure_output(
_inp(ai_used=None, categories={"drafting": "NOT USED", "revision": "NOT USED"})
)
self.assertEqual(result.row, 6)
self.assertEqual(result.kind, "silence")
def test_row_7_empty_input_emits_cold_start_annotation(self) -> None:
result = referee.decide_disclosure_output(_inp(ai_used=None, categories={}))
self.assertEqual(result.row, 7)
self.assertEqual(result.kind, "not_supplied_annotation")
# ============================================================================
# §4 Auto-promotion forbiddance — load-bearing negative tests
# ============================================================================
class AutoPromotionForbiddanceTest(unittest.TestCase):
def test_uncertain_never_rendered_as_used_in_row_4(self) -> None:
result = referee.decide_disclosure_output(
_inp(
ai_used=True,
categories={"drafting": "USED", "revision": "UNCERTAIN"},
)
)
self.assertEqual(result.row, 4)
# USED facet at full strength
self.assertIn("drafting", result.used_facets)
# UNCERTAIN facet annotated, never promoted
self.assertIn("revision", result.uncertain_facets)
self.assertNotIn("revision", result.used_facets)
def test_explicit_promote_raises(self) -> None:
with self.assertRaises(referee.AutoPromotionForbidden):
referee.render_facet_as_used(category_state="UNCERTAIN")
# ============================================================================
# §4.3 G1 invariant — no entry-level field touched
# ============================================================================
class G1InvariantTest(unittest.TestCase):
def test_referee_does_not_read_corpus_entry_field(self) -> None:
# The referee receives flat input; assert the dataclass has no
# 'corpus_entry_ai_disclosure' (the would-be entry-level field).
fields = {f.name for f in referee.RendererInput.__dataclass_fields__.values()}
self.assertNotIn("corpus_entry_ai_disclosure", fields)
self.assertNotIn("ai_disclosure", fields)
# ============================================================================
# §4.3 G2 invariant — track gate by slr_lineage
# ============================================================================
class G2InvariantTest(unittest.TestCase):
def test_prisma_track_requires_slr_lineage(self) -> None:
# slr_lineage=False with --policy-anchor=prisma-trAIce → rejected
with self.assertRaises(referee.TrackGateError):
referee.decide_disclosure_output(
_inp(
ai_used=True,
categories={"drafting": "USED"},
policy_anchor="prisma-trAIce",
slr_lineage=False,
)
)
def test_prisma_track_with_lineage_passes(self) -> None:
result = referee.decide_disclosure_output(
_inp(
ai_used=True,
categories={"drafting": "USED"},
policy_anchor="prisma-trAIce",
slr_lineage=True,
)
)
self.assertEqual(result.row, 4)
def test_cold_start_mode_param_substitutes_lineage(self) -> None:
result = referee.decide_disclosure_output(
_inp(
ai_used=True,
categories={"drafting": "USED"},
policy_anchor="prisma-trAIce",
slr_lineage=False,
mode_param="systematic-review",
)
)
self.assertEqual(result.row, 4)
# ============================================================================
# §4.3 G5 invariant — three-gate composition for M6 prompt disclosure
# ============================================================================
class G5InvariantTest(unittest.TestCase):
def test_prompt_disclosure_fires_when_all_three_gates_hold(self) -> None:
self.assertTrue(
referee.prompt_disclosure_required(
track="prisma-trAIce",
tool_type="LLM",
methodological_in_slr=True,
)
)
def test_prompt_disclosure_skipped_when_not_prisma_track(self) -> None:
self.assertFalse(
referee.prompt_disclosure_required(
track="icmje",
tool_type="LLM",
methodological_in_slr=True,
)
)
def test_prompt_disclosure_skipped_when_non_llm_tool(self) -> None:
self.assertFalse(
referee.prompt_disclosure_required(
track="prisma-trAIce",
tool_type="symbolic-solver",
methodological_in_slr=True,
)
)
def test_prompt_disclosure_skipped_when_copyediting_only(self) -> None:
self.assertFalse(
referee.prompt_disclosure_required(
track="prisma-trAIce",
tool_type="LLM",
methodological_in_slr=False,
)
)
# ============================================================================
# §4.3 G7 invariant — anchor-specific carve-out semantics
# ============================================================================
class G7InvariantTest(unittest.TestCase):
def test_nature_carveout_eliminates(self) -> None:
sem = referee.copyediting_carveout_semantics("nature")
self.assertEqual(sem, "eliminate")
def test_ieee_carveout_downgrades(self) -> None:
sem = referee.copyediting_carveout_semantics("ieee")
self.assertEqual(sem, "downgrade")
def test_prisma_carveout_out_of_scope(self) -> None:
sem = referee.copyediting_carveout_semantics("prisma-trAIce")
self.assertEqual(sem, "out_of_scope")
def test_icmje_carveout_not_addressed(self) -> None:
sem = referee.copyediting_carveout_semantics("icmje")
self.assertEqual(sem, "not_addressed")
# ============================================================================
# §4.3 G8 invariant — IEEE #5 + #6 paired mandate
# ============================================================================
class G8InvariantTest(unittest.TestCase):
def test_ieee_render_requires_both_level_and_sections(self) -> None:
with self.assertRaises(referee.PairedMandateViolation):
referee.assert_ieee_pairing_conformant(
level_of_involvement="full drafting",
affected_sections=None,
)
def test_ieee_render_requires_level_when_sections_present(self) -> None:
with self.assertRaises(referee.PairedMandateViolation):
referee.assert_ieee_pairing_conformant(
level_of_involvement=None,
affected_sections=["Methods"],
)
def test_ieee_render_passes_when_both_present(self) -> None:
# Should not raise
referee.assert_ieee_pairing_conformant(
level_of_involvement="full drafting",
affected_sections=["Methods"],
)
def test_ieee_row_4_render_enforces_pairing(self) -> None:
# Codex round-3 P2 #1 closure: previously row 4 returned
# anchor_render for IEEE even when only level_of_involvement was
# supplied (no affected_sections). The decision function must
# raise PairedMandateViolation in that case.
with self.assertRaises(referee.PairedMandateViolation):
referee.decide_disclosure_output(
_inp(
ai_used=True,
categories={"drafting": "USED"},
policy_anchor="ieee",
level_of_involvement="full drafting",
# affected_sections=None
)
)
def test_ieee_row_4_render_passes_when_both_inputs_present(self) -> None:
result = referee.decide_disclosure_output(
_inp(
ai_used=True,
categories={"drafting": "USED"},
policy_anchor="ieee",
level_of_involvement="full drafting",
affected_sections=["Methods"],
)
)
self.assertEqual(result.row, 4)
self.assertEqual(result.kind, "anchor_render")
def test_ieee_row_4_render_passes_when_neither_pairing_input_present(self) -> None:
# When neither IEEE-pairing input is supplied, the renderer still
# proceeds — the missing pairing surfaces as a per-facet "not
# supplied" annotation downstream (per protocol §3.4); it is not
# a hard violation at the decision-table level.
result = referee.decide_disclosure_output(
_inp(
ai_used=True,
categories={"drafting": "USED"},
policy_anchor="ieee",
level_of_involvement=None,
affected_sections=None,
)
)
self.assertEqual(result.row, 4)
self.assertEqual(result.kind, "anchor_render")
# ============================================================================
# §4.3 G9 invariant — anchor-specific image-rights regimes
# ============================================================================
class G9InvariantTest(unittest.TestCase):
def test_anchor_image_regimes_are_distinct(self) -> None:
nature = referee.image_rights_regime("nature")
ieee = referee.image_rights_regime("ieee")
icmje = referee.image_rights_regime("icmje")
prisma = referee.image_rights_regime("prisma-trAIce")
# All four distinct (not unified)
self.assertEqual(len({nature, ieee, icmje, prisma}), 4)
def test_nature_regime_is_default_deny(self) -> None:
self.assertEqual(referee.image_rights_regime("nature"), "default_deny_with_carveouts")
def test_ieee_regime_folds_into_acknowledgments(self) -> None:
self.assertEqual(referee.image_rights_regime("ieee"), "acknowledgments_only")
# ============================================================================
# §4.4 #1 — slr_lineage signal, no upstream chasing
# ============================================================================
class Concern1Test(unittest.TestCase):
def test_slr_lineage_input_directly_drives_track(self) -> None:
# Even with origin_mode=full, slr_lineage=true routes to prisma-trAIce
result = referee.decide_disclosure_output(
_inp(
ai_used=True,
categories={"drafting": "USED"},
policy_anchor="prisma-trAIce",
slr_lineage=True,
)
)
self.assertEqual(result.row, 4)
self.assertEqual(result.track, "prisma-trAIce")
# ============================================================================
# §4.4 #5 — Nature hybrid image output channel
# ============================================================================
class Concern5Test(unittest.TestCase):
def test_nature_image_render_emits_two_channels(self) -> None:
outputs = referee.nature_image_outputs(
images=[{"id": "fig1", "ai_generated": True}],
)
self.assertIn("annotation_block", outputs)
self.assertIn("suggested_patch", outputs)
# ARS does not modify manuscript source autonomously
self.assertNotIn("inline_modification", outputs)
# ============================================================================
# §4.4 #6 — UNCERTAIN per-facet annotation
# ============================================================================
class Concern6Test(unittest.TestCase):
def test_used_and_uncertain_mix_renders_used_with_uncertain_annotation(self) -> None:
result = referee.decide_disclosure_output(
_inp(
ai_used=None,
categories={
"drafting": "USED",
"revision": "UNCERTAIN",
"citation_check": "USED",
},
)
)
self.assertEqual(result.row, 4)
self.assertSetEqual(set(result.used_facets), {"drafting", "citation_check"})
self.assertSetEqual(set(result.uncertain_facets), {"revision"})
# ============================================================================
# §4.4 #7 — venue+anchor conflict resolution
# ============================================================================
class Concern7Test(unittest.TestCase):
def test_consistent_nature_pair_passes(self) -> None:
# --venue=Nature + --policy-anchor=nature is consistent
result = referee.decide_disclosure_output(
_inp(
ai_used=True,
categories={"drafting": "USED"},
policy_anchor="nature",
venue="Nature",
)
)
self.assertEqual(result.row, 4)
def test_conflicting_pair_rejected(self) -> None:
with self.assertRaises(referee.VenueAnchorConflict):
referee.decide_disclosure_output(
_inp(
ai_used=True,
categories={"drafting": "USED"},
policy_anchor="ieee",
venue="Nature",
)
)
def test_unmapped_venue_with_anchor_rejected(self) -> None:
# Codex round-1 P2 #2 closure: unmapped venue (ICLR) with anchor
# must reject, not silently fall through.
with self.assertRaises(referee.VenueAnchorConflict):
referee.decide_disclosure_output(
_inp(
ai_used=True,
categories={"drafting": "USED"},
policy_anchor="ieee",
venue="ICLR",
)
)
def test_nature_spelling_variant_with_non_nature_anchor_rejected(self) -> None:
# Codex round-1 P2 #2 secondary case: even a Nature-shaped venue
# string outside the canonical set must reject when paired with a
# non-nature anchor.
with self.assertRaises(referee.VenueAnchorConflict):
referee.decide_disclosure_output(
_inp(
ai_used=True,
categories={"drafting": "USED"},
policy_anchor="ieee",
venue="Nature (Nature Publishing Group)",
)
)
def test_nature_db_label_with_nature_anchor_passes(self) -> None:
# Codex round-2 P2 #1 closure: the v3.2 venue_disclosure_policies
# entry uses "Nature (Nature Publishing Group)" as its label.
# That exact string + policy_anchor='nature' must be treated as a
# consistent pair (not raised). Without this, every author using
# the existing policy database name would be blocked.
result = referee.decide_disclosure_output(
_inp(
ai_used=True,
categories={"drafting": "USED"},
policy_anchor="nature",
venue="Nature (Nature Publishing Group)",
)
)
self.assertEqual(result.row, 4)
# ============================================================================
# Category state enum validation (codex round-2 P2 #2)
# ============================================================================
class CategoryStateEnumValidationTest(unittest.TestCase):
def test_lowercase_used_raises(self) -> None:
with self.assertRaises(referee.InvalidCategoryState):
referee.decide_disclosure_output(
_inp(ai_used=None, categories={"drafting": "used"})
)
def test_typo_category_state_raises(self) -> None:
with self.assertRaises(referee.InvalidCategoryState):
referee.decide_disclosure_output(
_inp(ai_used=None, categories={"drafting": "USE D"})
)
def test_valid_states_pass(self) -> None:
# All three canonical states pass without raising; smoke check.
for state in ("USED", "NOT USED", "UNCERTAIN"):
referee.decide_disclosure_output(
_inp(ai_used=None, categories={"drafting": state})
)
# ============================================================================
# Selector-unsupplied + venue-only paths (codex round-4 P2 #2)
# ============================================================================
class SelectorUnsuppliedTest(unittest.TestCase):
def test_no_selector_at_all_raises(self) -> None:
with self.assertRaises(referee.SelectorUnsupplied):
referee.decide_disclosure_output(
_inp(ai_used=None, categories={}, policy_anchor=None, venue=None)
)
def test_venue_only_delegates_to_venue_path(self) -> None:
# Codex round-4 P2 #2: RendererInput(venue="ICLR", policy_anchor=None)
# used to default policy_anchor to "icmje" and produce a conflict
# error. Now the referee delegates the venue-only case to v3.2.
result = referee.decide_disclosure_output(
_inp(
ai_used=True,
categories={"drafting": "USED"},
policy_anchor=None,
venue="ICLR",
)
)
self.assertEqual(result.row, 0)
self.assertEqual(result.kind, "delegated_to_venue_path")
self.assertEqual(result.track, "ICLR")
# ============================================================================
# Nature Portfolio venue variants (codex round-4 P2 #3)
# ============================================================================
class NaturePortfolioVariantTest(unittest.TestCase):
def test_nature_medicine_with_nature_anchor_passes(self) -> None:
# Codex round-4 P2 #3: Nature Portfolio journals (Nature Medicine,
# Nature Climate Change, Nature Communications, …) all inherit
# the parent Nature AI policy. Pairing any of them with
# policy_anchor='nature' is a consistent pair, not a conflict.
result = referee.decide_disclosure_output(
_inp(
ai_used=True,
categories={"drafting": "USED"},
policy_anchor="nature",
venue="Nature Medicine",
)
)
self.assertEqual(result.row, 4)
def test_nature_communications_with_nature_anchor_passes(self) -> None:
result = referee.decide_disclosure_output(
_inp(
ai_used=True,
categories={"drafting": "USED"},
policy_anchor="nature",
venue="Nature Communications",
)
)
self.assertEqual(result.row, 4)
def test_unrelated_journal_with_nature_anchor_still_rejects(self) -> None:
# Non-Nature-prefixed venue + nature anchor remains a conflict.
with self.assertRaises(referee.VenueAnchorConflict):
referee.decide_disclosure_output(
_inp(
ai_used=True,
categories={"drafting": "USED"},
policy_anchor="nature",
venue="Cell",
)
)
def test_helper_returns_true_for_canonical_names(self) -> None:
for v in referee.NATURE_VENUE_NAMES:
self.assertTrue(referee.is_nature_portfolio_venue(v))
def test_helper_returns_true_for_prefix_journals(self) -> None:
for v in ("Nature Medicine", "Nature Climate Change", "Nature Energy"):
self.assertTrue(referee.is_nature_portfolio_venue(v))
def test_helper_returns_false_for_unrelated_journals(self) -> None:
for v in ("Science", "Cell", "PLOS ONE", "Nature"):
self.assertEqual(
referee.is_nature_portfolio_venue(v),
v in referee.NATURE_VENUE_NAMES,
msg=f"{v} miscategorized",
)
# ============================================================================
# Policy anchor enum validation (codex round-1 P2 #3)
# ============================================================================
class PolicyAnchorEnumValidationTest(unittest.TestCase):
def test_typo_anchor_value_raises(self) -> None:
with self.assertRaises(referee.InvalidPolicyAnchor):
referee.decide_disclosure_output(
_inp(
ai_used=True,
categories={"drafting": "USED"},
policy_anchor="ICMJE", # uppercase typo
)
)
def test_unknown_anchor_value_raises(self) -> None:
with self.assertRaises(referee.InvalidPolicyAnchor):
referee.decide_disclosure_output(
_inp(
ai_used=True,
categories={"drafting": "USED"},
policy_anchor="cope", # not in canonical 4
)
)
# ============================================================================
# §4.4 #10 — ai_used:true substantive-content gate
# ============================================================================
class Concern10Test(unittest.TestCase):
def test_bare_ai_used_true_no_categories_triggers_categorization_flow(self) -> None:
result = referee.decide_disclosure_output(
_inp(ai_used=True, categories={})
)
# Should trigger v3.2 categorization flow, not full anchor render
self.assertEqual(result.kind, "prompt_for_categorization")
def test_bare_ai_used_true_all_not_used_triggers_categorization_flow(self) -> None:
# ai_used=true contradicts everything-NOT-USED; row 1 doesn't apply
# (row 1 needs USED) → caught by concern #10 gate. Codex round-5
# P2 #2: protocol §3 G10 row 4 sub-gate spec extended to cover
# this case (was bare-flag only previously). Referee already
# handled it correctly; protocol doc now aligned.
result = referee.decide_disclosure_output(
_inp(
ai_used=True,
categories={"drafting": "NOT USED", "revision": "NOT USED"},
)
)
self.assertEqual(result.kind, "prompt_for_categorization")
def test_ai_used_true_with_uncertain_only_triggers_categorization(self) -> None:
# Edge case from round-5 P2 #2: ai_used=true + only UNCERTAIN
# categories should also trigger categorization flow rather than
# a row-4 render (no USED facets means no substantive content).
result = referee.decide_disclosure_output(
_inp(
ai_used=True,
categories={"drafting": "UNCERTAIN"},
)
)
self.assertEqual(result.kind, "prompt_for_categorization")
# ============================================================================
# §4.4 #11 — G1 invariant scope narrowing (data layer untouched, plumbing OK)
# ============================================================================
class Concern11Test(unittest.TestCase):
def test_renderer_input_excludes_corpus_entry_fields(self) -> None:
fields = {f.name for f in referee.RendererInput.__dataclass_fields__.values()}
# No field that would be a "corpus-entry" addition
for forbidden in ("corpus_entry_id", "corpus_entry_ai_disclosure", "literature_corpus"):
self.assertNotIn(forbidden, fields)
def test_plumbing_inputs_are_runtime_signals(self) -> None:
# slr_lineage + mode_param are runtime renderer inputs, not entry-level
fields = {f.name for f in referee.RendererInput.__dataclass_fields__.values()}
self.assertIn("slr_lineage", fields)
self.assertIn("mode_param", fields)
if __name__ == "__main__":
unittest.main()
+56
View File
@@ -0,0 +1,56 @@
# Nature Portfolio — Canonical AI Policy Source
**Status**: Source of truth for Nature substantive policy content cited by both:
1. `academic-paper/references/policy_anchor_table.md` — Nature anchor (#108 policy-anchor track, verbatim quotes per 16 fields)
2. `academic-paper/references/venue_disclosure_policies.md` — Nature venue entry (v3.2 venue track, summary form)
**Provenance**: extracted from the Nature Portfolio AI editorial policy page (`https://www.nature.com/nature-portfolio/editorial-policies/ai`), wayback snapshot `nature:wayback=20260513075542` (sha256 `cf691cba…`). Section structure: `§AI authorship`, `§Generative AI images`, `§AI use by peer reviewers`, `§Editorial use`. ARS author-side matrix covers `§AI authorship` + `§Generative AI images` only.
**G4 invariant** (Decision Doc §4.3): edits to Nature substantive policy content must go through **this file** first; both downstream consumers (policy_anchor_table.md Nature section and venue_disclosure_policies.md Nature entry) re-cite from here. Direct edits to either consumer that change Nature substantive content are non-conformant.
**Dedup lint**: `verify_nature_dedup_with_venue()` in `scripts/check_policy_anchor_table.py` confirms (a) this file exists, (b) both consumers reference the path `shared/policy_data/nature_policy.md` as their canonical pointer.
---
## Nature substantive policy quotes (verbatim, source-of-truth)
### §AI authorship — text-use policy
> "Large Language Models (LLMs), such as ChatGPT, do not currently satisfy our authorship criteria. Notably an attribution of authorship carries with it accountability for the work, which cannot be effectively applied to LLMs. Use of an LLM should be properly documented in the Methods section (and if a Methods section is not available, in a suitable alternative part) of the manuscript."
> "In all cases, there must be human accountability for the final version of the text and agreement from the authors that the edits reflect their original work."
### §AI authorship — copyediting carve-out
> "The use of an LLM (or other AI-tool) for "AI assisted copy editing" purposes does not need to be declared."
The carve-out predicate (paraphrased policy framing, partial verbatim): AI-assisted improvements to human-generated texts for readability and style — explicitly **excluding** generative editorial work and autonomous content creation.
### §Generative AI images — image-rights regime
> "Springer Nature journals are unable to permit its use for publication."
> "All exceptions must be labelled clearly as generated by AI within the image field."
Carve-outs (per Nature policy text, three categories): figures using AI to enhance or compose existing scientific data; AI-generated images that are themselves the subject of academic study (e.g., AI-art history); image-generation tools used to produce illustrations clearly marked as conceptual rather than scientific.
### §Generative AI images — non-generative ML caption rule
> "should be disclosed in the relevant caption upon submission"
---
## Consumer integration notes
- `policy_anchor_table.md` Nature anchor: extracts these quotes per 16-field matrix (3 explicit-mandate + 2 explicit-recommend + 4 implicit + 7 not-addressed).
- `venue_disclosure_policies.md` Nature entry: summarises the same source as venue-track guidance (Methods placement default, full responsibility statement requirement, no AI authorship).
- Renderer (LLM-prose): when the user selects either `--venue=Nature` or `--policy-anchor=nature`, the renderer cites this file's verbatim quotes in the emitted disclosure paragraph. Both tracks share the substantive policy content; placement (Methods vs anchor-specific) is the track-specific rendering decision.
---
## Related
- Discovery doc §4.5 — Nature anchor matrix with cross-anchor observation
- Decision Doc §2.4 G4 (renderer target) + §4.3 G4 invariant (Nature dedup)
- Impl spec §3 concern #5 resolution (hybrid image output channel)