"""Audit claim-to-evidence mappings and local citation markers.""" from __future__ import annotations import argparse import re from typing import Any from _common import ( InputError, Issue, emit_report, is_nonempty_string, issue, read_csv, read_json, read_text, require_list, require_object, run, ) TOOL = "audit_claims" REQUIRED_FIELDS = { "claim_id", "section", "claim_kind", "claim_text_sha256", "evidence_ids", "verification_status", "uncertainty", "analysis_intent", } CLAIM_ID_RE = re.compile(r"^C[0-9]{3,8}$") EVIDENCE_ID_RE = re.compile(r"^E[0-9]{3,8}$") SHA256_RE = re.compile(r"^[a-f0-9]{64}$") CLAIM_MARKER_RE = re.compile(r"\[claim:(C[0-9]{3,8})\]") EVIDENCE_MARKER_RE = re.compile( r"\[evidence:((?:E[0-9]{3,8})(?:\s*,\s*E[0-9]{3,8})*)\]" ) CITATION_MARKER_RE = re.compile(r"\[@(E[0-9]{3,8})\]") NUMERIC_RE = re.compile(r"(?]=?\s*)?[0-9]+(?:\.[0-9]+)?%?") CLAIM_KINDS = {"factual", "numeric", "method", "result", "interpretive", "declaration"} UNCERTAINTY = {"not_applicable", "not_estimated", "low", "moderate", "high"} ANALYSIS_INTENT = {"confirmatory", "exploratory", "descriptive", "not_applicable"} def _split_evidence_ids(value: str) -> list[str]: return [part.strip() for part in value.split(";") if part.strip()] def load_sources(path: str) -> dict[str, bool]: data = require_object(read_json(path), "source_manifest") sources: dict[str, bool] = {} for index, raw_source in enumerate(require_list(data.get("sources"), "sources")): source = require_object(raw_source, f"sources[{index}]") evidence_id = source.get("evidence_id") if not isinstance(evidence_id, str) or not EVIDENCE_ID_RE.fullmatch( evidence_id ): raise InputError("source manifest contains an invalid evidence_id") if evidence_id in sources: raise InputError("source manifest contains duplicate evidence_id values") verification = require_object(source.get("verification"), "verification") sources[evidence_id] = ( verification.get("status") == "verified" and verification.get("source_opened") is True ) return sources def load_claims( path: str, sources: dict[str, bool], ) -> tuple[dict[str, dict[str, Any]], list[Issue]]: fields, rows = read_csv(path) if set(fields) != REQUIRED_FIELDS: missing = ",".join(sorted(REQUIRED_FIELDS - set(fields))) extra = ",".join(sorted(set(fields) - REQUIRED_FIELDS)) raise InputError( f"claim registry headers must match the schema; missing={missing}; extra={extra}" ) issues: list[Issue] = [] claims: dict[str, dict[str, Any]] = {} for row_number, row in enumerate(rows, start=2): location = f"row:{row_number}" claim_id = row["claim_id"].strip() if not CLAIM_ID_RE.fullmatch(claim_id): issues.append(issue("error", "INVALID_CLAIM_ID", location=location)) continue if claim_id in claims: issues.append(issue("error", "DUPLICATE_CLAIM_ID", item_id=claim_id)) continue claims[claim_id] = row if row["claim_kind"] not in CLAIM_KINDS: issues.append(issue("error", "INVALID_CLAIM_KIND", item_id=claim_id)) if not is_nonempty_string(row["section"]): issues.append(issue("error", "MISSING_CLAIM_SECTION", item_id=claim_id)) if not SHA256_RE.fullmatch(row["claim_text_sha256"]): issues.append(issue("error", "INVALID_CLAIM_TEXT_HASH", item_id=claim_id)) if row["verification_status"] != "verified": issues.append(issue("error", "CLAIM_NOT_VERIFIED", item_id=claim_id)) if row["uncertainty"] not in UNCERTAINTY: issues.append( issue("error", "INVALID_UNCERTAINTY_STATUS", item_id=claim_id) ) if row["analysis_intent"] not in ANALYSIS_INTENT: issues.append(issue("error", "INVALID_ANALYSIS_INTENT", item_id=claim_id)) evidence_ids = _split_evidence_ids(row["evidence_ids"]) row["_evidence_ids"] = evidence_ids if not evidence_ids: issues.append(issue("error", "CLAIM_WITHOUT_EVIDENCE", item_id=claim_id)) for evidence_id in evidence_ids: if not EVIDENCE_ID_RE.fullmatch(evidence_id): issues.append( issue("error", "INVALID_CLAIM_EVIDENCE_ID", item_id=claim_id) ) elif evidence_id not in sources: issues.append( issue("error", "UNKNOWN_CLAIM_EVIDENCE", item_id=claim_id) ) elif not sources[evidence_id]: issues.append( issue("error", "UNVERIFIED_CLAIM_EVIDENCE", item_id=claim_id) ) return claims, issues def audit_markdown( text: str, claims: dict[str, dict[str, Any]], sources: dict[str, bool], ) -> tuple[list[Issue], set[str]]: issues: list[Issue] = [] used_claims: set[str] = set() in_fence = False for line_number, line in enumerate(text.splitlines(), start=1): stripped = line.strip() if stripped.startswith(("```", "~~~")): in_fence = not in_fence continue if in_fence or not stripped or stripped.startswith(("#", "