From 7f878f7ad2eb8c1be3d2fffebeee74362fa79d02 Mon Sep 17 00:00:00 2001 From: Edward Cheng-I Wu <132531341+Imbad0202@users.noreply.github.com> Date: Mon, 20 Jul 2026 06:50:06 +0800 Subject: [PATCH] feat(integrity): #512 PDF read-integrity preflight for locally-extracted page anchors (#566) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(integrity): #512 PDF read-integrity preflight for locally-extracted page anchors Closes the local-extraction-channel gap between v3.7.3 locator presence and the #182 existence gate: a page anchor derived from a silently truncated/mispaginated PDF read passes every existing gate. - scripts/pdf_read_preflight.py: three independent page-count signals (raw root /Count, own cycle-guarded /Kids walk, pypdf page list) -> PASS/FAIL/UNAVAILABLE JSON sidecar with file sha256 + parser warnings. pypdf-backed with the verify_submission_package ImportError precedent. - R-L3-1-D firm rule in the three v3.7.3 emitters (+ agents/ mirrors): local-PDF page anchors require a PASS sidecar in context, else anchor:none or an independently-visible locator + explicit warning. - claim_ref_alignment_audit_agent Step 4: precondition bound to ref_retrieval_method == manual_pdf, sidecars joined on ref_slug (sha256 confirmatory until #513), [pdf_read_integrity_unverified] advisory tag - never UNSUPPORTED on this basis alone. - pipeline_orchestrator_agent §3.6: preflight once per locally-read corpus PDF, upstream of the writers; #528 content lock re-pinned. - 15-test synthetic-PDF suite (in-test assembly, no binary fixtures). Closes #512 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EA3EvegVqKrkM62u7k9PHF * fix(integrity): #512 round-1 cross-model review closures (3 P1 + 2 P2) - preflight: trailing-data-after-final-%%EOF veto (truncated incremental update otherwise PASSes on the older revision's agreeing counts); parser warnings survive early exits (appended in capture finally) - orchestrator: preflight moved to Stage 1 corpus intake, independent of the opt-in audit mode (audit-gated preflight left default runs sidecar-less at R-L3-1-D, gate-refusing valid citations); #528 content lock re-pinned - executable path: run_audit_pipeline(pdf_preflight_sidecars=...) tags manual_pdf page-anchor rows at the Step-6 emission point after cache resolution (cache hits cannot bypass; tag never enters cache body) - finalizer: [LOW-WARN-PDF-READ-INTEGRITY-UNVERIFIED] advisory on SUPPORTED rows carrying the tag (content-based-fallback support no longer renders the advisory invisible) - tests: preflight 15→18, +8 pipeline, +3 finalizer Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EA3EvegVqKrkM62u7k9PHF * fix(integrity): #512 round-2 cross-model review closures (7 P1) - preflight: xref-coverage cross-check (stale startxref pointing at a previous revision's xref with its own %%EOF now vetoes PASS); /Count must be a real integer object (float/string coercion rejected) - R-L3-1-D: FAIL vs UNAVAILABLE split - positive truncation evidence refuses the page anchor; absence of verification (standalone dispatch, no-Python installs, unpreflighted files) is an explicit-warning advisory, never a manufactured refusal (mirrors detection-vs- terminality precedent) - pipeline: retrieve_fn receives pdf_preflight_verdict on page-anchor citations so passage selection can go content-based BEFORE the judge reads a page-scoped passage; freshness (sha256 re-check) documented as the orchestrator's contract - orchestrator: sha256 re-check before dispatch; cross-runtime coverage (standalone deep-research/academic-paper dispatch, skipped Stage 1); tagged-SUPPORTED row added to the operational finalizer matrix; content lock re-pinned - tests: preflight 18→20, pipeline +1 (verdict-passing contract) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EA3EvegVqKrkM62u7k9PHF * fix(integrity): #512 round-3 cross-model review closures (2 P1) - xref-coverage: redefined-object variant caught - the newest raw copy of every directly-stored object must be the copy the active xref chain references (calibration guard skips offset-shifted files rather than mass-flagging) - trailing-data predicate uses ISO 32000 PDF whitespace: NUL padding after %%EOF passes, vertical tab is data - tests: preflight 20→23 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EA3EvegVqKrkM62u7k9PHF * fix(integrity): #512 round-4 cross-model review closure (1 P1) Object-header scan recognizes bare-CR line boundaries (ISO 32000 permits CR-only line endings; Python's (?m)^ does not treat CR as a line start, blinding both xref-coverage checks on CR-only files). Tests 23→25. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EA3EvegVqKrkM62u7k9PHF * fix(integrity): #512 round-5 cross-model review closures (2 P1) - header scan boundary/separator class extended to full ISO 32000 whitespace (NUL-preceded replacement headers are seen) - compressed-object variant: a direct raw replacement of an object whose active copy lives in an object stream, appended after its container with a stale startxref, vetoes PASS; a raw copy before the container (legitimate superseded-into-objstm update) stays clean - new ObjStm + cross-reference-stream fixture; tests 25→28 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EA3EvegVqKrkM62u7k9PHF * fix(integrity): #512 round-6 cross-model review closure (1 P1) Header scan accepts ten-digit object numbers (\d{1,9} blinded the coverage checks to replacements with object IDs >= 1e9). Tests 28→29. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EA3EvegVqKrkM62u7k9PHF * fix(integrity): #512 round-7 cross-model review closure (1 P1) Header-scan separators implement the full ISO 32000 lexer model: %-comments-to-EOL are token separators, so comment-obfuscated headers (e.g. '2 0%note\nobj') no longer hide from the coverage checks. This closes the lexical-variant family structurally rather than per-case. Tests 29→30. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EA3EvegVqKrkM62u7k9PHF * fix(integrity): #512 round-8 cross-model review closure (1 P1) Header-scan numeric tokens implement the full ISO 32000 integer form: optional sign and leading-zero padding (pypdf coerces via int(), so '+2 0 obj' / '00000000002 0 obj' are valid headers). Together with r7's separator model, both halves of the lexer are now structurally complete. Tests 30→31. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EA3EvegVqKrkM62u7k9PHF --------- Co-authored-by: Claude Fable 5 --- CHANGELOG.md | 4 + academic-paper/agents/draft_writer_agent.md | 3 +- .../agents/claim_ref_alignment_audit_agent.md | 2 + .../agents/pipeline_orchestrator_agent.md | 4 +- agents/report_compiler_agent.md | 3 +- agents/synthesis_agent.md | 3 +- deep-research/agents/report_compiler_agent.md | 3 +- deep-research/agents/synthesis_agent.md | 3 +- .../2026-07-20-512-pdf-read-preflight-spec.md | 140 ++++++ scripts/check_pipeline_boundary_semantics.py | 2 +- scripts/claim_audit_finalizer.py | 15 + scripts/claim_audit_pipeline.py | 71 ++- scripts/pdf_read_preflight.py | 372 +++++++++++++++ scripts/test_claim_audit_finalizer.py | 36 ++ scripts/test_claim_audit_pipeline.py | 105 +++++ scripts/test_pdf_read_preflight.py | 436 ++++++++++++++++++ 16 files changed, 1194 insertions(+), 8 deletions(-) create mode 100644 docs/design/2026-07-20-512-pdf-read-preflight-spec.md create mode 100644 scripts/pdf_read_preflight.py create mode 100644 scripts/test_pdf_read_preflight.py diff --git a/CHANGELOG.md b/CHANGELOG.md index eb16ef47..c85c3e03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### Added + +- **PDF read-integrity preflight for locally-extracted page anchors (#512).** Closes the local-extraction-channel gap between the v3.7.3 locator-presence rules and the #182 existence gate: PDF readers silently truncate documents with malformed cross-reference tables, so a real, correctly-cited source could acquire an apparently valid `page` anchor from a truncated or mispaginated read and pass every existing gate. New `scripts/pdf_read_preflight.py` (pypdf-backed with the `verify_submission_package.py` ImportError-degradation precedent; not a "grep the first `/Count`" check — xref streams, `/Prev` chains, and object streams ride pypdf's machinery) compares three independent page-count signals — the raw root page-tree `/Count`, the script's own cycle-guarded `/Kids`-walk leaf count, and pypdf's flattened page list — and emits a JSON sidecar (`pdf_read_preflight/1`: verdict + file sha256 + the three counts + captured parser-repair warnings). `PASS` requires all three to agree with zero parser warnings; count disagreement is `FAIL` (the truncation/mispagination signal itself); everything the preflight cannot vouch for — encryption, cycles, repair chatter even with agreeing counts, missing pypdf — is `UNAVAILABLE`. Enforcement sits upstream of the writers per the issue: the three v3.7.3 emitters gain firm rule R-L3-1-D (a locally-read PDF's `page` anchor requires a `PASS` sidecar in context; otherwise `anchor:none` or an independently-visible locator + explicit warning), `claim_ref_alignment_audit_agent` Step 4 gains the precondition bound to the existing `ref_retrieval_method == manual_pdf` discriminator (sidecars join on `ref_slug`; the sha256 is confirmatory until #513 supplies an anchor-side hash field) with the `[pdf_read_integrity_unverified]` advisory rationale tag (never an UNSUPPORTED verdict on this basis alone), and the §3.6 orchestrator — the layer that CAN run Bash — runs the preflight once per locally-read corpus PDF and passes sidecars into audit and drafting context. Cross-model review rounds 1-8 (3 P1 + 2 P2, then 7, 2, 1, 2, 1, 1, 1 P1, all closed): rounds 6-8 finished the header scan's lexer fidelity (ten-digit object numbers; %-comment token separators; signed and zero-padded integer tokens — the scan now implements the full ISO 32000 separator AND numeric-token model, closing the lexical-obfuscation family structurally); round 5 extended the header scan's boundary/separator class to full ISO 32000 whitespace (NUL-preceded replacement headers are seen) and added the compressed-object variant (a direct raw replacement of an object whose active copy lives inside an object stream, appended after its container with a stale startxref, now vetoes PASS; a raw copy before the container is the legitimate superseded-into-objstm case and stays clean); round 4 fixed the object-header scan for ISO-valid bare-CR line endings (Python's multiline anchor does not treat CR as a line start, which blinded both xref-coverage checks on CR-only files); round 3 hardened the stale-startxref check against redefined-object-number variants (the newest raw copy of every directly-stored object must be the copy the active xref chain references, with a calibration guard for offset-shifted files) and switched the trailing-data predicate to ISO 32000 §7.2.2 PDF whitespace (NUL padding passes, vertical tab is data). Round 2 added the stale-startxref xref-coverage cross-check (raw object headers absent from the active xref chain veto PASS), plus a strict integer-object requirement on `/Count` (float/string coercion rejected), the FAIL-vs-UNAVAILABLE split in R-L3-1-D (positive truncation evidence refuses the page anchor; mere absence of verification — standalone dispatch, no-Python installs, unpreflighted files — is an explicit-warning advisory, never a manufactured refusal), the `pdf_preflight_verdict` key on citations handed to `retrieve_fn` (content-based passage selection BEFORE the judge reads a page-scoped passage), the orchestrator sha256 freshness re-check + cross-runtime coverage note, and the tagged-SUPPORTED row in the operational finalizer matrix. Round 1: trailing-data-after-final-`%%EOF` veto (a truncated incremental update otherwise PASSes on the older revision's agreeing counts); preflight moved to Stage 1 corpus intake, independent of the opt-in audit mode (an audit-gated preflight left default-mode runs sidecar-less at R-L3-1-D, gate-refusing valid citations); the executable-path enforcement — `run_audit_pipeline(pdf_preflight_sidecars=...)` tags rows at the Step-6 emission point after cache resolution so cache hits cannot bypass, and the finalizer surfaces `[LOW-WARN-PDF-READ-INTEGRITY-UNVERIFIED]` on SUPPORTED rows so content-based-fallback support does not render the advisory invisible; parser warnings survive early exits (appended in the capture handler's finally). 18-test synthetic-PDF suite + 8 pipeline/3 finalizer tests (no binary fixtures); `agents/` mirrors re-synced; the #528 orchestrator content lock re-pinned per its documented procedure. Provenance: mechanism observed in kengo006/alexandria; ranked P1 of three in the 2026-07-11 dual-track adoption review. Spec: `docs/design/2026-07-20-512-pdf-read-preflight-spec.md`. + ### Fixed - **SETUP Method 4a description-length figure de-drifted.** `docs/SETUP.md` / `docs/SETUP.zh-TW.md` stated the four skill `description` fields "currently sit in the 440-842 range"; the actual lengths have since grown to 566-986 characters. Replaced the hardcoded range with the durable comparative statement (each exceeds claude.ai's 200-character upload cap while staying under Claude Code's 1,024-character allowance), so the sentence cannot silently drift again as descriptions evolve. Rationale for not trimming the descriptions themselves is unchanged. diff --git a/academic-paper/agents/draft_writer_agent.md b/academic-paper/agents/draft_writer_agent.md index 1e1e849d..5da49f0f 100644 --- a/academic-paper/agents/draft_writer_agent.md +++ b/academic-paper/agents/draft_writer_agent.md @@ -437,11 +437,12 @@ Anchor kinds (closed enum): Full example: `Smith (2024) `. -Three firm rules: +Four firm rules: - **R-L3-1-A (production-mandatory locator):** During drafting, every visible citation MUST carry an anchor with `` ≠ `none`. The finalizer treats `` as MED-WARN-NO-LOCATOR (gate-refused). Emitting `none` does NOT bypass the gate — it triggers it. Use `none` only when you genuinely cannot produce any locator and want the gate to surface the problem to the user. - **R-L3-1-B (quote length cap):** When `` = `quote`, the URL-decoded value MUST be ≤25 words by whitespace split (per `shared/references/word_count_conventions.md`). Quotes exceeding 25 words MUST be replaced by `page` or `section` locator. - **R-L3-1-C (no anchor reading by emitting agents):** Generate the `` value from the corpus context already in this prompt (the same context that provides the slug). You MUST NOT read entry frontmatter to discover anchor candidates — that breaks the v3.6.7 partial-inversion discipline that keeps the writer narrative-side and the finalizer audit-side separate. If the corpus context does not include enough source detail to produce a verifiable locator, emit `` and let the gate surface it. +- **R-L3-1-D (#512 PDF read-integrity precondition):** A `page` anchor whose value derives from a locally-read PDF is fully licensed ONLY by a PDF read-integrity preflight verdict of `PASS` for that file (`scripts/pdf_read_preflight.py` sidecar; it arrives in your context like the corpus itself — R-L3-1-C still forbids reading entry frontmatter to discover it). Two non-PASS regimes, strict where there is evidence and advisory where there is only absence: (1) verdict `FAIL` — positive truncation/mispagination evidence — do NOT trust the page number: emit `` (the existing gate then surfaces it) or an independently-visible non-page locator (`section` / `paragraph` grounded in text visible in your context), plus an explicit PDF-integrity warning line. (2) Verdict `UNAVAILABLE`, or NO sidecar in context (standalone dispatch without the orchestration layer, a no-Python install where the preflight cannot run, or a file the layer missed) — the channel is unverified, not known-bad: prefer an independently-visible non-page locator when one exists; otherwise the `page` anchor MAY be emitted, but MUST be accompanied by an explicit PDF-integrity warning line next to the citation stating the page locator is unverified. Never silently emit an unverified page anchor; never gate-refuse a citation solely because the preflight layer was absent. Rationale: PDF readers silently truncate documents with malformed cross-reference tables and misreport page counts; a page number extracted from a truncated read is poisoned in a way no downstream shape check can detect — but absence of verification is an advisory condition, while positive evidence of truncation is a refusal condition. URL-encoding for `quote:` values uses standard percent-encoding (`%20` for space, `%2C` for comma, `%3A` for colon, etc.) **AND additionally percent-encodes any consecutive run of two or more hyphen characters: `--` MUST be written as `%2D%2D`** (and `---` as `%2D%2D%2D`, etc.). Standard RFC 3986 encoding treats `-` as an unreserved character and does NOT encode it, but a quote containing `--` (e.g., from an em-dash, a divider, or a nested HTML comment opener) would leave a literal `--` in the anchor value that prematurely closes the HTML comment. A single hyphen between word characters (e.g., `AI-generated`, `well-known`) is safe and may remain raw. Always percent-encode space, comma, colon, AND any consecutive-hyphen run. Never rely on the absence of `-->` in the quoted text. v3.7.3 gemini review F1 + codex round-6 F15 closure (prompt-vs-lint alignment). diff --git a/academic-pipeline/agents/claim_ref_alignment_audit_agent.md b/academic-pipeline/agents/claim_ref_alignment_audit_agent.md index 4368cbd7..c3ea0131 100644 --- a/academic-pipeline/agents/claim_ref_alignment_audit_agent.md +++ b/academic-pipeline/agents/claim_ref_alignment_audit_agent.md @@ -163,6 +163,8 @@ Use `anchor_value` to locate the relevant passage inside `retrieved_excerpt`: The located passage is what the judge sees. If `quote` mode fails to locate the exact substring, fall back to passing the full retrieved excerpt with a `[anchor_quote_unlocated]` rationale tag — do NOT mark the citation UNSUPPORTED on a locator miss alone. +**PDF read-integrity precondition for `page` anchors (#512):** applies to rows whose Step 2 `ref_retrieval_method` is `manual_pdf` — the machine-readable "locally-read PDF" signal; do NOT re-infer the channel from prose context. For those rows, the orchestrator supplies `scripts/pdf_read_preflight.py` sidecars keyed by `ref_slug`; the sidecar's `sha256` is confirmatory when the corpus entry's `source_pointer` resolves to a hashable file, not the primary join key (until the #513 read-ledger lands, no anchor-side field carries a file hash to match against). Treat the page number as a trustworthy retrieval scope only when the row's sidecar verdict is `PASS`. On a missing sidecar or a `FAIL` / `UNAVAILABLE` verdict, do NOT mark the citation UNSUPPORTED on this basis alone: locate the passage by content instead of by the untrusted page number, and tag the audit row's rationale with `[pdf_read_integrity_unverified]` so the finding surfaces downstream (advisory — terminality stays with the existing formatter-gate machinery). In the executable pipeline this is enforced in code, not prose: `run_audit_pipeline(pdf_preflight_sidecars=...)` (`scripts/claim_audit_pipeline.py`, keyed by `ref_slug`) appends the tag at the Step-6 emission point for every completed `manual_pdf` page-anchor row without a `PASS` sidecar — after cache resolution, so cache hits cannot bypass it — and the finalizer surfaces `[LOW-WARN-PDF-READ-INTEGRITY-UNVERIFIED]` on SUPPORTED rows carrying the tag (`scripts/claim_audit_finalizer.py`), so the advisory is visible even when content-based fallback finds support. Rationale: PDF readers silently truncate documents with malformed cross-reference tables; a page number from a truncated or mispaginated read can look perfectly well-formed. + ### Step 5 — Judge invocation The judge is invoked ONCE per citation with both the alignment question and the active-constraints set in the same call. The unified contract produces a single verdict in `{SUPPORTED, UNSUPPORTED, AMBIGUOUS, PARTIAL, VIOLATED}` so the pipeline can dispatch on it without a second round-trip. diff --git a/academic-pipeline/agents/pipeline_orchestrator_agent.md b/academic-pipeline/agents/pipeline_orchestrator_agent.md index 13bba98d..22f2bbea 100644 --- a/academic-pipeline/agents/pipeline_orchestrator_agent.md +++ b/academic-pipeline/agents/pipeline_orchestrator_agent.md @@ -429,6 +429,7 @@ The cost is multiplicative: a 10-stage pipeline with cross-model enabled produce - All in-text citations with their resolved `` + `` marker pairs (post-finalizer) - The `claim_intent_manifests[]` aggregate from the writing-stage agents (per spec §3.2 + the v3.8 "Claim Intent Manifest Emission" sibling sections on `synthesis_agent` / `draft_writer_agent` / `report_compiler_agent`) - The `literature_corpus[]` aggregate (retrieval input) +- **PDF read-integrity sidecars (#512).** The orchestrator runs `python scripts/pdf_read_preflight.py --output .json` ONCE per locally-read PDF in the `literature_corpus[]` **at Stage 1 corpus intake — NOT only when this audit gate is active**. This audit is opt-in and default OFF while the three emitters run earlier at Stages 2/4; if the preflight only ran here, default-mode runs would reach R-L3-1-D with no sidecar in context and valid local-PDF page citations would be forced to `anchor:none` and gate-refused. Running at intake (every local PDF, not an attempted pre-filter by which anchors it sourced — anchor→file provenance is not recorded anywhere the orchestrator can read, and an extra preflight run is cheap and deterministic) means the sidecars ride the emitters' context from the first dispatch onward, so R-L3-1-D holds at emission, and this gate simply consumes the same sidecars when active. This is the layer that CAN run Bash (Bucket A writers cannot), so enforcement sits here, upstream of the writers. Pass the sidecars keyed by `ref_slug` (verdict `PASS`/`FAIL`/`UNAVAILABLE` + file `sha256` + declared/enumerated/reader page counts; the hash is confirmatory, and the natural #513 join key later): `PASS` licenses page-scoped retrieval; `FAIL`/`UNAVAILABLE`/missing routes the row to the `[pdf_read_integrity_unverified]` advisory path. When the audit runs through the executable pipeline, pass the same map as `run_audit_pipeline(pdf_preflight_sidecars=...)` so the tag is applied on the executable path too, cache hits included. **Freshness:** before every dispatch that consumes sidecars, re-hash each PDF and compare against its sidecar `sha256`; on mismatch (file replaced since intake) re-run the preflight — a stale `PASS` must never license new bytes. **Coverage beyond this pipeline:** when the emitters are dispatched standalone by `deep-research` / `academic-paper`, or Stage 1 is skipped for user-supplied research, whatever layer ingests local PDFs runs the same preflight before the first emitter dispatch; where no layer can run it (e.g. a no-Python install), R-L3-1-D's no-sidecar regime applies (advisory warning, never a refusal manufactured by the missing layer). - **The Stage 4 draft sentence stream — all uncited sentences with `sentence_text` + `section_path` + optional `adjacent_text`** (the surrounding 1–3 clauses for context). Required for the §4 step 5 stream (d) `constraint_violations[]` HIGH-WARN path (any uncited sentence whose scope matches an MNC/NC rule + judge returns VIOLATED) AND the §4 step 6 `uncited_assertions[]` LOW-WARN advisory path. Without this stream the `[HIGH-WARN-CONSTRAINT-VIOLATION-UNCITED]` gate-refuse annotation cannot fire for author-declared MUST-NOT violations that carry no citation. See `claim_ref_alignment_audit_agent.md` Input contract for the full schema. **Outputs feeding formatter hard gate (same Stage 5 pass).** @@ -447,10 +448,11 @@ The cost is multiplicative: a 10-stage pipeline with cross-model enabled produce - Per-stage `defect_stage` histogram appendix (renders when ≥5 completed entries via `scripts/claim_audit_finalizer.py:render_stage6_histogram`) — added to the existing Stage 6 AI Self-Reflection Report after gate pass. -**Finalizer matrix (8-row).** The matrix discriminates the previously-conflated paywall vs anchorless cases by reading `ref_retrieval_method` alongside `(judgment, defect_stage)`. Rows are evaluated top-to-bottom, first match wins. Spec source-of-truth: §5. Implementation: `scripts/claim_audit_finalizer.py:classify_claim_audit_result`. +**Finalizer matrix (8-row + one #512 conditional).** The matrix discriminates the previously-conflated paywall vs anchorless cases by reading `ref_retrieval_method` alongside `(judgment, defect_stage)`. Rows are evaluated top-to-bottom, first match wins. Spec source-of-truth: §5 (+ the #512 spec for the tagged-SUPPORTED row). Implementation: `scripts/claim_audit_finalizer.py:classify_claim_audit_result`. | `judgment` | `defect_stage` | `ref_retrieval_method` | Annotation | Severity Tier | Gate behavior | |---|---|---|---|---|---| +| SUPPORTED, rationale contains `[pdf_read_integrity_unverified]` (#512) | `null` | (any) | `[LOW-WARN-PDF-READ-INTEGRITY-UNVERIFIED]` | LOW-WARN | pass | | SUPPORTED | `null` | (any) | (no annotation) | — | pass | | AMBIGUOUS | source_description / citation_anchor / synthesis_overclaim / null | (any) | `[CLAIM-AUDIT-AMBIGUOUS]` | LOW-WARN | pass | | UNSUPPORTED | source_description / metadata / citation_anchor / synthesis_overclaim | (any) | `[HIGH-WARN-CLAIM-NOT-SUPPORTED]` | HIGH-WARN | gate-refuse | diff --git a/agents/report_compiler_agent.md b/agents/report_compiler_agent.md index 1f3f0809..a2b5dca0 100644 --- a/agents/report_compiler_agent.md +++ b/agents/report_compiler_agent.md @@ -243,11 +243,12 @@ Anchor kinds (closed enum): Full example: `Smith (2024) `. -Three firm rules: +Four firm rules: - **R-L3-1-A (production-mandatory locator):** During compilation, every visible citation MUST carry an anchor with `` ≠ `none`. The finalizer treats `` as MED-WARN-NO-LOCATOR (gate-refused). Emitting `none` does NOT bypass the gate — it triggers it. Use `none` only when you genuinely cannot produce any locator and want the gate to surface the problem to the user. - **R-L3-1-B (quote length cap):** When `` = `quote`, the URL-decoded value MUST be ≤25 words by whitespace split (per `shared/references/word_count_conventions.md`). Quotes exceeding 25 words MUST be replaced by `page` or `section` locator. - **R-L3-1-C (no anchor reading by emitting agents):** Generate the `` value from the corpus context already in this prompt (the same context that provides the slug). You MUST NOT read entry frontmatter to discover anchor candidates — that breaks the v3.6.7 partial-inversion discipline that keeps the compiler narrative-side and the finalizer audit-side separate. If the corpus context does not include enough source detail to produce a verifiable locator, emit `` and let the gate surface it. +- **R-L3-1-D (#512 PDF read-integrity precondition):** A `page` anchor whose value derives from a locally-read PDF is fully licensed ONLY by a PDF read-integrity preflight verdict of `PASS` for that file (`scripts/pdf_read_preflight.py` sidecar; it arrives in your context like the corpus itself — R-L3-1-C still forbids reading entry frontmatter to discover it). Two non-PASS regimes, strict where there is evidence and advisory where there is only absence: (1) verdict `FAIL` — positive truncation/mispagination evidence — do NOT trust the page number: emit `` (the existing gate then surfaces it) or an independently-visible non-page locator (`section` / `paragraph` grounded in text visible in your context), plus an explicit PDF-integrity warning line. (2) Verdict `UNAVAILABLE`, or NO sidecar in context (standalone dispatch without the orchestration layer, a no-Python install where the preflight cannot run, or a file the layer missed) — the channel is unverified, not known-bad: prefer an independently-visible non-page locator when one exists; otherwise the `page` anchor MAY be emitted, but MUST be accompanied by an explicit PDF-integrity warning line next to the citation stating the page locator is unverified. Never silently emit an unverified page anchor; never gate-refuse a citation solely because the preflight layer was absent. Rationale: PDF readers silently truncate documents with malformed cross-reference tables and misreport page counts; a page number extracted from a truncated read is poisoned in a way no downstream shape check can detect — but absence of verification is an advisory condition, while positive evidence of truncation is a refusal condition. URL-encoding for `quote:` values uses standard percent-encoding (`%20` for space, `%2C` for comma, `%3A` for colon, etc.) **AND additionally percent-encodes any consecutive run of two or more hyphen characters: `--` MUST be written as `%2D%2D`** (and `---` as `%2D%2D%2D`, etc.). Standard RFC 3986 encoding treats `-` as an unreserved character and does NOT encode it, but a quote containing `--` (e.g., from an em-dash, a divider, or a nested HTML comment opener) would leave a literal `--` in the anchor value that prematurely closes the HTML comment. A single hyphen between word characters (e.g., `AI-generated`, `well-known`) is safe and may remain raw. Always percent-encode space, comma, colon, AND any consecutive-hyphen run. Never rely on the absence of `-->` in the quoted text. v3.7.3 gemini review F1 + codex round-6 F15 closure (prompt-vs-lint alignment). diff --git a/agents/synthesis_agent.md b/agents/synthesis_agent.md index 79258e99..43ce65b0 100644 --- a/agents/synthesis_agent.md +++ b/agents/synthesis_agent.md @@ -289,11 +289,12 @@ Anchor kinds (closed enum): Full example: `Smith (2024) `. -Three firm rules: +Four firm rules: - **R-L3-1-A (production-mandatory locator):** During synthesis emission, every visible citation MUST carry an anchor with `` ≠ `none`. The finalizer treats `` as MED-WARN-NO-LOCATOR (gate-refused). Emitting `none` does NOT bypass the gate — it triggers it. Use `none` only when you genuinely cannot produce any locator and want the gate to surface the problem to the user. - **R-L3-1-B (quote length cap):** When `` = `quote`, the URL-decoded value MUST be ≤25 words by whitespace split (per `shared/references/word_count_conventions.md`). Quotes exceeding 25 words MUST be replaced by `page` or `section` locator. - **R-L3-1-C (no anchor reading by emitting agents):** Generate the `` value from the corpus context already in this prompt (the same context that provides the slug). You MUST NOT read entry frontmatter to discover anchor candidates — that breaks the v3.6.7 partial-inversion discipline that keeps the agent narrative-side and the finalizer audit-side separate. If the corpus context does not include enough source detail to produce a verifiable locator, emit `` and let the gate surface it. +- **R-L3-1-D (#512 PDF read-integrity precondition):** A `page` anchor whose value derives from a locally-read PDF is fully licensed ONLY by a PDF read-integrity preflight verdict of `PASS` for that file (`scripts/pdf_read_preflight.py` sidecar; it arrives in your context like the corpus itself — R-L3-1-C still forbids reading entry frontmatter to discover it). Two non-PASS regimes, strict where there is evidence and advisory where there is only absence: (1) verdict `FAIL` — positive truncation/mispagination evidence — do NOT trust the page number: emit `` (the existing gate then surfaces it) or an independently-visible non-page locator (`section` / `paragraph` grounded in text visible in your context), plus an explicit PDF-integrity warning line. (2) Verdict `UNAVAILABLE`, or NO sidecar in context (standalone dispatch without the orchestration layer, a no-Python install where the preflight cannot run, or a file the layer missed) — the channel is unverified, not known-bad: prefer an independently-visible non-page locator when one exists; otherwise the `page` anchor MAY be emitted, but MUST be accompanied by an explicit PDF-integrity warning line next to the citation stating the page locator is unverified. Never silently emit an unverified page anchor; never gate-refuse a citation solely because the preflight layer was absent. Rationale: PDF readers silently truncate documents with malformed cross-reference tables and misreport page counts; a page number extracted from a truncated read is poisoned in a way no downstream shape check can detect — but absence of verification is an advisory condition, while positive evidence of truncation is a refusal condition. URL-encoding for `quote:` values uses standard percent-encoding (`%20` for space, `%2C` for comma, `%3A` for colon, etc.) **AND additionally percent-encodes any consecutive run of two or more hyphen characters: `--` MUST be written as `%2D%2D`** (and `---` as `%2D%2D%2D`, etc.). Standard RFC 3986 encoding treats `-` as an unreserved character and does NOT encode it, but a quote containing `--` (e.g., from an em-dash, a divider, or a nested HTML comment opener) would leave a literal `--` in the anchor value that prematurely closes the HTML comment. A single hyphen between word characters (e.g., `AI-generated`, `well-known`) is safe and may remain raw. Always percent-encode space, comma, colon, AND any consecutive-hyphen run. Never rely on the absence of `-->` in the quoted text. v3.7.3 gemini review F1 + codex round-6 F15 closure (prompt-vs-lint alignment). diff --git a/deep-research/agents/report_compiler_agent.md b/deep-research/agents/report_compiler_agent.md index 1f3f0809..a2b5dca0 100644 --- a/deep-research/agents/report_compiler_agent.md +++ b/deep-research/agents/report_compiler_agent.md @@ -243,11 +243,12 @@ Anchor kinds (closed enum): Full example: `Smith (2024) `. -Three firm rules: +Four firm rules: - **R-L3-1-A (production-mandatory locator):** During compilation, every visible citation MUST carry an anchor with `` ≠ `none`. The finalizer treats `` as MED-WARN-NO-LOCATOR (gate-refused). Emitting `none` does NOT bypass the gate — it triggers it. Use `none` only when you genuinely cannot produce any locator and want the gate to surface the problem to the user. - **R-L3-1-B (quote length cap):** When `` = `quote`, the URL-decoded value MUST be ≤25 words by whitespace split (per `shared/references/word_count_conventions.md`). Quotes exceeding 25 words MUST be replaced by `page` or `section` locator. - **R-L3-1-C (no anchor reading by emitting agents):** Generate the `` value from the corpus context already in this prompt (the same context that provides the slug). You MUST NOT read entry frontmatter to discover anchor candidates — that breaks the v3.6.7 partial-inversion discipline that keeps the compiler narrative-side and the finalizer audit-side separate. If the corpus context does not include enough source detail to produce a verifiable locator, emit `` and let the gate surface it. +- **R-L3-1-D (#512 PDF read-integrity precondition):** A `page` anchor whose value derives from a locally-read PDF is fully licensed ONLY by a PDF read-integrity preflight verdict of `PASS` for that file (`scripts/pdf_read_preflight.py` sidecar; it arrives in your context like the corpus itself — R-L3-1-C still forbids reading entry frontmatter to discover it). Two non-PASS regimes, strict where there is evidence and advisory where there is only absence: (1) verdict `FAIL` — positive truncation/mispagination evidence — do NOT trust the page number: emit `` (the existing gate then surfaces it) or an independently-visible non-page locator (`section` / `paragraph` grounded in text visible in your context), plus an explicit PDF-integrity warning line. (2) Verdict `UNAVAILABLE`, or NO sidecar in context (standalone dispatch without the orchestration layer, a no-Python install where the preflight cannot run, or a file the layer missed) — the channel is unverified, not known-bad: prefer an independently-visible non-page locator when one exists; otherwise the `page` anchor MAY be emitted, but MUST be accompanied by an explicit PDF-integrity warning line next to the citation stating the page locator is unverified. Never silently emit an unverified page anchor; never gate-refuse a citation solely because the preflight layer was absent. Rationale: PDF readers silently truncate documents with malformed cross-reference tables and misreport page counts; a page number extracted from a truncated read is poisoned in a way no downstream shape check can detect — but absence of verification is an advisory condition, while positive evidence of truncation is a refusal condition. URL-encoding for `quote:` values uses standard percent-encoding (`%20` for space, `%2C` for comma, `%3A` for colon, etc.) **AND additionally percent-encodes any consecutive run of two or more hyphen characters: `--` MUST be written as `%2D%2D`** (and `---` as `%2D%2D%2D`, etc.). Standard RFC 3986 encoding treats `-` as an unreserved character and does NOT encode it, but a quote containing `--` (e.g., from an em-dash, a divider, or a nested HTML comment opener) would leave a literal `--` in the anchor value that prematurely closes the HTML comment. A single hyphen between word characters (e.g., `AI-generated`, `well-known`) is safe and may remain raw. Always percent-encode space, comma, colon, AND any consecutive-hyphen run. Never rely on the absence of `-->` in the quoted text. v3.7.3 gemini review F1 + codex round-6 F15 closure (prompt-vs-lint alignment). diff --git a/deep-research/agents/synthesis_agent.md b/deep-research/agents/synthesis_agent.md index 79258e99..43ce65b0 100644 --- a/deep-research/agents/synthesis_agent.md +++ b/deep-research/agents/synthesis_agent.md @@ -289,11 +289,12 @@ Anchor kinds (closed enum): Full example: `Smith (2024) `. -Three firm rules: +Four firm rules: - **R-L3-1-A (production-mandatory locator):** During synthesis emission, every visible citation MUST carry an anchor with `` ≠ `none`. The finalizer treats `` as MED-WARN-NO-LOCATOR (gate-refused). Emitting `none` does NOT bypass the gate — it triggers it. Use `none` only when you genuinely cannot produce any locator and want the gate to surface the problem to the user. - **R-L3-1-B (quote length cap):** When `` = `quote`, the URL-decoded value MUST be ≤25 words by whitespace split (per `shared/references/word_count_conventions.md`). Quotes exceeding 25 words MUST be replaced by `page` or `section` locator. - **R-L3-1-C (no anchor reading by emitting agents):** Generate the `` value from the corpus context already in this prompt (the same context that provides the slug). You MUST NOT read entry frontmatter to discover anchor candidates — that breaks the v3.6.7 partial-inversion discipline that keeps the agent narrative-side and the finalizer audit-side separate. If the corpus context does not include enough source detail to produce a verifiable locator, emit `` and let the gate surface it. +- **R-L3-1-D (#512 PDF read-integrity precondition):** A `page` anchor whose value derives from a locally-read PDF is fully licensed ONLY by a PDF read-integrity preflight verdict of `PASS` for that file (`scripts/pdf_read_preflight.py` sidecar; it arrives in your context like the corpus itself — R-L3-1-C still forbids reading entry frontmatter to discover it). Two non-PASS regimes, strict where there is evidence and advisory where there is only absence: (1) verdict `FAIL` — positive truncation/mispagination evidence — do NOT trust the page number: emit `` (the existing gate then surfaces it) or an independently-visible non-page locator (`section` / `paragraph` grounded in text visible in your context), plus an explicit PDF-integrity warning line. (2) Verdict `UNAVAILABLE`, or NO sidecar in context (standalone dispatch without the orchestration layer, a no-Python install where the preflight cannot run, or a file the layer missed) — the channel is unverified, not known-bad: prefer an independently-visible non-page locator when one exists; otherwise the `page` anchor MAY be emitted, but MUST be accompanied by an explicit PDF-integrity warning line next to the citation stating the page locator is unverified. Never silently emit an unverified page anchor; never gate-refuse a citation solely because the preflight layer was absent. Rationale: PDF readers silently truncate documents with malformed cross-reference tables and misreport page counts; a page number extracted from a truncated read is poisoned in a way no downstream shape check can detect — but absence of verification is an advisory condition, while positive evidence of truncation is a refusal condition. URL-encoding for `quote:` values uses standard percent-encoding (`%20` for space, `%2C` for comma, `%3A` for colon, etc.) **AND additionally percent-encodes any consecutive run of two or more hyphen characters: `--` MUST be written as `%2D%2D`** (and `---` as `%2D%2D%2D`, etc.). Standard RFC 3986 encoding treats `-` as an unreserved character and does NOT encode it, but a quote containing `--` (e.g., from an em-dash, a divider, or a nested HTML comment opener) would leave a literal `--` in the anchor value that prematurely closes the HTML comment. A single hyphen between word characters (e.g., `AI-generated`, `well-known`) is safe and may remain raw. Always percent-encode space, comma, colon, AND any consecutive-hyphen run. Never rely on the absence of `-->` in the quoted text. v3.7.3 gemini review F1 + codex round-6 F15 closure (prompt-vs-lint alignment). diff --git a/docs/design/2026-07-20-512-pdf-read-preflight-spec.md b/docs/design/2026-07-20-512-pdf-read-preflight-spec.md new file mode 100644 index 00000000..ecf12852 --- /dev/null +++ b/docs/design/2026-07-20-512-pdf-read-preflight-spec.md @@ -0,0 +1,140 @@ +# #512 — PDF read-integrity preflight for locally-extracted page/quote anchors + +**Date:** 2026-07-20 · **Issue:** #512 · **Status:** implemented in the same PR + +## Problem + +The v3.7.3 Three-Layer Citation Emission guards locator *presence* and the #182 gate guards +citation *existence*, but nothing guards the **local extraction channel** those locators come +from. PDF readers silently truncate documents with malformed cross-reference tables and +misreport page counts; a real, correctly-cited source can then acquire an apparently valid +`page` anchor derived from a truncated or mispaginated read and pass every existing gate +(the emitters anchor in good faith from poisoned context; the v3.7.3 lint checks anchor +shape, not faithfulness; the #182 gate reduces anchors to a kind-only boolean). + +Provenance: mechanism observed in kengo006/alexandria (page-tree `/Count` cross-check before +trusting page numbers); dual-track in-repo verification (2026-07-11) confirmed the gap. + +## Design + +Two layers, enforcement upstream of the writers (Bucket A agents cannot run Bash): + +### Layer 1 — `scripts/pdf_read_preflight.py` + +Stdlib CLI + `pypdf` for object plumbing, following the repo's existing +`verify_submission_package.py` precedent (`try: import pypdf / except ImportError: pypdf = +None`; CI installs it via `requirements-dev.txt`, local runs without it degrade). Not "grep +the first `/Count`": pypdf's xref machinery covers classic tables, xref streams, `/Prev` +incremental-update chains, and object streams; the script then computes **three independent +page-count signals** on top of it: + +1. `declared_page_count` — the root page tree's `/Count`, read from the raw `/Root → /Pages` + object (not from pypdf's page list). +2. `enumerated_page_count` — a recursive walk of `/Kids`, counting `/Type /Page` leaves, + with a visited-set cycle guard and a node budget. +3. `reader_page_count` — `len(reader.pages)` (pypdf's own flattening), as a third opinion. + +Parser warnings are captured from the `pypdf` logger (repair chatter is exactly the +"silently repaired xref" signal the issue names) and recorded. + +**Trailing-data check** (cross-model review round 1, P1): a PDF truncated partway through +an incremental update keeps an OLDER valid `%%EOF`; pypdf silently reads that previous +revision, so all three counts agree on the OLD page tree — the exact truncation case the +preflight exists to catch. Non-whitespace bytes after the LAST `%%EOF` are that +signature: recorded as a `trailing-data` warning and a PASS veto. Complete incremental +updates always end with their own `%%EOF`, so legitimate multi-revision files pass. + +**Verdict** (single enum, mirrors the repo's PASS-posture vocabulary): + +| Verdict | Condition | +|---|---| +| `PASS` | all three counts agree, count > 0, no captured parser warnings, no trailing data after the final `%%EOF` | +| `FAIL` | parse completed but the counts disagree — the truncation/mispagination signal | +| `UNAVAILABLE` | anything preventing a confident parse: unreadable/missing file, encryption, missing/malformed page tree, cycle or node-budget hit, pypdf not installed, count agreement but parser-repair warnings or trailing data present | + +Parser warnings captured before a structural failure survive every early exit (they are +appended in the capture handler's `finally`), so a repair warning that preceded a later +encryption/tree error still reaches the sidecar. + +`UNAVAILABLE` (not `FAIL`) on repair warnings with agreeing counts: a repaired read may +still be complete, but the preflight cannot vouch for it — and only `PASS` licenses a page +anchor downstream, so the conservative bucket is the honest one. + +**Sidecar** — JSON to stdout or `--output`; shape (`schema: "pdf_read_preflight/1"`): + +```json +{ + "schema": "pdf_read_preflight/1", + "verdict": "PASS | FAIL | UNAVAILABLE", + "file": "", + "sha256": "", + "declared_page_count": 12, + "enumerated_page_count": 12, + "reader_page_count": 12, + "warnings": [""], + "generated_at": "", + "tool": "pdf_read_preflight/" +} +``` + +Exit code 0 whenever a verdict was produced (the verdict is data, not an error); 2 on usage +errors only — so orchestration can always consume the JSON without exit-code branching. + +### Layer 2 — prompt rules + +- **Three emitters** (`synthesis_agent`, `draft_writer_agent`, `report_compiler_agent`): a + `PDF Read-Integrity Precondition (#512)` rule appended inside the existing + `## Three-Layer Citation Emission (v3.7.3)` section — a `page` anchor whose value derives + from a locally-read PDF may be emitted only when the orchestration layer supplied a + preflight `PASS` for that file; on `FAIL`/`UNAVAILABLE` (or no sidecar), emit + `anchor:none` (the existing precedence-zero NO-LOCATOR machinery then surfaces it) or an + independently-visible non-page locator, plus an explicit PDF-integrity warning line. The + R-L3-1-C no-frontmatter-reads inversion is untouched: the sidecar verdict arrives in + context like the corpus itself. +- **`claim_ref_alignment_audit_agent`** (the Stage 4→5 L3 audit): the precondition binds to + the existing Step 2 `ref_retrieval_method == manual_pdf` discriminator (the machine-readable + "locally-read PDF" signal), not a re-inferred prose test. Sidecars join on `ref_slug`; the + sidecar `sha256` is confirmatory only — until #513's read ledger lands, no anchor-side field + carries a file hash, so a hash cannot be the primary key. Non-`PASS` or missing sidecar + becomes the `[pdf_read_integrity_unverified]` advisory rationale tag (never an UNSUPPORTED + verdict on this basis alone — terminality stays with the existing formatter gate machinery). +- **Executable audit path** (cross-model review round 1, P1): the prose rule alone never + executes in `scripts/claim_audit_pipeline.py`. `run_audit_pipeline` gains + `pdf_preflight_sidecars: dict[ref_slug → sidecar] | None` — `None` (unwired caller) is + byte-equivalent legacy; a provided map tags every completed `manual_pdf` page-anchor row + without a `PASS` sidecar at the single Step-6 emission point, AFTER cache resolution, so a + cache hit cannot bypass the check and the tag never enters the cached judge body. The tag + is appended (INV-6/INV-14 `startswith` contracts untouched) within the rationale budget. + `claim_audit_finalizer.classify_claim_audit_result` surfaces + `[LOW-WARN-PDF-READ-INTEGRITY-UNVERIFIED]` (advisory, never gate-refuse) on SUPPORTED rows + carrying the tag — otherwise the expected common case (content-based fallback finds + support) would render the advisory invisible at the formatter. +- **`pipeline_orchestrator_agent`** (the layer that CAN run Bash): run the preflight once per + locally-read PDF in the `literature_corpus[]` **at Stage 1 corpus intake, independent of + audit mode** (cross-model review round 1, P1: the Stage 4→5 audit is opt-in default OFF + while the emitters run earlier — an audit-gated preflight would leave default-mode runs + sidecar-less at R-L3-1-D, forcing valid local-PDF page citations to `anchor:none` and a + gate refusal). Deliberately NOT "only PDFs that sourced a page anchor": anchor→file + provenance is not recorded anywhere the orchestrator can read, an extra preflight is cheap + and deterministic, and the audit side narrows via `manual_pdf`. Sidecars ride the emitters' + and audit contexts keyed by `ref_slug`. This file is one of the five #528 content-locked + surfaces — the `CONTENT_LOCKS` hash in `scripts/check_pipeline_boundary_semantics.py` is + updated in the same commit per that lint's documented procedure. + +## Out of scope (deliberate, from the issue) + +- Extending `check_v3_7_3_three_layer_citation.py` (it lints emitted Markdown, not PDFs). +- Quote-accuracy verification against source text (the L3 claim-audit channel, tracked + separately). +- A passport schema aggregate. The sidecar is a file-level retrieval artifact; if #513 + (`read_scope` ledger) lands, the sidecar's `sha256` + verdict are the natural join keys, + and naming here follows `citation_provenance.schema.json` precedent for that future join. + +## Test plan + +`scripts/test_pdf_read_preflight.py` (auto-discovered by `pytest.yml`'s `pytest scripts/`), +synthetic in-test PDFs (no binary fixtures): flat valid PDF (PASS), nested page tree (PASS, +enumeration exercises recursion), lying root `/Count` (FAIL), truncated tail (UNAVAILABLE or +FAIL, never PASS), encrypted marker (UNAVAILABLE), page-tree cycle (UNAVAILABLE via guard), +non-PDF bytes (UNAVAILABLE), missing file (UNAVAILABLE), pypdf absent (monkeypatched → +UNAVAILABLE with `pypdf-not-installed` warning), sidecar shape + hash stability, exit codes. diff --git a/scripts/check_pipeline_boundary_semantics.py b/scripts/check_pipeline_boundary_semantics.py index 81894ba7..bc0abffd 100644 --- a/scripts/check_pipeline_boundary_semantics.py +++ b/scripts/check_pipeline_boundary_semantics.py @@ -71,7 +71,7 @@ REPO_ROOT = Path(__file__).resolve().parent.parent # --------------------------------------------------------------------------- CONTENT_LOCKS = { "academic-pipeline/SKILL.md": "69c1a8bfe01ab252b13fe9685e778591d3078fd82a0d6d84763f1bae1d3ff510", - "academic-pipeline/agents/pipeline_orchestrator_agent.md": "10c34f1edb3e157d68c656a42dff420f74dbd8fc7eacca7e2f4688b06482bdff", + "academic-pipeline/agents/pipeline_orchestrator_agent.md": "9c9cdc58ded9f63e855c6d37697a4e02650f45b6f93c1f337b032cafd986cd99", "academic-pipeline/agents/state_tracker_agent.md": "6528349959ae9ef9c126bab3117a57da894e9517104a83a0d1d7ad74daf36d5f", "academic-pipeline/references/pipeline_state_machine.md": "d61ac458e39d8bd7f8ad0c85b01887a1ffc5657578b883af15989e2677d60e89", "academic-pipeline/references/process_summary_protocol.md": "5c7053230d73b39d0a5d9d6f5e9f339c12570ae6d3aa2eae2eaf74f51d571e94", diff --git a/scripts/claim_audit_finalizer.py b/scripts/claim_audit_finalizer.py index f5342c86..b76899f3 100644 --- a/scripts/claim_audit_finalizer.py +++ b/scripts/claim_audit_finalizer.py @@ -50,6 +50,11 @@ TIER_HIGH_WARN = "high_warn" # Changing a literal here MUST coordinate with the formatter prose update. # --------------------------------------------------------------------------- +# #512 PDF read-integrity: pipeline-side tag substring (keep in lockstep with +# claim_audit_pipeline.PDF_READ_INTEGRITY_TAG) + the advisory annotation it drives. +PDF_READ_INTEGRITY_TAG = "[pdf_read_integrity_unverified]" +ANNOTATION_LOW_WARN_PDF_READ_INTEGRITY = "[LOW-WARN-PDF-READ-INTEGRITY-UNVERIFIED]" + ANNOTATION_CLAIM_AUDIT_AMBIGUOUS = "[CLAIM-AUDIT-AMBIGUOUS]" ANNOTATION_HIGH_WARN_CLAIM_NOT_SUPPORTED = "[HIGH-WARN-CLAIM-NOT-SUPPORTED]" ANNOTATION_HIGH_WARN_NEGATIVE_CONSTRAINT_VIOLATION = ( @@ -168,6 +173,16 @@ def classify_claim_audit_result(entry: dict[str, Any]) -> dict[str, Any]: rationale = entry.get("rationale", "") if judgment == "SUPPORTED": + # #512: a SUPPORTED row that reached support through an unverified local-PDF + # page anchor still surfaces an advisory — without this branch the pipeline's + # rationale tag would be invisible at the formatter for the expected common + # case (content-based fallback finds support). Advisory only, never a gate. + if PDF_READ_INTEGRITY_TAG in rationale: + return { + "annotation": ANNOTATION_LOW_WARN_PDF_READ_INTEGRITY, + "tier": TIER_LOW_WARN, + "gate_refuse": False, + } return {"annotation": None, "tier": TIER_NONE, "gate_refuse": False} if judgment == "AMBIGUOUS": diff --git a/scripts/claim_audit_pipeline.py b/scripts/claim_audit_pipeline.py index a55208c9..d61f8a78 100644 --- a/scripts/claim_audit_pipeline.py +++ b/scripts/claim_audit_pipeline.py @@ -75,6 +75,31 @@ _RATIONALE_TRUNC_MARK = "…[truncated]" # Widest fault-class prefix across both exception families ("retrieval_network_error: "). _WIDEST_FAULT_PREFIX = "retrieval_network_error: " +# #512 PDF read-integrity rationale tag. Appended (never prepended — INV-6/INV-14 +# startswith contracts stay intact) to a completed manual_pdf page-anchor row whose +# preflight sidecar is missing or non-PASS. The finalizer keys its advisory +# annotation on this exact substring; keep in lockstep with +# claim_audit_finalizer.PDF_READ_INTEGRITY_TAG. +PDF_READ_INTEGRITY_TAG = "[pdf_read_integrity_unverified]" + + +def _tag_pdf_read_integrity(entry: dict[str, Any]) -> None: + """#512: mark a manual_pdf page-anchor row whose preflight did not vouch. + + Applied at the single Step-6 emission point AFTER cache resolution, so a cache + hit cannot bypass it (the tag is run-context, never written into the judge + cache body). Idempotent; respects the rationale maxLength budget.""" + rationale = entry.get("rationale") or "" + if PDF_READ_INTEGRITY_TAG in rationale: + return + if rationale: + clamped = _clamp_to_rationale_budget( + rationale, reserved=len(PDF_READ_INTEGRITY_TAG) + 1 + ) + entry["rationale"] = f"{clamped} {PDF_READ_INTEGRITY_TAG}" + else: + entry["rationale"] = PDF_READ_INTEGRITY_TAG + def _clamp_to_rationale_budget(text: str, *, reserved: int) -> str: """Clamp `text` so that `reserved + len(result)` fits the rationale maxLength. @@ -979,6 +1004,7 @@ def run_audit_pipeline( cache: dict[str, Any] | None = None, uncited_sentences: list[dict[str, Any]] | None = None, all_uncited_sentences: list[dict[str, Any]] | None = None, + pdf_preflight_sidecars: dict[str, dict[str, Any]] | None = None, ) -> dict[str, list[dict[str, Any]]]: """Run §4 Step 1-6 + manifest set-diff over caller-supplied inputs. @@ -1016,6 +1042,23 @@ def run_audit_pipeline( - `all_uncited_sentences[]`: `sentence_text` + `section_path` only. Constraint judging does not consult `trigger_tokens`. + `pdf_preflight_sidecars` (#512): optional map of `ref_slug` → + `pdf_read_preflight/1` sidecar dict (see `scripts/pdf_read_preflight.py`). + `None` (default) = the caller has not wired the preflight layer — legacy + byte-equivalent behavior, no tagging. When provided (even empty), every + completed `manual_pdf` row with `anchor_kind == "page"` whose sidecar is + missing or non-`PASS` gets `PDF_READ_INTEGRITY_TAG` appended to its + rationale at the Step-6 emission point, cache hit or not. Additionally, + page-anchor citations handed to `retrieve_fn` carry a + `pdf_preflight_verdict` key (`PASS`/`FAIL`/`UNAVAILABLE`/`MISSING`): + retrieve_fn implementations SHOULD locate the passage by content rather + than by the page scope when the verdict is not `PASS` — the judge must + never be fed a passage selected by an untrusted page number. Sidecar + freshness is the CALLER's contract: this function does no file I/O, so + the orchestrator re-hashes each PDF against its sidecar `sha256` before + dispatch and re-runs the preflight on mismatch (a stale `PASS` from a + replaced file must not license the new bytes). + Returns: dict with six aggregate arrays keyed by passport-aggregate name: claim_audit_results, uncited_assertions, claim_drifts, @@ -1154,8 +1197,20 @@ def run_audit_pipeline( # surface as INV-14 retrieval_* audit_tool_failure rows instead of # aborting the pass (Step 13 R2 codex P2 finding, symmetric to the # R1 _invoke_judge wrapper). + # #512 r2: when the preflight layer is wired, page-anchor citations carry + # the sidecar verdict INTO retrieval so implementations can locate the + # passage by content instead of the untrusted page scope BEFORE the judge + # sees it — tagging after the fact cannot fix a judge that already read + # the wrong passage. Copy-on-write; API-path retrieve_fns ignore the key. + retrieval_citation = citation + if pdf_preflight_sidecars is not None and anchor_kind == "page": + _sc = pdf_preflight_sidecars.get(citation["ref_slug"]) + retrieval_citation = dict(citation) + retrieval_citation["pdf_preflight_verdict"] = ( + _sc.get("verdict") if _sc else "MISSING" + ) try: - retrieval = _invoke_retrieve(retrieve_fn, citation) + retrieval = _invoke_retrieve(retrieve_fn, retrieval_citation) except RetrievalInvocationError as ret_err: entry = _retrieval_failure_entry( citation, @@ -1270,6 +1325,20 @@ def run_audit_pipeline( judge_model=judge_model, ) entry["scoped_manifest_id"] = written_scope + # #512 PDF read-integrity precondition, executable path. Sits AFTER cache + # resolution so hits and fresh invocations are tagged identically (codex + # #512 P1: a cache hit must not bypass the check). `None` = caller has not + # wired the preflight layer → byte-equivalent legacy behavior; a provided + # dict (even empty) means the orchestrator ran the layer, so a missing or + # non-PASS sidecar for a manual_pdf page-anchor row is tagged. + if ( + pdf_preflight_sidecars is not None + and method == "manual_pdf" + and anchor_kind == "page" + ): + sidecar = pdf_preflight_sidecars.get(citation["ref_slug"]) + if not (sidecar and sidecar.get("verdict") == "PASS"): + _tag_pdf_read_integrity(entry) claim_audit_results.append(entry) # Precedence rule 1: cited constraint violation absorbs the drift signal. diff --git a/scripts/pdf_read_preflight.py b/scripts/pdf_read_preflight.py new file mode 100644 index 00000000..e21f8828 --- /dev/null +++ b/scripts/pdf_read_preflight.py @@ -0,0 +1,372 @@ +"""PDF read-integrity preflight (#512). + +Guards the LOCAL EXTRACTION CHANNEL behind v3.7.3 `page` anchors: PDF readers silently +truncate documents with malformed cross-reference tables and misreport page counts, so a +real, correctly-cited source can acquire an apparently valid page locator derived from a +truncated or mispaginated read — and pass every downstream gate (the v3.7.3 lint checks +anchor shape, the #182 gate reduces anchors to a kind-only boolean). This preflight is run +at the orchestration/retrieval layer (never by Bucket A writer agents, which cannot run +Bash) BEFORE page numbers from a locally-read PDF are trusted as anchor values. + +Mechanism (observed in kengo006/alexandria, reshaped per the #512 dual-track review): +three independent page-count signals must agree — + + 1. declared_page_count — the root page tree's /Count, read from the raw object; + 2. enumerated_page_count — this script's own recursive /Kids walk counting /Type /Page + leaves (cycle-guarded, node-budgeted); + 3. reader_page_count — pypdf's flattened page list, as a third opinion. + +Verdict: PASS only when all three agree, the count is positive, and the parse emitted no +repair warnings. FAIL when the parse completed but counts disagree (the truncation / +mispagination signal itself). UNAVAILABLE for anything the preflight cannot vouch for: +unreadable or missing file, encryption, missing/malformed page tree, a /Kids cycle or +node-budget hit, pypdf absent, or parser-repair warnings even with agreeing counts (a +repaired read may be complete, but only PASS licenses a page anchor downstream, so the +conservative bucket is the honest one). + +Object plumbing rides pypdf (already a repo dependency; `verify_submission_package.py` +precedent), which handles classic xref tables, xref streams, /Prev incremental-update +chains, and object streams — this is deliberately NOT a "grep the first /Count" check. + +CLI: `python scripts/pdf_read_preflight.py FILE [--output SIDECAR.json]`. Exit 0 whenever +a verdict was produced (the verdict is data, not an error; orchestration consumes the +JSON without exit-code branching); exit 2 on usage errors only. + +Design: docs/design/2026-07-20-512-pdf-read-preflight-spec.md. +""" + +from __future__ import annotations + +import argparse +import io +import json +import logging +import re +import sys +from datetime import datetime, timezone +from pathlib import Path + +try: + from audit_snapshot import sha256_hex +except ImportError: # pragma: no cover - dual-path import (verify_submission_package precedent) + from scripts.audit_snapshot import sha256_hex + +try: + import pypdf +except ImportError: # degrade to UNAVAILABLE, mirroring verify_submission_package.py + pypdf = None + +TOOL_VERSION = "pdf_read_preflight/1.0.0" +SCHEMA = "pdf_read_preflight/1" + +# Hard ceiling on page-tree nodes visited by the enumeration walk. Real documents sit +# far below this; hitting it means a pathological or adversarial tree we must not vouch +# for (and must not spin on). +NODE_BUDGET = 50_000 + +PASS, FAIL, UNAVAILABLE = "PASS", "FAIL", "UNAVAILABLE" + + +class _WarningCollector(logging.Handler): + """Captures pypdf's parser chatter — repair messages ARE the silent-xref-repair + signal this preflight exists to surface.""" + + def __init__(self): + super().__init__(level=logging.WARNING) + self.messages: list[str] = [] + + def emit(self, record): + self.messages.append(record.getMessage()) + + +class _TreeProblem(Exception): + """Structural page-tree problem that forecloses a confident enumeration.""" + + +def _kid_key(kid): + """Stable identity for a /Kids entry (indirect ref when available).""" + ref = getattr(kid, "indirect_reference", None) or ( + kid if hasattr(kid, "idnum") else None + ) + if ref is not None: + return ("ref", ref.idnum, ref.generation) + return ("id", id(kid)) + + +def _walk_page_tree(node, visited, budget): + """Count /Type /Page leaves under `node`, guarding cycles and runaway trees.""" + count = 0 + stack = [node] + while stack: + if len(visited) > budget: + raise _TreeProblem("page-tree node budget exceeded") + current = stack.pop() + key = _kid_key(current) + if key in visited: + raise _TreeProblem("page-tree cycle detected") + visited.add(key) + obj = current.get_object() if hasattr(current, "get_object") else current + node_type = str(obj.get("/Type", "")) + if node_type == "/Page": + count += 1 + elif node_type == "/Pages": + kids = obj.get("/Kids", []) + stack.extend(kids) + else: + raise _TreeProblem(f"unexpected page-tree node type {node_type or '(none)'}") + return count + + +def run_preflight(path) -> dict: + """Run the read-integrity preflight on one PDF; always returns a sidecar dict.""" + path = Path(path) + result = { + "schema": SCHEMA, + "verdict": UNAVAILABLE, + "file": str(path), + "sha256": None, + "declared_page_count": None, + "enumerated_page_count": None, + "reader_page_count": None, + "warnings": [], + "generated_at": datetime.now(timezone.utc).isoformat(), + "tool": TOOL_VERSION, + } + warnings = result["warnings"] + + try: + data = path.read_bytes() + except OSError as exc: + warnings.append(f"unreadable: {exc}") + return result + result["sha256"] = sha256_hex(data) + + # Structural check independent of the parser: a PDF truncated partway through an + # incremental update keeps an OLDER valid %%EOF, and pypdf silently reads that + # previous revision — all three counts then agree on the OLD page tree, which would + # PASS the exact truncation case this preflight exists to catch (codex #512 P1). + # Non-whitespace bytes after the LAST %%EOF are that signature: record the warning + # now, veto PASS at the verdict step. A complete incremental update always ends + # with its own %%EOF, so legitimate multi-revision files are not flagged. + # PDF whitespace per ISO 32000 §7.2.2 — NOT Python's: NUL is whitespace (common + # padding after %%EOF, must not veto), vertical tab 0x0B is NOT (r3 P1). + _PDF_WS = b"\x00\x09\x0a\x0c\x0d\x20" + trailing_ok = True + eof_at = data.rfind(b"%%EOF") + if eof_at != -1 and data[eof_at + 5 :].translate(None, _PDF_WS): + trailing_ok = False + warnings.append( + f"trailing-data: {len(data) - (eof_at + 5)} bytes after the final %%EOF " + "include non-whitespace content (possible truncated incremental update)" + ) + + if pypdf is None: + warnings.append("pypdf-not-installed: preflight cannot parse the document") + return result + + collector = _WarningCollector() + pypdf_logger = logging.getLogger("pypdf") + pypdf_logger.addHandler(collector) + try: + try: + reader = pypdf.PdfReader(io.BytesIO(data)) # bytes already in hand for the hash + except Exception as exc: # malformed beyond pypdf's tolerance + warnings.append(f"parse-error: {exc}") + return result + + if getattr(reader, "is_encrypted", False): + warnings.append("encrypted: preflight cannot verify an encrypted document") + return result + + try: + root = reader.trailer["/Root"].get_object() + pages_node = root["/Pages"] + pages_obj = pages_node.get_object() + raw_count = pages_obj["/Count"] + # Require an actual PDF integer object. `int()` would coerce a float + # /Count 2.7 to 2 (or a text string "2") and then agree with two real + # leaves — a malformed page tree must be UNAVAILABLE, not PASS (r2 P1). + # pypdf NumberObject subclasses int; FloatObject subclasses float. + if isinstance(raw_count, bool) or not isinstance(raw_count, int): + warnings.append( + f"page-tree-unresolvable: /Count is not an integer object " + f"({type(raw_count).__name__}: {raw_count!r})" + ) + return result + declared = int(raw_count) + except Exception as exc: + warnings.append(f"page-tree-unresolvable: {exc}") + return result + result["declared_page_count"] = declared + + try: + enumerated = _walk_page_tree(pages_node, set(), NODE_BUDGET) + except Exception as exc: # incl. _TreeProblem — same degradation either way + warnings.append(f"page-tree-walk: {exc}") + return result + result["enumerated_page_count"] = enumerated + + # The walk above verified the /Kids tree is cycle-free, so flattening the same + # tree cannot spin. + try: + reader_count = len(reader.pages) + except Exception as exc: + warnings.append(f"reader-page-list: {exc}") + return result + result["reader_page_count"] = reader_count + + # Xref-coverage check (r2 P1): a malformed incremental update can append new + # objects PLUS a syntactically complete startxref that still points at the + # PREVIOUS revision's xref, followed by its own %%EOF — the trailing-data + # check then sees nothing after the final %%EOF while pypdf silently reads + # the old revision. Cross-check: every raw `N M obj` header in the file must + # be an object number the parsed xref chain knows about. An unreferenced + # object number = a revision the active xref chain cannot see. (Offsets are + # deliberately not compared — pypdf normalizes them; object-number coverage + # is the stable signal. Best-effort: if pypdf's xref internals are absent, + # skip rather than crash.) + try: + xref_map = getattr(reader, "xref", None) + if isinstance(xref_map, dict) and xref_map: + known_objs = set() + for gen_table in xref_map.values(): + if isinstance(gen_table, dict): + known_objs.update(gen_table.keys()) + compressed = getattr(reader, "xref_objStm", None) + if isinstance(compressed, dict): + known_objs.update(compressed.keys()) + # Header token separators implement the FULL ISO 32000 lexer model, + # not Python's \s and not just whitespace: PDF permits bare-CR line + # endings (r4 P1), treats NUL as whitespace (r5 P1), and treats + # %-comments-to-end-of-line as token separators (r7 P1) — so + # `2 0%note\nobj` is a valid header. Anything the PDF lexer accepts + # as a separator must not hide a header from the coverage checks. + # Numeric tokens carry the full ISO 32000 integer form too (r8 P1): + # an optional sign and any leading-zero padding are valid and + # accepted by pypdf's int() coercion, so `+2 0 obj` or + # `00000000002 0 obj` must not hide from the scan either. + _ws = rb"[\x00\t\n\x0c\r ]" + _sep = rb"(?:" + _ws + rb"|%[^\r\n]*[\r\n])" + _num = rb"[+-]?0*\d{1,10}" + raw_offsets: dict[int, list[int]] = {} + for m in re.finditer( + rb"(?:^|" + _sep + rb")" + _sep + rb"*(" + _num + rb")" + _sep + rb"+" + _num + _sep + rb"+obj\b", + data, + ): + raw_offsets.setdefault(int(m.group(1)), []).append(m.start(1)) + orphaned = set(raw_offsets) - {int(n) for n in known_objs} + if orphaned: + warnings.append( + "xref-coverage: object number(s) " + f"{sorted(orphaned)[:5]} present in the file but absent from " + "the active xref chain (possible stale startxref / " + "unreachable newer revision)" + ) + trailing_ok = False + # Redefined-object variant (r3 P1): a malformed update can append a + # REPLACEMENT body for an existing object number plus a stale + # startxref — number-membership alone then sees no orphan while + # pypdf reads the old copy. The newest raw copy of every directly- + # stored object must be the one the active chain references. + # Calibration guard: pypdf applies a global delta when a file has + # junk before %PDF; if NO active offset matches any raw offset the + # comparison is uncalibrated — skip rather than mass-flag. + direct_offsets = {} + for gen_table in xref_map.values(): + if isinstance(gen_table, dict): + for objnum, off in gen_table.items(): + if isinstance(off, int) and int(objnum) in raw_offsets: + direct_offsets[int(objnum)] = off + if direct_offsets and any( + off in raw_offsets[n] for n, off in direct_offsets.items() + ): + superseded = sorted( + n + for n, off in direct_offsets.items() + if max(raw_offsets[n]) > off + ) + if superseded: + warnings.append( + "xref-coverage: later unreferenced revision(s) of object " + f"number(s) {superseded[:5]} exist after the copy the " + "active xref chain references (possible stale startxref)" + ) + trailing_ok = False + # Compressed-object variant (r5 P1): the active copy of N lives + # inside an object stream (no direct offset in reader.xref), so the + # loop above never inspects it — but a direct raw replacement of N + # appended AFTER its container, with a stale startxref, is exactly + # the unreachable-newer-revision case. A raw copy BEFORE the + # container is the legitimate superseded-into-objstm update and is + # not flagged. + if isinstance(compressed, dict): + compressed_superseded = [] + for objnum, ref in compressed.items(): + n = int(objnum) + if n not in raw_offsets or n in direct_offsets: + continue + container = ref[0] if isinstance(ref, (tuple, list)) and ref else None + container_off = None + if container is not None: + for gen_table in xref_map.values(): + if ( + isinstance(gen_table, dict) + and container in gen_table + and isinstance(gen_table[container], int) + ): + container_off = gen_table[container] + break + if container_off is not None and max(raw_offsets[n]) > container_off: + compressed_superseded.append(n) + if compressed_superseded: + warnings.append( + "xref-coverage: direct replacement(s) of compressed object " + f"number(s) {sorted(compressed_superseded)[:5]} appear after " + "their object-stream container (possible stale startxref)" + ) + trailing_ok = False + except Exception as exc: # best-effort cross-check, never a crash path + warnings.append(f"xref-coverage-skipped: {exc}") + finally: + pypdf_logger.removeHandler(collector) + # Append captured parser chatter HERE so every early return above (encryption, + # unresolvable tree, walk problems) still carries it — the repair warning that + # preceded a later structural error is part of the sidecar contract too. + warnings.extend(f"pypdf: {m}" for m in collector.messages) + + if not (declared == enumerated == reader_count): + result["verdict"] = FAIL + return result + if declared <= 0: + warnings.append("empty-page-tree: agreeing counts but zero pages") + return result + if collector.messages or not trailing_ok: + # Counts agree, but the parse needed repair or the file carries data after its + # final %%EOF — cannot vouch, per the spec. + return result + result["verdict"] = PASS + return result + + +def main(argv=None) -> int: + parser = argparse.ArgumentParser( + description="PDF read-integrity preflight (#512): PASS/FAIL/UNAVAILABLE sidecar " + "for page-anchor trust decisions." + ) + parser.add_argument("pdf", help="path to the locally-read PDF") + parser.add_argument( + "--output", + help="write the JSON sidecar here instead of stdout", + ) + args = parser.parse_args(argv) + + sidecar = json.dumps(run_preflight(args.pdf), indent=2, ensure_ascii=False) + if args.output: + Path(args.output).write_text(sidecar + "\n", encoding="utf-8") + else: + print(sidecar) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/test_claim_audit_finalizer.py b/scripts/test_claim_audit_finalizer.py index e5fff3db..9e14f681 100644 --- a/scripts/test_claim_audit_finalizer.py +++ b/scripts/test_claim_audit_finalizer.py @@ -939,5 +939,41 @@ class TUAFFinalizerRouting(unittest.TestCase): self.assertIn(expected, annotations) + +# T-512 — SUPPORTED row carrying the PDF read-integrity tag surfaces the advisory. + + +class T512PdfReadIntegrityAdvisory(unittest.TestCase): + def test_supported_with_tag_gets_low_warn_advisory(self) -> None: + from scripts.claim_audit_finalizer import ( + ANNOTATION_LOW_WARN_PDF_READ_INTEGRITY, + PDF_READ_INTEGRITY_TAG, + ) + + out = classify_claim_audit_result( + _result( + judgment="SUPPORTED", + defect_stage=None, + ref_retrieval_method="manual_pdf", + rationale=f"supported via content match {PDF_READ_INTEGRITY_TAG}", + ) + ) + self.assertEqual(out["annotation"], ANNOTATION_LOW_WARN_PDF_READ_INTEGRITY) + self.assertEqual(out["tier"], "low_warn") + self.assertFalse(out["gate_refuse"]) + + def test_supported_without_tag_unchanged(self) -> None: + out = classify_claim_audit_result( + _result(judgment="SUPPORTED", defect_stage=None, ref_retrieval_method="manual_pdf") + ) + self.assertIsNone(out["annotation"]) + + def test_tag_constants_in_lockstep_with_pipeline(self) -> None: + from scripts.claim_audit_finalizer import PDF_READ_INTEGRITY_TAG as fin_tag + from scripts.claim_audit_pipeline import PDF_READ_INTEGRITY_TAG as pipe_tag + + self.assertEqual(fin_tag, pipe_tag) + + if __name__ == "__main__": # pragma: no cover unittest.main() diff --git a/scripts/test_claim_audit_pipeline.py b/scripts/test_claim_audit_pipeline.py index ff69028c..b599382e 100644 --- a/scripts/test_claim_audit_pipeline.py +++ b/scripts/test_claim_audit_pipeline.py @@ -2289,5 +2289,110 @@ class TP360NonStringJudgeRationale(_PipelineTestBase): self.assertEqual(self._validate_passport(out, [manifest]), []) + +# --------------------------------------------------------------------------- +# T-512 — PDF read-integrity tag on manual_pdf page-anchor rows (#512). +# --------------------------------------------------------------------------- + + +class T512PdfReadIntegrityTag(_PipelineTestBase): + """#512: completed manual_pdf page-anchor rows are tagged when the preflight + sidecar is missing or non-PASS; cache hits cannot bypass; None = legacy.""" + + @staticmethod + def _manual_pdf(citation: dict[str, Any]) -> dict[str, Any]: + return {"ref_retrieval_method": "manual_pdf", "retrieved_excerpt": "uploaded excerpt"} + + def _tag(self) -> str: + from scripts.claim_audit_pipeline import PDF_READ_INTEGRITY_TAG + + return PDF_READ_INTEGRITY_TAG + + def test_missing_sidecar_tags_rationale(self) -> None: + out = self.run_pipeline( + citations=[_citation()], retrieve_fn=self._manual_pdf, pdf_preflight_sidecars={} + ) + self.assertIn(self._tag(), out["claim_audit_results"][0]["rationale"]) + + def test_pass_sidecar_not_tagged(self) -> None: + out = self.run_pipeline( + citations=[_citation()], + retrieve_fn=self._manual_pdf, + pdf_preflight_sidecars={"smith2024preprints": {"verdict": "PASS"}}, + ) + self.assertNotIn(self._tag(), out["claim_audit_results"][0]["rationale"]) + + def test_fail_and_unavailable_sidecars_tagged(self) -> None: + for verdict in ("FAIL", "UNAVAILABLE"): + with self.subTest(verdict=verdict): + out = self.run_pipeline( + citations=[_citation()], + retrieve_fn=self._manual_pdf, + pdf_preflight_sidecars={"smith2024preprints": {"verdict": verdict}}, + ) + self.assertIn(self._tag(), out["claim_audit_results"][0]["rationale"]) + + def test_none_param_is_legacy_untagged(self) -> None: + out = self.run_pipeline(citations=[_citation()], retrieve_fn=self._manual_pdf) + self.assertNotIn(self._tag(), out["claim_audit_results"][0]["rationale"]) + + def test_api_rows_never_tagged(self) -> None: + out = self.run_pipeline(citations=[_citation()], pdf_preflight_sidecars={}) + self.assertNotIn(self._tag(), out["claim_audit_results"][0]["rationale"]) + + def test_non_page_anchor_never_tagged(self) -> None: + out = self.run_pipeline( + citations=[_citation(anchor_kind="quote", anchor_value="verbatim%20text")], + retrieve_fn=self._manual_pdf, + pdf_preflight_sidecars={}, + ) + self.assertNotIn(self._tag(), out["claim_audit_results"][0]["rationale"]) + + def test_cache_hit_cannot_bypass_tag(self) -> None: + cache: dict[str, Any] = {} + first = self.run_pipeline( + citations=[_citation()], + retrieve_fn=self._manual_pdf, + cache=cache, + pdf_preflight_sidecars={}, + ) + self.assertEqual(len(cache), 1) + second = self.run_pipeline( + citations=[_citation()], + retrieve_fn=self._manual_pdf, + cache=cache, + pdf_preflight_sidecars={}, + ) + for out in (first, second): + self.assertIn(self._tag(), out["claim_audit_results"][0]["rationale"]) + # Tag never contaminates the cached judge body (run-context only). + (cached,) = cache.values() + self.assertNotIn(self._tag(), str(cached)) + + def test_retrieve_fn_receives_preflight_verdict(self) -> None: + seen: list[Any] = [] + + def spy_retrieve(citation: dict[str, Any]) -> dict[str, Any]: + seen.append(citation.get("pdf_preflight_verdict")) + return {"ref_retrieval_method": "manual_pdf", "retrieved_excerpt": "x"} + + self.run_pipeline( + citations=[_citation()], + retrieve_fn=spy_retrieve, + pdf_preflight_sidecars={"smith2024preprints": {"verdict": "FAIL"}}, + ) + self.run_pipeline( + citations=[_citation()], retrieve_fn=spy_retrieve, pdf_preflight_sidecars={} + ) + self.run_pipeline(citations=[_citation()], retrieve_fn=spy_retrieve) + self.assertEqual(seen, ["FAIL", "MISSING", None]) + + def test_tagged_row_passes_consistency_lint(self) -> None: + out = self.run_pipeline( + citations=[_citation()], retrieve_fn=self._manual_pdf, pdf_preflight_sidecars={} + ) + self.assertEqual(self._validate_passport(out), []) + + if __name__ == "__main__": unittest.main() diff --git a/scripts/test_pdf_read_preflight.py b/scripts/test_pdf_read_preflight.py new file mode 100644 index 00000000..2c696ec5 --- /dev/null +++ b/scripts/test_pdf_read_preflight.py @@ -0,0 +1,436 @@ +"""Tests for scripts/pdf_read_preflight.py (#512 PDF read-integrity preflight). + +Fixtures are synthetic PDFs assembled in-test with correct xref offsets (no binary +fixture files): a flat valid document, a nested page tree, a root /Count that lies, +a truncated tail, an encrypted trailer, a page-tree cycle, and non-PDF bytes. The +preflight must answer PASS only when the declared root /Count, its own /Kids-walk +enumeration, and pypdf's flattened page list all agree with no parser warnings — +anything less confident lands in FAIL (counts disagree) or UNAVAILABLE (cannot +vouch). Design: docs/design/2026-07-20-512-pdf-read-preflight-spec.md. +""" + +import hashlib +import json +import os +import subprocess +import sys +import tempfile +import unittest +from datetime import datetime +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO_ROOT / "scripts")) + +import pdf_read_preflight as preflight # noqa: E402 + + +# --- synthetic-PDF assembly --------------------------------------------------------------- + + +def _build_pdf(objects): + """Assemble a classic-xref PDF from `objects` (list of object BODIES, bytes, without + the `N 0 obj`/`endobj` wrapper; object numbers are 1-based list positions). Returns + the full file bytes with a correct xref table and trailer pointing at object 1 as + /Root.""" + out = bytearray(b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n") + offsets = [] + for i, body in enumerate(objects, start=1): + offsets.append(len(out)) + out += b"%d 0 obj\n" % i + body + b"\nendobj\n" + xref_at = len(out) + out += b"xref\n0 %d\n" % (len(objects) + 1) + out += b"0000000000 65535 f \n" + for off in offsets: + out += b"%010d 00000 n \n" % off + out += ( + b"trailer\n<< /Size %d /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF\n" + % (len(objects) + 1, xref_at) + ) + return bytes(out) + + +def _page(parent_num): + return b"<< /Type /Page /Parent %d 0 R /MediaBox [0 0 612 792] >>" % parent_num + + +def _flat_pdf(page_count=2, declared=None): + """Catalog(1) -> Pages(2) -> `page_count` leaf pages. `declared` overrides /Count.""" + declared = page_count if declared is None else declared + kids = b" ".join(b"%d 0 R" % (3 + i) for i in range(page_count)) + objects = [ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [%s] /Count %d >>" % (kids, declared), + ] + objects += [_page(2) for _ in range(page_count)] + return _build_pdf(objects) + + +def _nested_pdf(): + """Root Pages(2) -> [inner Pages(3) -> [page(4), page(5)], page(6)]; 3 leaves.""" + objects = [ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [3 0 R 6 0 R] /Count 3 >>", + b"<< /Type /Pages /Parent 2 0 R /Kids [4 0 R 5 0 R] /Count 2 >>", + _page(3), + _page(3), + _page(2), + ] + return _build_pdf(objects) + + +def _cyclic_pdf(): + """Pages(2) -> Pages(3) -> back to Pages(2): a /Kids cycle, zero real leaves.""" + objects = [ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + b"<< /Type /Pages /Parent 2 0 R /Kids [2 0 R] /Count 1 >>", + ] + return _build_pdf(objects) + + +def _encrypted_pdf(): + """Structurally flat PDF whose trailer carries /Encrypt — preflight must not vouch.""" + raw = _flat_pdf(1) + return raw.replace( + b"/Root 1 0 R >>", + b"/Root 1 0 R /Encrypt << /Filter /Standard /V 1 /R 2 /O (x) /U (x) /P -1 >> >>", + ) + + +def _objstm_pdf(): + """PDF 1.5-style fixture: catalog/pages/page live in an object stream (obj 4), + the xref is a cross-reference stream (obj 5); both unfiltered so offsets stay + computable. Exercises the compressed-object side of the coverage checks.""" + import struct + + bodies = [ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>", + ] + offs, payload = [], b"" + for b in bodies: + offs.append(len(payload)) + payload += b + b" " + header = b" ".join(b"%d %d" % (i + 1, o) for i, o in enumerate(offs)) + b" " + content = header + payload + out = bytearray(b"%PDF-1.5\n%\xe2\xe3\xcf\xd3\n") + objstm_at = len(out) + out += ( + b"4 0 obj\n<< /Type /ObjStm /N 3 /First %d /Length %d >>\nstream\n" + % (len(header), len(content)) + ) + content + b"\nendstream\nendobj\n" + xref_at = len(out) + rows = [(0, 0, 0), (2, 4, 0), (2, 4, 1), (2, 4, 2), (1, objstm_at, 0), (1, xref_at, 0)] + xdata = b"".join(struct.pack(">BHB", *r) for r in rows) + out += ( + b"5 0 obj\n<< /Type /XRef /Size 6 /Root 1 0 R /W [1 2 1] /Index [0 6] /Length %d >>\nstream\n" + % len(xdata) + ) + xdata + b"\nendstream\nendobj\n" + out += b"startxref\n%d\n%%%%EOF\n" % xref_at + return bytes(out) + + +def _write(tmpdir, name, data): + p = Path(tmpdir) / name + p.write_bytes(data) + return p + + +class PreflightVerdictTest(unittest.TestCase): + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.tmp = self._tmp.name + self.addCleanup(self._tmp.cleanup) + + def run_on(self, data, name="doc.pdf"): + return preflight.run_preflight(_write(self.tmp, name, data)) + + def test_flat_valid_pdf_passes_with_agreeing_counts(self): + r = self.run_on(_flat_pdf(2)) + self.assertEqual(r["verdict"], "PASS", r) + self.assertEqual( + (r["declared_page_count"], r["enumerated_page_count"], r["reader_page_count"]), + (2, 2, 2), + ) + self.assertEqual(r["warnings"], []) + + def test_nested_page_tree_enumerates_leaves_only(self): + r = self.run_on(_nested_pdf()) + self.assertEqual(r["verdict"], "PASS", r) + self.assertEqual(r["enumerated_page_count"], 3) + self.assertEqual(r["declared_page_count"], 3) + + def test_lying_root_count_fails(self): + # Root declares 5 pages, the tree holds 2 — the mispagination signal itself. + r = self.run_on(_flat_pdf(2, declared=5)) + self.assertEqual(r["verdict"], "FAIL", r) + self.assertEqual(r["declared_page_count"], 5) + self.assertEqual(r["enumerated_page_count"], 2) + + def test_truncated_pdf_never_passes(self): + whole = _flat_pdf(3) + r = self.run_on(whole[: int(len(whole) * 0.6)], name="cut.pdf") + self.assertNotEqual(r["verdict"], "PASS", r) + + def test_encrypted_pdf_unavailable(self): + r = self.run_on(_encrypted_pdf()) + self.assertEqual(r["verdict"], "UNAVAILABLE", r) + self.assertTrue(any("encrypt" in w.lower() for w in r["warnings"]), r["warnings"]) + + def test_page_tree_cycle_unavailable_not_hang(self): + r = self.run_on(_cyclic_pdf()) + self.assertEqual(r["verdict"], "UNAVAILABLE", r) + self.assertTrue(any("cycle" in w.lower() for w in r["warnings"]), r["warnings"]) + + def test_non_pdf_bytes_unavailable(self): + r = self.run_on(b"just some text, not a PDF at all\n", name="not.pdf") + self.assertEqual(r["verdict"], "UNAVAILABLE", r) + + def test_missing_file_unavailable_with_null_hash(self): + r = preflight.run_preflight(Path(self.tmp) / "nope.pdf") + self.assertEqual(r["verdict"], "UNAVAILABLE", r) + self.assertIsNone(r["sha256"]) + + def test_pypdf_missing_unavailable(self): + real = preflight.pypdf + preflight.pypdf = None + try: + r = self.run_on(_flat_pdf(1)) + finally: + preflight.pypdf = real + self.assertEqual(r["verdict"], "UNAVAILABLE", r) + self.assertTrue(any("pypdf" in w for w in r["warnings"]), r["warnings"]) + + def test_zero_page_tree_never_passes(self): + r = self.run_on(_flat_pdf(0)) + self.assertNotEqual(r["verdict"], "PASS", r) + + def test_trailing_data_after_final_eof_never_passes(self): + # A PDF truncated partway through an incremental update keeps the OLDER valid + # %%EOF; pypdf silently reads that revision and all three counts agree on the + # old tree. The trailing-bytes check must veto PASS (codex #512 P1). + data = _flat_pdf(2) + b"6 0 obj\n<< /Type /Page /Parent 2 0 R >>\n" + r = self.run_on(data, name="cut_incremental.pdf") + self.assertEqual(r["verdict"], "UNAVAILABLE", r) + self.assertTrue(any("trailing-data" in w for w in r["warnings"]), r["warnings"]) + + def test_whitespace_after_final_eof_still_passes(self): + r = self.run_on(_flat_pdf(2) + b"\n\n \n") + self.assertEqual(r["verdict"], "PASS", r) + + def test_stale_startxref_with_own_eof_never_passes(self): + # Malformed incremental update: new objects appended, then a syntactically + # complete startxref that still points at the PREVIOUS revision's xref, + # followed by its own %%EOF. The trailing-data check alone sees nothing after + # the final %%EOF; the xref-coverage check must flag the unreachable object + # (codex #512 r2 P1). + base = _build_pdf( + [ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + _page(2), + ] + ) + old_startxref = base[base.rfind(b"startxref") :] # points at revision-1 xref + stale = base + b"\n4 0 obj\n<< /Type /Page /Parent 2 0 R >>\nendobj\n" + old_startxref + r = self.run_on(stale, name="stale.pdf") + self.assertNotEqual(r["verdict"], "PASS", r) + self.assertTrue(any("xref-coverage" in w for w in r["warnings"]), r["warnings"]) + + def test_nul_padding_after_final_eof_still_passes(self): + # NUL is PDF whitespace (ISO 32000 §7.2.2) and a common post-%%EOF padding; + # Python's strip() does not know that (codex #512 r3 P1). + r = self.run_on(_flat_pdf(2) + b"\x00" * 16) + self.assertEqual(r["verdict"], "PASS", r) + + def test_vertical_tab_after_final_eof_vetoes_pass(self): + # 0x0B is Python whitespace but NOT PDF whitespace — it is data. + r = self.run_on(_flat_pdf(2) + b"\x0b") + self.assertNotEqual(r["verdict"], "PASS", r) + + def test_redefined_object_with_stale_startxref_never_passes(self): + # Malformed update variant (codex #512 r3 P1): a REPLACEMENT body for an + # EXISTING object number is appended, then a stale copy of the original + # startxref/%%EOF. Object-number membership sees no orphan; the newest-copy- + # must-be-referenced check must flag it. + base = _flat_pdf(2) + old_startxref = base[base.rfind(b"startxref") :] + replacement = b"\n2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n" + r = self.run_on(base + replacement + old_startxref, name="redefined.pdf") + self.assertNotEqual(r["verdict"], "PASS", r) + self.assertTrue(any("xref-coverage" in w for w in r["warnings"]), r["warnings"]) + + def test_cr_only_line_endings_still_pass(self): + # ISO 32000 permits bare-CR line endings; byte count is unchanged so the + # xref offsets stay valid. + r = self.run_on(_flat_pdf(2).replace(b"\n", b"\r"), name="cr.pdf") + self.assertEqual(r["verdict"], "PASS", r) + + def test_cr_only_stale_startxref_never_passes(self): + # r4 P1: a CR-only file must not blind the object-header scan — the stale + # startxref variant has to be caught in this convention too. + base = _flat_pdf(2).replace(b"\n", b"\r") + old_startxref = base[base.rfind(b"startxref") :] + replacement = b"\r2 0 obj\r<< /Type /Pages /Kids [3 0 R] /Count 1 >>\rendobj\r" + r = self.run_on(base + replacement + old_startxref, name="cr_stale.pdf") + self.assertNotEqual(r["verdict"], "PASS", r) + self.assertTrue(any("xref-coverage" in w for w in r["warnings"]), r["warnings"]) + + def test_objstm_xref_stream_pdf_passes(self): + # Object-stream + cross-reference-stream layout must parse and PASS. + r = self.run_on(_objstm_pdf(), name="objstm.pdf") + self.assertEqual(r["verdict"], "PASS", r) + self.assertEqual(r["enumerated_page_count"], 1) + + def test_direct_replacement_of_compressed_object_never_passes(self): + # r5 P1: active copy of object 2 lives inside an object stream; a direct raw + # replacement appended AFTER the container with a stale startxref is + # unreachable but is neither orphaned nor covered by the direct-offset loop. + base = _objstm_pdf() + old_startxref = base[base.rfind(b"startxref") :] + replacement = b"\n2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n" + r = self.run_on(base + replacement + old_startxref, name="objstm_stale.pdf") + self.assertNotEqual(r["verdict"], "PASS", r) + self.assertTrue(any("compressed object" in w for w in r["warnings"]), r["warnings"]) + + def test_nul_preceded_replacement_header_never_passes(self): + # r5 P1: NUL is PDF whitespace; a replacement header preceded only by NUL + # padding must still be seen by the coverage scan. + base = _flat_pdf(2) + old_startxref = base[base.rfind(b"startxref") :] + replacement = b"\x002 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n" + r = self.run_on(base + replacement + old_startxref, name="nul_stale.pdf") + self.assertNotEqual(r["verdict"], "PASS", r) + self.assertTrue(any("xref-coverage" in w for w in r["warnings"]), r["warnings"]) + + def test_ten_digit_object_id_replacement_never_passes(self): + # r6 P1: object numbers may reach ten digits; the header scan's digit cap + # must not blind the coverage checks to such replacements. + base = _flat_pdf(2) + old_startxref = base[base.rfind(b"startxref") :] + replacement = b"\n1000000001 0 obj\n<< /Type /Pages /Count 9 >>\nendobj\n" + r = self.run_on(base + replacement + old_startxref, name="tendigit.pdf") + self.assertNotEqual(r["verdict"], "PASS", r) + self.assertTrue(any("xref-coverage" in w for w in r["warnings"]), r["warnings"]) + + def test_comment_separated_replacement_header_never_passes(self): + # r7 P1: %-comments are token separators in the PDF lexer, so + # `2 0%note\nobj` is a valid object header the scan must still see. + base = _flat_pdf(2) + old_startxref = base[base.rfind(b"startxref") :] + replacement = b"\n2 0%note\nobj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n" + r = self.run_on(base + replacement + old_startxref, name="comment_stale.pdf") + self.assertNotEqual(r["verdict"], "PASS", r) + self.assertTrue(any("xref-coverage" in w for w in r["warnings"]), r["warnings"]) + + def test_signed_or_zero_padded_replacement_header_never_passes(self): + # r8 P1: ISO 32000 integers permit a leading sign (and arbitrary zero + # padding); pypdf's header reader coerces via int(), so these header forms + # must not hide from the scan. + base = _flat_pdf(2) + old_startxref = base[base.rfind(b"startxref") :] + for header in (b"+2 0 obj", b"00000000002 0 obj"): + with self.subTest(header=header): + replacement = ( + b"\n" + header + b"\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n" + ) + r = self.run_on(base + replacement + old_startxref, name="signed_stale.pdf") + self.assertNotEqual(r["verdict"], "PASS", r) + self.assertTrue(any("xref-coverage" in w for w in r["warnings"]), r["warnings"]) + + def test_non_integer_count_unavailable(self): + # /Count 1.0 — int() would truncate-coerce and agree with one real leaf; a + # malformed page tree must be UNAVAILABLE, not PASS (codex #512 r2 P1). + objects = [ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1.0 >>", + _page(2), + ] + r = self.run_on(_build_pdf(objects), name="floatcount.pdf") + self.assertEqual(r["verdict"], "UNAVAILABLE", r) + self.assertTrue(any("not an integer" in w for w in r["warnings"]), r["warnings"]) + + def test_parser_warnings_survive_early_exit(self): + # pypdf logs a repair warning, THEN parsing dies: the sidecar must carry BOTH + # the captured warning and the later error (codex #512 P2 — early returns must + # not drop collector messages). + import logging as _logging + + class _StubReader: + def __init__(self, stream): + _logging.getLogger("pypdf").warning("synthetic repair warning") + raise ValueError("boom") + + class _StubPypdf: + PdfReader = _StubReader + + real = preflight.pypdf + preflight.pypdf = _StubPypdf + try: + r = self.run_on(_flat_pdf(1)) + finally: + preflight.pypdf = real + self.assertEqual(r["verdict"], "UNAVAILABLE", r) + self.assertTrue(any(w == "pypdf: synthetic repair warning" for w in r["warnings"]), r["warnings"]) + self.assertTrue(any(w.startswith("parse-error:") for w in r["warnings"]), r["warnings"]) + + +class SidecarShapeTest(unittest.TestCase): + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.tmp = self._tmp.name + self.addCleanup(self._tmp.cleanup) + + def test_sidecar_fields_and_hash(self): + data = _flat_pdf(2) + p = _write(self.tmp, "doc.pdf", data) + r = preflight.run_preflight(p) + self.assertEqual(r["schema"], "pdf_read_preflight/1") + self.assertEqual(r["file"], str(p)) + self.assertEqual(r["sha256"], hashlib.sha256(data).hexdigest()) + datetime.fromisoformat(r["generated_at"]) # parses or raises + self.assertTrue(r["tool"].startswith("pdf_read_preflight/")) + json.dumps(r) # JSON-serializable end to end + + +class CliTest(unittest.TestCase): + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.tmp = self._tmp.name + self.addCleanup(self._tmp.cleanup) + + def _cli(self, *args): + return subprocess.run( + [sys.executable, str(REPO_ROOT / "scripts" / "pdf_read_preflight.py"), *args], + capture_output=True, + text=True, + timeout=60, + ) + + def test_cli_stdout_json_and_exit_zero_on_verdict(self): + p = _write(self.tmp, "doc.pdf", _flat_pdf(2)) + proc = self._cli(str(p)) + self.assertEqual(proc.returncode, 0, proc.stderr) + self.assertEqual(json.loads(proc.stdout)["verdict"], "PASS") + + def test_cli_missing_file_is_a_verdict_not_an_error(self): + proc = self._cli(str(Path(self.tmp) / "nope.pdf")) + self.assertEqual(proc.returncode, 0, proc.stderr) + self.assertEqual(json.loads(proc.stdout)["verdict"], "UNAVAILABLE") + + def test_cli_output_flag_writes_sidecar(self): + p = _write(self.tmp, "doc.pdf", _flat_pdf(1)) + out = Path(self.tmp) / "doc.read_integrity.json" + proc = self._cli(str(p), "--output", str(out)) + self.assertEqual(proc.returncode, 0, proc.stderr) + self.assertEqual(json.loads(out.read_text())["verdict"], "PASS") + + def test_cli_no_args_usage_error(self): + proc = self._cli() + self.assertEqual(proc.returncode, 2) + + +if __name__ == "__main__": + unittest.main()