mirror of
https://github.com/Imbad0202/academic-research-skills.git
synced 2026-09-14 13:51:17 +08:00
eb9e7bed17
Ships the contamination-triangulation policy layer deferred by v3.9.0 (#102): opt-in strict modes that promote the advisory k=3 triangulation signal to a non-acknowledgeable terminal HIGH-BLOCK at the citation-emission boundary. Built on PR-A's canonical firm-rules + sync-lint base. Default behavior (absent / all-advisory terminal_policies) stays byte-equivalent to v3.9.0. Schema: - New standalone shared/contracts/passport/terminal_policies.schema.json (passport-level, NOT entry-level). contamination_triangulation enum {advisory, strict, strict_articles_only}; temporal_integrity accepts only advisory (forward-reserved namespace). - literature_corpus_entry.schema.json gains venue_type (closed enum incl. explicit unknown), venue_type_provenance (no _inferred values), venue_type_source (required iff trusted_source_declared). Bidirectional type/provenance pair dependency + one-way unknown-type rule. - check_literature_corpus_schema.py validates a passport-level terminal_policies block before iterating entries. Finalizer / formatter (agent prompts): - pipeline_orchestrator_agent.md: sole policy evaluator. Stamps a fully-encoded policy_hash slug ONLY under a non-advisory passport; co-emits a TERMINAL-BLOCK token under strict (k=3) alongside the advisory suffix. strict_articles_only is a precision mode (DOI + journal/conference venue + declared provenance); DOI-less / unknown-venue stays advisory by design. - formatter_agent.md: generic rule-11 HIGH-BLOCK refusal + STAMP-ONLY two-gate freshness check; never re-evaluates policy logic. Firm rule / migration / adapters / lint: - R-L3-2-A reworded to the broad default-advisory + opt-in-strict form (canonical single-source); contradiction guard added to check_firm_rules_sync.py. - migrate_literature_corpus_to_v3_10.py deep-merge-seeds terminal_policies (idempotent, dry-run, no venue backfill). - folder_scan / zotero / obsidian adapters declare venue_type. - New check_v3_10_policy.py (alongside the v3.9.0 lint) + CI wiring in spec-consistency.yml and _ci_pytest_manifest.toml. Docs: CHANGELOG Unreleased v3.10 entry; CLAUDE.md v3.10 Key Additions; v3.9.0 / v3.9.4 spec superseded-framing notes. Tests: 1857 passed / 3 skipped (+67 vs PR-A baseline of 1790, 0 regression). Spec: docs/design/2026-05-31-ars-v3.10-policy-layer-rescope-spec.md Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
376 lines
16 KiB
Python
376 lines
16 KiB
Python
#!/usr/bin/env python3
|
|
"""Firm-rules sync + collision lint (v3.10 PR-A).
|
|
|
|
Pins two things against regression:
|
|
|
|
1. **Sync** (R-CIM-* only): the canonical R-CIM-* (claim-manifest) firm-rule
|
|
wording in `shared/references/firm_rules.md` matches the copies mirrored into
|
|
the three writing-stage agent prompts. The mirrors differ only by the agent
|
|
self-reference noun (the `<AGENT>` placeholder in the canonical block); this
|
|
lint normalizes that noun before comparing the operative clause.
|
|
The contamination R-L3-2-* blocks are NOT wording-synced here: their mirrors
|
|
are intentionally by-ID prose references rather than full-block copies (see
|
|
firm_rules.md "Mirrored in (contamination rules)" note), so only their IDs
|
|
are guarded (see check 2) and their surrounding prose is contradiction-guarded
|
|
(see check 3). The v3.10 PR-B reword of R-L3-2-A to the broad
|
|
default-advisory-plus-opt-in-strict form changed ONLY the canonical block; the
|
|
mirrors stay by-ID references, so there is no full-block copy to wording-sync.
|
|
|
|
2. **Collision guard** (the v3.10 PR-A disambiguation): the `R-L3-2-A/B/C` ID
|
|
(contamination) MUST NOT appear inside any `## Claim Intent Manifest Emission`
|
|
prompt section or inside `claim_intent_manifest.schema.json`; and `R-CIM-A/B/C`
|
|
MUST NOT appear inside a contamination context. Before v3.10 the same
|
|
`R-L3-2-A/B/C` ID named two unrelated rule families; a blind grep-replace of
|
|
either would corrupt the other. This guard fails if the collision reappears.
|
|
|
|
3. **Contradiction guard** (v3.10 PR-B): after the R-L3-2-A reword, a contamination
|
|
mirror's by-ID reference MUST NOT carry an unqualified "advisory only" / "never
|
|
block" claim ABOUT THE CONTAMINATION SIGNAL ITSELF, because a user-enabled strict
|
|
terminal policy can now promote a contamination signal to a terminal block. The
|
|
guard scans only the contamination R-L3-2-A reference sentence(s) — the sentence
|
|
in each contamination-context file that names `R-L3-2-A` — for forbidden absolute
|
|
phrasing. It deliberately does NOT scan the whole file, so the Collaboration Depth
|
|
Observer's legitimate "advisory only / never blocks" wording (a DIFFERENT subsystem
|
|
that genuinely never blocks) is not false-flagged. This inherits the
|
|
new-enforcement-must-respect-existing-guardrails discipline.
|
|
|
|
Usage:
|
|
python scripts/check_firm_rules_sync.py
|
|
python scripts/check_firm_rules_sync.py --root PATH (for test fixtures)
|
|
|
|
Exit codes:
|
|
0 — all checks pass
|
|
1 — one or more checks failed
|
|
2 — invocation error (e.g., file missing)
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
|
|
FIRM_RULES_REL = "shared/references/firm_rules.md"
|
|
|
|
# Prompts that mirror the claim-manifest rules. The canonical `<AGENT>`
|
|
# placeholder is matched as a wildcard, not a fixed noun, because each prompt's
|
|
# self-reference noun differs AND can differ between R-CIM-A and R-CIM-B within
|
|
# the same prompt (e.g. synthesis_agent uses "agent" in R-CIM-A but "synthesis
|
|
# agent" in R-CIM-B). So the lint compares the canonical operative clause with
|
|
# `<AGENT>` allowed to be any short noun phrase.
|
|
CLAIM_MANIFEST_PROMPTS = [
|
|
"deep-research/agents/synthesis_agent.md",
|
|
"deep-research/agents/report_compiler_agent.md",
|
|
"academic-paper/agents/draft_writer_agent.md",
|
|
]
|
|
|
|
CLAIM_MANIFEST_SCHEMA = "shared/contracts/passport/claim_intent_manifest.schema.json"
|
|
|
|
# The Claim Intent Manifest section heading; the collision guard scans this
|
|
# section for forbidden contamination IDs.
|
|
CIM_SECTION_HEADER = "## Claim Intent Manifest Emission"
|
|
|
|
# Contamination contexts the collision guard scans for forbidden R-CIM IDs.
|
|
# Update this list when a new agent/reference file starts carrying R-L3-2-* rules
|
|
# (a deliberate, infrequent operation — a hardcoded list is intentional here per
|
|
# the no-unrequested-abstraction rule; the set is small and stable).
|
|
# Asymmetry to be aware of: a new contamination file that mirrors an R-L3-2-* rule
|
|
# but is NOT added here is scanned by nothing, so a stray R-CIM-* leaking into it
|
|
# passes silently. (The CLAIM_MANIFEST_PROMPTS side fails loudly instead: a listed
|
|
# prompt that is missing is reported.) Registering the file here is the guard.
|
|
CONTAMINATION_CONTEXT_FILES = [
|
|
"academic-paper/agents/formatter_agent.md",
|
|
"deep-research/references/crossref_api_protocol.md",
|
|
"deep-research/references/openalex_api_protocol.md",
|
|
"academic-pipeline/agents/pipeline_orchestrator_agent.md",
|
|
"deep-research/agents/bibliography_agent.md",
|
|
]
|
|
|
|
# The full contamination namespace (A-E). The historical collision was only on
|
|
# A/B/C (the three IDs the claim-manifest prompts borrowed), but a claim-manifest
|
|
# surface must not carry ANY contamination ID — guarding D/E too seals the
|
|
# namespace against a future leak rather than just patching the known overlap.
|
|
CONTAMINATION_IDS = ("R-L3-2-A", "R-L3-2-B", "R-L3-2-C", "R-L3-2-D", "R-L3-2-E")
|
|
CIM_IDS = ("R-CIM-A", "R-CIM-B", "R-CIM-C")
|
|
|
|
CANONICAL_BLOCK_RE = re.compile(
|
|
r"<!--\s*canonical:(?P<id>[A-Za-z0-9-]+)\s*-->\n(?P<body>.*?)\n<!--\s*/canonical:(?P=id)\s*-->",
|
|
re.DOTALL,
|
|
)
|
|
|
|
|
|
def _norm(text: str) -> str:
|
|
"""Collapse whitespace so byte-for-byte comparison tolerates wrapping."""
|
|
return re.sub(r"\s+", " ", text).strip()
|
|
|
|
|
|
def parse_canonical_blocks(firm_rules_text: str) -> dict[str, str]:
|
|
"""Extract {rule_id: operative_clause} from firm_rules.md canonical markers."""
|
|
blocks: dict[str, str] = {}
|
|
for m in CANONICAL_BLOCK_RE.finditer(firm_rules_text):
|
|
blocks[m.group("id")] = _norm(m.group("body"))
|
|
return blocks
|
|
|
|
|
|
# A canonical `<AGENT>` slot matches a SHORT noun phrase: a head word plus at
|
|
# most ONE more word, joined by a single space or hyphen. The real self-reference
|
|
# nouns are all ≤ 2 words ("agent", "synthesis agent", "compiler", "writer",
|
|
# "draft-writer"), so this covers them while rejecting a semantic edit smuggled
|
|
# into the slot — e.g. "agent or compiler" (3 words) fails to match, so changing
|
|
# "per agent invocation" into "per agent or compiler invocation" is correctly
|
|
# flagged as drift rather than silently accepted.
|
|
AGENT_WILDCARD = r"[A-Za-z]+(?:[- ][A-Za-z]+)?"
|
|
|
|
|
|
def _canonical_to_regex(canonical_clause: str) -> re.Pattern[str]:
|
|
"""Build a regex from an already-normalized canonical clause.
|
|
|
|
Literal text is escaped; each `<AGENT>` placeholder becomes a bounded
|
|
noun-phrase wildcard so any prompt's self-reference noun matches. Callers
|
|
pass clauses from `parse_canonical_blocks`, which already `_norm`-ed them.
|
|
"""
|
|
parts = canonical_clause.split("<AGENT>")
|
|
pattern = AGENT_WILDCARD.join(re.escape(p) for p in parts)
|
|
return re.compile(pattern)
|
|
|
|
|
|
def check_claim_manifest_sync(
|
|
cim_patterns: dict[str, re.Pattern[str]],
|
|
prompt_texts: dict[str, str],
|
|
violations: list[str],
|
|
) -> None:
|
|
"""Each prompt mirror must contain the canonical R-CIM operative clause.
|
|
|
|
Note: this is a presence check (canonical clause appears, modulo the <AGENT>
|
|
noun). It detects deletion and word-for-word alteration. It does NOT catch an
|
|
*additive override* appended after the canonical clause — appending context is
|
|
legitimate here (report_compiler R-CIM-B appends a standalone-mode sentence).
|
|
"""
|
|
for rel in CLAIM_MANIFEST_PROMPTS:
|
|
text = prompt_texts.get(rel)
|
|
if text is None:
|
|
violations.append(f"missing claim-manifest prompt: {rel}")
|
|
continue
|
|
normed = _norm(text)
|
|
for rid in CIM_IDS:
|
|
pat = cim_patterns.get(rid)
|
|
if pat is None:
|
|
violations.append(f"firm_rules.md missing canonical block: {rid}")
|
|
continue
|
|
if not pat.search(normed):
|
|
violations.append(
|
|
f"{rel}: {rid} mirror drifted from canonical "
|
|
f"(canonical operative clause not found, modulo the <AGENT> noun)"
|
|
)
|
|
|
|
|
|
def check_collision_guard(
|
|
root: Path,
|
|
prompt_texts: dict[str, str],
|
|
contamination_texts: dict[str, str],
|
|
violations: list[str],
|
|
) -> None:
|
|
"""R-L3-2 IDs must not leak into claim-manifest surfaces, and vice versa."""
|
|
# (1) Forbidden contamination IDs inside claim-manifest prompt sections.
|
|
for rel in CLAIM_MANIFEST_PROMPTS:
|
|
text = prompt_texts.get(rel)
|
|
if text is None:
|
|
continue # missing prompt already flagged by the sync check
|
|
section = _extract_section(text, CIM_SECTION_HEADER)
|
|
if section is None:
|
|
# The Claim Intent Manifest section header was not found — the guard
|
|
# would otherwise scan nothing and pass vacuously. Flag it so a
|
|
# header rename can never silently disable the collision guard.
|
|
violations.append(
|
|
f"{rel}: Claim Intent Manifest section header not found "
|
|
f"({CIM_SECTION_HEADER!r}); collision guard cannot run"
|
|
)
|
|
continue
|
|
for bad in CONTAMINATION_IDS:
|
|
if bad in section:
|
|
violations.append(
|
|
f"{rel}: contamination ID {bad} reappeared inside the "
|
|
f"Claim Intent Manifest section (collision regression)"
|
|
)
|
|
|
|
# (2) Forbidden contamination IDs inside the claim-manifest schema.
|
|
schema_path = root / CLAIM_MANIFEST_SCHEMA
|
|
if not schema_path.exists():
|
|
violations.append(f"missing collision-guard file: {CLAIM_MANIFEST_SCHEMA}")
|
|
else:
|
|
schema_text = schema_path.read_text(encoding="utf-8")
|
|
for bad in CONTAMINATION_IDS:
|
|
if bad in schema_text:
|
|
violations.append(
|
|
f"{CLAIM_MANIFEST_SCHEMA}: contamination ID {bad} present "
|
|
f"(should be R-CIM-* after v3.10 PR-A)"
|
|
)
|
|
|
|
# (3) Forbidden R-CIM IDs inside contamination contexts.
|
|
for rel in CONTAMINATION_CONTEXT_FILES:
|
|
text = contamination_texts.get(rel)
|
|
if text is None:
|
|
violations.append(f"missing collision-guard file: {rel}")
|
|
continue
|
|
for bad in CIM_IDS:
|
|
if bad in text:
|
|
violations.append(
|
|
f"{rel}: claim-manifest ID {bad} leaked into a contamination "
|
|
f"context (collision regression)"
|
|
)
|
|
|
|
|
|
# v3.10 PR-B contradiction guard. After the R-L3-2-A reword, a contamination
|
|
# mirror's R-L3-2-A reference MUST NOT assert (about the contamination signal)
|
|
# that it is unconditionally non-blocking — a strict terminal policy can now
|
|
# block. These phrases are scanned ONLY within the sentence(s) that name
|
|
# `R-L3-2-A` in a contamination-context file, never the whole file, so the
|
|
# Collaboration Depth Observer's legitimate "advisory only / never blocks"
|
|
# (a different subsystem) is not false-flagged.
|
|
CONTRADICTION_PHRASES = (
|
|
"advisory only",
|
|
"only advisory", # reversed word order paraphrase
|
|
"purely advisory",
|
|
"never block",
|
|
"never blocks",
|
|
"cannot block",
|
|
"must not block",
|
|
"non-blocking",
|
|
"does not block",
|
|
"retains discretion",
|
|
)
|
|
|
|
# Sentence boundary. Split on a real sentence end (a period/!/? followed by
|
|
# whitespace or end-of-string) or a newline — NOT on `;`. A semicolon joins
|
|
# clauses that belong to the same statement ("handled per R-L3-2-A; signals never
|
|
# block"), so splitting on it would put a contradiction phrase in a different
|
|
# chunk from the `R-L3-2-A` token and silently miss it (codex P2). We deliberately
|
|
# keep semicolon-joined clauses together; the scan is still scoped to chunks that
|
|
# contain `R-L3-2-A`, so unrelated prose in the same file is not flagged.
|
|
_SENTENCE_SPLIT_RE = re.compile(r"(?:[.!?](?=\s|$))|\n")
|
|
|
|
|
|
def check_contradiction_guard(
|
|
contamination_texts: dict[str, str], violations: list[str]
|
|
) -> None:
|
|
"""No contamination R-L3-2-A reference sentence may assert an unqualified
|
|
non-blocking claim that the v3.10 strict policy contradicts."""
|
|
for rel in CONTAMINATION_CONTEXT_FILES:
|
|
text = contamination_texts.get(rel)
|
|
if text is None:
|
|
continue # missing file already flagged by the collision guard
|
|
for raw_sentence in _SENTENCE_SPLIT_RE.split(text):
|
|
if "R-L3-2-A" not in raw_sentence:
|
|
continue
|
|
lowered = raw_sentence.lower()
|
|
for phrase in CONTRADICTION_PHRASES:
|
|
if phrase in lowered:
|
|
violations.append(
|
|
f"{rel}: the R-L3-2-A reference contains the contradiction "
|
|
f"phrase {phrase!r} — after the v3.10 reword a strict "
|
|
f"terminal policy CAN block, so an unqualified "
|
|
f"non-blocking claim about the contamination signal is "
|
|
f"stale. Qualify it (e.g. 'advisory by default; strict "
|
|
f"can promote to a terminal block')."
|
|
)
|
|
|
|
|
|
_HEADER_LINE_RE = re.compile(r"#{1,2} ")
|
|
_FENCE_RE = re.compile(r"\s*(```|~~~)")
|
|
|
|
|
|
def _extract_section(text: str, header: str) -> str | None:
|
|
"""Return the body from the `header` line up to the next `#`/`##` heading.
|
|
|
|
`header` is a fixed level-2 constant (CIM_SECTION_HEADER). The header LINE
|
|
may carry a trailing suffix after the header text — live prompts use
|
|
`## Claim Intent Manifest Emission (v3.8)` — so a trailing suffix on the
|
|
header line is tolerated, but it must be followed by a space or the line end
|
|
(not more word chars) so `## Claim Intent Manifest Emissionary` does not
|
|
match. The section stops at the next line beginning with `# ` or `## ` (a
|
|
`###` sub-heading inside the section does NOT stop it). A `# `/`## ` line
|
|
INSIDE a fenced code block (``` or ~~~) does NOT stop it either — otherwise a
|
|
forbidden ID hidden after a fake heading in a fenced example would escape the
|
|
collision guard. Returns None when the header line is absent (so callers can
|
|
flag a silently-missing section rather than treating it as vacuously clean).
|
|
"""
|
|
lines = text.splitlines(keepends=True)
|
|
start = None
|
|
for i, line in enumerate(lines):
|
|
stripped = line.rstrip("\n")
|
|
if stripped == header or (
|
|
stripped.startswith(header) and stripped[len(header):len(header) + 1] == " "
|
|
):
|
|
start = i + 1
|
|
break
|
|
if start is None:
|
|
return None
|
|
|
|
body: list[str] = []
|
|
in_fence = False
|
|
for line in lines[start:]:
|
|
fence = _FENCE_RE.match(line)
|
|
if fence:
|
|
in_fence = not in_fence
|
|
body.append(line)
|
|
continue
|
|
if not in_fence and _HEADER_LINE_RE.match(line):
|
|
break
|
|
body.append(line)
|
|
return "".join(body)
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description="firm-rules sync + collision lint")
|
|
parser.add_argument("--root", default=str(REPO_ROOT), help="repo root (for fixtures)")
|
|
args = parser.parse_args()
|
|
|
|
root = Path(args.root).resolve()
|
|
firm_rules_path = root / FIRM_RULES_REL
|
|
if not firm_rules_path.exists():
|
|
print(f"ERROR: firm_rules.md not found: {firm_rules_path}", file=sys.stderr)
|
|
return 2
|
|
|
|
canon = parse_canonical_blocks(firm_rules_path.read_text(encoding="utf-8"))
|
|
if not canon:
|
|
print("ERROR: no canonical blocks parsed from firm_rules.md", file=sys.stderr)
|
|
return 2
|
|
|
|
# Compile each R-CIM pattern once; read each claim-manifest prompt once and
|
|
# share the text between the sync check and the collision guard.
|
|
cim_patterns = {rid: _canonical_to_regex(canon[rid]) for rid in CIM_IDS if rid in canon}
|
|
prompt_texts: dict[str, str] = {}
|
|
for rel in CLAIM_MANIFEST_PROMPTS:
|
|
path = root / rel
|
|
if path.exists():
|
|
prompt_texts[rel] = path.read_text(encoding="utf-8")
|
|
|
|
# Read each contamination-context file once and share it between the
|
|
# collision guard (loop 3) and the contradiction guard — both scan the same
|
|
# five files, so one read each.
|
|
contamination_texts: dict[str, str] = {}
|
|
for rel in CONTAMINATION_CONTEXT_FILES:
|
|
path = root / rel
|
|
if path.exists():
|
|
contamination_texts[rel] = path.read_text(encoding="utf-8")
|
|
|
|
violations: list[str] = []
|
|
check_claim_manifest_sync(cim_patterns, prompt_texts, violations)
|
|
check_collision_guard(root, prompt_texts, contamination_texts, violations)
|
|
check_contradiction_guard(contamination_texts, violations)
|
|
|
|
if violations:
|
|
print("firm-rules sync/collision lint FAILED:", file=sys.stderr)
|
|
for v in violations:
|
|
print(f" - {v}", file=sys.stderr)
|
|
return 1
|
|
|
|
print("firm-rules sync/collision lint PASSED")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|