Data-quality roadmap: R-A–R-F + DQ-5 fixes (citations 590→1075, section signal 10%→95%) #16

Merged
user2595 merged 31 commits from integration/roadmap into main 2026-06-27 07:12:31 +00:00
33 changed files with 2850 additions and 96 deletions

3
.gitignore vendored
View File

@@ -38,3 +38,6 @@ htmlcov/
# Claude Code — settings.json IS committed (team-wide); only personal overrides ignored # Claude Code — settings.json IS committed (team-wide); only personal overrides ignored
.claude/settings.local.json .claude/settings.local.json
# Generated wiki index (regenerated by `codex wiki compile`; concepts.yaml is the source)
wiki/index.md

View File

@@ -116,10 +116,10 @@ def search_paper(
rows = conn.execute( rows = conn.execute(
""" """
SELECT id, title, year, SELECT id, title, year,
abstract_emb <-> %(emb)s AS distance abstract_emb <-> %(emb)s::vector AS distance
FROM papers FROM papers
WHERE abstract_emb IS NOT NULL WHERE abstract_emb IS NOT NULL
ORDER BY abstract_emb <-> %(emb)s ORDER BY abstract_emb <-> %(emb)s::vector
LIMIT %(limit)s LIMIT %(limit)s
""", """,
{"emb": emb, "limit": limit}, {"emb": emb, "limit": limit},
@@ -596,7 +596,12 @@ def graph_report(
"""Show citation graph report: top hub papers, dangling citations, cluster summary.""" """Show citation graph report: top hub papers, dangling citations, cluster summary."""
from codex.config import get_settings from codex.config import get_settings
from codex.db import get_conn from codex.db import get_conn
from codex.graph import build_citation_graph, citation_pagerank, dangling_citations from codex.graph import (
build_citation_graph,
citation_pagerank,
citing_coverage,
dangling_citations,
)
settings = get_settings() settings = get_settings()
with get_conn() as conn: with get_conn() as conn:
@@ -613,6 +618,8 @@ def graph_report(
pr = citation_pagerank(graph) pr = citation_pagerank(graph)
hubs = sorted(pr.items(), key=lambda x: -x[1])[:top_n] hubs = sorted(pr.items(), key=lambda x: -x[1])[:top_n]
dangling = sorted(dangling_citations(graph, known_ids)) dangling = sorted(dangling_citations(graph, known_ids))
n_citing, _ = citing_coverage(graph, known_ids)
coverage = n_citing / n_papers if n_papers else 0.0
small_corpus = n_papers < settings.graph_min_corpus_size small_corpus = n_papers < settings.graph_min_corpus_size
warning = ( warning = (
f"only {n_papers} ingested papers; recommended >= {settings.graph_min_corpus_size} " f"only {n_papers} ingested papers; recommended >= {settings.graph_min_corpus_size} "
@@ -627,6 +634,11 @@ def graph_report(
{ {
"nodes": graph.number_of_nodes(), "nodes": graph.number_of_nodes(),
"edges": graph.number_of_edges(), "edges": graph.number_of_edges(),
"citing_coverage": {
"citing": n_citing,
"papers": n_papers,
"fraction": round(coverage, 4),
},
"small_corpus_warning": warning, # null unless below threshold (audit U-3) "small_corpus_warning": warning, # null unless below threshold (audit U-3)
"hubs": [ "hubs": [
{"paper_id": pid, "title": titles.get(pid), "pagerank": score} {"paper_id": pid, "title": titles.get(pid), "pagerank": score}
@@ -642,7 +654,17 @@ def graph_report(
if warning: if warning:
typer.echo(f"Warning: {warning}.", err=True) typer.echo(f"Warning: {warning}.", err=True)
# R-D: low citing-paper coverage starves PageRank/coupling even at a healthy
# paper count — warn on the share of papers that actually have out-edges.
if coverage < settings.graph_min_citing_coverage:
typer.echo(
f"Warning: only {n_citing}/{n_papers} papers ({coverage:.0%}) have out-edges "
f"(recommended ≥ {settings.graph_min_citing_coverage:.0%}); "
f"low citing coverage weakens PageRank/coupling.",
err=True,
)
typer.echo(f"Graph: {graph.number_of_nodes()} nodes, {graph.number_of_edges()} edges") typer.echo(f"Graph: {graph.number_of_nodes()} nodes, {graph.number_of_edges()} edges")
typer.echo(f"Citing coverage: {n_citing}/{n_papers} papers with out-edges ({coverage:.0%})")
typer.echo("") typer.echo("")
typer.echo(f"TOP-{top_n} HUB PAPERS (PageRank)") typer.echo(f"TOP-{top_n} HUB PAPERS (PageRank)")
typer.echo("-" * 48) typer.echo("-" * 48)

View File

@@ -322,6 +322,18 @@ class Settings(BaseSettings):
), ),
) )
graph_min_citing_coverage: float = Field(
default=0.8,
ge=0.0,
le=1.0,
description=(
"Minimum share of ingested papers that must have ≥1 out-edge (i.e. "
"cite something) before `codex graph report` warns. Low citing "
"coverage starves PageRank/coupling even when the paper count looks "
"healthy — the share of *citing* papers is what matters (R-D)."
),
)
@lru_cache(maxsize=1) @lru_cache(maxsize=1)
def get_settings() -> Settings: def get_settings() -> Settings:

View File

@@ -109,6 +109,20 @@ def citation_pagerank(graph: nx.DiGraph, *, damping: float = 0.85) -> dict[str,
return cast(dict[str, float], nx.pagerank(graph, alpha=damping)) return cast(dict[str, float], nx.pagerank(graph, alpha=damping))
def citing_coverage(graph: nx.DiGraph, known_ids: set[str]) -> tuple[int, int]:
"""Return ``(papers_with_out_edges, total_papers)`` — the citing-paper coverage.
A paper "covers" the citation graph when it has ≥1 out-edge (it cites at least
one work). Low citing coverage starves the F-15 layer (PageRank / coupling /
co-citation) even when the total paper count looks healthy, because those
metrics are driven by out-edges, not by node count. Surfaced by
``codex graph report`` (R-D); the warning threshold is
:attr:`codex.config.Settings.graph_min_citing_coverage`.
"""
n_citing = sum(1 for pid in known_ids if pid in graph and graph.out_degree(pid) > 0)
return n_citing, len(known_ids)
def find_related(paper_id: str, graph: nx.DiGraph, *, min_shared: int = 2) -> list[str]: def find_related(paper_id: str, graph: nx.DiGraph, *, min_shared: int = 2) -> list[str]:
"""Bibliographic coupling: papers sharing ≥ ``min_shared`` references with ``paper_id``. """Bibliographic coupling: papers sharing ≥ ``min_shared`` references with ``paper_id``.

View File

@@ -2,7 +2,6 @@
from __future__ import annotations from __future__ import annotations
import contextlib
import logging import logging
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
@@ -14,8 +13,8 @@ from codex.config import get_settings
from codex.db import get_conn from codex.db import get_conn
from codex.embed import get_embedder from codex.embed import get_embedder
from codex.models import Citation, Paper from codex.models import Citation, Paper
from codex.quality import classify_section, filter_chunks from codex.quality import is_quality_chunk, section_label
from codex.sources import openalex, semanticscholar from codex.sources import arxiv, crossref, openalex, semanticscholar
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -48,6 +47,113 @@ def _make_bibkey(paper: Paper) -> str | None:
return surnames[0] + "EtAl" + year return surnames[0] + "EtAl" + year
def _s2_id_for(pid: str) -> str | None:
"""Format a canonical paper id for the Semantic Scholar paper endpoint.
S2 needs a namespaced id (``arXiv:<id>``, ``DOI:<doi>``) or a bare 40-hex
S2 paperId — a bare arXiv id like ``2305.10988`` 404s. DOIs are detected by
the ``10.`` prefix; everything else is assumed to be an arXiv id (modern
``2305.10988`` or legacy ``math/0603097``).
"""
p = (pid or "").strip()
if not p:
return None
if p.lower().startswith(("arxiv:", "doi:")):
return p
if p.startswith("10."):
return f"DOI:{p}"
return f"arXiv:{p}"
def _norm_cited_id(cited_id: str) -> str:
"""Normalize a DOI cited-id to its canonical bare, lower-cased form.
DOIs are case-insensitive and may arrive bare (``10.…``), as a
``https://doi.org/…`` URL, or ``doi:…``-prefixed: GROBID emits whichever
form the PDF reference text used, while S2/OpenAlex hand back bare DOIs.
Stripping the prefix and lower-casing keeps the citation-graph join from
fragmenting the same reference into distinct case-/URL-variant nodes. arXiv
ids and S2 paperIds are left untouched. Mirrors ``openalex._normalize_doi``."""
s = cited_id.strip()
lowered = s.lower()
for prefix in ("https://doi.org/", "http://doi.org/", "doi:"):
if lowered.startswith(prefix):
return lowered[len(prefix) :]
return lowered if lowered.startswith("10.") else s
def _recover_abstract(paper: Paper) -> str | None:
"""Recover a missing abstract from S2, then Crossref (DQ-2 supplement).
Tried in order of coverage for this corpus: Semantic Scholar (indexes most
arXiv + journal abstracts), then Crossref (publisher-deposited, DOI only).
Network/parse failures degrade to None rather than aborting the ingest.
"""
s2_id = _s2_id_for(paper.id)
if s2_id is not None:
try:
abstract = semanticscholar.fetch_abstract(s2_id)
if abstract and abstract.strip():
return abstract.strip()
except Exception:
logger.warning("S2 abstract recovery failed for %s", paper.id, exc_info=True)
if paper.id.startswith("10."):
try:
abstract = crossref.fetch_abstract(paper.id)
if abstract and abstract.strip():
return abstract.strip()
except Exception:
logger.warning("Crossref abstract recovery failed for %s", paper.id, exc_info=True)
return None
def _s2_reference_supplement(paper: Paper) -> list[Citation]:
"""Fetch a paper's references from Semantic Scholar (DQ-1 supplement).
Used when OpenAlex returns an empty ``referenced_works`` list — common for
arXiv preprints and institutional theses that OpenAlex indexes without a
parsed bibliography. ``citing_id`` is rewritten to the canonical ``paper.id``
so the FK holds, and cited DOIs are case-normalised. Network/parse failures
degrade to an empty list rather than aborting the ingest.
"""
s2_id = _s2_id_for(paper.id)
if s2_id is None:
return []
try:
raw = semanticscholar.fetch_references(s2_id)
except Exception:
logger.warning("S2 reference supplement failed for %s", paper.id, exc_info=True)
return []
return [
Citation(citing_id=paper.id, cited_id=_norm_cited_id(c.cited_id), context=c.context)
for c in raw
if c.cited_id
]
def _crossref_reference_supplement(paper: Paper) -> list[Citation]:
"""Fetch a paper's references from Crossref (roadmap R-B supplement).
Third leg of the citation fallback chain (OpenAlex-empty → S2 → Crossref).
Crossref carries publisher-deposited reference lists keyed on the citing
work's DOI, so only DOI papers can be looked up (arXiv-only ids → []).
``citing_id`` is rewritten to the canonical ``paper.id`` and cited DOIs are
case-normalised. Network/parse failures degrade to [] rather than aborting.
"""
if not paper.id.startswith("10."):
return []
try:
raw = crossref.fetch_references(paper.id)
except Exception:
logger.warning("Crossref reference supplement failed for %s", paper.id, exc_info=True)
return []
return [
Citation(citing_id=paper.id, cited_id=_norm_cited_id(c.cited_id), context=c.context)
for c in raw
if c.cited_id
]
def ingest_paper( def ingest_paper(
paper_id: str, paper_id: str,
source_path: str | None = None, source_path: str | None = None,
@@ -89,10 +195,11 @@ def ingest_paper(
len(paper_id) > 4 and paper_id[4:5] == "." and paper_id[:4].isdigit() len(paper_id) > 4 and paper_id[4:5] == "." and paper_id[:4].isdigit()
) )
if looks_like_arxiv: if looks_like_arxiv:
arxiv_key = paper_id if pid_lower.startswith("arxiv:") else f"arXiv:{paper_id}" # OpenAlex 404 on an arXiv id → recover authoritative metadata from
with contextlib.suppress(Exception): # the arXiv API (DQ-2); fall back to a minimal stub if arXiv also
semanticscholar.fetch_references(arxiv_key) # has nothing. Citation recovery from S2 happens in the citation
paper = Paper(id=paper_id, title="") # block below (DQ-1 supplement).
paper = arxiv.fetch_metadata(paper_id) or Paper(id=paper_id, title="")
else: else:
raise ValueError(f"Paper not found: {paper_id}") raise ValueError(f"Paper not found: {paper_id}")
@@ -100,12 +207,23 @@ def ingest_paper(
raise ValueError(f"Paper not found: {paper_id}") raise ValueError(f"Paper not found: {paper_id}")
# Canonical identity is the caller-supplied id — what the CLI / ingest scripts # Canonical identity is the caller-supplied id — what the CLI / ingest scripts
# use and what papers.id is contracted to hold (arXiv id or DOI). OpenAlex's # use and what papers.id is contracted to hold (arXiv id or DOI). Pin it to the
# fetch_paper fills paper.id with the URL-form doi/id (e.g. # caller's id (audit C-7), but normalize it the same way cited-ids are (strip a
# "https://doi.org/10.48550/arxiv.math/0603097"), which broke bare-id lookups # https://doi.org/ or doi: prefix, lower-case DOIs) so a non-bare caller cannot
# and cross-citation matching (audit C-7). The OpenAlex work id is retained # store a URL-form papers.id that would defeat DQ-5's ON CONFLICT (id) idempotency
# separately as openalex_id and in the paper_identifiers alias table. # or the `paper.id.startswith("10.")` recovery gates below. arXiv/W ids pass
paper.id = paper_id.strip() # through unchanged. The OpenAlex work id is retained as openalex_id / in
# paper_identifiers.
paper.id = _norm_cited_id(paper_id.strip())
# DQ-2: OpenAlex often has the paper but no abstract (it cannot redistribute
# some publishers' abstracts). Supplement from S2, then Crossref (uses the
# canonical paper.id set above), so the paper gets a real (non-zero) abstract
# embedding instead of a zero vector.
if not (paper.abstract or "").strip():
recovered = _recover_abstract(paper)
if recovered:
paper.abstract = recovered
# Auto-generate bibkey when source has none (D-04). # Auto-generate bibkey when source has none (D-04).
if paper.bibkey is None: if paper.bibkey is None:
@@ -208,44 +326,63 @@ def ingest_paper(
if source_path is not None: if source_path is not None:
from codex.parsing.grobid import extract_references from codex.parsing.grobid import extract_references
from codex.parsing.nougat import pdf_to_markdown from codex.parsing.nougat import pdf_to_markdown
from codex.parsing.tex import chunk_text, latex_to_text from codex.parsing.tex import chunk_sections, chunk_text
suffix = Path(source_path).suffix.lower() suffix = Path(source_path).suffix.lower()
text = "" # (section_title | None, chunk_text) pairs. `.tex` is section-aware so
# the stored `section` column reflects the real heading (R-C / R-12);
# `.txt`/`.pdf` carry no section structure, so the title is None.
raw_pairs: list[tuple[str | None, str]] = []
if suffix == ".tex": if suffix == ".tex":
text = latex_to_text(Path(source_path).read_text()) raw_pairs = chunk_sections(Path(source_path).read_text())
elif suffix == ".txt": elif suffix == ".txt":
text = Path(source_path).read_text(encoding="utf-8", errors="replace") text = Path(source_path).read_text(encoding="utf-8", errors="replace")
raw_pairs = [(None, c) for c in chunk_text(text)]
elif suffix == ".pdf": elif suffix == ".pdf":
text = pdf_to_markdown(source_path) raw_pairs = [(None, c) for c in chunk_text(pdf_to_markdown(source_path))]
# Also extract GROBID refs # Also extract GROBID refs
grobid_refs = extract_references(source_path) grobid_refs = extract_references(source_path)
for ref in grobid_refs: for ref in grobid_refs:
cited_id = ref.get("doi") or ref.get("arxiv_id") cited_id = ref.get("doi") or ref.get("arxiv_id")
if cited_id: if cited_id:
pdf_citations.append(Citation(citing_id=paper.id, cited_id=cited_id)) # Normalize the GROBID DOI (bare/URL/``doi:`` → bare lower-case)
# so PDF-derived edges join the same graph nodes as the
# S2/OpenAlex paths; arXiv ids pass through untouched.
pdf_citations.append(
Citation(citing_id=paper.id, cited_id=_norm_cited_id(cited_id))
)
else: else:
logger.warning("Unbekannter Dateityp: %s — kein Text-Parsing", source_path) logger.warning("Unbekannter Dateityp: %s — kein Text-Parsing", source_path)
raw_chunks = chunk_text(text) if text else [] # Quality-gate the pairs (keeps each chunk aligned with its section title).
chunks_text = filter_chunks(raw_chunks, settings=get_settings()) settings = get_settings()
kept_pairs = [
(title, content)
for title, content in raw_pairs
if is_quality_chunk(content, settings=settings)
]
# Delete existing chunks for this paper # Delete existing chunks for this paper
conn.execute("DELETE FROM chunks WHERE paper_id = %s", (paper.id,)) conn.execute("DELETE FROM chunks WHERE paper_id = %s", (paper.id,))
if chunks_text: if kept_pairs:
# Embed all chunks in one batch # Embed all chunks in one batch
chunk_embeddings = embedder.encode_dense(chunks_text) chunk_contents = [content for _, content in kept_pairs]
chunk_embeddings = embedder.encode_dense(chunk_contents)
chunk_rows = [ chunk_rows = [
( (
paper.id, paper.id,
ord_idx, ord_idx,
content, content,
chunk_embeddings[ord_idx].tolist(), chunk_embeddings[ord_idx].tolist(),
classify_section(content), # .tex: label from the real \section heading — keeps the
# controlled bucket when it matches, else stores the cleaned
# title (R-F); .txt/.pdf (title=None) classify by content.
# NB: `section` semantics now differ by source (write-only col).
section_label(title, content),
) )
for ord_idx, content in enumerate(chunks_text) for ord_idx, (title, content) in enumerate(kept_pairs)
] ]
with conn.cursor() as cur: with conn.cursor() as cur:
cur.executemany( cur.executemany(
@@ -256,7 +393,7 @@ def ingest_paper(
chunks_upserted = len(chunk_rows) chunks_upserted = len(chunk_rows)
# --------------------------------------------------------------- # ---------------------------------------------------------------
# 5. Fetch citations (OpenAlex if openalex_id, else S2) # 5. Fetch citations (fallback chain: OpenAlex → S2 → Crossref)
# Insert with ON CONFLICT (citing_id, cited_id) DO NOTHING # Insert with ON CONFLICT (citing_id, cited_id) DO NOTHING
# --------------------------------------------------------------- # ---------------------------------------------------------------
api_citations: list[Citation] api_citations: list[Citation]
@@ -267,8 +404,17 @@ def ingest_paper(
api_citations = [ api_citations = [
Citation(citing_id=paper.id, cited_id=c.cited_id, context=c.context) for c in raw Citation(citing_id=paper.id, cited_id=c.cited_id, context=c.context) for c in raw
] ]
# DQ-1: OpenAlex indexes many arXiv preprints / theses WITHOUT a
# parsed reference list (referenced_works == []). Fall back to the
# Semantic Scholar references, which often has them.
if not api_citations:
api_citations = _s2_reference_supplement(paper)
else: else:
api_citations = semanticscholar.fetch_references(paper_id) api_citations = _s2_reference_supplement(paper)
# R-B: if OpenAlex and S2 both came back empty, try Crossref's
# publisher-deposited references (DOI papers only) as a third leg.
if not api_citations:
api_citations = _crossref_reference_supplement(paper)
# Merge API citations and GROBID PDF citations; dedup via set # Merge API citations and GROBID PDF citations; dedup via set
all_citations_set: set[tuple[str, str]] = set() all_citations_set: set[tuple[str, str]] = set()

View File

@@ -26,6 +26,7 @@ def _text(element: ET.Element | None) -> str:
def extract_references( def extract_references(
pdf_path: str, pdf_path: str,
grobid_url: str | None = None, grobid_url: str | None = None,
timeout: float = 60.0,
) -> list[dict[str, str]]: ) -> list[dict[str, str]]:
"""Extract a structured reference list from a PDF via GROBID. """Extract a structured reference list from a PDF via GROBID.
@@ -35,6 +36,9 @@ def extract_references(
Path to the PDF file on disk. Path to the PDF file on disk.
grobid_url: grobid_url:
Base URL of the GROBID server. Defaults to ``Settings().grobid_url``. Base URL of the GROBID server. Defaults to ``Settings().grobid_url``.
timeout:
HTTP client timeout in seconds. Large PDFs (e.g. theses) can exceed the
60s default, especially against a slower/emulated GROBID; raise it then.
Returns Returns
------- -------
@@ -46,7 +50,7 @@ def extract_references(
if grobid_url is None: if grobid_url is None:
grobid_url = Settings().grobid_url grobid_url = Settings().grobid_url
with open(pdf_path, "rb") as fh, httpx.Client(timeout=60.0) as client: with open(pdf_path, "rb") as fh, httpx.Client(timeout=timeout) as client:
response = client.post( response = client.post(
f"{grobid_url}/api/processReferences", f"{grobid_url}/api/processReferences",
files={"input": (pdf_path, fh, "application/pdf")}, files={"input": (pdf_path, fh, "application/pdf")},

View File

@@ -17,6 +17,10 @@ _SECTION_RE = re.compile(
re.DOTALL, re.DOTALL,
) )
# \input{file} / \include{file} — arXiv papers routinely split the body across
# files, so these must be inlined before parsing (R-C multi-file flattening).
_INPUT_RE = re.compile(r"\\(?:input|include)\s*\{([^}]+)\}")
# Patterns for LaTeX noise removal (applied in order). # Patterns for LaTeX noise removal (applied in order).
_COMMENT_RE = re.compile(r"%[^\n]*") _COMMENT_RE = re.compile(r"%[^\n]*")
_CITE_RE = re.compile(r"\\cite\*?\{[^}]*\}") _CITE_RE = re.compile(r"\\cite\*?\{[^}]*\}")
@@ -157,3 +161,63 @@ def latex_to_text(latex: str) -> str:
return "\n\n".join(body for _, body in sections) return "\n\n".join(body for _, body in sections)
# Fallback: no section structure — clean the whole string. # Fallback: no section structure — clean the whole string.
return _clean_latex(latex) return _clean_latex(latex)
def _norm_texkey(name: str) -> str:
"""Normalize a tex file name / ``\\input`` argument to a comparable key.
Strips a leading ``./``, any directory-irrelevant whitespace, and a trailing
``.tex`` so ``\\input{sections/intro}`` matches the archive member
``sections/intro.tex``.
"""
n = name.strip()
if n.startswith("./"): # a single leading "./" only — don't eat "../" or ".hidden"
n = n[2:]
return n[:-4] if n.endswith(".tex") else n
def flatten_inputs(primary: str, files: dict[str, str]) -> str:
"""Recursively inline ``\\input``/``\\include`` directives (R-C).
*files* maps each source file's archive name to its contents (keys may carry
a ``.tex`` suffix or a ``./`` prefix — they are normalized internally). arXiv
multi-file projects leave the ``\\documentclass`` file as little more than a
skeleton of ``\\input`` lines, so the body must be assembled before parsing.
Unresolved references are dropped; recursion is capped at depth 10 to guard
against include cycles.
"""
norm = {_norm_texkey(k): v for k, v in files.items()}
def _expand(text: str, depth: int) -> str:
if depth > 10:
# Cap reached (likely an \input cycle): strip any still-unresolved
# directives rather than leak a literal "\input{…}" into the parsed text.
return _INPUT_RE.sub("", text)
def _repl(match: re.Match[str]) -> str:
content = norm.get(_norm_texkey(match.group(1)))
return _expand(content, depth + 1) if content is not None else ""
return _INPUT_RE.sub(_repl, text)
return _expand(primary, 0)
def chunk_sections(latex: str, size: int = 512, overlap: int = 64) -> list[tuple[str | None, str]]:
"""Chunk LaTeX *within* each section, pairing every chunk with its title.
Unlike :func:`latex_to_text` (which discards titles and flattens the whole
document into one block before word-window chunking), this keeps chunks from
spanning section boundaries and remembers which ``\\section`` each came from.
The caller can then label the stored ``section`` column from the real heading
instead of mostly ``body`` (R-C / audit R-12). Falls back to title-less
chunks of the whole cleaned document when there are no section commands.
"""
sections = extract_sections(latex)
if not sections:
return [(None, chunk) for chunk in chunk_text(_clean_latex(latex), size, overlap)]
pairs: list[tuple[str | None, str]] = []
for title, body in sections:
for chunk in chunk_text(body, size, overlap):
pairs.append((title or None, chunk))
return pairs

View File

@@ -110,6 +110,65 @@ def classify_section(text: str) -> str:
return "body" return "body"
def _clean_title(title: str) -> str:
"""Normalize a LaTeX ``\\section`` title for use as a section label.
Strips LaTeX word-commands (``\\emph`` …), accent/escape macros (``\\"o`` →
``o``, ``\\&``), ``~`` ties and ``{}$``, drops leading section numbering
(``3.2 ``), collapses whitespace, lower-cases, and truncates to 60 chars at a
word boundary so the stored ``section`` value is a clean, comparable string.
"""
t = title.replace("~", " ") # LaTeX non-breaking tie → space
t = re.sub(r"\\[a-zA-Z]+\*?", " ", t) # word-commands: \emph, \mathcal …
t = re.sub(r"\\[^a-zA-Z]", "", t) # accent/escape macros: \"o → o, \', \`, \&
t = re.sub(r"[{}$]", "", t) # braces / math delimiters
t = re.sub(r"^\s*\d+(?:\.\d+)*\.?\s*", "", t) # leading numbering "3.2 "
t = " ".join(t.split()).strip().lower()
return t[:60].rsplit(" ", 1)[0] if len(t) > 60 else t
# A heading collapses to a controlled bucket only when it IS one of these canonical
# section words — NOT when it merely starts with one. Prefix-matching via
# classify_section mislabels real sections ("Abstract Nonsense and Categories" →
# abstract, "References Architecture" → bibliography); an exact map avoids that while
# keeping cross-source consistency for the bare headings.
_SECTION_TITLE_BUCKETS = {
"abstract": "abstract",
"zusammenfassung": "abstract",
"introduction": "intro",
"einleitung": "intro",
"proof": "proof",
"beweis": "proof",
"references": "bibliography",
"bibliography": "bibliography",
"bibliographie": "bibliography",
"literatur": "bibliography",
}
def section_label(title: str | None, content: str) -> str:
"""Label a chunk's section, preferring the real ``\\section`` heading (R-F).
For section-aware ``.tex`` ingest *title* is the real heading: a bare canonical
heading ("Introduction", "References", "Proof") maps to its controlled bucket
(cross-source consistency); any other heading is stored as its cleaned real
title (e.g. ``"preliminaries"``, ``"introduction to operator algebras"``) — far
more signal than collapsing to ``body`` AND without the prefix-match mislabels
that ``classify_section`` would produce on descriptive titles.
When *title* is None / blank (the ``.pdf`` / ``.txt`` / ``run_quality_pass``
paths) this falls back to content-based :func:`classify_section` — unchanged
behaviour. Safe to store free-text titles because the ``section`` column is
write-only (no consumer filters on the controlled vocabulary).
"""
if not title or not title.strip():
return classify_section(content)
cleaned = _clean_title(title)
if not cleaned:
return classify_section(content)
return _SECTION_TITLE_BUCKETS.get(cleaned, cleaned)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Batch filter # Batch filter
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@@ -1,21 +1,110 @@
"""arXiv API client. """arXiv API client.
Provides: Provides:
- fetch_metadata: resolve an arXiv ID to a Paper via the Atom export API.
- fetch_source: download the .tar.gz source of a paper and extract the primary .tex file. - fetch_source: download the .tar.gz source of a paper and extract the primary .tex file.
- fetch_pdf_url: return the canonical PDF URL for a given arXiv ID. - fetch_pdf_url: return the canonical PDF URL for a given arXiv ID.
""" """
from __future__ import annotations from __future__ import annotations
import gzip
import io import io
import logging import logging
import tarfile import tarfile
import xml.etree.ElementTree as ET
import httpx import httpx
from tenacity import retry, retry_if_exception, stop_after_attempt, wait_exponential
from codex.models import Paper
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_BASE = "https://arxiv.org" _BASE = "https://arxiv.org"
_EXPORT = "https://export.arxiv.org"
_ATOM = "{http://www.w3.org/2005/Atom}"
def _is_retryable(exc: BaseException) -> bool:
if isinstance(exc, httpx.HTTPStatusError):
return exc.response.status_code == 429 or exc.response.status_code >= 500
return False
@retry(
retry=retry_if_exception(_is_retryable),
stop=stop_after_attempt(4),
wait=wait_exponential(min=1, max=20),
before_sleep=lambda rs: logger.warning("arXiv retry %d", rs.attempt_number),
)
def _query(arxiv_id: str) -> httpx.Response:
response = httpx.get(
f"{_EXPORT}/api/query",
params={"id_list": arxiv_id},
timeout=30,
follow_redirects=True,
)
response.raise_for_status()
return response
def fetch_metadata(arxiv_id: str) -> Paper | None:
"""Resolve an arXiv ID to a Paper via the Atom export API.
Authoritative metadata source for arXiv preprints — used as an ingest
fallback when OpenAlex 404s on an arXiv id (DQ-2). The arXiv id (bare,
e.g. ``"1911.00966"`` or legacy ``"math/0603097"``) becomes ``Paper.id``;
``openalex_id`` and ``bibkey`` are left for the caller to populate.
Returns
-------
Paper | None
Populated Paper (title, authors, year, abstract), or None if arXiv
has no entry for the id.
"""
bare = arxiv_id[len("arxiv:") :] if arxiv_id.lower().startswith("arxiv:") else arxiv_id
try:
response = _query(bare)
except httpx.HTTPError:
logger.warning("arXiv metadata fetch failed for %s", bare, exc_info=True)
return None
try:
root = ET.fromstring(response.text)
except ET.ParseError:
return None
entry = root.find(f"{_ATOM}entry")
if entry is None:
return None
# An id-not-found query still returns a feed but with no <entry>.
title = (entry.findtext(f"{_ATOM}title") or "").strip()
if not title:
return None
summary = (entry.findtext(f"{_ATOM}summary") or "").strip()
published = entry.findtext(f"{_ATOM}published") or ""
year = int(published[:4]) if published[:4].isdigit() else None
authors = [
name.strip()
for a in entry.findall(f"{_ATOM}author")
if (name := a.findtext(f"{_ATOM}name")) and name.strip()
]
return Paper(
id=bare,
title=" ".join(title.split()),
authors=authors,
year=year,
abstract=" ".join(summary.split()) or None,
)
def _has_documentclass(content: str) -> bool:
"""True if *content* has an UN-commented ``\\documentclass`` line.
A plain ``"\\documentclass" in content`` substring test also matches a
commented-out ``%\\documentclass`` (common in arXiv preambles); requiring the
command to start its line avoids selecting the wrong primary file.
"""
return any(line.lstrip().startswith("\\documentclass") for line in content.splitlines())
def fetch_source(arxiv_id: str) -> str | None: def fetch_source(arxiv_id: str) -> str | None:
@@ -23,8 +112,9 @@ def fetch_source(arxiv_id: str) -> str | None:
Downloads the .tar.gz source bundle from ``https://arxiv.org/src/{arxiv_id}``, Downloads the .tar.gz source bundle from ``https://arxiv.org/src/{arxiv_id}``,
locates the primary .tex file (preferring any file containing ``\\documentclass``, locates the primary .tex file (preferring any file containing ``\\documentclass``,
falling back to the largest .tex by size), and returns its contents as a UTF-8 falling back to the largest .tex by size), and inlines its ``\\input``/
string. ``\\include`` directives from the other archive members so multi-file projects
return the full document body, not just the primary file's include skeleton.
Parameters Parameters
---------- ----------
@@ -45,39 +135,58 @@ def fetch_source(arxiv_id: str) -> str | None:
if response.status_code != 200: if response.status_code != 200:
response.raise_for_status() response.raise_for_status()
from codex.parsing.tex import flatten_inputs
raw = response.content raw = response.content
# Image/font/binary members are never \input'd as text; skip them so the source
# map stays text-only (decode-with-replace would otherwise store garbled bytes).
skip_ext = (".png", ".jpg", ".jpeg", ".gif", ".bmp", ".pdf", ".eps", ".ps", ".ttf", ".otf")
try: try:
with tarfile.open(fileobj=io.BytesIO(raw), mode="r:gz") as tf: with tarfile.open(fileobj=io.BytesIO(raw), mode="r:gz") as tf:
tex_members = [m for m in tf.getmembers() if m.name.endswith(".tex")] files: dict[str, str] = {}
if not tex_members: primary_name: str | None = None
logger.debug("No .tex files found in arXiv source for %s", arxiv_id) for member in tf.getmembers():
return None if not member.isfile() or member.name.lower().endswith(skip_ext):
continue
# Prefer the file containing \documentclass (primary document)
primary: tarfile.TarInfo | None = None
for member in tex_members:
f = tf.extractfile(member) f = tf.extractfile(member)
if f is None: if f is None:
continue continue
content_bytes = f.read() # Keep ALL text members resolvable so \input of a non-.tex include
if b"\\documentclass" in content_bytes: # (e.g. a .def or extension-less body file) is not silently dropped (R-C).
primary = member files[member.name] = f.read().decode("utf-8", errors="replace")
# Decode and return immediately — first match wins # Primary doc = a .tex with an UN-commented \documentclass.
return content_bytes.decode("utf-8", errors="replace") if (
primary_name is None
and member.name.endswith(".tex")
and _has_documentclass(files[member.name])
):
primary_name = member.name
if primary is None: tex_files = {k: v for k, v in files.items() if k.endswith(".tex")}
# Fallback: largest .tex by size if not tex_files:
largest = max(tex_members, key=lambda m: m.size) logger.debug("No .tex files found in arXiv source for %s", arxiv_id)
f = tf.extractfile(largest) return None
if f is None:
return None # No \documentclass anywhere → fall back to the largest .tex.
return f.read().decode("utf-8", errors="replace") if primary_name is None:
except tarfile.TarError as exc: primary_name = max(tex_files, key=lambda k: len(tex_files[k]))
logger.warning("Failed to open tar archive for %s: %s", arxiv_id, exc)
# Inline \input/\include so multi-file projects yield the full body,
# not just the primary file's skeleton of include directives (R-C).
return flatten_inputs(files[primary_name], files)
except tarfile.TarError:
# Old arXiv submissions are served as a single bare gzipped .tex with no
# tar wrapper (e.g. legacy math/* papers) — gunzip the body directly.
try:
single = gzip.decompress(raw).decode("utf-8", errors="replace")
except (OSError, EOFError) as exc:
logger.warning("Failed to read arXiv source for %s: %s", arxiv_id, exc)
return None
if "\\" in single[:5000]: # looks like LaTeX (has backslash commands)
return single
logger.debug("arXiv source for %s is not LaTeX", arxiv_id)
return None return None
return None # unreachable but satisfies type checker
def fetch_pdf_url(arxiv_id: str) -> str: def fetch_pdf_url(arxiv_id: str) -> str:
"""Return the canonical PDF URL for an arXiv paper. """Return the canonical PDF URL for an arXiv paper.

126
codex/sources/crossref.py Normal file
View File

@@ -0,0 +1,126 @@
"""Crossref API client.
Provides:
- fetch_abstract: retrieve a work's abstract (JATS-stripped) by DOI.
- fetch_references: retrieve a work's reference list (DOI edges) by DOI.
Crossref is a *third* metadata source after OpenAlex and Semantic Scholar
(DQ-2 abstracts / roadmap R-B references). Requests use the Polite Pool (mailto
query parameter) and are retried on 429/5xx with exponential back-off via tenacity.
"""
from __future__ import annotations
import logging
import re
from typing import Any
import httpx
from tenacity import retry, retry_if_exception, stop_after_attempt, wait_exponential
from codex.config import get_settings
from codex.models import Citation
logger = logging.getLogger(__name__)
_BASE = "https://api.crossref.org"
_TAG_RE = re.compile(r"<[^>]+>")
def _is_retryable(exc: BaseException) -> bool:
if isinstance(exc, httpx.HTTPStatusError):
return exc.response.status_code == 429 or exc.response.status_code >= 500
return False
def _polite_params() -> dict[str, str]:
# Reuse the OpenAlex mailto for Crossref's Polite Pool (same address).
mailto = get_settings().openalex_mailto
return {"mailto": mailto} if mailto else {}
@retry(
retry=retry_if_exception(_is_retryable),
stop=stop_after_attempt(5),
wait=wait_exponential(min=1, max=30),
before_sleep=lambda rs: logger.warning(
"Crossref retry %d after %s",
rs.attempt_number,
rs.outcome.exception(), # type: ignore[union-attr]
),
)
def _get(url: str, params: dict[str, str] | None = None) -> httpx.Response:
merged: dict[str, str] = {**_polite_params(), **(params or {})}
response = httpx.get(url, params=merged, timeout=30, follow_redirects=True)
response.raise_for_status()
return response
def _strip_jats(abstract: str | None) -> str | None:
"""Strip JATS/XML tags from a Crossref abstract and collapse whitespace."""
if not abstract:
return None
text = _TAG_RE.sub("", abstract)
text = " ".join(text.split()).strip()
return text or None
def fetch_abstract(doi: str) -> str | None:
"""Fetch a work's abstract from Crossref by DOI.
Crossref stores abstracts as JATS-tagged markup (``<jats:p>…</jats:p>``);
tags are stripped before returning. Many works (notably book chapters)
have no abstract deposited — returns None in that case and on 404.
Parameters
----------
doi:
A bare DOI (``"10.1007/s00454-019-00132-8"``).
Returns
-------
str | None
The plain-text abstract, or None when absent.
"""
url = f"{_BASE}/works/{doi}"
try:
response = _get(url)
except httpx.HTTPStatusError as exc:
if exc.response.status_code == 404:
return None
raise
message: dict[str, Any] = response.json().get("message", {})
return _strip_jats(message.get("abstract"))
def fetch_references(doi: str) -> list[Citation]:
"""Fetch a work's reference list from Crossref by DOI (roadmap R-B).
Crossref carries publisher-deposited reference lists in ``message.reference``.
Each entry that cites a DOI-registered work exposes a bare ``DOI`` field;
only those become citation-graph edges (book / older references carry just
``unstructured`` text with no resolvable id and are skipped). ``citing_id``
is the queried *doi* — the caller rewrites it to the canonical ``papers.id``
and normalises the cited DOIs (mirrors the Semantic Scholar reference path).
Parameters
----------
doi:
A bare DOI (``"10.1007/s00454-019-00132-8"``).
Returns
-------
list[Citation]
One Citation per reference that carries a DOI. Empty on 404 or when no
references are deposited for the work.
"""
url = f"{_BASE}/works/{doi}"
try:
response = _get(url)
except httpx.HTTPStatusError as exc:
if exc.response.status_code == 404:
return []
raise
message: dict[str, Any] = response.json().get("message", {})
references: list[dict[str, Any]] = message.get("reference") or []
return [Citation(citing_id=doi, cited_id=ref["DOI"]) for ref in references if ref.get("DOI")]

View File

@@ -85,13 +85,55 @@ def _reconstruct_abstract(inverted_index: dict[str, list[int]] | None) -> str |
return " ".join(w for _, w in word_positions) return " ".join(w for _, w in word_positions)
def _normalize_doi(doi: str) -> str:
"""Normalize an OpenAlex DOI to the canonical bare, lower-cased form.
OpenAlex returns the ``doi`` field as a full URL
(``https://doi.org/10.1007/s00454-019-00132-8``), but the canonical
``papers.id`` produced by the M-1 migration is the BARE, lower-cased DOI
(``10.1007/s00454-019-00132-8``). Stripping the ``https://doi.org/`` /
``doi:`` prefix and lower-casing (DOIs are case-insensitive by spec) makes a
re-ingested DOI paper match its stored row, so the ingest
``INSERT … ON CONFLICT (id)`` updates in place instead of attempting a fresh
INSERT that trips the separate ``papers_openalex_id_key`` unique constraint
(DQ-5). Mirrors the cited-id convention in ``ingest._norm_cited_id``.
"""
s = doi.strip().lower()
for prefix in ("https://doi.org/", "http://doi.org/", "doi:"):
if s.startswith(prefix):
return s[len(prefix) :]
return s
# arXiv works are registered under a DOI of the form 10.48550/arXiv.<id>.
_ARXIV_DOI_PREFIX = "10.48550/arxiv."
def _canonical_id(doi: str) -> str:
"""Return the canonical ``papers.id`` for an OpenAlex ``doi`` value.
Builds on :func:`_normalize_doi` (bare, lower-cased DOI), then strips the
arXiv DOI prefix ``10.48550/arxiv.`` so an arXiv work's id is the BARE arXiv
id (``2305.10988`` / ``math/0603097``) — the form the corpus is keyed on —
not the arXiv DOI. Without this, re-ingesting an arXiv paper by its bare id
misses ``INSERT … ON CONFLICT (id)`` and trips ``papers_openalex_id_key``.
This is the arXiv half of DQ-5 (the DOI half was fixed there; this was
explicitly deferred and surfaced again driving the R-C .tex re-ingest).
"""
normalized = _normalize_doi(doi)
if normalized.startswith(_ARXIV_DOI_PREFIX):
return normalized[len(_ARXIV_DOI_PREFIX) :]
return normalized
def _map_paper(data: dict[str, Any]) -> Paper: def _map_paper(data: dict[str, Any]) -> Paper:
authors: list[str] = [ authors: list[str] = [
a.get("author", {}).get("display_name") or "" for a in data.get("authorships", []) a.get("author", {}).get("display_name") or "" for a in data.get("authorships", [])
] ]
abstract = _reconstruct_abstract(data.get("abstract_inverted_index")) abstract = _reconstruct_abstract(data.get("abstract_inverted_index"))
doi = data.get("doi")
return Paper( return Paper(
id=data.get("doi") or data.get("id") or "", id=_canonical_id(doi) if doi else (data.get("id") or ""),
title=data.get("title") or "", title=data.get("title") or "",
openalex_id=data.get("id"), openalex_id=data.get("id"),
authors=authors, authors=authors,

View File

@@ -90,7 +90,11 @@ def fetch_references(paper_id: str) -> list[Citation]:
raise raise
data = response.json() data = response.json()
raw_refs: list[dict[str, Any]] = data.get("data", []) # S2 returns HTTP 200 with ``{"data": null}`` for a known paper that has no
# parsed reference list (e.g. some theses). ``.get("data", [])`` would yield
# the explicit ``None`` (the default only applies when the key is absent),
# so iterate over a guaranteed list instead.
raw_refs: list[dict[str, Any]] = data.get("data") or []
citations: list[Citation] = [] citations: list[Citation] = []
for entry in raw_refs: for entry in raw_refs:
cited_paper: dict[str, Any] = entry.get("citedPaper", {}) cited_paper: dict[str, Any] = entry.get("citedPaper", {})
@@ -106,6 +110,24 @@ def fetch_references(paper_id: str) -> list[Citation]:
return citations return citations
def fetch_abstract(paper_id: str) -> str | None:
"""Fetch just the abstract for a paper from Semantic Scholar.
Used as an ingest abstract supplement (DQ-2) when OpenAlex has the paper
but no abstract. ``paper_id`` should be namespaced (``arXiv:…`` / ``DOI:…``).
Returns None on 404 or when S2 has no abstract.
"""
url = f"{_BASE_GRAPH}/paper/{paper_id}"
try:
response = _get(url, params={"fields": "abstract"})
except httpx.HTTPStatusError as exc:
if exc.response.status_code == 404:
return None
raise
abstract = response.json().get("abstract")
return abstract or None
def fetch_recommendations(paper_id: str, limit: int = 20) -> list[str]: def fetch_recommendations(paper_id: str, limit: int = 20) -> list[str]:
"""Fetch recommended paper IDs from Semantic Scholar. """Fetch recommended paper IDs from Semantic Scholar.

View File

@@ -0,0 +1,59 @@
# Open items — session 2026-06-17 (handoff notice)
Everything still open after this session. Done items are in
`docs/audit/DATA-QUALITY-2026-06-15.md` (DQ-1..DQ-5 + roadmap R-A,R-B,R-C,R-D,R-F all
DONE; R-E deferred). This file is only the **loose ends**.
## 1. ✅ RESOLVED — Jetson is online (the tunnel had dropped, not the host)
- What looked like the Jetson going offline was the **SSH tunnel** breaking, not the
host. The Jetson (`192.168.178.103`) is online. Re-open the tunnel with keepalive:
`ssh -o ServerAliveInterval=15 -f -N -L 5433:localhost:5432 alfred@192.168.178.103`.
Better still: run long ops in tmux-on-Jetson (item #4) to avoid the tunnel entirely.
## 2. ✅ DONE — R-F accent re-clean
- R-F is functionally DONE (section signal 34/329 → **314/329 = 95 %**). The remaining
cosmetic accent/tie artifacts in live `chunks.section` labels were re-cleaned in place
once the tunnel was back: applied `codex.quality._clean_title` to every label
containing `\` or `~`**8 labels fixed**, no re-embed. Verified **0 artifacts**
remain. R-F fully complete.
## 3. ✅ READY TO MERGE — all branches integrated + validated
All five feature branches are merged (**zero conflicts**) into **`integration/roadmap`**
(off `audit/loop-1`) and validated green: **391 tests pass, ruff + ruff-format + mypy
clean, tree clean**. This is the single merge candidate for `main`.
Contents (DQ-1..DQ-5 + R-A..R-F + infra docs):
| Source branch | Contents |
|---|---|
| `audit/loop-1` (base) | DQ-1..DQ-5, **R-A** (GROBID backfill + native arm64 GROBID), this notice |
| `feat/section-labels` (incl. `feat/tex-ingest`) | **R-C** (.tex ingest) + DQ-5 arXiv-id fix + gzip fallback + **R-F** (section labels) |
| `feat/crossref-references` | **R-B** (Crossref refs) + GROBID-on-demand tweak |
| `feat/graph-coverage-warning` | **R-D** (citing-coverage warning) |
| `chore/infra-ssh-stability` | infra TODO (#4) |
**To land on `main`** (user-triggered):
```
git checkout main && git merge --no-ff integration/roadmap
```
…or open a PR `integration/roadmap``main`. The individual feature branches are
preserved unchanged for per-feature review if preferred.
**Cleanup — branches prunable AFTER the merge lands** (not deleted here; destructive):
- Already in `main`: `fix/cli-readme-drift`, `fix/d04-d05-ingest-fixes`.
- Folded into `integration/roadmap`: the 5 feature branches + `audit/loop-1`.
- Pre-session / stale (verify before pruning): `feat/F-15-literature-graph`,
`feat/F-16-chunk-quality`, `fix/audit-wave-1`/`-2`/`-3`, `scratch/spike-embed`.
- Working tree is clean — only gitignored `.venv`/caches/`.env`; no stray tracked files.
## 4. Infra TODO — tmux-on-Jetson for stable long ops (documented, not implemented)
- `docs/infra/jetson-ssh-stability.md` (branch `chore/infra-ssh-stability`). The
Mac↔Jetson tunnel dropped repeatedly on multi-minute writes all session. Fix: run long
ops **on the Jetson in `tmux`** (DB is localhost there → no tunnel; survives SSH drops);
`autossh` for interactive tunnels. Open.
## 5. Known limitations (documented, NOT action items)
- `math/0306167` (old AMS-TeX, no `\section{}`) stays `section=body` — inherent.
- The 2 edited-volume books yielded 0 GROBID-parsed DOI/arXiv refs in the R-A sweep —
inherent (per-chapter refs, no clean end-bibliography).
- **DQ-6** (upstream PDF→`.txt` extraction fidelity, done outside codex-py) — flagged
earlier, largely **mooted** by R-A/R-C ingesting from PDF/`.tex` directly.

View File

@@ -1,6 +1,15 @@
# DATA-QUALITY AUDIT — codex knowledge base (handoff for a fresh session) # DATA-QUALITY AUDIT — codex knowledge base (handoff for a fresh session)
**Status:** baseline scan done 2026-06-15; four investigation items open (DQ-1…DQ-4). **Status:** baseline scan done 2026-06-15. **DQ-1 RESOLVED 2026-06-16** (S2 supplement
applied to live DB + wired into ingest). **DQ-4 MEASURED 2026-06-16** — retrieval is
strong after fixing a P0 `codex search paper` crash. **DQ-2 RESOLVED 2026-06-16**
(1911.00966 fully recovered from arXiv; s00454 abstract from S2; book chapter is a
documented limit; recovery wired into ingest). **DQ-3 RESOLVED 2026-06-16**
(content fidelity clean — full coverage, only OCR junk dropped). **All four
assessment items (DQ-1..DQ-4) DONE. DQ-5 RESOLVED 2026-06-17** (DOI id normalized
to bare canonical form in `openalex._map_paper`; live re-ingest of
`10.1007/s00454-019-00132-8` confirmed idempotent → **unblocks R-A/R-C**).
**Roadmap R-A..R-E** added 2026-06-16 (free-source acquisition levers — see Roadmap section).
**Author:** Audit-Loop (Opus). **Intended reader:** a *cold* session with no memory **Author:** Audit-Loop (Opus). **Intended reader:** a *cold* session with no memory
of the audit conversation — everything needed to continue is in this file. of the audit conversation — everything needed to continue is in this file.
@@ -50,7 +59,35 @@ incomplete, or unrepresentative data. This document is that second axis — a
## Findings (open investigation items) ## Findings (open investigation items)
### DQ-1 — Citation coverage: 12 / 29 papers (41 %) have ZERO citations · **HIGH** ### DQ-1 — Citation coverage: 12 / 29 papers (41 %) had ZERO citations · **RESOLVED 2026-06-16**
**Resolution (what was done):**
- **Verified upstream, not a bug.** Queried the OpenAlex API directly for all 11
zero-citation papers that have an `openalex_id`: every one returns
`referenced_works_count = 0` server-side, W-ids all match what we stored. They
are `type: preprint` (arXiv) / `article` / `dissertation` — works OpenAlex
indexes without a parsed bibliography. Confirmed source-coverage limit.
- **Semantic Scholar supplement applied to the live DB** (idempotent backfill,
`ON CONFLICT (citing_id,cited_id) DO NOTHING`): **+330 citations (590 → 920)**.
Zero-out-edge papers **12 → 2**; in-corpus citing coverage **17/29 → 27/29**;
graph 489→704 nodes, 590→920 edges; discovery-lead targets 472→677. 9 of the
new edges are in-corpus (e.g. `2305.10988 → 10.1007/s00454-019-00132-8`).
- **Wired into ingest** so future re-ingests self-heal: when OpenAlex returns an
empty `referenced_works`, `ingest.py` now falls back to
`_s2_reference_supplement()` (citing_id rewritten to canonical `papers.id`,
DOI cited-ids lowercased). Same helper drove the one-off backfill.
- **Bug fixed (would have crashed the batch):** `semanticscholar.fetch_references`
used `data.get("data", [])`, but S2 returns HTTP 200 `{"data": null}` for a
known paper with no parsed refs (e.g. `depositonce-5415`) → `TypeError`.
Changed to `data.get("data") or []`. Removed a now-redundant discarded S2 probe
in the OpenAlex-404 arXiv branch. New regression test added.
- **Irreducible remainder (documented limit):** the **2 `depositonce` theses**
(`10.14279/depositonce-20357`, `-5415`) stay at zero — neither OpenAlex nor S2
indexes references for TU-Berlin institutional DOIs. Accept as inherent.
- **Files touched (uncommitted in working tree):** `codex/sources/semanticscholar.py`,
`codex/ingest.py`, `tests/ingest/test_ingest.py`. Full suite: 331 passed.
**Original finding (for context):**
- **Measured:** 12 papers contribute **no** out-edges; the 590-edge citation graph - **Measured:** 12 papers contribute **no** out-edges; the 590-edge citation graph
comes from only ~17 papers. The whole F-15 layer (PageRank / coupling / comes from only ~17 papers. The whole F-15 layer (PageRank / coupling /
co-citation / discovery leads) therefore runs on ~59 % of the corpus. co-citation / discovery leads) therefore runs on ~59 % of the corpus.
@@ -79,7 +116,32 @@ incomplete, or unrepresentative data. This document is that second axis — a
supplement, or (b) documented as an inherent OpenAlex-coverage limit with the supplement, or (b) documented as an inherent OpenAlex-coverage limit with the
graph caveated accordingly. graph caveated accordingly.
### DQ-2 — Metadata gaps: 3 no-abstract, 1 fully metadata-less · **MED** ### DQ-2 — Metadata gaps: 3 no-abstract, 1 fully metadata-less · **RESOLVED 2026-06-16**
**Resolution (what was done):**
- **`1911.00966` fully recovered** from the arXiv Atom API (OpenAlex 404'd on it):
title "A discrete version of Liouville's theorem on conformal maps",
Pinkall & Springborn, 2019, 384-char abstract → bibkey `PinkallSpringborn2019`
auto-generated, abstract embedded (non-zero). It was the worst node in the
corpus — now `@cite`-able, in wiki grounding, and its **10 chunks are visible
to chunk search** (previously filtered out by `bibkey IS NOT NULL`). Citations
were already added in DQ-1 (27 S2 refs).
- **`10.1007/s00454-019-00132-8` abstract recovered from S2** (1186 chars) and
re-embedded (targeted `UPDATE`, not full re-ingest — see DQ-5).
- **`10.1007/978-3-642-17413-1_7` (book chapter): documented inherent limit.**
No abstract in OpenAlex, S2, **or Crossref** (verified). Left as the single
remaining zero-vector paper; user may add an abstract by hand.
- **Corpus now: 1 paper without abstract** (the book chapter), down from 3+1.
Zero-vector search pollution (DQ-4 secondary finding) reduced from 2 tail
fillers to 1.
- **Wired into ingest** so this self-heals: OpenAlex-404 on an arXiv id now
recovers metadata via `arxiv.fetch_metadata` (new); an empty abstract is
supplemented via `_recover_abstract` → S2 then Crossref (new
`codex/sources/crossref.py`). New `semanticscholar.fetch_abstract`.
- **Files touched:** `codex/sources/arxiv.py`, `codex/sources/crossref.py` (new),
`codex/sources/semanticscholar.py`, `codex/ingest.py`, + tests. Full suite: 342.
**Original finding (for context):**
- **Measured:** - **Measured:**
- **No abstract (3):** `10.1007/978-3-642-17413-1_7`, - **No abstract (3):** `10.1007/978-3-642-17413-1_7`,
`10.1007/s00454-019-00132-8`, `1911.00966`. These get a **zero-vector** `10.1007/s00454-019-00132-8`, `1911.00966`. These get a **zero-vector**
@@ -98,7 +160,55 @@ incomplete, or unrepresentative data. This document is that second axis — a
- **Acceptance:** every paper has at least a bibkey + non-zero abstract embedding, - **Acceptance:** every paper has at least a bibkey + non-zero abstract embedding,
or the exceptions are documented with rationale. or the exceptions are documented with rationale.
### DQ-3 — Content fidelity (chunks vs source `.txt`): NOT YET VERIFIED · **MED** ### DQ-3 — Content fidelity (chunks vs source `.txt`): VERIFIED CLEAN · **RESOLVED 2026-06-16**
**Resolution (read-only; re-ran `chunk_text` + `is_quality_chunk` on all 29
source files and compared to stored chunks):**
- **No paper is silently gutted.** Coverage (stored chunk chars / source chars)
is **109114% for every paper** — the >100% is exactly the 64/512-word overlap
inflation, i.e. full retention. `stored == kept` for all 29, so the live DB
matches what the current pipeline produces.
- **Only 18 chunks dropped corpus-wide, all `low_alpha`** (0 short, 0 bib),
concentrated in `10.1007/0-387-29555-0_13` (6) and `depositonce-5415` (10).
Inspected: they are **garbled OCR tables** from scanned-book sources (alpha
0.300.36, e.g. `"(u: 1) 7 < u D:= ud3424 else H®: out.v. Family 1…"`) —
exactly the artefacts the alpha-ratio gate targets. **No real math content lost**
(the handoff's feared failure mode did not occur; math prose stays > 0.40 alpha).
- **Head/tail alignment exact** on sampled papers: chunk 0 begins at the source
head (title/authors/abstract captured), last chunk ends at the source tail → no
front/back truncation.
- **Note:** 0 bibliography drops across the corpus → the upstream `.txt` extraction
had already stripped reference lists, which is why DQ-1's citation graph relies
on the API/GROBID sources rather than text-mined refs.
- **Acceptance met** for all 29 papers (not just a sample). No action needed.
- **⚠ SCOPE LIMIT — DQ-3 covers `.txt chunks` ONLY.** It treats each source
`.txt` as ground truth and verifies the *ingest pipeline* preserves it. It does
**not** measure the layer above — **PDF/source → `.txt`** — which was done by an
upstream tool in `ConformalLabpp` (outside codex-py) and is unexamined. There is
already evidence that layer is lossy: the 18 dropped chunks were garbled OCR
tables *already present in the `.txt`* (i.e. the PDF→txt step garbled the
table/formula regions), and the bibliographies were stripped upstream. So
"VERIFIED CLEAN" means **the loader faithfully preserves whatever the `.txt`
contains, not that no information was lost from the source paper.** See DQ-6.
### DQ-6 — PDF/source → `.txt` extraction fidelity: NOT MEASURED · **MED, open**
- **Open question:** how much did the upstream PDF→`.txt` extraction (done in
`ConformalLabpp`, outside codex-py) drop or garble vs the original papers?
Math/equations, tables, multi-column layouts, and figure captions are the usual
casualties of PDF text extraction — and we have direct evidence of garble
(DQ-3's low_alpha drops were already-garbled tables in the `.txt`).
- **Checkable:** the 36 original PDFs are present in `…/ConformalLabpp/papers/`
(map each paper's `source_path` `.txt` to its sibling `.pdf` by filename stem).
- **To investigate:** for a sample (incl. the known-bad scanned book
`0-387-29555-0_13` and a born-digital arXiv PDF), extract PDF text independently
and compare length / page-coverage to the `.txt`; spot-check whether specific
theorem statements & equations survive. Flag papers where the `.txt` is missing
large spans.
- **Acceptance:** a per-sample source→txt coverage estimate, or a list of papers
whose `.txt` is materially degraded (candidates for re-extraction — note roadmap
**R-A/R-C** would re-ingest from PDF/`.tex` directly and largely sidestep this).
**Original open question (for context):**
- **Open question:** does the stored chunk set faithfully reconstruct each source - **Open question:** does the stored chunk set faithfully reconstruct each source
file, or did the chunker / `filter_chunks` silently drop material (e.g. an file, or did the chunker / `filter_chunks` silently drop material (e.g. an
abstract, a section, math-heavy passages)? The whole corpus was ingested from abstract, a section, math-heavy passages)? The whole corpus was ingested from
@@ -111,7 +221,40 @@ incomplete, or unrepresentative data. This document is that second axis — a
- **Acceptance:** sampled papers retain ≳ the expected fraction of source text; - **Acceptance:** sampled papers retain ≳ the expected fraction of source text;
no paper is silently gutted by the quality gate. no paper is silently gutted by the quality gate.
### DQ-4 — Retrieval quality: NOT YET MEASURED · **MED** ### DQ-4 — Retrieval quality · **MEASURED 2026-06-16 — good, after fixing a P0 crash**
**P0 bug found + fixed: `codex search paper` was crash-broken on the live DB.**
The CLI passed the query embedding uncast, so pgvector raised
`operator does not exist: vector <-> double precision[]` on every paper search.
The working call sites (`mcp_server.py`, `wiki.py`) cast `%(emb)s::vector`; the
CLI did not. Fixed both `<->` occurrences in `codex/cli.py search_paper`; added a
regression guard in `tests/cli/test_cli.py` asserting the cast (mocked-DB unit
tests can't catch the type error — this is why it survived the code audit).
**Relevance verdict (post-fix): strong.** Six domain queries, both surfaces
(paper-level abstract search + chunk-level). **rank-1 was the exactly-correct
paper in 6/6 queries** for chunk search and 5/6 for paper-level (LaplaceBeltrami
put the exact paper at rank-2 behind the closely-related "Polygon Laplacian Made
Simple" — acceptable). Examples: "combinatorial Yamabe flow" → `math/0306167`
(exact, all 5 top chunks that paper); "variational principle for Delaunay
triangulations" → `math/0603097` (exact); "circle packing rigidity" →
`2601.22903` (exact). Three of the DQ-1-rescued papers (`2305.10988`,
`1005.2698`, `depositonce-5415`) now surface as top hits. The KB is trustworthy
for lookups.
**Secondary finding → reinforces DQ-2: zero-vector abstract pollution.** Papers
with no abstract get a zero `abstract_emb`, which sits at constant L2 distance
≈1.000 from every query and appears as filler in the paper-level tail whenever
< 5 strongly-relevant papers exist. `1911.00966` (fully degraded, DQ-2) showed up
at distance 1.000 in 4/6 queries. Harmless at rank>1 with a visible 1.000 score,
but noise — and a concrete reason to fix DQ-2. Chunk-level search is immune (it
filters `bibkey IS NOT NULL`, and 1911.00966 has no bibkey/chunks).
**Note:** the MCP `search` docstring claims "hybrid dense + FTS" but the SQL is
dense-only; `score = 1.0 - dist` can go negative (bge-m3 L2 ranges 02). Ranking
is fine (monotonic); only the absolute score label is misleading.
**Original open question (for context):**
- **Open question:** end-to-end, do real queries return *relevant* results? Clean - **Open question:** end-to-end, do real queries return *relevant* results? Clean
chunks + a working index don't guarantee useful retrieval. chunks + a working index don't guarantee useful retrieval.
- **To investigate next:** run a handful of domain queries through the actual - **To investigate next:** run a handful of domain queries through the actual
@@ -123,16 +266,288 @@ incomplete, or unrepresentative data. This document is that second axis — a
- **Acceptance:** a short relevance table (query → top-5 → on/off-topic) good - **Acceptance:** a short relevance table (query → top-5 → on/off-topic) good
enough to trust the KB for lookups, or a list of failure modes to fix. enough to trust the KB for lookups, or a list of failure modes to fix.
### DQ-5 — `ingest_paper` is NOT idempotent for DOI papers · **RESOLVED 2026-06-17** (found 2026-06-16)
**Resolution (what was done):**
- **Root-cause fix, minimal + contained:** added `openalex._normalize_doi()` and
applied it in `_map_paper` so the DOI branch yields the canonical **bare,
lower-cased** form (strips `https://doi.org/` / `http://doi.org/` / `doi:`;
lower-cases — DOIs are case-insensitive, matching `ingest._norm_cited_id`). The
no-DOI fallback (OpenAlex W-id URL) is left untouched. `_map_paper`→`fetch_paper`
→`ingest_paper` is the only consumer chain, so blast radius is one function.
- **Checked nothing depended on the URL form.** It was the opposite: the full-URL
id silently *defeated* `_s2_id_for(paper.id)` and `_recover_abstract`'s
`paper.id.startswith("10.")` (a `https://…` string is neither `10.`- nor
`doi:`-prefixed), so S2/Crossref recovery never fired for DOI papers. The fix
repairs those paths too.
- **Tests (offline, mocked DB):** closed audit **T-3** — the OpenAlex fixture now
emits the real URL-form DOI and asserts `paper.id` is the bare form; added
`_normalize_doi` unit tests (https/http/`doi:`/upper-case/already-bare) and a
W-id-fallback test; added ingest regression `test_ingest_doi_paper_upserts_bare
_canonical_id` (runs the real `_map_paper` through ingest, asserts the papers
upsert carries the **bare** id into `%(id)s` and uses `ON CONFLICT (id)`).
Verified it has teeth (fails on the pre-fix code). Full suite **348 passed**;
ruff + mypy --strict clean.
- **Live idempotency confirmed (tunnel up):** re-ingested
`10.1007/s00454-019-00132-8` against the Jetson DB → no `UniqueViolation`;
`IngestResult(paper_id='10.1007/s00454-019-00132-8', citations_upserted=45)`.
After-state: **UPDATE in place** — `id` and `openalex_id` unchanged, **`added_at`
unchanged** (proves no fresh INSERT), exactly **1** row for the DOI (no URL-form
duplicate), citations 45→45, total papers 29→29.
- **Unblocks R-A (GROBID re-ingest) and R-C (.tex re-ingest)** — both re-ingest
existing papers. **Files touched:** `codex/sources/openalex.py`,
`tests/sources/test_openalex.py`, `tests/ingest/test_ingest.py`.
- **Note (out of scope, pre-existing):** arXiv papers stored as bare ids
(`2305.10988`) are still not round-trip idempotent if OpenAlex returns an
arXiv-DOI/W-id instead of the bare arXiv id — a separate canonicalization
concern, not this UniqueViolation. The DOI path (this finding) is fixed.
**Original finding (for context):**
- **Symptom:** re-ingesting an existing DOI paper crashes with
`UniqueViolation: papers_openalex_id_key`. Surfaced trying to re-ingest
`10.1007/s00454-019-00132-8` during the DQ-2 backfill.
- **Root cause:** `openalex._map_paper` sets `Paper.id = data["doi"]`, which is the
**full URL** `https://doi.org/10.1007/…`. The stored canonical id is the **bare**
`10.1007/…` (post the M-1 canonical-id migration). So `INSERT … ON CONFLICT (id)`
finds no id match, attempts a fresh INSERT, and trips the *separate* unique
constraint on `openalex_id` (already held by the canonical-id row).
- **Confirmed:** `openalex.fetch_paper("10.1007/s00454-019-00132-8").id ==
"https://doi.org/10.1007/s00454-019-00132-8"` (≠ stored bare id).
- **Impact:** any re-ingest of a DOI paper aborts. This **blocks roadmap R-A
(GROBID on PDFs) and R-C (.tex ingest)** — both re-ingest existing papers. Also
implies *new* DOI ingests store full-URL ids, inconsistent with the bare-id
corpus (latent split-identity risk).
- **Proposed fix (handle with care — M-1 incident territory):** normalize the DOI
in `_map_paper` to the canonical bare, lower-cased form (strip
`https://doi.org/`), matching what the M-1 migration produced. Add a re-ingest
idempotency test over a DOI paper. Did **not** fix inline this session — the
canonical-id derivation is exactly where the M-1 migration incident occurred, so
it needs its own focused change + a live re-ingest check.
- **Workaround used for DQ-2:** targeted `UPDATE … SET abstract, abstract_emb`
instead of full re-ingest.
---
## Roadmap — data-acquisition levers (post-audit, all free sources)
These are *forward-looking acquisition improvements*, distinct from the DQ-1..DQ-4
audit findings (which assessed existing data). They came out of a strategy
discussion on 2026-06-16: for this Math/CS corpus the valuable data (references,
abstracts, full text) is almost entirely **open** (arXiv, OpenAlex, Crossref, S2),
so the quality lever is *combining more free sources*, not buying paywall access.
Paywall/uni-login was explicitly considered and **deferred** (see R-E). Each item
is self-contained so a cold session can pick it up.
### R-A — Run GROBID reference extraction on PDFs · **CORE DONE 2026-06-17** (full sweep optional)
**Resolution (what was done) — citing coverage 27/29 → 29/29 (100%); edges 920 → 1022:**
- **References-only backfill, not full re-ingest.** Added
[scripts/ra_grobid_backfill.py](../../scripts/ra_grobid_backfill.py): per paper,
run `grobid.extract_references(pdf)`, normalize cited-ids to the canonical bare
form (DOIs de-URL'd + lower-cased; `arXiv:` stripped), INSERT into `citations`
(`ON CONFLICT DO NOTHING`). Deliberately avoids `ingest_paper(source_path=pdf)`,
whose PDF branch re-runs Nougat + `DELETE FROM chunks` and would overwrite the
DQ-3-verified-clean `.txt` chunks (and needs Nougat). Idempotent; dry-run by
default; 7 tests; ruff/mypy clean.
- **GROBID provisioning:** the pinned `grobid/grobid:0.8.2` is amd64-only, but the
Jetson and the dev Mac are both arm64 (this is why GROBID never ran). Ran the
lighter CRF image (`grobid/grobid:0.8.2-crf`) on the Mac under Podman+Rosetta
(PDFs are local; citations written over the SSH DB tunnel). Made
`extract_references` timeout configurable (theses need >60s emulated). A separate
task is provisioning a native arm64 GROBID on the Jetson (`docs/infra/grobid-jetson.md`).
- **Both `depositonce` theses closed:** `…depositonce-20357` (lutz-2024) **+96**
edges (5 in-corpus); `…depositonce-5415` (sechelmann-2016) **+6** edges — its
147 MB/173-page scan blew pdfalto's 120s limit, so the 9 bibliography pages were
extracted with PyMuPDF first (77 refs, only 6 with a DOI/arXiv). **No paper has
zero out-edges now.**
- **Optional remaining (secondary acceptance):** sweep the other 27 already-covered
papers for *extra* GROBID refs via `python scripts/ra_grobid_backfill.py --write`
(the 2 book PDFs need the same bib-page extraction as sechelmann).
**Original plan (for context):**
- **✓ Unblocked (DQ-5 RESOLVED 2026-06-17):** DOI re-ingest is now idempotent, so
the GROBID re-ingest no longer aborts on `papers_openalex_id_key`.
- **What:** the whole corpus was ingested from `.txt` (`PAPERS_DIR=…/papers/txt`),
so the GROBID PDF path never ran. Re-ingest with a PDF `source_path` per paper
so `codex.parsing.grobid.extract_references` parses each bibliography.
- **Why:** extracts references the APIs lack — a *complement* to DQ-1's S2
supplement. Crucially, the **2 `depositonce` theses** that stayed at zero in
DQ-1 (no API references anywhere) almost certainly have a references section in
their open-access PDFs → GROBID could finally give them out-edges. Closes the
last 2/29.
- **How:** no new code — ingest already merges + dedups `api_citations +
pdf_citations` ([codex/ingest.py:288](../../codex/ingest.py)). GROBID server is
already configured (`GROBID_URL=http://192.168.178.103:8070` in
`.env.jetson-ingest`). Download free arXiv PDFs, re-ingest with `source_path`.
- **Acceptance:** the 2 theses gain references; published papers gain
GROBID-parsed refs beyond OpenAlex/S2 coverage; citing coverage 27/29 → 29/29.
### R-B — Add Crossref as a third citation + abstract source · **DONE 2026-06-17**
**Resolution (what was done):**
- **`crossref.fetch_references(doi)`** added (mirrors `semanticscholar.fetch_references`):
GETs `/works/{doi}`, returns one `Citation` per `message.reference[]` entry that
carries a registered `DOI` (book/`unstructured`-only refs are skipped — they
can't join the graph). `citing_id` is the queried DOI; the ingest supplement
rewrites it to `paper.id`.
- **Wired as the third leg of the citation fallback chain** in `ingest.py`:
OpenAlex-empty → S2 → **Crossref** (`_crossref_reference_supplement`, DOI papers
only — Crossref's works endpoint is DOI-keyed). cited DOIs pass through
`_norm_cited_id` (bare, lower-cased). Network/parse failures degrade to [].
- **Tests:** `fetch_references` unit tests (DOI edges / `unstructured` skipped / no
`reference` key / 404) + ingest tests asserting the Crossref leg fires when
OpenAlex+S2 are empty (cited DOI normalised) **and** does *not* fire when OpenAlex
has citations. Suite **361 green**, ruff/mypy clean. The DQ-2 abstract half
(`fetch_abstract`) already shipped earlier.
- **Live-validated:** `fetch_references` against the real API returned 33 / 40 / 24
DOI-bearing refs for `10.1007/s00454-019-00132-8` / `10.1145/3132705` /
`10.1111/cgf.13931` (Springer / ACM / Wiley).
- **No live corpus change (by design):** as a *fallback*, Crossref fires only when
OpenAlex **and** S2 are both empty. Post-DQ-1/R-A the only such papers were the 2
`depositonce` theses, which Crossref doesn't index either — so R-B is a
forward-looking third leg for future DOI ingests, not a backfill. (Harvesting
Crossref's *unique* refs for already-covered papers would need an always-merged
supplement instead — a deliberate scope choice not taken, per the doc's "fallback
chain" spec.) **Files:** `codex/sources/crossref.py`, `codex/ingest.py`, + tests.
**Original plan (for context):**
- **What:** new `codex.sources.crossref` client hitting Crossref REST
`/works/{doi}` for the `reference` array and `abstract`.
- **Why:** Crossref carries **publisher-deposited references** for many DOIs (free,
polite pool via `mailto`) — a third leg after OpenAlex/S2. It also recovers
abstracts OpenAlex is contractually barred from redistributing (likely the 2
Springer DOIs missing abstracts in DQ-2). cited_ids are DOIs → flow through the
existing graph resolver unchanged.
- **How:** mirror `codex/sources/openalex.py` (tenacity retry, polite pool). Extend
the ingest fallback chain to OpenAlex-empty → S2 → Crossref (same
`_s2_reference_supplement` shape). Add unit tests with mocked HTTP.
- **Acceptance:** Crossref recovers refs and/or an abstract for ≥1 paper where
OpenAlex+S2 are empty.
### R-C — Prefer arXiv `.tex` source over `.txt` for chunk fidelity · **DONE 2026-06-17**
**Resolution (what was done) — all 13 arXiv papers re-ingested from `.tex`:**
- **Multi-file flatten:** `arxiv.fetch_source` now inlines `\input`/`\include`
(`tex.flatten_inputs`) so multi-file projects yield the full body, not the
primary file's include skeleton (e.g. math/0603097 = 15 files / 12 includes;
2305.10988 = 6 / 5). Also handles legacy single-file **bare-gzipped** `.tex`
(no tar wrapper) for old math/* papers (commit 121b582).
- **Section-aware chunking:** new `tex.chunk_sections` returns `(title, chunk)`
pairs (chunks never span a `\section`); the ingest `.tex` path labels the stored
`section` from the real heading instead of running `classify_section` on a
header-less word-window. Quality-gated as pairs so labels stay aligned.
- **Unblocked by the arXiv half of DQ-5** (commit f504ca6): `_canonical_id`
strips the `10.48550/arxiv.` DOI prefix so an arXiv paper's id is the bare
arXiv id — without it, re-ingest tripped `papers_openalex_id_key` (confirmed
live, then fixed; idempotent re-ingest verified).
- **Outcome:** `section` column gained signal — ~34/329 arXiv chunks now
intro/theorem/proof (was ~all `body`); math/0603097 `{body:31}`→`{body:29,
proof:9}`, 2310.17529 gained proof+intro, etc. Math papers with descriptively
titled sections stay `body` (classify_section's fixed vocab) — modest but real
(R-12). No paper gutted (chunk counts stable/▲); F-16 gate unchanged.
- **Files:** `codex/parsing/tex.py`, `codex/sources/arxiv.py`, `codex/ingest.py`,
`codex/sources/openalex.py`, `scripts/rc_tex_reingest.py` (driver, dry-run
default), + tests. Full suite **370 passed**; ruff/mypy clean. Branch
`feat/tex-ingest` (commits 1da81c7, 121b582, f504ca6).
**Original plan (for context):**
- **What:** re-ingest from arXiv LaTeX source through the existing
`codex.parsing.tex.latex_to_text` path instead of flattened `.txt`.
- **Why:** highest-fidelity input — preserves math, structure, and section
boundaries. Directly serves **DQ-3** (content fidelity) and audit **R-12** (the
`section` column is low-signal because word-window `.txt` chunks rarely start at
a real header; `.tex` headers fix this).
- **How:** download free arXiv source tarballs, feed `.tex` to ingest with
`source_path`. Handle multi-file projects (main `.tex` + includes).
- **Acceptance:** `section` column gains signal (fewer `body`-only); DQ-3 coverage
vs source improves; no regression in the F-16 quality gate.
### R-D — F-15: warn on low *citing-paper* coverage, not just paper count · **DONE 2026-06-17**
**Resolution (what was done):**
- Added `graph.citing_coverage(graph, known_ids)` → `(papers_with_out_edges,
total)`, a `graph_min_citing_coverage` setting (default 0.8), and wired both into
`codex graph report`: it now prints `Citing coverage: N/M papers with out-edges
(P%)` (and in `--json`), and emits a `Warning` when the share is below the
threshold ("low citing coverage weakens PageRank/coupling"). Separate from the
existing `graph_min_corpus_size` (total-count) warning.
- Tests: `citing_coverage` unit tests + CLI tests (line shown; warning fires at
low coverage; suppressed at 100%; JSON field). Full suite green; ruff/mypy clean.
- **Live:** `codex graph report` on the corpus prints `Citing coverage: 29/29
papers with out-edges (100%)` with no warning (post-R-A). Branch
`feat/graph-coverage-warning`. Files: `codex/config.py`, `codex/graph.py`,
`codex/cli.py`, + tests.
**Original plan (for context):**
- **What:** the open DQ-1 sub-item: `graph_min_corpus_size` only flags total paper
count. Add a warning when the share of papers with ≥1 out-edge is low (e.g.
< 80%), since that is what actually starves PageRank/coupling.
- **Why:** at 17/29 the graph silently ran on 59% of the corpus with no signal.
Post-DQ-1 it's 27/29 (93%) — but the guard should catch future regressions.
- **Acceptance:** `codex graph report` surfaces citing-coverage % and warns below
threshold.
### R-E — Paywall / institutional access · **DEFERRED — only for a paywall-only expansion**
- **Decision (2026-06-16):** a uni login does **not** help the current corpus —
every DQ-1/DQ-2 gap is in openly-available works (arXiv preprints, OA theses),
and all metadata/citation sources are free APIs. Revisit *only* if the corpus
expands to papers that exist solely behind a paywall with no preprint; then
institutional access → full-text PDF → GROBID refs (R-A) + clean chunks (R-C).
- **ToS caveat:** manual download of individual papers you have legitimate access
to is fine; **automated bulk download through a uni proxy (EZproxy/Shibboleth)
violates most publisher terms and can get the whole institution's access
revoked.** Do not wire a publisher login into the ingest pipeline.
### R-F — Richer section labels: store real `\section` titles · **DONE 2026-06-17**
**Resolution (what was done):**
- **Why:** R-C labelled `.tex` chunks by mapping the real `\section` heading through
`classify_section`'s fixed vocab, so descriptively-titled Math sections collapsed
to `body` (only **34/329 = 10 %** non-`body`). A research pass confirmed the
`section` column is **write-only** (nothing filters on the vocab — `mcp_server.py`
and `wiki.py` don't even SELECT it), so storing free-text titles is safe.
- **Change:** new `quality.section_label(title, content)` (+ `_clean_title`) — keep the
controlled bucket (intro/theorem/proof/abstract/bibliography) when the heading maps
to one, else store the cleaned real title ("the flip algorithm", "main results", …).
The `.tex` ingest path uses it; `.pdf`/`.txt`/`run_quality_pass` keep
`classify_section` unchanged. `_clean_title` strips LaTeX commands/accents/ties +
leading numbering, lower-cases, truncates at a word boundary. ~1 helper + 1 ingest
line; no schema change (column already free `TEXT`).
- **Result (live):** re-applied to the 13 arXiv papers → non-`body` **34/329 (10 %) →
314/329 (95 %)**. Only `math/0306167` (old AMS-TeX, no `\section{}`) stays `body`.
- **Tests:** `section_label` / `_clean_title` units + `.tex` ingest stores a
descriptive heading verbatim. Full suite 377 passed; ruff/mypy clean. Branch
`feat/section-labels` (off `feat/tex-ingest`).
- **Accent re-clean — DONE 2026-06-17.** The `_clean_title` polish (`m\"obius`→`mobius`,
`~` ties, word-boundary truncation) landed in code, and the existing live labels were
re-cleaned in place (apply `_clean_title` to any `chunks.section` containing `\` or
`~`; **8 labels** fixed, no re-embed). Verified: **0** labels with LaTeX artifacts
remain; non-`body` holds at **314/329 (95 %)**. R-F fully complete.
--- ---
## Priority for the next session ## Priority for the next session
1. **DQ-1** (biggest lever — 41 % of the corpus is invisible to the graph; the S2 1. ~~**DQ-1**~~**DONE 2026-06-16** (live DB at 920 citations, 27/29 citing
supplement is concrete and reuses existing code). coverage; remedy wired into ingest).
2. **DQ-4** (cheap, high-information — tells you if the KB is actually usable). 2. ~~**DQ-4**~~**DONE 2026-06-16** (P0 `search paper` crash fixed; relevance
3. **DQ-2**, then **DQ-3**. verified strong; surfaced the zero-vector pollution that motivates DQ-2).
3. ~~**DQ-2**~~**DONE 2026-06-16** (1911.00966 fully recovered; s00454 abstract
from S2; book chapter documented; recovery wired into ingest).
4. ~~**DQ-3**~~**DONE 2026-06-16** (content fidelity clean; full coverage, only
18 OCR-junk chunks dropped, no math lost). **Assessment axis DQ-1..DQ-4 complete.**
5. ~~**DQ-5**~~**DONE 2026-06-17** (DOI id normalized to bare form in
`openalex._map_paper`; live re-ingest of `10.1007/s00454-019-00132-8` idempotent
— UPDATE in place, `added_at` unchanged; suite 348 green). **Unblocks R-A/R-C.**
6. **Roadmap levers** (see section above — now all unblocked): **R-A** (GROBID on
arXiv PDFs — closes the last 2 zero-citation theses), **R-B** (Crossref refs —
abstract half already shipped in DQ-2), **R-C** (`.tex` ingest — serves DQ-3),
**R-D** (F-15 coverage warning, quick). **R-E** (paywall) deferred.
All four are *data* work (queries + maybe a re-ingest/enrichment), not code-audit DQ-1..DQ-4 are *data*/assessment work; DQ-5 + roadmap R-A..R-D add **new code**
work — the code is already remediated (see the AUDIT-* docs and PRs #12#14). (canonical-id fix, re-ingest scripts, Crossref references, a `.tex` path, a graph
warning) on top of the already-remediated pipeline (see the AUDIT-* docs and PRs
#12#14). The DQ-1/DQ-2 ingest wiring + Crossref/arXiv abstract recovery already
landed this session.
--- ---

192
docs/infra/grobid-jetson.md Normal file
View File

@@ -0,0 +1,192 @@
# GROBID on the Jetson (aarch64) — deploy runbook
**Status:** ACTIVE
**Host:** `alfred@192.168.178.103` — NVIDIA Jetson Orin Nano (aarch64, 8 GB RAM, 8 GB swap, 6 cores)
**Service:** GROBID reference extraction for roadmap **R-A** (`scripts/ra_grobid_backfill.py`)
**Last updated:** 2026-06-17
**Verified:** 2026-06-17 — `grobid/grobid:0.9.0-crf` pulled as native `arm64`, `/api/isalive``true`,
end-to-end `extract_references` on `lutz-2024-thesis.pdf` → 116 refs (101 with DOI/arXiv) in ~36s.
---
## Why this image (`grobid/grobid:0.9.0-crf`)
GROBID had never come up on the Jetson because `infra/docker-compose.yml` pinned
`grobid/grobid:0.8.2`, and **every `0.8.x` tag (and the full `0.9.0`) is published
amd64-only**. A Docker tag resolves to a multi-arch *manifest list*; on aarch64
the `0.8.2` list has no `linux/arm64` entry, so there is simply nothing to pull or
run (hence `curl localhost:8070` → connection refused, nothing listening).
The fix is to select a tag whose manifest list actually contains a `linux/arm64`
build:
| Candidate | arm64? | Size | Notes |
|-------------------------------|:------:|--------:|----------------------------------------|
| `grobid/grobid:0.8.2` | ✗ | ~9.5 GB | full DL image, amd64-only (old pin) |
| `grobid/grobid:0.9.0` / `-full`| ✗ | ~10 GB | full DL image, amd64-only |
| **`grobid/grobid:0.9.0-crf`** | **✓** | ~500 MB | **CRF-only, official, multi-arch** ← |
| `lfoppiano/grobid:0.9.0-crf` | ✓ | ~500 MB | same build, community namespace (alt.) |
We use **`grobid/grobid:0.9.0-crf`**:
- **CRF-only** is sufficient for reference extraction (`/api/processReferences`)
and is ~500 MB vs ~10 GB for the deep-learning image.
- **Native arm64** — no qemu emulation (too slow / RAM-heavy on a Jetson).
- **Official namespace**, **pinned version** (not `latest-crf`, which is a moving
target). arm64 builds only exist from `0.9.0-crf` onward — there is no arm64
`0.8.x`, so this is a forced (but compatible) bump from the old `0.8.2` pin. The
TEI that `codex/parsing/grobid.py` parses (`biblStruct`, `persName`,
`idno[@type='DOI']`, …) is unchanged across the bump.
> Caveat from upstream: the arm64 image is documented as "tested only on macOS,
> not linux/arm64". The container is the *same* `linux/arm64` ELF either way, so
> bare-metal Jetson works; the end-to-end check below is what de-risks it.
---
## One-time prerequisites
### 1. Docker permission for `alfred`
The SSH user `alfred` is in the `sudo` group but **not** the `docker` group, so
`docker …` fails with `permission denied … /var/run/docker.sock`. Grant access
once (requires the sudo password):
```bash
ssh alfred@192.168.178.103
sudo usermod -aG docker alfred # add to docker group (persistent)
```
Then **start a fresh login** so the new group takes effect (group membership is
only re-evaluated at login — an existing session/`ssh` won't see it; a *new*
`ssh` connection will):
```bash
exit && ssh alfred@192.168.178.103
docker ps # should now work without sudo
```
(Alternatively run all `docker` commands below under `sudo`, but group membership
is cleaner and lets `ra_grobid_backfill.py` / curl checks be driven over plain SSH.)
### 2. Docker Compose v2 plugin (arm64)
This host has Docker Engine but **not** the Compose v2 CLI plugin (on Linux they
are separate packages; only Docker Desktop bundles Compose). Without it
`docker compose …` fails with `docker: unknown command: docker compose`. Install
the arm64 plugin into the user-local plugin dir (no sudo needed):
```bash
mkdir -p ~/.docker/cli-plugins
curl -sSL https://github.com/docker/compose/releases/latest/download/docker-compose-linux-aarch64 \
-o ~/.docker/cli-plugins/docker-compose
chmod +x ~/.docker/cli-plugins/docker-compose
docker compose version # confirm it resolves
```
---
## Deploy
GROBID is **service-scoped** on purpose: Postgres (`papers-db`) is already running
on this host, so we bring up only the `grobid` service and never `docker compose up`
the whole stack (that would try to start a second `papers-db`).
```bash
# copy this repo's compose file to the Jetson (first time only)
scp infra/docker-compose.yml infra/schema.sql alfred@192.168.178.103:~/codex-infra/
ssh alfred@192.168.178.103
cd ~/codex-infra
docker compose up -d grobid # pulls grobid/grobid:0.9.0-crf (arm64), starts papers-grobid
docker compose logs -f grobid # wait for "Started ... in N seconds" (model load ~2040s)
```
The `deploy.resources.limits.memory: 4g` cap in the compose file is honored by
Compose v2 here (no swarm needed). 3 GB suffices for references; 4 GB leaves
headroom alongside Postgres. The `restart: "no"` policy means GROBID does **not**
auto-start on boot — see "Run on-demand" below.
---
## Run on-demand (stop when idle)
GROBID is only needed **during reference extraction** (`scripts/ra_grobid_backfill.py`);
nothing else in the stack uses it. Idle it still holds ~3.5 GB, so on the 8 GB Jetson
keep it stopped and start it only for an R-A run:
```bash
ssh alfred@192.168.178.103 'docker start papers-grobid' # ~30s model load
curl -s --retry 30 --retry-delay 2 --retry-connrefused \
http://192.168.178.103:8070/api/isalive # wait for: true
# ... run R-A from the Mac ...
PYTHONPATH=. python scripts/ra_grobid_backfill.py --write
ssh alfred@192.168.178.103 'docker stop papers-grobid' # reclaim ~3.5 GB
```
First-time deploy uses `docker compose up -d grobid` (above); afterwards the
container persists, so `docker start/stop papers-grobid` is all you need.
> Why on-demand rather than a lighter tool: refextract was evaluated head-to-head
> on the Jetson and rejected — far worse DOI recall on this math corpus (lutz
> thesis: GROBID **101** usable IDs vs refextract **2**), and it was *slower* per
> PDF despite a smaller footprint. GROBID stays; running it on-demand removes its
> only real downside (idle RAM).
---
## Verify `GET /api/isalive` → `true`
```bash
# on the Jetson
curl -s http://localhost:8070/api/isalive # => true
# from the Mac (LAN) — same value the corpus' GROBID_URL points at
curl -s http://192.168.178.103:8070/api/isalive # => true
```
Anything other than `true` (timeout / connection refused / `false`) means the
container isn't up — check `docker compose logs grobid` and that nothing else
holds port 8070.
---
## End-to-end reference-extraction check + running R-A
With `GROBID_URL=http://192.168.178.103:8070` (already set in `.env.jetson-ingest`):
```bash
# one PDF, references only — should print N>0 parsed references
PYTHONPATH=. python - <<'PY'
from codex.parsing.grobid import extract_references
refs = extract_references("/Users/tarikmoussa/Desktop/ConformalLabpp/papers/lutz-2024-thesis.pdf",
grobid_url="http://192.168.178.103:8070")
print(f"{len(refs)} references; first DOI/arXiv:",
next(((r['doi'], r['arxiv_id']) for r in refs if r['doi'] or r['arxiv_id']), None))
PY
```
Then run the references-only backfill (DB tunnel up — see
`docs/audit/DATA-QUALITY-2026-06-15.md`):
```bash
ssh -N -L 5433:localhost:5432 alfred@192.168.178.103 & # DB tunnel
cp .env.jetson-ingest .env
PYTHONPATH=. python scripts/ra_grobid_backfill.py --zero-edge-only # dry-run first
PYTHONPATH=. python scripts/ra_grobid_backfill.py --write # apply
```
---
## Troubleshooting
- **Container killed mid-request / exit 137** — OOM. The CRF image needs ~3 GB for
references; raise the compose `memory` limit or check Postgres isn't starving the
box (`free -h`, `docker stats papers-grobid`).
- **`isalive` true but `processReferences` 500s** — usually a `pdfalto` failure on a
malformed PDF; confirm with a clean PDF (`lutz-2024-thesis.pdf`) and check
`docker compose logs grobid`.
- **`permission denied … docker.sock`** — the docker-group prerequisite above wasn't
applied, or the SSH session predates it (re-login).

View File

@@ -0,0 +1,66 @@
# Infra TODO — stable long-running ops against the Jetson (tmux + autossh)
**Status:** OPEN (infra). **Filed:** 2026-06-17. **Priority:** MED — it slows live ops
but has a known workaround (short/idempotent ops).
## Problem (observed)
Live data ops run on the **Mac** and reach the Jetson Postgres through an SSH
port-forward tunnel (`ssh -f -N -L 5433:localhost:5432 alfred@192.168.178.103`,
`DATABASE_URL``localhost:5433`). During the R-A / R-C / R-F work this tunnel
**dropped repeatedly mid-run**: any operation longer than a few minutes (corpus
re-ingest, the bge-m3 embed loop, the GROBID sweep) had a high chance of failing
partway when the tunnel died, leaving the work half-applied. At one point the Jetson
went **fully offline** (ping 100 % loss, `ssh:22` timeout) mid-operation.
`ingest_paper()` and the backfill scripts open one DB connection for the whole run, so
a tunnel drop raises `OperationalError: connection refused` and aborts — partial state,
manual retry. Adding `ServerAliveInterval` to the tunnel helped only marginally.
## Recommended fix
**1. Run long ops *on the Jetson* inside `tmux` (primary fix).**
The Jetson is where Postgres (and GROBID) live, so running ingest there means the DB is
`localhost`**no tunnel at all** — and `tmux` keeps the process alive across SSH
disconnects (detach / reattach to monitor). This removes the tunnel from the critical
path for every long-running write.
- [ ] Confirm the `codex` env is deployable on the Jetson (Python 3.12+, deps incl. the
bge-m3 embedder; the Orin Nano GPU should handle it — GROBID already runs there).
If not, stand up a venv / container for it.
- [ ] Standard pattern: `ssh alfred@jetson`, then
`tmux new -s codex``set -a; source .env; set +a`
`PYTHONPATH=. python scripts/<job>.py --write` → detach (`Ctrl-b d`).
Reattach later with `tmux attach -t codex`. The job survives the SSH drop.
- [ ] Point the Jetson-side `DATABASE_URL` at `localhost:5432` directly (no `:5433`).
- [ ] Document this as the default for any multi-minute write (re-ingest, sweeps,
embed loops) in `docs/audit/DATA-QUALITY-2026-06-15.md` "How to reach the data".
**2. `autossh` for when a tunnel *is* needed (secondary).**
For interactive/dev use from the Mac (psql, quick probes, read-only queries), replace
the plain `ssh -f -N -L` with `autossh -M 0 -f -N -o ServerAliveInterval=15 -o
ServerAliveCountMax=3 -L 5433:localhost:5432 alfred@jetson` so a dropped tunnel
auto-reconnects. NB: autossh only restarts the *tunnel* — an in-flight transaction
still fails, so this is for short/interactive use, not long writes (use tmux-on-Jetson
for those).
**3. (Optional, code) make backfill scripts resilient.**
Lower-value once (1)/(2) are in place, but: open a fresh `get_conn()` per paper (not one
for the whole run) and/or wrap the per-item write in a small retry, so a transient drop
costs one item, not the whole batch. The R-A backfill already does per-paper connections;
`rc_tex_reingest.py` opens a connection per paper too — but `ingest_paper` itself holds
one connection for its whole body, which is the unit that fails.
## Why this matters
The chronic flakiness directly cost time this session and left one cosmetic task
unfinished (the R-F accent re-clean — see DATA-QUALITY-2026-06-15.md R-F "Pending"). The
**robust pattern that already works** is short, idempotent operations (per-paper, or a
single quick `UPDATE`); tmux-on-Jetson makes even the long ops safe.
## Acceptance
- A documented, repeatable way to run a full corpus re-ingest that survives an SSH drop
(tmux-on-Jetson), verified by detaching/reattaching across a disconnect.
- The DATA-QUALITY doc's "How to reach the data" section recommends tmux-on-Jetson for
long writes and `autossh` for interactive tunnels.

View File

@@ -28,20 +28,34 @@ services:
restart: unless-stopped restart: unless-stopped
grobid: grobid:
# CRF-only image is sufficient for reference extraction and is lighter than # CRF-only image: sufficient for reference extraction (/api/processReferences)
# the full deeplearning variant. Check https://hub.docker.com/r/grobid/grobid # and ~500MB vs ~10GB for the full deep-learning variant.
# for the latest 0.8.x tag before deploying. #
image: grobid/grobid:0.8.2 # The `-crf` tag is also the ONLY GROBID variant published as a multi-arch
# manifest that includes linux/arm64 — the full image (grobid/grobid:0.9.0)
# and every 0.8.x tag are amd64-only. So this is what lets GROBID run
# *natively* on the aarch64 Jetson (no slow/RAM-heavy qemu emulation). arm64
# builds start at 0.9.0-crf; there is no arm64 0.8.x.
# Deploy + docker-permission fix: docs/infra/grobid-jetson.md
image: grobid/grobid:0.9.0-crf
container_name: papers-grobid container_name: papers-grobid
ports: ports:
- "8070:8070" - "8070:8070"
restart: unless-stopped init: true # reap zombie children (GROBID docs recommend --init)
# GROBID is RAM-hungry; uncomment to cap usage on constrained hardware ulimits:
# (e.g. Nvidia Jetson): core: 0 # disable core dumps (GROBID docs: --ulimit core=0)
# deploy: # On-demand only: GROBID is needed solely during reference extraction
# resources: # (scripts/ra_grobid_backfill.py), not for normal corpus operations — start it
# limits: # for an R-A run, stop it after to reclaim ~3.5GB. "no" = no auto-start on boot.
# memory: 4g # See docs/infra/grobid-jetson.md ("Run on-demand").
restart: "no"
# GROBID is RAM-hungry; cap usage on the constrained Jetson (Orin Nano, 8GB
# RAM shared with Postgres). 3GB suffices for references; 4g leaves headroom.
# Honored by `docker compose up` under Compose v2 (no swarm needed).
deploy:
resources:
limits:
memory: 4g
volumes: volumes:
pgdata: pgdata:

1
scripts/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Operational one-off scripts (importable for tests)."""

View File

@@ -0,0 +1,227 @@
"""R-A — GROBID reference backfill (references-only, idempotent).
Adds GROBID-parsed citations to the live corpus **without** re-chunking the body,
so the DQ-3-verified-clean ``.txt`` chunks are preserved. Complements the DQ-1
Semantic-Scholar supplement with references the APIs lack (notably the two
TU-Berlin ``depositonce`` theses, which neither OpenAlex nor S2 indexes).
Why references-only instead of ``ingest_paper(source_path=pdf)``
----------------------------------------------------------------
The PDF branch of :func:`codex.ingest.ingest_paper` runs Nougat OCR and then
``DELETE FROM chunks`` + re-INSERT — it would replace the clean ``.txt`` chunks
(DQ-3) with unmeasured PDF-OCR text and additionally requires the Nougat server.
This backfill touches only the ``citations`` table (``ON CONFLICT DO NOTHING``),
so it is safe, idempotent, and needs only a reachable GROBID server.
Prerequisite
------------
A reachable GROBID server. The corpus' ``GROBID_URL`` points at the Jetson
(``http://192.168.178.103:8070``). GROBID now runs natively on that aarch64 host
via the multi-arch CRF-only image (``grobid/grobid:0.9.0-crf``) — see
``docs/infra/grobid-jetson.md`` for the deploy + docker-permission fix and the
``GET /api/isalive`` check. (The old ``grobid/grobid:0.8.2`` pin was amd64-only,
which is why GROBID never came up; pass ``--grobid-url`` to override the default.)
Use the SSH DB tunnel for ``DATABASE_URL`` as documented in
``docs/audit/DATA-QUALITY-2026-06-15.md``.
Usage
-----
# dry run (no writes) over the two zero-edge theses
PYTHONPATH=. python scripts/ra_grobid_backfill.py --zero-edge-only
# write every paper's GROBID-parsed refs to the live DB
PYTHONPATH=. python scripts/ra_grobid_backfill.py --write
Default is **dry-run**; pass ``--write`` to mutate the live DB.
"""
from __future__ import annotations
import argparse
import logging
from pathlib import Path
import psycopg
import psycopg.rows
from codex.config import get_settings
from codex.db import get_conn
from codex.models import Citation
from codex.parsing.grobid import extract_references
logger = logging.getLogger(__name__)
DEFAULT_PDF_DIR = "/Users/tarikmoussa/Desktop/ConformalLabpp/papers"
def _normalize_cited_id(doi: str = "", arxiv_id: str = "") -> str | None:
"""Normalize a GROBID-extracted reference id to the corpus' canonical form.
DOIs → bare, lower-cased (strip ``https://doi.org/`` / ``http://`` / ``doi:``),
matching ``papers.id`` and ``codex.sources.openalex._normalize_doi`` (DQ-5).
arXiv ids → strip a leading ``arXiv:`` prefix, leaving the bare id
(``2305.10988``, ``math/0603097``). DOI takes precedence when both exist.
Kept self-contained rather than reusing ``ingest._norm_cited_id`` because it
additionally strips the ``arXiv:`` prefix (which GROBID can emit but that
helper leaves untouched), and to avoid importing a private cross-module name.
"""
doi = (doi or "").strip()
if doi:
s = doi.lower()
for prefix in ("https://doi.org/", "http://doi.org/", "doi:"):
if s.startswith(prefix):
return s[len(prefix) :]
return s
arx = (arxiv_id or "").strip()
if arx:
return arx[len("arxiv:") :].strip() if arx.lower().startswith("arxiv:") else arx
return None
def build_grobid_citations(
paper_id: str,
pdf_path: str | Path,
grobid_url: str | None = None,
timeout: float = 60.0,
) -> list[Citation]:
"""Extract a paper's references via GROBID and return normalized Citations.
``citing_id`` is the canonical ``paper_id``; ``cited_id`` is normalized.
Self-citations and within-paper duplicates are dropped. Refs with neither a
DOI nor an arXiv id are skipped (they cannot join the graph).
"""
refs = extract_references(str(pdf_path), grobid_url=grobid_url, timeout=timeout)
seen: set[str] = set()
citations: list[Citation] = []
for ref in refs:
cited = _normalize_cited_id(ref.get("doi", ""), ref.get("arxiv_id", ""))
if not cited or cited == paper_id or cited in seen:
continue
seen.add(cited)
citations.append(Citation(citing_id=paper_id, cited_id=cited))
return citations
def _coverage(conn: psycopg.Connection[psycopg.rows.DictRow]) -> tuple[int, int, int]:
"""Return (n_papers, n_papers_with_out_edges, n_citations) from the live DB."""
row = conn.execute(
"""
SELECT (SELECT count(*) FROM papers) AS papers,
(SELECT count(DISTINCT citing_id) FROM citations) AS citing,
(SELECT count(*) FROM citations) AS edges
"""
).fetchone()
assert row is not None
return int(row["papers"]), int(row["citing"]), int(row["edges"])
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--pdf-dir", default=DEFAULT_PDF_DIR, help="directory of source PDFs")
parser.add_argument("--grobid-url", default=None, help="override Settings().grobid_url")
parser.add_argument("--only", default=None, help="restrict to a single paper id")
parser.add_argument(
"--zero-edge-only",
action="store_true",
help="only papers with 0 out-edges (the R-A primary targets)",
)
parser.add_argument("--limit", type=int, default=None, help="cap number of papers")
parser.add_argument(
"--timeout",
type=float,
default=300.0,
help="GROBID HTTP timeout (s); theses can need >60s, esp. emulated",
)
parser.add_argument(
"--write", action="store_true", help="WRITE to the live DB (default: dry-run)"
)
args = parser.parse_args(argv)
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
grobid_url = args.grobid_url or get_settings().grobid_url
pdf_dir = Path(args.pdf_dir)
pdfs = {p.stem: p for p in pdf_dir.glob("*.pdf")}
# Read the target list in a short-lived connection, then release it: the GROBID
# extraction below is a slow per-paper network loop and must NOT hold an
# idle-in-transaction connection open across it (a mid-loop SSH-tunnel drop would
# otherwise abort the whole batch — see docs/infra/jetson-ssh-stability.md).
with get_conn() as conn:
rows = conn.execute(
"""
SELECT p.id, p.source_path,
(SELECT count(*) FROM citations ci WHERE ci.citing_id = p.id) AS out_edges
FROM papers p
ORDER BY out_edges, p.id
"""
).fetchall()
targets: list[tuple[str, Path]] = []
for r in rows:
if args.only and r["id"] != args.only:
continue
if args.zero_edge_only and r["out_edges"] != 0:
continue
stem = Path(r["source_path"]).stem if r["source_path"] else None
pdf = pdfs.get(stem) if stem else None
if pdf is None:
logger.warning("no PDF for %s (stem=%s) — skipping", r["id"], stem)
continue
targets.append((r["id"], pdf))
if args.limit is not None:
targets = targets[: args.limit]
logger.info(
"GROBID=%s | papers to process=%d | mode=%s",
grobid_url,
len(targets),
"WRITE" if args.write else "DRY-RUN",
)
# GROBID extraction — no DB connection held during this slow network loop.
all_citations: list[Citation] = []
for paper_id, pdf in targets:
try:
cits = build_grobid_citations(
paper_id, pdf, grobid_url=grobid_url, timeout=args.timeout
)
except Exception as exc:
logger.warning("GROBID extraction failed for %s (%s): %s", paper_id, pdf.name, exc)
continue
logger.info("%-38s %3d refs (%s)", paper_id, len(cits), pdf.name)
all_citations.extend(cits)
logger.info("total candidate citations: %d", len(all_citations))
if not args.write:
logger.info("DRY-RUN — no rows written. Re-run with --write to apply.")
return 0
# Fresh connection for the write — the read connection was already released.
with get_conn() as conn:
before = _coverage(conn)
with conn.cursor() as cur:
cur.executemany(
"""
INSERT INTO citations (citing_id, cited_id, context)
VALUES (%s, %s, %s)
ON CONFLICT (citing_id, cited_id) DO NOTHING
""",
[(c.citing_id, c.cited_id, c.context) for c in all_citations],
)
conn.commit()
after = _coverage(conn)
logger.info(
"coverage papers=%d citing %d->%d edges %d->%d (+%d)",
after[0],
before[1],
after[1],
before[2],
after[2],
after[2] - before[2],
)
return 0
if __name__ == "__main__":
raise SystemExit(main())

131
scripts/rc_tex_reingest.py Normal file
View File

@@ -0,0 +1,131 @@
"""R-C — re-ingest arXiv papers from LaTeX source (section-aware, higher fidelity).
For each arXiv paper, download its source via :func:`codex.sources.arxiv.fetch_source`
(multi-file ``\\input``/``\\include`` flattened), write it to a temp ``.tex``, and
re-ingest through :func:`codex.ingest.ingest_paper`. The ``.tex`` ingest path is
section-aware (:func:`codex.parsing.tex.chunk_sections`), so the stored ``section``
column is labelled from real ``\\section`` headings instead of mostly ``body`` —
serving DQ-3 (fidelity vs the OCR'd ``.txt``) and audit R-12 (weak ``section`` column).
Only arXiv papers have downloadable source; DOI/journal papers and books are skipped.
Re-ingest REPLACES the paper's chunks (DELETE + re-INSERT) — that is the intended R-C
improvement (flattened ``.txt`` → clean ``.tex`` chunks). It is idempotent and
**dry-run by default**; pass ``--write`` to mutate the live corpus.
Usage
-----
# dry-run: list arXiv papers + source availability (no writes)
PYTHONPATH=. python scripts/rc_tex_reingest.py
# re-ingest every arXiv paper from .tex
PYTHONPATH=. python scripts/rc_tex_reingest.py --write
# one paper
PYTHONPATH=. python scripts/rc_tex_reingest.py --only 2305.10988 --write
Needs ``DATABASE_URL`` (Jetson tunnel) as documented in
``docs/audit/DATA-QUALITY-2026-06-15.md``.
"""
from __future__ import annotations
import argparse
import logging
import tempfile
import psycopg
import psycopg.rows
from codex.db import get_conn
from codex.ingest import ingest_paper
from codex.sources import arxiv
logger = logging.getLogger(__name__)
def is_arxiv_id(paper_id: str) -> bool:
"""Return True for arXiv ids (the only papers with downloadable LaTeX source).
arXiv ids are modern (``2305.10988``) or legacy (``math/0603097``). DOIs start
with ``10.``; OpenAlex W-ids start with ``W`` or an ``https://openalex.org/`` URL.
"""
p = (paper_id or "").strip()
# Exclude DOIs (incl. the arXiv DOI 10.48550/arXiv.*), OpenAlex W-ids, and any
# http(s) URL form; everything else is treated as a bare arXiv id.
return (
bool(p)
and not p.startswith(("10.", "W"))
and not p.lower().startswith(("http://", "https://"))
)
def _section_dist(conn: psycopg.Connection[psycopg.rows.DictRow], paper_id: str) -> dict[str, int]:
"""Return the section-label distribution for a paper's stored chunks."""
rows = conn.execute(
"SELECT coalesce(section, '(null)') AS s, count(*) AS n "
"FROM chunks WHERE paper_id = %s GROUP BY 1 ORDER BY 2 DESC",
(paper_id,),
).fetchall()
return {r["s"]: int(r["n"]) for r in rows}
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--only", default=None, help="restrict to a single arXiv id")
parser.add_argument("--limit", type=int, default=None, help="cap number of papers")
parser.add_argument(
"--write", action="store_true", help="re-ingest into the live DB (default: dry-run)"
)
args = parser.parse_args(argv)
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
with get_conn() as conn:
rows = conn.execute("SELECT id FROM papers ORDER BY id").fetchall()
targets = [
r["id"]
for r in rows
if is_arxiv_id(r["id"]) and (args.only is None or r["id"] == args.only)
]
if args.limit is not None:
targets = targets[: args.limit]
logger.info(
"arXiv papers to process=%d | mode=%s",
len(targets),
"WRITE" if args.write else "DRY-RUN",
)
for pid in targets:
try:
src = arxiv.fetch_source(pid)
except Exception as exc:
logger.warning("%s: source fetch failed: %s", pid, exc)
continue
if not src:
logger.warning("%s: no LaTeX source available — skipping", pid)
continue
if not args.write:
logger.info("%s: source OK (%d chars)", pid, len(src))
continue
with get_conn() as conn:
before = _section_dist(conn, pid)
with tempfile.NamedTemporaryFile("w", suffix=".tex", delete=True) as tf:
tf.write(src)
tf.flush()
res = ingest_paper(pid, source_path=tf.name)
with get_conn() as conn:
after = _section_dist(conn, pid)
logger.info(
"%s: chunks %d->%d | section before=%s after=%s",
pid,
sum(before.values()),
res.chunks_upserted,
before,
after,
)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -106,6 +106,13 @@ class TestSearch:
assert "Test Paper" in result.stdout assert "Test Paper" in result.stdout
assert "0.123" in result.stdout assert "0.123" in result.stdout
# Regression guard (DQ-4): the pgvector `<->` operator requires the
# query embedding cast to ::vector. Without it, real Postgres raises
# "operator does not exist: vector <-> double precision[]". Mocked DBs
# don't catch the type error, so assert the cast is in the SQL.
executed_sql = mock_conn.execute.call_args_list[0][0][0]
assert "<->" in executed_sql and "::vector" in executed_sql
def test_search_formula_command(self) -> None: def test_search_formula_command(self) -> None:
"""Test `search formula` command with mocked DB.""" """Test `search formula` command with mocked DB."""
with patch("codex.db.get_conn") as mock_get_conn: with patch("codex.db.get_conn") as mock_get_conn:

View File

@@ -119,6 +119,36 @@ class TestGraphReport:
assert result.exit_code == 0 assert result.exit_code == 0
assert "Warning" in result.output or "warning" in result.output assert "Warning" in result.output or "warning" in result.output
def test_shows_citing_coverage_line(self):
# _sample_graph: A, B, C all cite → 3/3 = 100%, so the line shows but no warning.
out = self._run(known_ids=("A", "B", "C")).output
assert "Citing coverage" in out
assert "weakens" not in out # 100% coverage → no R-D warning
def test_low_citing_coverage_warning(self):
# Only A has an out-edge; B and C are ingested but cite nothing → 1/3 = 33%.
g = nx.DiGraph()
g.add_edge("A", "X")
g.add_nodes_from(["B", "C"])
result = self._run(graph=g, known_ids=("A", "B", "C"))
assert result.exit_code == 0
assert "weakens PageRank" in result.output # the R-D coverage warning
def test_json_includes_citing_coverage(self):
import json
g = nx.DiGraph()
g.add_edge("A", "X")
g.add_nodes_from(["B", "C"])
conn = _make_conn_with_paper_ids("A", "B", "C")
with (
patch("codex.db.get_conn", side_effect=_make_conn_cm(conn)),
patch("codex.graph.build_citation_graph", return_value=g),
):
result = runner.invoke(app, ["graph", "report", "--json"])
data = json.loads(result.output)
assert data["citing_coverage"] == {"citing": 1, "papers": 3, "fraction": 0.3333}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# codex graph related # codex graph related

View File

@@ -10,6 +10,7 @@ import pytest
from codex.graph import ( from codex.graph import (
build_citation_graph, build_citation_graph,
citation_pagerank, citation_pagerank,
citing_coverage,
dangling_citations, dangling_citations,
find_co_cited, find_co_cited,
find_related, find_related,
@@ -287,3 +288,24 @@ class TestDanglingCitations:
g = build_citation_graph(_make_conn(rows)) g = build_citation_graph(_make_conn(rows))
dangling = dangling_citations(g, set()) dangling = dangling_citations(g, set())
assert set(dangling) == {"A", "B"} assert set(dangling) == {"A", "B"}
# ---------------------------------------------------------------------------
# citing_coverage (R-D)
# ---------------------------------------------------------------------------
class TestCitingCoverage:
def test_counts_papers_with_out_edges(self):
# _small_graph: A→.., B→.., C→D have out-edges; D, E have none.
g = _small_graph()
n_citing, n_papers = citing_coverage(g, {"A", "B", "C", "D", "E"})
assert (n_citing, n_papers) == (3, 5)
def test_paper_absent_from_graph_counts_as_no_out_edges(self):
# A cites; Z was ingested but has no chunks/edges, so it isn't a graph node.
g = _small_graph()
assert citing_coverage(g, {"A", "Z"}) == (1, 2)
def test_empty(self):
assert citing_coverage(nx.DiGraph(), set()) == (0, 0)

View File

@@ -10,6 +10,7 @@ from contextlib import contextmanager
from typing import Any from typing import Any
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import httpx
import numpy as np import numpy as np
import psycopg import psycopg
import pytest import pytest
@@ -107,6 +108,60 @@ def test_ingest_paper_basic() -> None:
) )
def test_ingest_citation_fallback_to_crossref() -> None:
"""R-B: OpenAlex + S2 both empty → Crossref references supply the edges.
Crossref is the third leg of the fallback chain. Its cited DOIs are
case-normalised and citing_id is rewritten to the canonical paper.id.
"""
paper = _make_paper(paper_id="10.1007/s00454-019-00132-8", openalex_id="W123")
mock_conn = MagicMock()
mock_conn.execute = MagicMock()
mock_conn.executemany = MagicMock()
mock_conn.commit = MagicMock()
# Upper-case suffix proves the cited DOI is normalised to bare lower-case.
crossref_refs = [Citation(citing_id=paper.id, cited_id="10.1090/S0002-9947-04-03545-7")]
with (
patch("codex.ingest.openalex.fetch_paper", return_value=paper),
patch("codex.ingest.openalex.fetch_citations", return_value=[]), # OpenAlex empty
patch("codex.ingest.semanticscholar.fetch_references", return_value=[]), # S2 empty
patch("codex.ingest.crossref.fetch_references", return_value=crossref_refs) as mock_cr,
patch("codex.ingest.get_embedder", return_value=_fake_embedder()),
patch("codex.ingest.get_conn", side_effect=_make_conn_cm(mock_conn)),
):
result = ingest_paper(paper.id)
# Crossref consulted with the bare DOI; one edge upserted.
mock_cr.assert_called_once_with(paper.id)
assert result.citations_upserted == 1
mock_cursor = mock_conn.cursor.return_value.__enter__.return_value
cit_rows = mock_cursor.executemany.call_args_list[0][0][1]
assert cit_rows[0][0] == paper.id
assert cit_rows[0][1] == "10.1090/s0002-9947-04-03545-7"
def test_ingest_crossref_not_called_when_openalex_has_citations() -> None:
"""R-B: the Crossref leg fires only when OpenAlex+S2 are empty (it's a fallback)."""
paper = _make_paper(paper_id="10.1007/s00454-019-00132-8", openalex_id="W123")
mock_conn = MagicMock()
with (
patch("codex.ingest.openalex.fetch_paper", return_value=paper),
patch(
"codex.ingest.openalex.fetch_citations",
return_value=[Citation(citing_id="W123", cited_id="https://openalex.org/W9")],
),
patch("codex.ingest.crossref.fetch_references") as mock_cr,
patch("codex.ingest.get_embedder", return_value=_fake_embedder()),
patch("codex.ingest.get_conn", side_effect=_make_conn_cm(mock_conn)),
):
ingest_paper(paper.id)
mock_cr.assert_not_called()
def test_ingest_canonicalises_paper_id_to_caller_arg() -> None: def test_ingest_canonicalises_paper_id_to_caller_arg() -> None:
"""papers.id is the caller-supplied id, not OpenAlex's URL-form id (audit C-7). """papers.id is the caller-supplied id, not OpenAlex's URL-form id (audit C-7).
@@ -144,7 +199,8 @@ def test_ingest_canonicalises_paper_id_to_caller_arg() -> None:
def test_ingest_paper_source_tex(tmp_path: Any) -> None: def test_ingest_paper_source_tex(tmp_path: Any) -> None:
"""``.tex`` source → latex_to_text, chunk_text, embed, chunks inserted in DB.""" """``.tex`` source → section-aware chunking; the stored ``section`` column is
labelled from the real ``\\section`` heading, not the chunk body (R-C / R-12)."""
tex_file = tmp_path / "paper.tex" tex_file = tmp_path / "paper.tex"
tex_file.write_text(r"\section{Introduction} Hello world. This is a test paper.") tex_file.write_text(r"\section{Introduction} Hello world. This is a test paper.")
@@ -154,16 +210,20 @@ def test_ingest_paper_source_tex(tmp_path: Any) -> None:
mock_conn.executemany = MagicMock() mock_conn.executemany = MagicMock()
mock_conn.commit = MagicMock() mock_conn.commit = MagicMock()
fake_chunks = ["chunk one text here.", "chunk two text here."] # (section title, chunk content) pairs from the section-aware chunker.
section_pairs = [
("Introduction", "Body of the introduction goes here."),
("Proof of the Main Theorem", "Body of the proof goes here."),
("3.2 Discrete Conformal Maps", "Body about discrete conformal maps."),
]
with ( with (
patch("codex.ingest.openalex.fetch_paper", return_value=paper), patch("codex.ingest.openalex.fetch_paper", return_value=paper),
patch("codex.ingest.openalex.fetch_citations", return_value=[]), patch("codex.ingest.openalex.fetch_citations", return_value=[]),
patch("codex.ingest.get_embedder", return_value=_fake_embedder()), patch("codex.ingest.get_embedder", return_value=_fake_embedder()),
patch("codex.ingest.get_conn", side_effect=_make_conn_cm(mock_conn)), patch("codex.ingest.get_conn", side_effect=_make_conn_cm(mock_conn)),
patch("codex.parsing.tex.latex_to_text", return_value="Hello world. Intro text."), patch("codex.parsing.tex.chunk_sections", return_value=section_pairs),
patch("codex.parsing.tex.chunk_text", return_value=fake_chunks), patch("codex.ingest.is_quality_chunk", return_value=True),
patch("codex.ingest.filter_chunks", side_effect=lambda chunks, settings: chunks),
): ):
result = ingest_paper(paper.id, source_path=str(tex_file)) result = ingest_paper(paper.id, source_path=str(tex_file))
@@ -178,7 +238,16 @@ def test_ingest_paper_source_tex(tmp_path: Any) -> None:
chunk_sql: str = chunk_insert_call[0][0] chunk_sql: str = chunk_insert_call[0][0]
assert "INSERT INTO chunks" in chunk_sql assert "INSERT INTO chunks" in chunk_sql
assert result.chunks_upserted == len(fake_chunks) # R-F: a bare canonical heading → bucket ("Introduction"→intro); any other
# heading is stored as its cleaned real title ("proof of the main theorem",
# "discrete conformal maps") instead of being collapsed/mislabelled.
chunk_rows = chunk_insert_call[0][1]
assert [row[4] for row in chunk_rows] == [
"intro",
"proof of the main theorem",
"discrete conformal maps",
]
assert result.chunks_upserted == len(section_pairs)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -206,12 +275,15 @@ def test_ingest_paper_source_pdf(tmp_path: Any) -> None:
with ( with (
patch("codex.ingest.openalex.fetch_paper", return_value=paper), patch("codex.ingest.openalex.fetch_paper", return_value=paper),
patch("codex.ingest.openalex.fetch_citations", return_value=[]), patch("codex.ingest.openalex.fetch_citations", return_value=[]),
# OpenAlex empty → ingest now consults the S2 reference supplement (DQ-1);
# mock it empty so only the GROBID refs contribute to the count.
patch("codex.ingest.semanticscholar.fetch_references", return_value=[]),
patch("codex.ingest.get_embedder", return_value=_fake_embedder()), patch("codex.ingest.get_embedder", return_value=_fake_embedder()),
patch("codex.ingest.get_conn", side_effect=_make_conn_cm(mock_conn)), patch("codex.ingest.get_conn", side_effect=_make_conn_cm(mock_conn)),
patch("codex.parsing.nougat.pdf_to_markdown", return_value="pdf markdown text."), patch("codex.parsing.nougat.pdf_to_markdown", return_value="pdf markdown text."),
patch("codex.parsing.tex.chunk_text", return_value=fake_chunks), patch("codex.parsing.tex.chunk_text", return_value=fake_chunks),
patch("codex.parsing.grobid.extract_references", return_value=grobid_refs), patch("codex.parsing.grobid.extract_references", return_value=grobid_refs),
patch("codex.ingest.filter_chunks", side_effect=lambda chunks, settings: chunks), patch("codex.ingest.is_quality_chunk", return_value=True),
): ):
result = ingest_paper(paper.id, source_path=str(pdf_file)) result = ingest_paper(paper.id, source_path=str(pdf_file))
@@ -227,6 +299,73 @@ def test_ingest_paper_source_pdf(tmp_path: Any) -> None:
assert mock_cursor.executemany.call_count >= 2 assert mock_cursor.executemany.call_count >= 2
def test_ingest_pdf_grobid_citations_normalize_doi(tmp_path: Any) -> None:
"""R-A guard: GROBID extracts the DOI verbatim from the PDF reference text, so
it may be mixed-case, a ``https://doi.org/…`` URL, or ``doi:``-prefixed. Each
must be normalized to the bare, lower-cased canonical form before the Citation
is built — otherwise the same reference splits into case-/URL-variant graph
nodes that never join the bare-lowercase ``papers.id``. arXiv-only refs (no
DOI) are left untouched. Mirrors the S2-supplement normalization (DQ-1)."""
pdf_file = tmp_path / "paper.pdf"
pdf_file.write_bytes(b"%PDF-1.4 fake content")
paper = _make_paper()
mock_conn = MagicMock()
mock_conn.execute = MagicMock()
mock_conn.executemany = MagicMock()
mock_conn.commit = MagicMock()
def _ref(doi: str = "", arxiv_id: str = "") -> dict[str, str]:
# Faithful to GROBID's output shape: all five keys always present.
return {"title": "", "authors": "", "year": "", "doi": doi, "arxiv_id": arxiv_id}
grobid_refs = [
_ref(doi="10.1007/S00454-019-00132-8"), # mixed-case bare DOI
_ref(doi="https://doi.org/10.1145/AbC.123"), # URL-form DOI
_ref(doi="doi:10.1/MixedCase"), # doi:-prefixed DOI
_ref(arxiv_id="2101.00001"), # arXiv-only ref → untouched
]
with (
patch("codex.ingest.openalex.fetch_paper", return_value=paper),
patch("codex.ingest.openalex.fetch_citations", return_value=[]),
# OpenAlex empty → S2 supplement consulted (DQ-1); empty so only GROBID
# refs contribute to the citation rows under inspection.
patch("codex.ingest.semanticscholar.fetch_references", return_value=[]),
patch("codex.ingest.get_embedder", return_value=_fake_embedder()),
patch("codex.ingest.get_conn", side_effect=_make_conn_cm(mock_conn)),
patch("codex.parsing.nougat.pdf_to_markdown", return_value="pdf markdown text."),
patch("codex.parsing.tex.chunk_text", return_value=["pdf chunk one."]),
patch("codex.parsing.grobid.extract_references", return_value=grobid_refs),
patch("codex.ingest.is_quality_chunk", return_value=True),
):
result = ingest_paper(paper.id, source_path=str(pdf_file))
assert result.citations_upserted == 4
# Locate the citations INSERT among the cursor's executemany calls (chunks
# are also inserted via executemany, so filter by SQL rather than by index).
mock_cursor = mock_conn.cursor.return_value.__enter__.return_value
citation_calls = [
c for c in mock_cursor.executemany.call_args_list if "INSERT INTO citations" in c[0][0]
]
assert len(citation_calls) == 1
inserted_rows: list[tuple[str, str, Any]] = citation_calls[0][0][1]
# Every edge hangs off the canonical paper.id (citing side).
assert all(row[0] == paper.id for row in inserted_rows)
cited_ids = {row[1] for row in inserted_rows}
assert cited_ids == {
"10.1007/s00454-019-00132-8", # mixed-case → lower-cased
"10.1145/abc.123", # URL prefix stripped + lower-cased
"10.1/mixedcase", # doi: prefix stripped + lower-cased
"2101.00001", # arXiv id untouched
}
# No raw URL-form / prefixed DOI leaked into the citation graph.
assert not any(cid.startswith(("http", "doi:")) for cid in cited_ids)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 4. test_ingest_paper_not_found # 4. test_ingest_paper_not_found
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -279,7 +418,7 @@ def test_ingest_paper_idempotent() -> None:
def test_ingest_paper_arxiv_s2_fallback() -> None: def test_ingest_paper_arxiv_s2_fallback() -> None:
"""If OpenAlex returns None for an arXiv ID, S2 fallback produces a stub Paper.""" """OpenAlex None + arXiv has nothing → stub Paper; S2 references still consulted."""
paper_id = "2301.07041" paper_id = "2301.07041"
mock_conn = MagicMock() mock_conn = MagicMock()
mock_conn.execute = MagicMock() mock_conn.execute = MagicMock()
@@ -288,17 +427,121 @@ def test_ingest_paper_arxiv_s2_fallback() -> None:
with ( with (
patch("codex.ingest.openalex.fetch_paper", return_value=None), patch("codex.ingest.openalex.fetch_paper", return_value=None),
# arXiv + abstract sources also empty → genuine stub fallback path.
patch("codex.ingest.arxiv.fetch_metadata", return_value=None) as mock_arxiv,
patch("codex.ingest.semanticscholar.fetch_abstract", return_value=None),
patch("codex.ingest.crossref.fetch_abstract", return_value=None),
patch("codex.ingest.semanticscholar.fetch_references", return_value=[]) as mock_s2, patch("codex.ingest.semanticscholar.fetch_references", return_value=[]) as mock_s2,
patch("codex.ingest.get_embedder", return_value=_fake_embedder()), patch("codex.ingest.get_embedder", return_value=_fake_embedder()),
patch("codex.ingest.get_conn", side_effect=_make_conn_cm(mock_conn)), patch("codex.ingest.get_conn", side_effect=_make_conn_cm(mock_conn)),
): ):
result = ingest_paper(paper_id) result = ingest_paper(paper_id)
# S2 was consulted as fallback # arXiv metadata recovery attempted; S2 references consulted as fallback.
mock_arxiv.assert_called_once_with(paper_id)
mock_s2.assert_called() mock_s2.assert_called()
assert result.paper_id == paper_id assert result.paper_id == paper_id
def test_ingest_paper_openalex_404_recovers_from_arxiv() -> None:
"""DQ-2: OpenAlex 404 on an arXiv id → authoritative metadata from the arXiv API,
bibkey auto-generated, abstract embedded (no zero vector)."""
recovered = Paper(
id="1911.00966",
title="A discrete version of Liouville's theorem on conformal maps",
authors=["Ulrich Pinkall", "Boris Springborn"],
year=2019,
abstract="Liouville's theorem says that in dimension greater than two...",
)
fake_emb = _fake_embedder()
mock_conn = MagicMock()
mock_conn.execute = MagicMock()
mock_conn.executemany = MagicMock()
mock_conn.commit = MagicMock()
with (
patch("codex.ingest.openalex.fetch_paper", return_value=None),
patch("codex.ingest.arxiv.fetch_metadata", return_value=recovered) as mock_arxiv,
patch("codex.ingest.semanticscholar.fetch_references", return_value=[]),
patch("codex.ingest.get_embedder", return_value=fake_emb),
patch("codex.ingest.get_conn", side_effect=_make_conn_cm(mock_conn)),
):
result = ingest_paper("1911.00966")
mock_arxiv.assert_called_once_with("1911.00966")
assert result.paper_id == "1911.00966"
params: dict[str, Any] = mock_conn.execute.call_args_list[0][0][1]
assert params["title"].startswith("A discrete version")
assert params["bibkey"] == "PinkallSpringborn2019" # generated from recovered authors+year
# abstract present → real embedding computed, not a zero vector
fake_emb.encode_dense.assert_called_once()
def test_ingest_paper_empty_abstract_recovered_from_s2() -> None:
"""DQ-2: OpenAlex has the paper but no abstract → supplemented from S2 before embedding."""
paper = _make_paper(abstract=None) # has openalex_id, no abstract
fake_emb = _fake_embedder()
mock_conn = MagicMock()
mock_conn.execute = MagicMock()
mock_conn.executemany = MagicMock()
mock_conn.commit = MagicMock()
with (
patch("codex.ingest.openalex.fetch_paper", return_value=paper),
patch("codex.ingest.openalex.fetch_citations", return_value=[]),
patch("codex.ingest.semanticscholar.fetch_references", return_value=[]),
patch(
"codex.ingest.semanticscholar.fetch_abstract",
return_value="Recovered abstract text.",
) as mock_abs,
patch("codex.ingest.get_embedder", return_value=fake_emb),
patch("codex.ingest.get_conn", side_effect=_make_conn_cm(mock_conn)),
):
ingest_paper(paper.id)
mock_abs.assert_called_once()
params: dict[str, Any] = mock_conn.execute.call_args_list[0][0][1]
assert params["abstract"] == "Recovered abstract text."
fake_emb.encode_dense.assert_called_once_with(["Recovered abstract text."])
def test_ingest_paper_openalex_empty_uses_s2_supplement() -> None:
"""DQ-1: OpenAlex returns an empty reference list → ingest supplements from S2,
rewriting citing_id to paper.id and lowercasing DOI cited-ids."""
paper = _make_paper() # openalex_id="W123", id="2301.07041"
mock_conn = MagicMock()
mock_conn.execute = MagicMock()
mock_conn.executemany = MagicMock()
mock_conn.commit = MagicMock()
# S2 keys citing_id by the id form passed in; ingest must canonicalise it.
s2_refs = [
Citation(citing_id="arXiv:2301.07041", cited_id="10.1/UPPER", context="ctx"),
Citation(citing_id="arXiv:2301.07041", cited_id="2202.00002"),
]
with (
patch("codex.ingest.openalex.fetch_paper", return_value=paper),
patch("codex.ingest.openalex.fetch_citations", return_value=[]),
patch("codex.ingest.semanticscholar.fetch_references", return_value=s2_refs) as mock_s2,
patch("codex.ingest.get_embedder", return_value=_fake_embedder()),
patch("codex.ingest.get_conn", side_effect=_make_conn_cm(mock_conn)),
):
result = ingest_paper(paper.id)
# S2 consulted with a namespaced id (a bare arXiv id 404s on S2).
mock_s2.assert_called_once_with("arXiv:2301.07041")
assert result.citations_upserted == 2
mock_cursor = mock_conn.cursor.return_value.__enter__.return_value
inserted_rows: list[tuple[str, str, Any]] = mock_cursor.executemany.call_args_list[0][0][1]
# citing_id rewritten to paper.id, not the S2 id form.
assert all(row[0] == paper.id for row in inserted_rows)
cited = {row[1] for row in inserted_rows}
assert "10.1/upper" in cited # DOI lowercased
assert "2202.00002" in cited # arXiv id untouched
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 7. test_ingest_paper_no_abstract_uses_zero_vector # 7. test_ingest_paper_no_abstract_uses_zero_vector
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -317,6 +560,9 @@ def test_ingest_paper_no_abstract_uses_zero_vector() -> None:
with ( with (
patch("codex.ingest.openalex.fetch_paper", return_value=paper), patch("codex.ingest.openalex.fetch_paper", return_value=paper),
patch("codex.ingest.openalex.fetch_citations", return_value=[]), patch("codex.ingest.openalex.fetch_citations", return_value=[]),
# No abstract recoverable from any source (DQ-2) → genuine zero-vector case.
patch("codex.ingest.semanticscholar.fetch_abstract", return_value=None),
patch("codex.ingest.crossref.fetch_abstract", return_value=None),
patch("codex.ingest.get_embedder", return_value=fake_emb), patch("codex.ingest.get_embedder", return_value=fake_emb),
patch("codex.ingest.get_conn", side_effect=_make_conn_cm(mock_conn)), patch("codex.ingest.get_conn", side_effect=_make_conn_cm(mock_conn)),
): ):
@@ -526,6 +772,93 @@ def test_ingest_sets_bibkey_when_none(tmp_path: Any) -> None:
assert upsert_params["bibkey"] == "AliceBob2023" assert upsert_params["bibkey"] == "AliceBob2023"
def test_ingest_doi_paper_upserts_bare_canonical_id() -> None:
"""DQ-5 regression: re-ingesting an existing DOI paper must be idempotent.
OpenAlex returns the ``doi`` as a full URL (and possibly mixed-case), but the
stored canonical ``papers.id`` is the BARE, lower-cased DOI (M-1 migration).
The papers upsert must therefore carry the BARE id into ``%(id)s`` so
``ON CONFLICT (id)`` matches the existing row and UPDATEs it — rather than
attempting a fresh INSERT that trips the separate ``papers_openalex_id_key``
unique constraint (the UniqueViolation crash).
The real ``_map_paper``/``fetch_paper`` run here (only the HTTP ``_get``, the
embedder and the DB are mocked), so this exercises the actual normalization
through the ingest path. The DB is a MagicMock, so it cannot raise the real
constraint error; the live re-ingest of 10.1007/s00454-019-00132-8 is the
end-to-end UniqueViolation proof (see DATA-QUALITY-2026-06-15.md, DQ-5).
"""
bare = "10.1007/s00454-019-00132-8"
work = {
"id": "https://openalex.org/W2899262408",
# URL form + an upper-case suffix char — the worst case for ON CONFLICT.
"doi": "https://doi.org/10.1007/S00454-019-00132-8",
"title": "A Test Paper",
"publication_year": 2019,
"authorships": [{"author": {"display_name": "Alice Smith"}}],
"abstract_inverted_index": {"Hello": [0], "world": [1]},
# Non-empty so fetch_citations yields edges and the S2 fallback is skipped.
"referenced_works": ["https://openalex.org/W1"],
}
mock_conn = MagicMock()
mock_conn.execute = MagicMock()
mock_conn.executemany = MagicMock()
mock_conn.commit = MagicMock()
with (
patch(
"codex.ingest.openalex._get",
return_value=httpx.Response(200, json=work),
),
patch("codex.ingest.get_embedder", return_value=_fake_embedder()),
patch("codex.ingest.get_conn", side_effect=_make_conn_cm(mock_conn)),
):
result = ingest_paper(bare)
# The IngestResult and the upsert must use the bare canonical id, not the URL.
assert result.paper_id == bare
upsert_sql: str = mock_conn.execute.call_args_list[0][0][0]
upsert_params: dict[str, Any] = mock_conn.execute.call_args_list[0][0][1]
assert "INSERT INTO papers" in upsert_sql
assert "ON CONFLICT (id)" in upsert_sql
assert upsert_params["id"] == bare, (
f"upsert id must be the bare canonical DOI '{bare}', "
f"not the OpenAlex URL form '{upsert_params['id']}'"
)
def test_ingest_normalizes_non_bare_caller_id() -> None:
"""Review #1: a non-bare CALLER id (URL-form / mixed-case DOI) is normalized to the
bare canonical form before being pinned to papers.id (the C-7 pin runs it through
_norm_cited_id), so a sloppy caller cannot store a URL-form papers.id that would
defeat ON CONFLICT (id) idempotency or the ``paper.id.startswith('10.')`` gates."""
paper = _make_paper(paper_id="10.1007/s00454-019-00132-8", openalex_id="W1")
mock_conn = MagicMock()
mock_conn.execute = MagicMock()
mock_conn.executemany = MagicMock()
mock_conn.commit = MagicMock()
with (
patch("codex.ingest.openalex.fetch_paper", return_value=paper),
patch("codex.ingest.openalex.fetch_citations", return_value=[]),
patch("codex.ingest.semanticscholar.fetch_references", return_value=[]),
patch("codex.ingest.crossref.fetch_references", return_value=[]),
patch("codex.ingest.get_embedder", return_value=_fake_embedder()),
patch("codex.ingest.get_conn", side_effect=_make_conn_cm(mock_conn)),
):
# Caller passes the URL form with an upper-case suffix char (worst case).
result = ingest_paper("https://doi.org/10.1007/S00454-019-00132-8")
bare = "10.1007/s00454-019-00132-8"
assert result.paper_id == bare
upsert_params: dict[str, Any] = mock_conn.execute.call_args_list[0][0][1]
assert upsert_params["id"] == bare, (
f"non-bare caller must be normalized to '{bare}', got '{upsert_params['id']}'"
)
def test_ingest_disambiguates_bibkey_on_unique_violation() -> None: def test_ingest_disambiguates_bibkey_on_unique_violation() -> None:
"""A papers.bibkey UNIQUE collision is retried with an a/b/c suffix (audit C-15).""" """A papers.bibkey UNIQUE collision is retried with an a/b/c suffix (audit C-15)."""
paper = _make_paper() # bibkey auto-generates to "AliceBob2023" paper = _make_paper() # bibkey auto-generates to "AliceBob2023"

View File

@@ -4,7 +4,13 @@ from __future__ import annotations
import pytest import pytest
from codex.parsing.tex import chunk_text, extract_sections, latex_to_text from codex.parsing.tex import (
chunk_sections,
chunk_text,
extract_sections,
flatten_inputs,
latex_to_text,
)
class TestExtractSections: class TestExtractSections:
@@ -110,3 +116,62 @@ class TestLatexToText:
result = latex_to_text(latex) result = latex_to_text(latex)
assert r"\cite" not in result assert r"\cite" not in result
assert "%" not in result assert "%" not in result
class TestFlattenInputs:
def test_inlines_input_and_include(self) -> None:
files = {
"main.tex": r"Start \input{sec/intro} mid \include{proof} end",
"sec/intro.tex": "INTRO BODY",
"proof.tex": "PROOF BODY",
}
out = flatten_inputs(files["main.tex"], files)
assert "INTRO BODY" in out
assert "PROOF BODY" in out
assert r"\input" not in out and r"\include" not in out
def test_resolves_without_tex_suffix(self) -> None:
# \input{intro} must resolve the archive member "intro.tex".
files = {"main.tex": r"\input{intro}", "intro.tex": "BODY"}
assert "BODY" in flatten_inputs(files["main.tex"], files)
def test_recursive_includes(self) -> None:
files = {"a.tex": r"X \input{b}", "b.tex": r"Y \input{c}", "c.tex": "Z"}
out = flatten_inputs(files["a.tex"], files)
assert "X" in out and "Y" in out and "Z" in out
def test_unresolved_input_dropped(self) -> None:
# A reference with no matching file is removed, not left as a directive.
out = flatten_inputs(r"A \input{missing} B", {})
assert r"\input" not in out
assert "A" in out and "B" in out
def test_input_cycle_does_not_leak_directive(self) -> None:
# a -> b -> a: the depth cap must strip the unresolved directive, not leak it.
files = {"a.tex": r"AA \input{b}", "b.tex": r"BB \input{a}"}
out = flatten_inputs(files["a.tex"], files)
assert r"\input" not in out
assert "AA" in out and "BB" in out
def test_dot_slash_prefix_resolves_without_overstrip(self) -> None:
# "./intro" resolves intro.tex; lstrip must not eat a real leading dot/run.
files = {"main.tex": r"\input{./intro}", "intro.tex": "BODY"}
assert "BODY" in flatten_inputs(files["main.tex"], files)
class TestChunkSections:
def test_pairs_carry_section_titles(self) -> None:
latex = (
r"\section{Introduction}" + "\nIntro body text here.\n"
r"\section{Proof}" + "\nProof body text here.\n"
)
pairs = chunk_sections(latex)
titles = [t for t, _ in pairs]
assert "Introduction" in titles
assert "Proof" in titles
# No chunk spans a section boundary: each chunk belongs to one title.
assert all(isinstance(c, str) and c for _, c in pairs)
def test_fallback_titleless_when_no_sections(self) -> None:
pairs = chunk_sections("Plain text with no section commands at all here.")
assert pairs and all(title is None for title, _ in pairs)

View File

@@ -6,10 +6,12 @@ from unittest.mock import MagicMock
from codex.quality import ( from codex.quality import (
_bib_score, _bib_score,
_clean_title,
classify_section, classify_section,
filter_chunks, filter_chunks,
is_quality_chunk, is_quality_chunk,
run_quality_pass, run_quality_pass,
section_label,
) )
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -243,3 +245,58 @@ class TestRunQualityPass:
kwargs = update_calls[0][0][1] kwargs = update_calls[0][0][1]
assert kwargs["section"] == "abstract" assert kwargs["section"] == "abstract"
assert kwargs["id"] == 5 assert kwargs["id"] == 5
class TestCleanTitle:
def test_strips_leading_numbering(self):
assert _clean_title("3.2 Variational Principles") == "variational principles"
def test_strips_latex_and_braces(self):
assert _clean_title(r"The \emph{Discrete} Laplacian") == "the discrete laplacian"
def test_collapses_and_lowercases(self):
assert _clean_title(" Rigidity Results ") == "rigidity results"
def test_strips_accent_macros(self):
assert _clean_title(r"M\"obius Invariance") == "mobius invariance"
def test_strips_ties_and_accents(self):
assert _clean_title(r"Colin~de~Verdi\`ere") == "colin de verdiere"
def test_truncates_at_word_boundary(self):
long = "alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu nu omega"
out = _clean_title(long)
assert len(out) <= 60
assert not out.endswith(" ")
assert long.lower().startswith(out) # whole words from the start, no partial tail
class TestSectionLabel:
def test_bare_canonical_heading_maps_to_bucket(self):
# Only an EXACT canonical heading collapses to its controlled bucket.
assert section_label("Introduction", "body text") == "intro"
assert section_label("Proof", "body text") == "proof"
assert section_label("References", "body text") == "bibliography"
assert section_label("Abstract", "body text") == "abstract"
def test_descriptive_title_stored_verbatim(self):
# R-F: a heading that merely STARTS with a keyword keeps its real title,
# not the bucket — fixes classify_section prefix-match over-bucketing.
assert section_label("Preliminaries", "body text") == "preliminaries"
assert section_label("3.1 Discrete Conformal Maps", "x") == "discrete conformal maps"
assert section_label("Proof of Theorem 3", "x") == "proof of theorem 3"
assert section_label("Abstract Nonsense and Categories", "x") == (
"abstract nonsense and categories"
)
assert section_label("Introduction to Operator Algebras", "x") == (
"introduction to operator algebras"
)
def test_none_or_blank_title_falls_back_to_content(self):
# .pdf/.txt path: unchanged content-based classification.
assert section_label(None, "Theorem 1. Let X be ...") == "theorem"
assert section_label("", "ordinary body prose here") == "body"
def test_title_cleaning_to_empty_falls_back_to_content(self):
# A pure-numbering heading cleans to "" → classify by content instead.
assert section_label("3.2", "Lemma 2. Suppose ...") == "theorem"

View File

View File

@@ -0,0 +1,112 @@
"""Tests for scripts.ra_grobid_backfill — the R-A GROBID references backfill.
Only the pure, offline logic is covered (id normalization + Citation assembly);
``extract_references`` is mocked so no GROBID server or DB is required.
"""
from __future__ import annotations
from contextlib import contextmanager
from typing import Any
from unittest.mock import MagicMock, patch
from codex.models import Citation
from scripts.ra_grobid_backfill import _normalize_cited_id, build_grobid_citations, main
# ---------------------------------------------------------------------------
# _normalize_cited_id
# ---------------------------------------------------------------------------
def test_normalize_doi_url_to_bare_lowercase() -> None:
assert _normalize_cited_id(doi="https://doi.org/10.1007/S00454-019-00132-8") == (
"10.1007/s00454-019-00132-8"
)
def test_normalize_doi_prefixes() -> None:
assert _normalize_cited_id(doi="http://doi.org/10.1/X") == "10.1/x"
assert _normalize_cited_id(doi="doi:10.1/X") == "10.1/x"
assert _normalize_cited_id(doi="10.1/x") == "10.1/x"
def test_normalize_arxiv_prefix_stripped() -> None:
assert _normalize_cited_id(arxiv_id="arXiv:2305.10988") == "2305.10988"
assert _normalize_cited_id(arxiv_id="2305.10988") == "2305.10988"
# Legacy ids keep their slash.
assert _normalize_cited_id(arxiv_id="math/0603097") == "math/0603097"
def test_normalize_doi_takes_precedence_over_arxiv() -> None:
assert _normalize_cited_id(doi="10.5/y", arxiv_id="2305.10988") == "10.5/y"
def test_normalize_empty_returns_none() -> None:
assert _normalize_cited_id() is None
assert _normalize_cited_id(doi=" ", arxiv_id="") is None
# ---------------------------------------------------------------------------
# build_grobid_citations
# ---------------------------------------------------------------------------
def _ref(doi: str = "", arxiv_id: str = "") -> dict[str, str]:
return {"title": "t", "authors": "a", "year": "2020", "doi": doi, "arxiv_id": arxiv_id}
def test_build_citations_normalizes_dedups_and_drops_self() -> None:
paper_id = "10.1007/s00454-019-00132-8"
refs = [
_ref(doi="https://doi.org/10.1/A"), # → 10.1/a
_ref(doi="DOI:10.1/a"), # duplicate of the above after normalization
_ref(arxiv_id="arXiv:2305.10988"), # → 2305.10988
_ref(doi="", arxiv_id=""), # no id → skipped
_ref(doi="https://doi.org/10.1007/S00454-019-00132-8"), # self-cite → dropped
]
with patch("scripts.ra_grobid_backfill.extract_references", return_value=refs) as mock_ex:
cits = build_grobid_citations(paper_id, "some.pdf", grobid_url="http://grobid")
mock_ex.assert_called_once_with("some.pdf", grobid_url="http://grobid", timeout=60.0)
assert all(isinstance(c, Citation) for c in cits)
assert all(c.citing_id == paper_id for c in cits)
assert [c.cited_id for c in cits] == ["10.1/a", "2305.10988"]
def test_build_citations_empty_when_no_refs() -> None:
with patch("scripts.ra_grobid_backfill.extract_references", return_value=[]):
assert build_grobid_citations("10.1/x", "x.pdf") == []
# ---------------------------------------------------------------------------
# main — connection scope (review finding #4)
# ---------------------------------------------------------------------------
def test_main_uses_separate_connections_for_read_and_write(tmp_path: Any) -> None:
"""Review #4: the read connection is released BEFORE the slow GROBID loop, and a
fresh connection is opened for the write — so no idle-in-transaction connection
spans the per-paper network loop (which would abort on an SSH-tunnel drop)."""
(tmp_path / "paper.pdf").write_bytes(b"%PDF-1.4")
conns: list[MagicMock] = []
@contextmanager
def fake_get_conn() -> Any:
c = MagicMock()
c.execute.return_value.fetchall.return_value = [
{"id": "10.1/x", "source_path": "/d/paper.txt", "out_edges": 0}
]
c.execute.return_value.fetchone.return_value = {"papers": 1, "citing": 1, "edges": 1}
conns.append(c)
yield c
with (
patch("scripts.ra_grobid_backfill.get_conn", side_effect=fake_get_conn),
patch("scripts.ra_grobid_backfill.build_grobid_citations", return_value=[]),
):
rc = main(["--pdf-dir", str(tmp_path), "--write"])
assert rc == 0
# Two DISTINCT get_conn() context managers (read, then write) — not one held
# open across the GROBID loop.
assert len(conns) == 2

View File

@@ -0,0 +1,21 @@
"""Tests for scripts.rc_tex_reingest."""
from __future__ import annotations
from scripts.rc_tex_reingest import is_arxiv_id
def test_is_arxiv_id_accepts_modern_and_legacy() -> None:
assert is_arxiv_id("2305.10988")
assert is_arxiv_id("math/0603097")
assert is_arxiv_id("1005.2698")
def test_is_arxiv_id_rejects_dois_and_wids() -> None:
assert not is_arxiv_id("10.1007/s00454-019-00132-8")
assert not is_arxiv_id("10.14279/depositonce-20357")
assert not is_arxiv_id("W2971636899")
assert not is_arxiv_id("https://openalex.org/W2971636899")
assert not is_arxiv_id("http://openalex.org/W2971636899")
assert not is_arxiv_id("10.48550/arXiv.2305.10988") # arXiv DOI form, not bare id
assert not is_arxiv_id("")

View File

@@ -53,6 +53,94 @@ def test_fetch_source_returns_latex(monkeypatch: pytest.MonkeyPatch) -> None:
assert "Hello world" in result assert "Hello world" in result
def test_fetch_source_flattens_multifile(monkeypatch: pytest.MonkeyPatch) -> None:
"""Multi-file projects: \\input/\\include directives are inlined so the body
from the included files is returned, not just the primary skeleton (R-C)."""
main = (
b"\\documentclass{article}\\begin{document}"
b"\\input{sections/intro}\\include{proof}\\end{document}"
)
tar_bytes = _make_tar_gz(
{
"main.tex": main,
"sections/intro.tex": b"INTRODUCTION BODY TEXT",
"proof.tex": b"PROOF BODY TEXT",
}
)
def mock_get(
url: str,
*,
timeout: int = 60,
follow_redirects: bool = True,
) -> httpx.Response:
return httpx.Response(200, content=tar_bytes)
monkeypatch.setattr(httpx, "get", mock_get)
result = arxiv.fetch_source("2301.07041")
assert result is not None
assert "INTRODUCTION BODY TEXT" in result # \input was inlined
assert "PROOF BODY TEXT" in result # \include was inlined
assert "\\input" not in result and "\\include" not in result
def test_fetch_source_bare_gzip_single_file(monkeypatch: pytest.MonkeyPatch) -> None:
"""Legacy arXiv submissions are served as a single bare gzipped .tex (no tar
wrapper); fetch_source must gunzip the body directly rather than fail."""
import gzip
latex = b"\\documentstyle[a4]{article}\n\\section{Intro}\nOld single-file paper body."
gz_bytes = gzip.compress(latex)
def mock_get(
url: str,
*,
timeout: int = 60,
follow_redirects: bool = True,
) -> httpx.Response:
return httpx.Response(200, content=gz_bytes)
monkeypatch.setattr(httpx, "get", mock_get)
result = arxiv.fetch_source("math/0001176")
assert result is not None
assert "Old single-file paper body." in result
def test_fetch_source_resolves_non_tex_include(monkeypatch: pytest.MonkeyPatch) -> None:
"""\\input of a non-.tex body member (e.g. a .def file) is inlined, not dropped (R-C)."""
main = b"\\documentclass{article}\\begin{document}\\input{body.def}\\end{document}"
tar_bytes = _make_tar_gz({"main.tex": main, "body.def": b"NON TEX BODY TEXT"})
def mock_get(url: str, *, timeout: int = 60, follow_redirects: bool = True) -> httpx.Response:
return httpx.Response(200, content=tar_bytes)
monkeypatch.setattr(httpx, "get", mock_get)
result = arxiv.fetch_source("2301.07041")
assert result is not None
assert "NON TEX BODY TEXT" in result
def test_fetch_source_ignores_commented_documentclass(monkeypatch: pytest.MonkeyPatch) -> None:
"""A commented %\\documentclass must not select a file as the primary document."""
decoy = b"% \\documentclass{article}\n\\section{Stub}\nDECOY ONLY"
real = b"\\documentclass{book}\\begin{document}REAL BODY HERE\\end{document}"
# decoy sorts/inserts first; without the fix it would win on the substring match.
tar_bytes = _make_tar_gz({"aaa_decoy.tex": decoy, "real.tex": real})
def mock_get(url: str, *, timeout: int = 60, follow_redirects: bool = True) -> httpx.Response:
return httpx.Response(200, content=tar_bytes)
monkeypatch.setattr(httpx, "get", mock_get)
result = arxiv.fetch_source("2301.07041")
assert result is not None
assert "REAL BODY HERE" in result
assert "DECOY ONLY" not in result
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# fetch_source — 404 → None # fetch_source — 404 → None
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -138,3 +226,63 @@ def test_fetch_pdf_url_pure_computation() -> None:
url2 = arxiv.fetch_pdf_url("1234.56789") url2 = arxiv.fetch_pdf_url("1234.56789")
assert url2 == "https://arxiv.org/pdf/1234.56789.pdf" assert url2 == "https://arxiv.org/pdf/1234.56789.pdf"
# ---------------------------------------------------------------------------
# fetch_metadata — parse the Atom feed into a Paper (DQ-2)
# ---------------------------------------------------------------------------
_ATOM_FEED = """<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<entry>
<title>A discrete version of Liouville's theorem on conformal maps</title>
<summary>Liouville's theorem says that in dimension greater than two,
all conformal maps are Moebius transformations.</summary>
<published>2019-11-03T00:00:00Z</published>
<author><name>Ulrich Pinkall</name></author>
<author><name>Boris Springborn</name></author>
</entry>
</feed>"""
_ATOM_EMPTY = """<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom"><title>ArXiv Query</title></feed>"""
def test_fetch_metadata_parses_entry(monkeypatch: pytest.MonkeyPatch) -> None:
"""A valid Atom feed yields a populated Paper."""
def mock_get(url: str, **kwargs: object) -> httpx.Response:
return httpx.Response(200, text=_ATOM_FEED, request=httpx.Request("GET", url))
monkeypatch.setattr(httpx, "get", mock_get)
paper = arxiv.fetch_metadata("1911.00966")
assert paper is not None
assert paper.id == "1911.00966"
assert paper.title.startswith("A discrete version of Liouville")
assert paper.authors == ["Ulrich Pinkall", "Boris Springborn"]
assert paper.year == 2019
assert paper.abstract and "conformal maps" in paper.abstract
assert paper.openalex_id is None
def test_fetch_metadata_no_entry_returns_none(monkeypatch: pytest.MonkeyPatch) -> None:
"""A feed with no <entry> (id not found) returns None."""
def mock_get(url: str, **kwargs: object) -> httpx.Response:
return httpx.Response(200, text=_ATOM_EMPTY, request=httpx.Request("GET", url))
monkeypatch.setattr(httpx, "get", mock_get)
assert arxiv.fetch_metadata("0000.00000") is None
def test_fetch_metadata_strips_arxiv_prefix(monkeypatch: pytest.MonkeyPatch) -> None:
"""An ``arXiv:`` prefix is stripped so Paper.id is the bare id."""
def mock_get(url: str, **kwargs: object) -> httpx.Response:
return httpx.Response(200, text=_ATOM_FEED, request=httpx.Request("GET", url))
monkeypatch.setattr(httpx, "get", mock_get)
paper = arxiv.fetch_metadata("arXiv:1911.00966")
assert paper is not None
assert paper.id == "1911.00966"

View File

@@ -0,0 +1,104 @@
"""Tests for codex.sources.crossref."""
from __future__ import annotations
import httpx
import pytest
from codex.models import Citation
from codex.sources import crossref
def test_fetch_abstract_strips_jats(monkeypatch: pytest.MonkeyPatch) -> None:
"""A JATS-tagged abstract is returned as plain text with tags removed."""
body = {
"message": {
"abstract": "<jats:p>We prove a <jats:italic>theorem</jats:italic> about "
"polyhedra.</jats:p>"
}
}
def mock_get(url: str, params: dict[str, object] | None = None) -> httpx.Response:
return httpx.Response(200, json=body)
monkeypatch.setattr(crossref, "_get", mock_get)
result = crossref.fetch_abstract("10.1007/s00454-019-00132-8")
assert result == "We prove a theorem about polyhedra."
def test_fetch_abstract_absent_returns_none(monkeypatch: pytest.MonkeyPatch) -> None:
"""A work with no abstract field (e.g. a book chapter) returns None."""
def mock_get(url: str, params: dict[str, object] | None = None) -> httpx.Response:
return httpx.Response(200, json={"message": {"title": ["Some Chapter"]}})
monkeypatch.setattr(crossref, "_get", mock_get)
assert crossref.fetch_abstract("10.1007/978-3-642-17413-1_7") is None
def test_fetch_abstract_404_returns_none(monkeypatch: pytest.MonkeyPatch) -> None:
"""A 404 returns None rather than raising."""
def mock_get(url: str, params: dict[str, object] | None = None) -> httpx.Response:
raise httpx.HTTPStatusError(
"404",
request=httpx.Request("GET", url),
response=httpx.Response(404, request=httpx.Request("GET", url)),
)
monkeypatch.setattr(crossref, "_get", mock_get)
assert crossref.fetch_abstract("10.0000/nonexistent") is None
# ---------------------------------------------------------------------------
# fetch_references (roadmap R-B)
# ---------------------------------------------------------------------------
def test_fetch_references_returns_doi_edges(monkeypatch: pytest.MonkeyPatch) -> None:
"""Only references carrying a DOI become Citations; citing_id is the queried DOI."""
body = {
"message": {
"reference": [
{"key": "ref1", "DOI": "10.1090/s0002-9947-04-03545-7"},
{"key": "ref2", "unstructured": "A book with no DOI, 1992."}, # skipped
{"key": "ref3", "DOI": "10.1007/bf02392449", "article-title": "X"},
]
}
}
def mock_get(url: str, params: dict[str, object] | None = None) -> httpx.Response:
return httpx.Response(200, json=body)
monkeypatch.setattr(crossref, "_get", mock_get)
refs = crossref.fetch_references("10.1007/s00454-019-00132-8")
assert refs == [
Citation(citing_id="10.1007/s00454-019-00132-8", cited_id="10.1090/s0002-9947-04-03545-7"),
Citation(citing_id="10.1007/s00454-019-00132-8", cited_id="10.1007/bf02392449"),
]
def test_fetch_references_no_reference_key_returns_empty(monkeypatch: pytest.MonkeyPatch) -> None:
"""A work with no deposited reference list returns []."""
def mock_get(url: str, params: dict[str, object] | None = None) -> httpx.Response:
return httpx.Response(200, json={"message": {"title": ["No refs deposited"]}})
monkeypatch.setattr(crossref, "_get", mock_get)
assert crossref.fetch_references("10.1007/978-3-642-17413-1_7") == []
def test_fetch_references_404_returns_empty(monkeypatch: pytest.MonkeyPatch) -> None:
"""A 404 returns [] rather than raising."""
def mock_get(url: str, params: dict[str, object] | None = None) -> httpx.Response:
raise httpx.HTTPStatusError(
"404",
request=httpx.Request("GET", url),
response=httpx.Response(404, request=httpx.Request("GET", url)),
)
monkeypatch.setattr(crossref, "_get", mock_get)
assert crossref.fetch_references("10.0000/nonexistent") == []

View File

@@ -7,7 +7,7 @@ import pytest
from codex.models import Citation, Paper from codex.models import Citation, Paper
from codex.sources import openalex from codex.sources import openalex
from codex.sources.openalex import _resolve_id from codex.sources.openalex import _canonical_id, _normalize_doi, _resolve_id
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Helpers # Helpers
@@ -15,7 +15,8 @@ from codex.sources.openalex import _resolve_id
_SAMPLE_WORK = { _SAMPLE_WORK = {
"id": "https://openalex.org/W2741809807", "id": "https://openalex.org/W2741809807",
# Real OpenAlex returns the DOI as a full URL, not a bare DOI (audit T-3). # Real OpenAlex returns the DOI as a full URL, not a bare DOI (audit T-3 / DQ-5
# — the old bare fixture hid the URL-form behaviour both fixes address).
"doi": "https://doi.org/10.1145/3592430", "doi": "https://doi.org/10.1145/3592430",
"title": "Conformal Prediction: A Review", "title": "Conformal Prediction: A Review",
"publication_year": 2023, "publication_year": 2023,
@@ -68,6 +69,87 @@ def test_resolve_prefixed_unchanged() -> None:
assert _resolve_id("doi:10.1145/x") == "doi:10.1145/x" assert _resolve_id("doi:10.1145/x") == "doi:10.1145/x"
# ---------------------------------------------------------------------------
# _normalize_doi — canonical bare, lower-cased form (DQ-5)
# ---------------------------------------------------------------------------
def test_normalize_doi_strips_https_url() -> None:
# The exact form OpenAlex returns in the `doi` field.
assert _normalize_doi("https://doi.org/10.1007/s00454-019-00132-8") == (
"10.1007/s00454-019-00132-8"
)
def test_normalize_doi_strips_http_and_doi_prefix() -> None:
assert _normalize_doi("http://doi.org/10.1145/3592430") == "10.1145/3592430"
assert _normalize_doi("doi:10.1145/3592430") == "10.1145/3592430"
def test_normalize_doi_lowercases() -> None:
# DOIs are case-insensitive; the canonical id (and _norm_cited_id) is lower-case.
assert _normalize_doi("https://doi.org/10.1007/S00454-019-00132-8") == (
"10.1007/s00454-019-00132-8"
)
def test_normalize_doi_already_bare_is_idempotent() -> None:
assert _normalize_doi("10.1007/s00454-019-00132-8") == "10.1007/s00454-019-00132-8"
# ---------------------------------------------------------------------------
# _canonical_id — arXiv DOI → bare arXiv id (arXiv half of DQ-5)
# ---------------------------------------------------------------------------
def test_canonical_id_strips_arxiv_doi_modern() -> None:
# arXiv works carry the arXiv DOI; the canonical id is the bare arXiv id.
assert _canonical_id("https://doi.org/10.48550/arXiv.2305.10988") == "2305.10988"
def test_canonical_id_strips_arxiv_doi_legacy() -> None:
assert _canonical_id("https://doi.org/10.48550/arXiv.math/0603097") == "math/0603097"
def test_canonical_id_leaves_regular_doi_bare() -> None:
# A normal journal DOI is only normalized (bare + lower-case), not stripped.
assert _canonical_id("https://doi.org/10.1007/S00454-019-00132-8") == (
"10.1007/s00454-019-00132-8"
)
def test_fetch_paper_arxiv_doi_yields_bare_id(monkeypatch: pytest.MonkeyPatch) -> None:
"""An arXiv work maps to the BARE arXiv id (not the arXiv DOI), so re-ingesting
by arXiv id hits ON CONFLICT (id) instead of tripping papers_openalex_id_key."""
work = {**_SAMPLE_WORK, "doi": "https://doi.org/10.48550/arXiv.2305.10988"}
def mock_get(url: str, params: dict[str, str] | None = None) -> httpx.Response:
return httpx.Response(200, json=work)
monkeypatch.setattr(openalex, "_get", mock_get)
paper = openalex.fetch_paper("2305.10988")
assert paper is not None
assert paper.id == "2305.10988"
def test_map_paper_falls_back_to_openalex_id_without_doi(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A work with no DOI keeps the OpenAlex W-id (URL form) as id, unchanged —
normalization only rewrites the DOI branch."""
work = {k: v for k, v in _SAMPLE_WORK.items() if k != "doi"}
def mock_get(url: str, params: dict[str, str] | None = None) -> httpx.Response:
return httpx.Response(200, json=work)
monkeypatch.setattr(openalex, "_get", mock_get)
paper = openalex.fetch_paper("W2741809807")
assert paper is not None
assert paper.id == "https://openalex.org/W2741809807"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# fetch_paper — success # fetch_paper — success
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -86,15 +168,17 @@ def test_fetch_paper_success(monkeypatch: pytest.MonkeyPatch) -> None:
assert paper is not None assert paper is not None
assert isinstance(paper, Paper) assert isinstance(paper, Paper)
# DQ-5: openalex._canonical_id normalizes OpenAlex's URL-form doi to the BARE
# lower-cased DOI at the source layer; ingest additionally pins papers.id to the
# caller's id (audit C-7). Both converge on the bare canonical id — which is what
# makes the ingest ON CONFLICT (id) upsert idempotent. (T-3: the fixture is
# URL-form so this mapping can't silently regress.)
assert paper.id == "10.1145/3592430"
assert paper.title == "Conformal Prediction: A Review" assert paper.title == "Conformal Prediction: A Review"
assert paper.year == 2023 assert paper.year == 2023
assert paper.authors == ["Alice Smith", "Bob Jones"] assert paper.authors == ["Alice Smith", "Bob Jones"]
assert paper.abstract == "Conformal prediction is great" assert paper.abstract == "Conformal prediction is great"
assert paper.openalex_id == "https://openalex.org/W2741809807" assert paper.openalex_id == "https://openalex.org/W2741809807"
# fetch_paper maps id from the (URL-form) doi; ingest later canonicalises
# papers.id to the caller's bare id (audit C-7). Pin the real mapping here so
# the fixture cannot silently misrepresent OpenAlex again (audit T-3).
assert paper.id == "https://doi.org/10.1145/3592430"
def test_fetch_paper_arxiv_id_prefixed(monkeypatch: pytest.MonkeyPatch) -> None: def test_fetch_paper_arxiv_id_prefixed(monkeypatch: pytest.MonkeyPatch) -> None:

View File

@@ -124,3 +124,46 @@ def test_fetch_recommendations_404_returns_empty(
result = semanticscholar.fetch_recommendations("nonexistent") result = semanticscholar.fetch_recommendations("nonexistent")
assert result == [] assert result == []
# ---------------------------------------------------------------------------
# fetch_references — {"data": null} guard (DQ-1 regression)
# ---------------------------------------------------------------------------
def test_fetch_references_null_data_returns_empty(monkeypatch: pytest.MonkeyPatch) -> None:
"""S2 returns HTTP 200 with ``{"data": null}`` for a known paper that has no
parsed references; this must yield [] rather than raising TypeError."""
def mock_get(url: str, params: dict[str, object] | None = None) -> httpx.Response:
return httpx.Response(200, json={"data": None, "citingPaperInfo": {}})
monkeypatch.setattr(semanticscholar, "_get", mock_get)
assert semanticscholar.fetch_references("DOI:10.14279/depositonce-5415") == []
# ---------------------------------------------------------------------------
# fetch_abstract (DQ-2)
# ---------------------------------------------------------------------------
def test_fetch_abstract_returns_text(monkeypatch: pytest.MonkeyPatch) -> None:
"""fetch_abstract returns the abstract string when present."""
def mock_get(url: str, params: dict[str, object] | None = None) -> httpx.Response:
return httpx.Response(200, json={"abstract": "We study ideal hyperbolic polyhedra."})
monkeypatch.setattr(semanticscholar, "_get", mock_get)
assert semanticscholar.fetch_abstract("DOI:10.1007/s00454-019-00132-8") == (
"We study ideal hyperbolic polyhedra."
)
def test_fetch_abstract_null_returns_none(monkeypatch: pytest.MonkeyPatch) -> None:
"""A null/absent abstract yields None."""
def mock_get(url: str, params: dict[str, object] | None = None) -> httpx.Response:
return httpx.Response(200, json={"abstract": None})
monkeypatch.setattr(semanticscholar, "_get", mock_get)
assert semanticscholar.fetch_abstract("arXiv:1234.5678") is None