spectre_pdf 1.0.0

Native Rust PDF extraction engine: text, markdown for RAG, AcroForm widgets, image decoding, and encrypted PDFs. Lazy parser, persistent Document handle, no C dependencies.
Documentation
"""
Aggregate heading-decision diff across the full sweep corpus.

For each PDF, compute:
- TOC entries spec catches but pmu misses (spec_only)
- TOC entries pmu catches but spec misses (pmu_only) ← load-bearing
- Top categorical patterns in the misses (length, punctuation, structure)

Outputs a frequency table of MISS PATTERNS so we know what targeted fixes
to apply.
"""

from __future__ import annotations

import json
import re
import sys
from collections import Counter
from pathlib import Path

sys.path.insert(0, "/mnt/c/Users/RyanJ/spectre-rs/scripts")
from bench_markdown_heading_f1 import normalize_heading  # type: ignore
from sweep_heading_f1 import CORPUS, headings_from_toc  # type: ignore

import pymupdf4llm
import spectre_rs


def headings_md(md: str) -> set[str]:
    out: set[str] = set()
    for m in re.finditer(r"^#+\s+(.+?)$", md, re.M):
        h = normalize_heading(m.group(1))
        if h and len(h) > 1:
            out.add(h)
    return out


def classify_miss(h: str) -> str:
    """Bucket a missed heading by structural shape so we can target fixes."""
    if "," in h or ";" in h:
        return "MULTI_CLAUSE_COMMA"
    if "(" in h or ")" in h:
        return "PARENTHETICAL"
    words = h.split()
    if len(words) >= 8:
        return "LONG_8+"
    if len(words) >= 5:
        return "LONG_5_7"
    if any(w.isdigit() for w in words):
        return "WITH_NUMBER"
    if h.endswith(".") or h.endswith("?"):
        return "TRAILING_PUNCT"
    return "SHORT_PLAIN"


def main() -> None:
    cache_path = Path("/tmp/spectre_extra_corpus/pmu4llm_md_cache.json")
    md_cache: dict[str, str] = {}
    if cache_path.exists():
        md_cache = json.loads(cache_path.read_text())

    pmu_only_pattern: Counter[str] = Counter()
    pmu_only_examples: dict[str, list[str]] = {}
    spec_only_pattern: Counter[str] = Counter()
    total_pmu_only = 0
    total_spec_only = 0
    total_both_hit = 0
    total_both_miss = 0
    per_doc: list[tuple[str, int, int, int, int]] = []

    for label, path in CORPUS:
        p = Path(path)
        if not p.exists():
            continue
        b = p.read_bytes()
        try:
            sd = spectre_rs.Document(b)
            toc = sd.toc()
        except Exception:
            continue
        gt = headings_from_toc(toc)
        if len(gt) < 5:
            continue
        spec_md = sd.markdown()
        spec_heads = headings_md(spec_md)

        cache_key = f"{path}|{p.stat().st_mtime_ns}"
        if cache_key in md_cache:
            pmu_md = md_cache[cache_key]
        else:
            try:
                pmu_md = pymupdf4llm.to_markdown(path)
            except Exception:
                continue
            md_cache[cache_key] = pmu_md
            cache_path.write_text(json.dumps(md_cache))
        pmu_heads = headings_md(pmu_md)

        spec_only = spec_heads & gt - pmu_heads
        pmu_only = pmu_heads & gt - spec_heads
        both_hit = spec_heads & gt & pmu_heads
        both_miss = gt - spec_heads - pmu_heads

        total_spec_only += len(spec_only)
        total_pmu_only += len(pmu_only)
        total_both_hit += len(both_hit)
        total_both_miss += len(both_miss)
        per_doc.append((label, len(gt), len(spec_only), len(pmu_only), len(both_hit)))

        for h in pmu_only:
            cat = classify_miss(h)
            pmu_only_pattern[cat] += 1
            if cat not in pmu_only_examples:
                pmu_only_examples[cat] = []
            if len(pmu_only_examples[cat]) < 5:
                pmu_only_examples[cat].append(f"{label}: {h!r}")
        for h in spec_only:
            spec_only_pattern[classify_miss(h)] += 1

    print(f"Aggregate over {len(per_doc)} PDFs with TOC ≥5 entries")
    print(f"  both_hit:        {total_both_hit}")
    print(f"  spec_only:       {total_spec_only}")
    print(f"  pmu_only:        {total_pmu_only}  ← load-bearing fix-targets")
    print(f"  both_miss:       {total_both_miss}")
    print()

    print("pmu_only miss categories (sorted by count):")
    for cat, n in pmu_only_pattern.most_common():
        pct = n / max(total_pmu_only, 1) * 100
        print(f"  {cat:<22} {n:>5}  ({pct:>5.1f}%)")
    print()
    print("Examples per category:")
    for cat, ex in pmu_only_examples.items():
        print(f"\n  {cat}:")
        for e in ex:
            print(f"    {e}")
    print()

    print("\nspec_only miss categories (where spec uniquely catches):")
    for cat, n in spec_only_pattern.most_common():
        pct = n / max(total_spec_only, 1) * 100
        print(f"  {cat:<22} {n:>5}  ({pct:>5.1f}%)")


if __name__ == "__main__":
    main()