78 Commits

Author SHA1 Message Date
Tarik Moussa
6e05fdc209 docs: Cold-Start-Prompt fuer Evaluate/Review/Release ergaenzt
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-31 01:34:11 +02:00
Tarik Moussa
53c40f6da2 chore: interne Host-Adresse durch generischen Hostnamen ersetzt
Ersetzt die private LAN-IP des Jetson durch jetson.local in Doku,
Shell-Skripten, .env.example und Docstrings. Funktional betroffen ist
nur ein os.environ.setdefault-Fallback im Spike; gesetzte Env-Vars
(OLLAMA_BASE_URL, GROBID_URL) haben weiterhin Vorrang. uv.lock bleibt
unveraendert (enthaelt Versionsnummern, keine Adressen).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-31 00:51:27 +02:00
5aa2f7db40 Merge PR #16: data-quality roadmap R-A–R-F + DQ-5 (reconciled, reviewed)
Data-quality roadmap (R-A GROBID backfill, R-B Crossref refs, R-C .tex ingest, R-D citing-coverage warning, R-F section labels) + DQ-5 canonical-id idempotency, reconciled with audit-remediation main (#12-15), code-reviewed with 8 findings fixed. 408 tests pass; ruff + mypy clean.
2026-06-27 07:12:29 +00:00
Tarik Moussa
ab621fc5f5 test(review): cover findings #1 (non-bare caller id) and #4 (backfill connection scope)
#1: assert a URL-form / mixed-case caller id is normalized to the bare canonical form in the papers upsert (the fix's whole point; existing DQ-5 test only used a bare caller). #4: assert ra_grobid_backfill.main() opens two separate connections (read released before the GROBID loop, fresh one for the write) rather than holding one across the loop. Both pass; ruff clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 09:09:39 +02:00
Tarik Moussa
b2f01ce010 fix(review): address PR #16 code-review findings
#1 ingest: normalize the pinned caller id (_norm_cited_id) so a non-bare DOI caller cannot store a URL-form papers.id that defeats DQ-5 idempotency / the startswith(10.) recovery gates. #2 quality.section_label: collapse to a controlled bucket only for an EXACT canonical heading; descriptive titles ('Abstract Nonsense...') keep their real title instead of being mislabelled. #4 ra_grobid_backfill: release the read connection before the slow GROBID network loop, fresh connection for the write (no idle-in-transaction across the loop over the flaky tunnel).

#5/#10 tex: flatten_inputs strips unresolved input/include at the depth cap (no literal leak on cycles); _norm_texkey strips only a single leading ./ . #6/#7 arxiv.fetch_source: keep non-.tex members resolvable for input; pick primary on an UN-commented documentclass line. #13 is_arxiv_id: also exclude http:// and arXiv-DOI forms. Tests added/updated for each.

Left as deliberate decisions: #3 (pre-section text drop is pre-existing in extract_sections; abstract stored separately), #8 (script normalizer is intentionally self-contained, already documented), #9/#11/#12 (no current trigger / tightening the gzip heuristic would reject valid old LaTeX like documentstyle / title truncation is by design on a write-only column). ruff + mypy clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 22:50:23 +02:00
Tarik Moussa
9e5184385a merge: reconcile data-quality roadmap with audit-remediation main (#12-15)
Resolved 4 conflicts: cli.py (R-D citing-coverage warning + main's small-corpus JSON/hub-title changes both kept); ingest.py (C-7 paper.id=caller-id pin first, then DQ-2 abstract recovery); test_ingest.py + test_openalex.py (kept both branches' tests).

C-7/DQ-5 overlap (both canonicalise papers.id): kept both layers — openalex._canonical_id normalizes fetch_paper's id (DQ-5); ingest pins papers.id to the caller's bare id (C-7); they converge on the bare canonical id. Fixed the auto-merged test_fetch_paper_success that had contradictory bare/URL assertions. 402 tests pass; ruff + mypy clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 15:11:26 +02:00
Tarik Moussa
e817022097 docs: mark integration/roadmap merge-ready (391 green) + cleanup notes
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 02:01:38 +02:00
Tarik Moussa
2d78f9c48e merge: infra TODO — tmux-on-Jetson SSH stability 2026-06-22 01:59:48 +02:00
Tarik Moussa
6911f0065e merge: R-D (citing-coverage warning in graph report) 2026-06-22 01:59:48 +02:00
Tarik Moussa
aaa86dee2f merge: R-B (Crossref references as third citation fallback) 2026-06-22 01:59:02 +02:00
Tarik Moussa
42608c73b8 merge: R-C (.tex ingest) + R-F (section labels) + DQ-5 arXiv fix 2026-06-22 01:58:43 +02:00
Tarik Moussa
49c251eafa docs: open-items — Jetson back (tunnel had dropped), R-F accent re-clean done
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 01:54:32 +02:00
Tarik Moussa
f761ee63f8 docs(audit): R-F accent re-clean done — 8 labels fixed in place, 0 artifacts remain
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 01:53:36 +02:00
Tarik Moussa
200b7f8188 docs: session 2026-06-17 open-items handoff notice
Consolidates the session's loose ends: Jetson offline (blocker), R-F accent re-clean pending, 5 feature branches unmerged to main (+ merge order), tmux-on-Jetson infra TODO, and documented known limits.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 01:48:47 +02:00
Tarik Moussa
a050f6622a docs(infra): TODO — tmux-on-Jetson + autossh for stable long-running ops
The Mac->Jetson SSH tunnel dropped repeatedly during multi-minute live writes (re-ingest, embed loops, sweeps), aborting them partway. Document the fix: run long ops on the Jetson inside tmux (DB is localhost, no tunnel; survives SSH drops), use autossh for interactive tunnels, and optionally make backfill writes per-item resilient.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 09:32:03 +02:00
Tarik Moussa
5c60a7eda4 docs(audit): add R-F (richer section labels) resolution — 10% to 95% non-body
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 09:25:03 +02:00
Tarik Moussa
1a879d4827 feat(quality): clean LaTeX accents/ties + word-boundary truncation in section titles
Polish _clean_title for R-F: strip accent/escape macros (backslash-quote o to o, backslash-amp), convert tilde ties to spaces, and truncate at a word boundary so stored section titles read cleanly (mobius invariance, colin de verdiere). Tests added; ruff + mypy clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 09:17:43 +02:00
Tarik Moussa
c70ff9aa1e feat(quality): store real section titles for .tex chunks (R-F)
Add quality.section_label(title, content): for section-aware .tex ingest, keep the controlled bucket (intro/theorem/proof/abstract/bibliography) when the real \section heading maps to one, else store the cleaned title verbatim (e.g. 'preliminaries', 'rigidity') instead of collapsing to 'body'. Math papers title most sections descriptively, so R-C's vocab-only mapping left ~90% as 'body'; this lifts the section signal toward near-full. .pdf/.txt/run_quality_pass keep content-based classify_section unchanged. Safe because the section column is write-only (no consumer filters on the vocab).

New _clean_title (strip LaTeX/numbering, lower-case, truncate). Tests: section_label bucket/verbatim/fallback paths + _clean_title; .tex ingest stores a descriptive heading as its title. Full suite 377 passed; ruff + mypy clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 09:03:46 +02:00
Tarik Moussa
5672358bbe feat(graph): warn on low citing-paper coverage in graph report (R-D)
Add graph.citing_coverage() (papers with >=1 out-edge / total) and a graph_min_citing_coverage setting (default 0.8). codex graph report now surfaces 'Citing coverage: N/M papers with out-edges (P%)' in text and JSON, and warns when the share is below threshold -- low citing coverage starves PageRank/coupling even when the paper count looks healthy (the DQ-1 sub-item). Separate from the existing total-count graph_min_corpus_size warning.

Tests: citing_coverage unit tests + CLI tests (line shown, warning fires/suppressed, JSON field). Full suite green; ruff + mypy clean. Live: graph report shows 29/29 (100%), no warning.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 07:59:25 +02:00
Tarik Moussa
c22c0db3d0 docs(audit): mark R-C done (section-aware .tex ingest + DQ-5 arXiv half)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 07:47:43 +02:00
Tarik Moussa
f504ca62db fix(openalex): canonicalize arXiv DOI to bare arXiv id (DQ-5 arXiv half)
OpenAlex returns arXiv works under the arXiv DOI (10.48550/arXiv.<id>); _normalize_doi (DQ-5) left it as 10.48550/arxiv.<id>, but the corpus keys arXiv papers on the BARE id (2305.10988 / math/0603097). So re-ingesting an arXiv paper by its bare id missed INSERT ... ON CONFLICT (id) and tripped papers_openalex_id_key — the arXiv half of DQ-5, explicitly deferred there and surfaced again driving the R-C .tex re-ingest.

Add _canonical_id() (normalize DOI, then strip the 10.48550/arxiv. prefix) and use it in _map_paper. Regression tests (modern + legacy arXiv DOI, regular DOI unchanged, fetch_paper bare id). Confirmed live: re-ingesting 2305.10988 now UPDATEs in place. Full suite 370 passed; ruff + mypy clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 04:25:22 +02:00
Tarik Moussa
121b582f46 fix(arxiv): handle legacy single-file gzipped source (no tar wrapper)
Old arXiv submissions (e.g. legacy math/* papers) are served as a bare gzipped .tex rather than a .tar.gz, which tarfile.open rejected with 'invalid header'. Fall back to gunzipping the body directly when the tar parse fails, so R-C covers those papers too (recovers math/0001176 + math/0306167). Adds a regression test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 04:18:18 +02:00
Tarik Moussa
1da81c7b28 feat(tex): section-aware multi-file .tex ingest (R-C)
Flatten multi-file arXiv LaTeX (\input/\include) in arxiv.fetch_source so the full body is assembled rather than just the primary file's include skeleton, and add section-aware chunking (tex.chunk_sections) so .tex chunks never span a \section boundary and are labelled by their real heading. The ingest .tex path now produces (section, chunk) pairs, quality-gated together so labels stay aligned; the stored 'section' column gains genuine signal instead of mostly 'body' (serves DQ-3 fidelity + audit R-12).

Adds scripts/rc_tex_reingest.py to re-ingest arXiv papers from .tex (dry-run by default; replaces chunks). Tests: flatten_inputs, chunk_sections, multi-file fetch_source, section-label ingest, is_arxiv_id. Full suite 365 passed; ruff + mypy clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 04:15:20 +02:00
Tarik Moussa
bf90c6cd57 feat(grobid): run GROBID on-demand (restart "no") to free ~3.5GB when idle
GROBID is only needed during reference extraction (ra_grobid_backfill.py),
not for normal corpus operations (search, embeddings, wiki). Idle it still
holds ~3.5GB on the 8GB Jetson, so keep it stopped by default.

- infra/docker-compose.yml: grobid restart unless-stopped -> "no" (no auto-start
  on boot; db keeps unless-stopped). Start/stop papers-grobid around an R-A run.
- docs/infra/grobid-jetson.md: add "Run on-demand" section with the start/stop
  workflow, and record the refextract head-to-head evaluation on the Jetson that
  led to keeping GROBID: far worse DOI recall on this math corpus (lutz thesis
  101 usable IDs vs refextract 2) and slower per PDF despite a smaller footprint.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 21:36:04 +02:00
Tarik Moussa
82cea2a33b feat(crossref): reference extraction as third citation fallback (R-B)
Add crossref.fetch_references(doi), which parses message.reference[].DOI into Citations (DOI-less book/unstructured refs are skipped — they cannot join the graph). Wire it as the third leg of the ingest citation fallback chain: OpenAlex-empty -> S2 -> Crossref, for DOI papers only (Crossref's works endpoint is DOI-keyed). Cited DOIs are normalized to the canonical bare lower-case form via _norm_cited_id.

As a fallback it fires only when OpenAlex and S2 both return no references, so it adds a forward-looking third source without changing the already-covered corpus.

Tests: fetch_references unit tests (DOI edges / unstructured skipped / no refs / 404) and ingest tests for both fallback directions. Full suite 361 passed; ruff + mypy clean. Live-validated: 33/40/24 refs for Springer/ACM/Wiley DOIs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 21:32:46 +02:00
Tarik Moussa
1516684bbb feat(grobid): R-A reference backfill + native arm64 GROBID on Jetson
Enable roadmap R-A (GROBID reference extraction) end-to-end and run it
against local Jetson infra instead of a Mac/Rosetta emulation.

Backfill:
- Add scripts/ra_grobid_backfill.py: references-only, idempotent citation
  backfill (dry-run default). Deliberately not ingest_paper(source_path=pdf),
  which re-OCRs and replaces the DQ-3-clean .txt chunks; this touches only the
  citations table (ON CONFLICT DO NOTHING). 7 tests.
- Make extract_references timeout configurable (large theses exceed the 60s
  default, especially against a slower GROBID).

Jetson infra:
- Bump grobid/grobid 0.8.2 -> 0.9.0-crf. The -crf tag is the only GROBID
  variant published as an arm64 multi-arch manifest; every 0.8.x tag and the
  full deep-learning image are amd64-only, which is why GROBID never ran on the
  aarch64 Jetson. Enable the 4g memory cap + init/ulimits per GROBID docs.
- Add docs/infra/grobid-jetson.md runbook: arm64 image rationale, the two host
  prerequisites (docker-group membership + the Compose v2 CLI plugin, both
  missing on the Jetson), service-scoped deploy, and the GET /api/isalive check.
  Verified live 2026-06-17: native arm64 pull, isalive=true, end-to-end
  extract_references on lutz-2024-thesis.pdf = 116 refs (101 with DOI/arXiv).

docs(audit): mark R-A core done (citing coverage 27/29 -> 29/29; edges 920 -> 1022).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 08:28:34 +02:00
Tarik Moussa
b55aa5b429 chore: gitignore generated wiki/index.md
wiki/index.md is regenerated by `codex wiki compile` from wiki/concepts.yaml
(the tracked source), so the generated index should not be version-controlled.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 00:42:37 +02:00
Tarik Moussa
0021859094 fix(ingest): normalize GROBID-derived citation DOIs to bare lower-case
GROBID extracts a cited DOI verbatim from PDF reference text, so it can be mixed-case, a https://doi.org/ URL, or doi:-prefixed. The PDF citation branch inserted it unchanged, while every other path (S2 supplement, OpenAlex, papers.id itself) uses the bare lower-cased DOI. Once R-A runs GROBID reference extraction over arXiv PDFs, those un-normalized cited-ids would never equal a bare-lowercase papers.id/cited_id — dangling the cross-citations and splitting one reference into case-/URL-variant graph nodes.

Extend _norm_cited_id to strip https://doi.org//http://doi.org//doi: prefixes and lower-case (mirroring openalex._normalize_doi), making it the single canonical cited-id normalizer, and route the GROBID branch through it. arXiv ids and S2 paperIds still pass through untouched.

Add test_ingest_pdf_grobid_citations_normalize_doi covering mixed-case, URL-form, and doi:-prefixed DOIs plus an arXiv-only ref.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 00:39:04 +02:00
Tarik Moussa
b371119264 docs(audit): scope DQ-3 to txt→chunks + open DQ-6 (PDF→txt extraction fidelity)
Clarify that DQ-3's 'VERIFIED CLEAN' covers the ingest pipeline (txt -> chunks)
only — it treats the .txt as ground truth. The upstream PDF/source -> .txt
extraction (done outside codex-py) is unmeasured and shown to be lossy (DQ-3's
dropped chunks were garbled OCR tables already present in the .txt). Add DQ-6 to
measure that layer; 36 source PDFs are available. Note R-A/R-C (re-ingest from
PDF/.tex) would largely sidestep it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 00:33:45 +02:00
Tarik Moussa
ea73b1475c fix(openalex): normalize DOI to bare canonical id so re-ingest is idempotent (DQ-5)
openalex._map_paper set Paper.id from the raw `doi` field, which OpenAlex
returns as a full URL (https://doi.org/10.x). The canonical papers.id (M-1
migration) is the bare, lower-cased DOI, so re-ingesting a DOI paper missed
ON CONFLICT (id) and tripped the separate papers_openalex_id_key constraint
(UniqueViolation).

Fix: _normalize_doi() strips https://doi.org//http://doi.org//doi: and
lower-cases; applied in the DOI branch of _map_paper only (W-id fallback
untouched). Also un-breaks _s2_id_for/Crossref recovery, which the URL form
silently defeated. Corrected the _SAMPLE_WORK fixture to the URL form (the bare
fixture hid the bug) + added _normalize_doi and re-ingest idempotency regression
tests.

Live verification: re-ingest of 10.1007/s00454-019-00132-8 (previously crashed)
now UPDATEs in place — single row, added_at unchanged, no stray row, citations
45->45. Unblocks roadmap R-A/R-C. Full suite 348 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 00:31:01 +02:00
Tarik Moussa
fa43f69dfd docs(audit): DQ-3 content fidelity verified clean — assessment axis complete
Re-ran chunk_text + is_quality_chunk on all 29 source .txt files vs stored
chunks. Coverage 109-114% everywhere (the >100% is the 64/512-word overlap, i.e.
full retention); stored==kept for every paper. Only 18 chunks dropped corpus-wide,
all low_alpha = garbled OCR tables in 2 scanned-book sources (0-387-29555-0_13,
depositonce-5415) — no real math content lost. Head/tail aligned (no truncation).

Closes the data-quality assessment axis: DQ-1 (citations), DQ-2 (metadata),
DQ-3 (fidelity), DQ-4 (retrieval) all resolved. DQ-5 (re-ingest idempotency)
remains, tracked separately.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 00:23:16 +02:00
Tarik Moussa
ff03443af4 feat(ingest): recover missing metadata + abstracts from arXiv/S2/Crossref (DQ-2)
Three papers lacked abstracts and 1911.00966 was fully degraded (OpenAlex 404
-> empty `Paper(id, title="")` stub). Recovery sources verified, then wired in:

- arxiv.fetch_metadata: resolve an arXiv id to a Paper via the Atom export API
  (authoritative for preprints). Used as an ingest fallback on OpenAlex 404,
  replacing the empty stub.
- crossref.py (new): fetch_abstract(doi), JATS-stripped, Crossref Polite Pool.
- semanticscholar.fetch_abstract: fetch just the abstract field.
- ingest.py: _recover_abstract() supplements an empty abstract from S2 then
  Crossref *before* embedding, so the paper gets a real (non-zero) vector.

Live DB backfill: 1911.00966 fully recovered from arXiv (bibkey
PinkallSpringborn2019, 384-char abstract, its 10 chunks now visible to chunk
search); 10.1007/s00454-019-00132-8 abstract from S2 (1186 chars). Book chapter
10.1007/978-3-642-17413-1_7 has no abstract in OpenAlex/S2/Crossref -> documented
limit. Corpus now has 1 paper without an abstract.

Also documented DQ-5 (not fixed): ingest_paper is not idempotent for DOI papers
(openalex returns full-URL id vs the bare canonical id) -> re-ingest trips
papers_openalex_id_key. Blocks roadmap R-A/R-C; needs a careful _map_paper fix.

Tests: arxiv/crossref/s2 fetchers + ingest recovery paths (342 passing).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 00:12:59 +02:00
Tarik Moussa
51dc74470c feat(ingest): supplement citations from Semantic Scholar when OpenAlex is empty (DQ-1)
12/29 papers had zero citations: OpenAlex indexes arXiv preprints and theses
without a parsed reference list (verified referenced_works_count=0 server-side
for all 11 with an openalex_id). Not an ingest bug — a source-coverage limit.

- ingest.py: when OpenAlex returns no references, fall back to a Semantic
  Scholar reference supplement (_s2_reference_supplement); citing_id rewritten
  to canonical papers.id, DOI cited-ids lowercased. Removed a now-redundant
  discarded S2 probe in the OpenAlex-404 arXiv branch.
- semanticscholar.py: fix TypeError on S2's HTTP-200 {"data": null} no-refs
  responses (.get("data", []) returns None when the key is present-but-null).
- tests: regression test for the OpenAlex-empty -> S2 path.
- Live DB backfilled idempotently: +330 citations (590->920), zero-out-edge
  papers 12->2, citing coverage 17->27/29. Only the 2 TU-Berlin depositonce
  theses remain (no references in any source).
- docs: DATA-QUALITY-2026-06-15.md — DQ-1 resolution, DQ-4 result, and a
  free-source acquisition roadmap (R-A..R-E).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 23:51:48 +02:00
Tarik Moussa
553ae3995d fix(search): cast query embedding to ::vector in search paper (DQ-4)
`codex search paper` crashed on the live DB with "operator does not exist:
vector <-> double precision[]". The pgvector `<->` operator needs its RHS
typed as vector; peer call sites (mcp_server.py, wiki.py) cast but the CLI
did not. Mocked unit tests never exercise real pgvector, so it went unseen.

Add the cast to both `<->` occurrences and a regression guard asserting the
SQL contains `::vector`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 23:51:38 +02:00
d64edfc324 Merge pull request 'fix: audit remediation Wave 3 — M-1, MED/LOW sweep, ingest robustness + D-1 close-out' (#14) from fix/audit-wave-3 into main 2026-06-16 04:53:59 +00:00
07929bd4fe Merge pull request 'fix: audit remediation Wave 2 — identity normalization C-7, C-10 (+ T-3, ADR D-1)' (#13) from fix/audit-wave-2 into main 2026-06-16 04:53:40 +00:00
dca0dd793f Merge pull request 'fix: audit remediation Wave 1 — correctness fixes C-1, C-4, C-11 (+ doc fixes)' (#12) from fix/audit-wave-1 into main 2026-06-16 04:53:19 +00:00
fb06c6f65a Merge pull request 'docs+chore: audit findings + tooling (base of the remediation stack)' (#15) from audit/loop-1 into main 2026-06-16 04:52:58 +00:00
Tarik Moussa
64e3ee188c docs(audit): data-quality findings + handoff for a fresh session
Second audit axis (data, not code): is the information in the DB actually good?
Baseline scan of the live 29-paper corpus + four open investigation items
prepared self-contained for a cold session.

- Good: chunks 0/1507 fail the F-16 re-gate, healthy sizing, 0 dups.
- DQ-1 (HIGH): 12/29 papers have zero citations; 11 of them have an openalex_id,
  so OpenAlex returned empty referenced_works (source-coverage limit, not a bug)
  — propose an S2 references supplement.
- DQ-2 (MED): 3 papers no abstract; 1911.00966 fully metadata-less (OpenAlex 404).
- DQ-3 (MED): chunk-vs-source fidelity not yet verified.
- DQ-4 (MED): retrieval relevance not yet measured.

Includes connection steps, exact ids, priorities, and a reproduce appendix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-15 22:45:05 +02:00
Tarik Moussa
e1c0a98262 fix(ingest): make both upsert UNIQUE gaps visible (audit C-15 follow-up)
Per review: the two latent upsert conflicts should be observable even where not
auto-fixed.
- bibkey collision (auto-suffixed) now logs a warning so a paper landing as
  'BobenkoLutz2023a' instead of '…2023' isn't silent.
- openalex_id collision (two papers claiming the same OpenAlex work — a real data
  conflict, not auto-renamable) now raises a clear ValueError naming the
  openalex_id and the offending paper, instead of the raw psycopg UniqueViolation.

Regression test added for the openalex_id error path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-15 22:25:30 +02:00
Tarik Moussa
1f677211f7 docs(adr): finalise D-1 — re-measure F-15 spike on canonical corpus
Re-ran the citation graph after the C-7 re-ingest (29/29 papers canonical/bare):
489 nodes, 590 edges, 472 dangling, 5.4% spread. The resolution fix's signature:
Pinkall/Polthier 1993 resolves to its DOI and is now the rank-1 *in-KB* hub
(was a dangling OpenAlex node); Bobenko/Springborn 2007 holds rank 7. Foundational
hubs still surface — GO re-affirmed on the shipping topology. Updates the ADR
spike table with before/after numbers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-15 22:13:56 +02:00
Tarik Moussa
b52303d22b fix(ingest): retry on bibkey UNIQUE collision (audit C-15)
The id-keyed upsert (ON CONFLICT (id)) does not catch the papers.bibkey UNIQUE
constraint, so a second paper whose auto-generated key matches an existing one
(two same-author/year works → e.g. 'BobenkoLutz2023') failed the whole ingest
with a UniqueViolation. Surfaced by the canonical-id re-ingest: 2305.10988
collided with 2310.17529.

ingest now catches a bibkey UniqueViolation, rolls back, and retries the upsert
with an a/b/c… suffix. Non-bibkey violations re-raise unchanged. Mocks don't
raise, so the existing happy-path tests are untouched; a new test drives the
collision-and-retry path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-15 22:11:14 +02:00
Tarik Moussa
45c3b16dd7 docs: clarify sparse-not-wired and cite-boost tie-breaker (audit C-14, U-2)
- C-14: embed.py header now states that only the dense path is wired (search
  'hybrid' is dense + Postgres FTS); encode_sparse/encode are reserved for a
  future sparse-retrieval layer, not yet consumed.
- U-2: --cite-boost help clarifies it re-ranks within the top results (a
  tie-breaker), not a hard re-ranking — matching the small alpha effect.

Accepted (documented heuristics, no change): R-3 conflict detection is a
keyword-only signal labelled as such; R-12 classify_section is mostly 'body'
because chunks are word-windows that rarely start at a section header.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-15 21:47:40 +02:00
Tarik Moussa
f422b0fb84 fix(wiki): token-boundary grounding match + sharper ref-list filter (C-6, R-2)
- C-6: the content-5-gram check used 'gram in src' on space-joined strings, so the
  first/last gram token could match a prefix/suffix of a longer chunk word
  ('set' inside 'subset'). Both gram and chunk are now space-padded, so a match
  requires whole-token alignment. Regression test added.
- R-2: the reference-list filter's author pattern ('Surname, I.') also matched
  'Theorem A.'/'Lemma B.', so theorem-dense chunks could be dropped as a
  bibliography. The pattern now excludes common math-structure words.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-15 21:46:02 +02:00
Tarik Moussa
92928bab99 feat(cli): graph-report titles + JSON warning; wire recommend/cocited (U-1,U-3,U-4,C-9)
- U-1: 'graph report' shows the paper title next to each in-KB hub (dangling
  OpenAlex-id hubs are flagged), instead of bare ids.
- U-3: '--json' now includes a 'small_corpus_warning' field (null unless below
  graph_min_corpus_size) instead of silently dropping the warning.
- C-9: new 'codex discover recommend <id>' wires semanticscholar.fetch_recommendations
  (was implemented + tested but unreachable).
- U-4: new 'codex graph cocited <id>' wires graph.find_co_cited (likewise).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-15 21:43:19 +02:00
Tarik Moussa
c7fae5c877 fix(sources): drop no-op try/except + url-encode S2 ids (audit R-4, R-5)
- R-4: arxiv.fetch_source had a try/except httpx.RequestError that only re-raised
  — removed (no behaviour change, less noise).
- R-5: semanticscholar URLs now quote(paper_id, safe=':') so a legacy-arXiv '/'
  (arXiv:math/0603097) doesn't corrupt the path and silently return no references.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-15 21:39:48 +02:00
Tarik Moussa
b4fbd7930e feat(config): wrap credentials in SecretStr (audit S-4)
database_url, migration_database_url, mcp_auth_token and the mathpix app
id/key are now pydantic SecretStr, so they render as '**********' in any
repr/log/traceback instead of leaking the plaintext credential. Consumers
(db.get_conn, cli.migrate, mcp._require_http_token, mathpix.extract_formulas)
call .get_secret_value() at the point of use. Tests updated to the SecretStr
type.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-15 21:32:13 +02:00
Tarik Moussa
e7b854db10 fix(wiki): make grounding tokenizer robust to punctuation (audit R-1)
The grounding guard tokenized with .lower().split() and split sentences on bare
.!?, so faithful claims were wrongly marked ungrounded: 'volume,' != 'volume',
and a decimal like 'V = 1.5' split into a 1-word fragment '5' that fell below
the 5-content-word floor.

- _content_words now strips edge sentence-punctuation (.,;:!?"') from tokens
  while preserving math delimiters ()=+; claim and chunk are normalised
  identically so matching stays consistent.
- _SENTENCE_SPLIT_RE splits on .!? only when followed by whitespace/end, so
  decimals no longer create spurious sentence boundaries.

Regression test: a 5-word claim with a trailing comma now grounds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-15 16:37:30 +02:00
Tarik Moussa
77f9d79da0 fix(mcp): real cited bibkeys in wiki_read + constant-time token compare
- C-3 (MED): wiki_read returned 'sources' = [concept_slug] (a placeholder), not
  the cited sources. Now parses the page's inline [BibKey #loc] citations and
  returns the actual set of cited bibkeys.
- S-2 (LOW): _TokenAuth compared the Bearer header with != (length/equality
  timing side-channel). Use secrets.compare_digest for constant-time comparison.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-15 16:34:28 +02:00
Tarik Moussa
c4106b0f51 fix(synthesis): make coverage-map gaps opt-in (audit R-8)
find_gaps defaulted its probe topics to paper titles, so a title — which
retrieves mostly its own single bibkey — fell below synthesis_gap_min_coverage
and flagged almost every paper as a 'gap'. Coverage-map gaps now run only for
explicitly-requested topics; the CLI gains a repeatable --topic option. The
code-vs-corpus gap path (precise) is unchanged and still default.

Tests: the two cases that relied on default-title coverage gaps now pass explicit
topics; added a regression that no coverage gaps appear without --topic.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-15 16:32:01 +02:00
Tarik Moussa
cfbfa53398 fix(wiki): protect math in cross-refs; drop no-op DB call; fix mark order
- R-9 (MED): _inject_cross_refs now protects LaTeX math spans ($…$, $$…$$,
  \(…\), \[…\]) like inline code, so a concept name inside a formula is no longer
  rewritten to a [[slug]] link and corrupting the LaTeX. Regression test added.
- C-13 (LOW): mark ungrounded claims on the raw body BEFORE injecting cross-refs
  (render then inject), so a concept name inside a claim cannot stop the ⚠ regex
  from matching.
- C-12 (LOW): _try_embed_formulas is now a true no-op — it previously issued a
  per-concept information_schema round-trip that did nothing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-15 16:24:22 +02:00
Tarik Moussa
ae5e9a528b docs: document 'codex migrate' + MIGRATION_DATABASE_URL as the schema-sync path
README step 4 used an inline apply_schema over the app DATABASE_URL, which fails
under the privilege model (least-privilege app role cannot run DDL). Replace it
with 'codex migrate' and document MIGRATION_DATABASE_URL (owner/superuser) in the
README env table and .env.example, so the privileged migrate path is the
standard for future schema upgrades.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-15 16:20:54 +02:00
Tarik Moussa
10ff5ba3ab chore(infra): point migration helper preflight at 'codex migrate'
Now that 'codex migrate' applies the idempotent schema via a privileged role,
recommend it (with MIGRATION_DATABASE_URL) as the primary schema-sync fix in the
preflight abort message, keeping the manual owner-ALTER as a fallback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-15 15:54:25 +02:00
Tarik Moussa
cdb7d254df feat(db): idempotent schema + 'codex migrate' command (audit M-1, T-2)
M-1: schema.sql could not be re-applied (leading CREATE TABLE/INDEX lacked
IF NOT EXISTS), apply_schema was never called, and the live migration just
failed on a missing chunks.section column. Fixes:

- schema.sql: all CREATE TABLE/INDEX now use IF NOT EXISTS — the whole file is
  re-applyable as a no-op.
- codex migrate: new CLI command that applies schema.sql. Connects via
  MIGRATION_DATABASE_URL (falls back to DATABASE_URL) and catches
  InsufficientPrivilege with guidance — because the app role is DML-only and
  core tables are owned by 'postgres' (the privilege dimension found during the
  live migration incident).
- config: migration_database_url (optional privileged connection).
- db: apply_schema docstring corrected (idempotency now true) + privilege note.
- T-2: static test that schema.sql is fully idempotent; migrate privilege-error
  test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-15 15:53:25 +02:00
Tarik Moussa
b4cf924bf1 docs(audit): record M-1 migration incident + privilege dimension
The C-7 re-ingest hit M-1 live: TRUNCATE succeeded, then ingest failed on the
missing chunks.section column (schema.sql never applied to the live DB). Adding
the column then failed with 'must be owner' — the app user 'researcher' has DML
+ TRUNCATE but not DDL on the postgres-owned core tables. M-1's fix therefore
needs a privileged migration role, not just an idempotent schema. Records the
unblock and the helper's new preflight.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-15 15:45:39 +02:00
Tarik Moussa
9f172f5c68 fix(infra): preflight schema check before TRUNCATE in migration helper
The helper TRUNCATEd papers CASCADE and only then ran ingest_all.sh, which
failed on the missing chunks.section column (audit M-1) — leaving the corpus
wiped. Add a preflight that verifies chunks.section exists BEFORE the
destructive step and aborts early (no TRUNCATE) with the owner-level ALTER to
run, since the app user cannot alter postgres-owned tables.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-15 15:37:35 +02:00
Tarik Moussa
1ff8052b69 chore(infra): add canonical-id re-ingest migration helper (audit C-7)
One-time, guarded migration for the C-7 id-format change: a plain re-ingest
would duplicate every paper (new bare-id row beside the old URL-id PK), so this
wipes (TRUNCATE papers CASCADE) and rebuilds via ingest_all.sh.

Safety: requires the SSH tunnel, prints a BEFORE snapshot, gates the TRUNCATE
behind an explicit 'MIGRATE' confirmation, then prints AFTER verification —
C-7 (url_form_ids should be 0) and C-1 via the real resolver-based
discovery_leads() (ingested papers leaked should be 0). Uses .venv psycopg
(psql is not installed); DATABASE_URL is sourced, never echoed.

Joins PR #13 (Wave 2). Read-only verification SQL validated against the live DB.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-15 12:17:17 +02:00
Tarik Moussa
ce75bc02e5 docs(adr): audit addendum to ADR-F15 — ID mismatch resolved (audit D-1)
The ADR's GO rested on a live-corpus spike (478 dangling, Bobenko/Springborn
rank 7, 5.7% spread) measured before the resolution + canonicalisation landed.
Add an audit addendum: mark the 'Known Limitation: ID-Format Mismatch' as
resolved (C-1 shared resolver + C-7 canonical ids), and flag the Spike Result
numbers as pending re-measurement after the C-7 re-ingest. History preserved.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-15 12:10:46 +02:00
Tarik Moussa
8e2f9e037d fix(ingest): key formulas/figures on papers.id, not filename stem (audit C-10)
extract_formulas/extract_figures derive paper_id from Path(pdf_path).stem, which
need not equal papers.id (and won't, given canonical ids). Inserting that stem
violated the formulas/figures -> papers(id) FK on rich (.pdf) ingest. The DELETE
already used paper.id; the INSERTs now do too.

Strengthened the rich-ingest test: the fake formula/figure carry a filename-stem
paper_id distinct from papers.id, and the test asserts the inserted rows are
keyed on papers.id.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-15 12:09:38 +02:00
Tarik Moussa
c36e7c6ee7 fix(ingest): canonicalise papers.id to the caller id (audit C-7, T-3)
openalex.fetch_paper fills paper.id from OpenAlex's URL-form doi/id
(e.g. https://doi.org/10.48550/arxiv.math/0603097), and ingest upserted that as
papers.id. Live DB confirmed 35/36 ids were URL-form — breaking bare-id lookups
(graph related / discover citing / search by id) and leaving cited_id matching
to rely solely on openalex_id.

ingest now sets paper.id to the caller-supplied id (the arXiv id / DOI the CLI
and ingest scripts use); OpenAlex's work id is retained as openalex_id and in
paper_identifiers. Regression test: fetch_paper returns a URL-form id, papers.id
upsert uses the bare caller id.

T-3: the OpenAlex test fixture used a bare 'doi' (not the URL form the API
emits) and never asserted paper.id, which masked this. Fixture now uses the URL
form and the test pins fetch_paper's mapping.

NOTE: existing rows keep their URL-form ids until re-ingested — run ingest_all.sh
to migrate the 36-paper corpus (chosen migration path).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-15 12:06:53 +02:00
Tarik Moussa
6193f5630d docs: fix two doc/code contradictions (audit D-2, D-4)
- D-2 config.py: graph_cite_boost_alpha described the OLD inverted formula
  (score = dense * (1 + alpha*pr)); the code divides distance. Document the
  actual boosted_distance = distance / (1 + alpha*pr).
- D-4 synthesis.py: write_leads claimed an 'append-only' INDEX.md that keeps
  previously-recorded ids; _update_index actually rebuilds from on-disk glob.
  Docstring now matches the implementation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-15 11:13:52 +02:00
Tarik Moussa
eee147275a fix(discover): resolve cited_id via shared resolver (audit C-1)
discover.py compared the raw OpenAlex cited_id against papers.id (a DOI/arXiv
id), so an ingested paper cited by its OpenAlex id was mis-reported as a
discovery lead. graph.py already resolved this via papers.openalex_id, but the
fix was never propagated — two 'dangling' implementations disagreed.

Extract the resolution into one shared constant RESOLVED_CITATIONS_SQL in
graph.py; build_citation_graph and all four discover.py queries
(discovery_leads / citing_papers / cited_by / cocited_papers) now go through it,
so the 'what counts as ingested' rule cannot drift again.

Live-DB validated: before, 13 ingested papers leaked into discovery_leads;
after, the real discovery_leads() returns 0 ingested papers among its leads.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-15 11:12:55 +02:00
Tarik Moussa
3092f1814e fix(synthesis): namespace lead ids by kind to stop collisions (audit C-11)
Each stage (find_connections/gaps/improvements/propose_conjectures) numbered its
leads from seq=1, so connection L-0001, gap L-0001 and improvement L-0001 all
resolved to grounded/L-0001.md. The CLI concatenates them
(connections + gaps + improvements) and write_leads writes in order, so later
kinds silently overwrote earlier ones — survivors = max(per-kind count), not the
sum. Reproduced: 4 distinct leads -> 2 files.

_make_lead_id now takes the kind and emits L-C-/L-G-/L-I-/L-X- prefixes, keeping
same-seq ids distinct across stages. Regression tests cover the format and the
no-collision invariant.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-15 11:09:24 +02:00
Tarik Moussa
115bb63f2d fix(wiki): quarantine zero-citation pages instead of publishing (audit C-4)
compile_all's quarantine gate was guarded by `total_claims > 0`, so a page
with no parseable citations — including the LLM-outage case where
compile_concept returns an empty page — fell through to the publish branch and
was written to wiki/ + marked compiled. Such a page carries zero grounding
evidence.

Now: total_claims == 0 (or grounding_rate below threshold) quarantines and does
NOT update the compile-state, so a transient LLM failure retries next run
instead of freezing an empty/uncited page as 'compiled'. Empty bodies are
recorded but not written as draft files.

Regression test reproduces the exact bypass (uncited LLM output -> quarantined,
not published).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-15 11:06:12 +02:00
Tarik Moussa
8af9f9ad0a docs(audit): runtime-validate C-4 & C-11, close nougat.py review
Exercised the two runtime-confirmable HIGHs against real code paths (injected
fake LLM, no Ollama/DB):
- C-11 CONFIRMED: 4 distinct leads -> 2 files; connection+gap L-0001 silently
  overwritten by improvement L-0001 (2/4 leads lost).
- C-4 CONFIRMED: zero-claim (uncited) page published to wiki/ + marked compiled,
  not quarantined (grounding_rate=0.0 < 0.5 but total_claims>0 guard skips it).
- nougat.py now fully reviewed -> folds into R-6 (only retries ConnectError,
  wait_fixed(0), unwrapped in ingest). No new finding.

Audit/finding-identification pass complete; remediation planning still open.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-15 10:57:24 +02:00
Tarik Moussa
33fb91d478 docs(audit): live-DB verification — confirm C-7 & C-1, refine M-1
Read-only queries vs the live Jetson corpus (papers=36, citations=811):
- C-7 CONFIRMED: 35/36 papers.id are DOI-URL form (https://doi.org/...),
  incl. arXiv papers as arXiv-DOI URLs. Bare-ID lookups fail for 35/36.
- C-1 CONFIRMED: discover.py mis-flags 13 ingested papers as dangling;
  graph.py correctly resolves 57 cross-cite edges → the two paths diverge.
- M-1 REFINED HIGH→MED: paper_identifiers exists (39 rows), so no live crash;
  structural migration-tooling gap remains.
- C-2 partially refuted: 39 alias rows > 35 → ~4 diverging aliases, not inert.

Adjusted tally: 7 HIGH, 12 MED, 26 LOW.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-15 10:40:46 +02:00
Tarik Moussa
2d1b0b5e23 docs(audit): complete whole-repo audit (parts D-G + exec summary)
- Part D synthesis: C-11 HIGH (lead-ID collision → silent overwrite), R-8 MED, D-4 LOW.
- Part E wiki-rest: R-9 MED (cross-ref injection corrupts LaTeX math), C-12/C-13/R-10/R-11 LOW.
- Part F embed/quality/models: C-14/R-12 LOW + C-7 corroboration. Cleanest modules.
- Part G cli/config: C-15 MED (embedding_dim vs hardcoded vector(1024)), R-13/S-4/R-14/D-5 LOW.

Executive summary: 45 findings total (8 HIGH, 11 MED, 26 LOW). Five root themes;
dominant one is un-normalized identity (C-1/C-7/C-10/M-1). Whole repo reviewed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-15 10:14:50 +02:00
Tarik Moussa
7682ff112f docs(audit): whole-repo audit parts A-C (grounding, sources, parsing)
Closes request (a) — the second pass over loop-1 §8 open items:
- Part A grounding guard: C-4 (HIGH, zero-claim pages bypass quarantine),
  C-5/R-1 MED, C-6/R-2/R-3 LOW.
- Part B sources/: C-7 (HIGH, papers.id stored in OpenAlex URL form vs the
  bare-ID contract), T-3 MED, R-4/R-5/C-9 LOW.
- Part C parsing/: C-10 MED (formulas/figures key on filename not paper.id),
  R-6/R-7 LOW — all latent (live corpus is .txt).

Running tally: 30 findings, 7 HIGH. Common root: un-normalized identity.
Parts D-G (synthesis/wiki/embed/cli) still open.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-15 09:23:56 +02:00
Tarik Moussa
a0ad069b1d docs(audit): record development-loop-1 findings
Audit of main @5dfa6e4: 4 HIGH (S-1 secret-in-tree, M-1 missing migration
path, C-1 unpropagated ID-mismatch fix in discover.py, D-1 stale ADR-F15
validation, T-1 untested resolution SQL) plus MED/LOW findings. Findings
only; remediation planning deferred to the next loop phase.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-15 08:43:54 +02:00
Tarik Moussa
0c89eebeb8 chore(ops): ignore .env.* profiles + add batch ingest script
- .gitignore: ignore .env.* (keep .env.example) so local DB-credential
  profiles like .env.jetson-ingest can never be staged (audit S-1).
- ingest_all.sh: batch-ingest helper writing to the Jetson DB via SSH tunnel.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-15 08:43:53 +02:00
Tarik Moussa
9726042964 style: ruff format normalization to line-length=100
Committed code predated the line-length=100 ruff config; this brings the
five drifted files into compliance. No logic change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-15 08:43:53 +02:00
Tarik Moussa
5dfa6e4bae feat(graph): paper_identifiers table — multi-ID alias support
Fixes preprint/journal duplicate problem: arXiv papers were ingested
under their preprint OpenAlex ID, but corpus citations reference the
published-version OpenAlex ID → those citations were invisible in the
graph (appeared as dangling).

Changes:
- infra/schema.sql: CREATE TABLE paper_identifiers (paper_id, openalex_id)
  with UNIQUE index; seeded from papers.openalex_id on migration
- codex/graph.py: build_citation_graph LEFT JOINs paper_identifiers as
  a second resolution path: COALESCE(p.id, pi.paper_id, c.cited_id)
- codex/ingest.py: every ingest inserts openalex_id into paper_identifiers
  (ON CONFLICT DO NOTHING) — aliases can be added manually alongside

Live DB: 35 rows seeded + 4 published-version aliases added:
  math/0503219 → W2163787581 (DCG 2007)
  math/0306167 → W2136126748 (Commun Analysis Geom 2004)
  math/0203250 → W1567166970 (Trans AMS 2003)
  1005.2698    → W1511400044 (Geom & Topol 2015)

Effect: dangling 574→570; Bobenko+Springborn 2007 now IN-KB rank 9.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 07:42:21 +02:00
Tarik Moussa
664600fdc7 fix(graph): resolve cited_id to papers.id via openalex_id JOIN
build_citation_graph now does:
  SELECT c.citing_id, COALESCE(p.id, c.cited_id) AS cited_id
  FROM citations c
  LEFT JOIN papers p ON p.openalex_id = c.cited_id

Before: cited_id (OpenAlex IDs) never matched papers.id (DOIs) →
  13 cross-citations between ingested papers were invisible in graph.
After: in-KB papers use their canonical DOI key; dangling references
  keep their raw OpenAlex ID unchanged.

Test: test_cross_citation_uses_doi_when_in_kb (42 passed).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 07:15:21 +02:00
Tarik Moussa
a1a6f4590b docs(adr): ADR-F15 literature graph — GO, Bobenko+Springborn rank 7
Live-corpus spike (29 papers, 495 nodes, 590 edges, 478 dangling):
- Bobenko & Springborn 2007 at rank 7 — manual hub expectation PASS
- Pinkall & Polthier 1993 at rank 1 (foundational DGP)
- Score spread 5.7 % (0.002282→0.002159); flat but ordered correctly
- cite-boost alpha=0.3: ~0.06 % boost per unit PR — tie-breaker, not
  hard re-rank; correct formula is distance/(1+alpha*pr), not multiply
- Known limitation: citing_id(DOI)→cited_id(OpenAlex) mismatch renders
  inter-KB cross-citations invisible; non-blocking at corpus size 29

Closes R-46.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 07:11:50 +02:00
2c476dc038 Merge pull request 'feat(F-15): citation graph — PageRank, coupling, co-citation, CLI' (#11) from feat/F-15-literature-graph into main
feat(F-15): citation graph — PageRank, coupling, co-citation, CLI

R-41..R-45 done. R-46 (ADR spike) todo pending full-corpus ingest.
2026-06-15 03:18:22 +00:00
Tarik Moussa
1f1e0e82b3 fix(F-15): address Opus review findings (CRITICAL + WARN)
CRITICAL:
- cli.py: invert cite-boost formula: divide distance by (1 + alpha*pr)
  instead of multiply — high-PageRank papers now correctly rank higher
- cli.py: wire graph_min_corpus_size config into graph report; warning
  emitted to stderr so JSON stdout stays parseable

WARN:
- graph.py: document that damping is ignored in small-graph uniform branch
- cli.py: sort dangling citations before slicing for stable output
- cli.py: replace raise typer.Exit(0) with return in empty-graph path
- tests: fix *extra_args helper signatures; add cite-boost ordering test
  and small-corpus warning test

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 03:40:30 +02:00
Tarik Moussa
fd51b70000 feat(F-15): citation graph — PageRank, coupling, co-citation, CLI
- codex/graph.py: build_citation_graph (DiGraph from DB), citation_pagerank
  (graceful uniform fallback < 5 nodes), find_related (bibliographic coupling),
  find_co_cited, dangling_citations
- codex/config.py: graph_cite_boost_alpha=0.3, graph_min_corpus_size=15
- codex/cli.py: graph report [--top-n] [--json], graph related <paper-id>
  [--min-shared]; search paper --cite-boost (PageRank score weighting)
- tests/graph/: 39 tests (graph functions + CLI) — 326 total green

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 03:31:36 +02:00
9647897173 feat(F-16): chunk quality gate + section classification
3-signal quality filter (length/alpha-ratio/bib-score) + rule-based section classifier + retroactive CLI pass. 35 new tests, 287 total green.
2026-06-15 01:22:44 +00:00
61 changed files with 6472 additions and 282 deletions

View File

@@ -5,6 +5,12 @@
# Example: postgresql://researcher:change_me@localhost:5432/papers # Example: postgresql://researcher:change_me@localhost:5432/papers
DATABASE_URL=postgresql://researcher:change_me@localhost:5432/papers DATABASE_URL=postgresql://researcher:change_me@localhost:5432/papers
# Optional: privileged connection used by `codex migrate` to apply schema DDL.
# The app DATABASE_URL is often a least-privilege DML role that cannot CREATE/
# ALTER owner-held tables; point this at an owner/superuser connection.
# Falls back to DATABASE_URL when unset.
# MIGRATION_DATABASE_URL=postgresql://postgres:change_me@localhost:5432/papers
# GROBID service base URL (containerised — see infra/docker-compose.yml) # GROBID service base URL (containerised — see infra/docker-compose.yml)
GROBID_URL=http://localhost:8070 GROBID_URL=http://localhost:8070
@@ -24,6 +30,11 @@ EMBEDDING_DIM=1024
# Required by OpenAlex ToS when making automated requests. # Required by OpenAlex ToS when making automated requests.
OPENALEX_MAILTO=you@example.com OPENALEX_MAILTO=you@example.com
# Nougat HTTP-Server (Jetson: http://192.168.178.103:8080 | lokal: http://localhost:8080) # Nougat HTTP-Server (Jetson: http://jetson.local:8080 | lokal: http://localhost:8080)
# Used by `codex ingest <id> --rich` for full-PDF Mathpix Markdown output. # Used by `codex ingest <id> --rich` for full-PDF Mathpix Markdown output.
NOUGAT_URL=http://localhost:8080 NOUGAT_URL=http://localhost:8080
# F-16 Chunk Quality Gate thresholds (all optional — defaults shown)
CHUNK_MIN_CHARS=60 # Discard chunks shorter than this many characters
CHUNK_MIN_ALPHA_RATIO=0.40 # Discard chunks with < 40% alphabetic characters
CHUNK_MAX_BIB_SCORE=0.70 # Discard chunks scoring above this bibliography threshold

5
.gitignore vendored
View File

@@ -18,6 +18,8 @@ build/
# Environment secrets — NEVER commit # Environment secrets — NEVER commit
.env .env
.env.*
!.env.example
# Type-checker and linter caches # Type-checker and linter caches
.mypy_cache/ .mypy_cache/
@@ -36,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

@@ -19,13 +19,11 @@ $EDITOR .env
# 3. Install Python dependencies (requires uv) # 3. Install Python dependencies (requires uv)
uv sync uv sync
# 4. Apply the database schema (first run only) # 4. Apply the database schema (idempotent — safe to re-run after upgrades)
uv run python -c " # Needs a role that can run DDL. If DATABASE_URL is a least-privilege app
from codex.db import get_conn, apply_schema # role, point MIGRATION_DATABASE_URL at an owner/superuser connection first:
with get_conn() as conn: # MIGRATION_DATABASE_URL=postgresql://postgres:...@host:5432/papers
apply_schema(conn) uv run codex migrate
print('Schema applied.')
"
``` ```
--- ---
@@ -34,7 +32,8 @@ print('Schema applied.')
| Variable | Default | Description | | Variable | Default | Description |
|---|---|---| |---|---|---|
| `DATABASE_URL` | `postgresql://researcher:change_me@localhost:5432/papers` | libpq connection string | | `DATABASE_URL` | `postgresql://researcher:change_me@localhost:5432/papers` | libpq connection string (app role; DML) |
| `MIGRATION_DATABASE_URL` | *(falls back to `DATABASE_URL`)* | Privileged connection used by `codex migrate` for schema DDL (owner/superuser) |
| `GROBID_URL` | `http://localhost:8070` | GROBID HTTP API base URL | | `GROBID_URL` | `http://localhost:8070` | GROBID HTTP API base URL |
| `OLLAMA_BASE_URL` | `http://localhost:11434` | Local Ollama endpoint (optional) | | `OLLAMA_BASE_URL` | `http://localhost:11434` | Local Ollama endpoint (optional) |
| `EMBEDDING_MODEL` | `BAAI/bge-m3` | sentence-transformers model name | | `EMBEDDING_MODEL` | `BAAI/bge-m3` | sentence-transformers model name |

View File

@@ -13,6 +13,8 @@ discover_app = typer.Typer(help="Discovery queries over the citation graph.")
prov_app = typer.Typer(help="Provenance: @cite scan, code_links, bib export.") prov_app = typer.Typer(help="Provenance: @cite scan, code_links, bib export.")
wiki_app = typer.Typer(help="Wiki-compile: grounded concept pages over the RAG substrate.") wiki_app = typer.Typer(help="Wiki-compile: grounded concept pages over the RAG substrate.")
synth_app = typer.Typer(help="Synthesis: grounded leads and quarantined conjectures (F-13).") synth_app = typer.Typer(help="Synthesis: grounded leads and quarantined conjectures (F-13).")
quality_app = typer.Typer(help="Chunk quality gate and section classification (F-16).")
graph_app = typer.Typer(help="Citation graph analytics: PageRank, coupling, dangling leads (F-15).")
# F-09: search sub-app with paper (semantic) and formula (FTS) subcommands # F-09: search sub-app with paper (semantic) and formula (FTS) subcommands
search_app = typer.Typer( search_app = typer.Typer(
help="Search: semantic paper search and formula full-text search.", help="Search: semantic paper search and formula full-text search.",
@@ -22,9 +24,42 @@ app.add_typer(discover_app, name="discover")
app.add_typer(prov_app, name="provenance") app.add_typer(prov_app, name="provenance")
app.add_typer(wiki_app, name="wiki") app.add_typer(wiki_app, name="wiki")
app.add_typer(synth_app, name="synthesis") app.add_typer(synth_app, name="synthesis")
app.add_typer(quality_app, name="quality")
app.add_typer(graph_app, name="graph")
app.add_typer(search_app, name="search") app.add_typer(search_app, name="search")
@app.command()
def migrate() -> None:
"""Apply infra/schema.sql to bring the database schema up to date (idempotent).
Must connect as a role that can run DDL (owns / can CREATE + ALTER the tables).
The application's DATABASE_URL is usually a least-privilege DML role and will
fail with InsufficientPrivilege; set MIGRATION_DATABASE_URL to a privileged
(owner/superuser) connection (audit M-1).
"""
import psycopg
import psycopg.rows
from codex.config import get_settings
from codex.db import apply_schema
settings = get_settings()
url = (settings.migration_database_url or settings.database_url).get_secret_value()
try:
with psycopg.connect(url, row_factory=psycopg.rows.dict_row) as conn:
apply_schema(conn)
except psycopg.errors.InsufficientPrivilege as exc:
typer.echo(
"ERROR: schema migration needs a role that owns the tables "
"(CREATE/ALTER). Set MIGRATION_DATABASE_URL to a privileged connection "
f"and retry. ({exc})",
err=True,
)
raise typer.Exit(1) from exc
typer.echo("Schema applied — database is up to date.")
@app.command() @app.command()
def ingest( def ingest(
paper_id: str = typer.Argument(..., help="arXiv ID, DOI, or OpenAlex W-ID"), paper_id: str = typer.Argument(..., help="arXiv ID, DOI, or OpenAlex W-ID"),
@@ -64,8 +99,15 @@ def search_callback(ctx: typer.Context) -> None:
def search_paper( def search_paper(
query: str = typer.Argument(..., help="Natural-language search query"), query: str = typer.Argument(..., help="Natural-language search query"),
limit: int = typer.Option(10, "--limit", "-n", help="Number of results"), limit: int = typer.Option(10, "--limit", "-n", help="Number of results"),
cite_boost: bool = typer.Option(
False,
"--cite-boost",
help="Re-rank the top results by citation PageRank — a within-page "
"tie-breaker, not a hard re-ranking (F-15). Graceful when corpus < 5 papers.",
),
) -> None: ) -> None:
"""Semantic similarity search over paper abstracts.""" """Semantic similarity search over paper abstracts."""
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
@@ -74,14 +116,33 @@ 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},
).fetchall() ).fetchall()
if cite_boost and rows:
from codex.graph import build_citation_graph, citation_pagerank
settings = get_settings()
graph = build_citation_graph(conn)
pr = citation_pagerank(graph)
alpha = settings.graph_cite_boost_alpha
results = []
for row in rows:
pr_score = pr.get(row["id"], 0.0)
# distance is lower=better; divide to reduce distance for high-PR papers
boosted = row["distance"] / (1.0 + alpha * pr_score)
results.append((boosted, row))
results.sort(key=lambda x: x[0])
for boosted_dist, row in results:
typer.echo(f"[{boosted_dist:.3f}] {row['id']} ({row['year']}) — {row['title']}")
return
for row in rows: for row in rows:
typer.echo(f"[{row['distance']:.3f}] {row['id']} ({row['year']}) — {row['title']}") typer.echo(f"[{row['distance']:.3f}] {row['id']} ({row['year']}) — {row['title']}")
@@ -128,6 +189,24 @@ def discover_leads(
typer.echo(f"{item['pull']:>4}× {item['cited_id']}") typer.echo(f"{item['pull']:>4}× {item['cited_id']}")
@discover_app.command("recommend")
def discover_recommend(
paper_id: str = typer.Argument(
..., help="Semantic Scholar paper ID (or arXiv:/DOI: prefixed)."
),
limit: int = typer.Option(20, "--limit", "-n", help="Number of recommendations."),
) -> None:
"""Recommended papers for PAPER_ID via Semantic Scholar."""
from codex.sources.semanticscholar import fetch_recommendations
rec = fetch_recommendations(paper_id, limit=limit)
if not rec:
typer.echo(f"No recommendations found for {paper_id}.")
return
for pid in rec:
typer.echo(pid)
@discover_app.command("citing") @discover_app.command("citing")
def discover_citing(paper_id: str = typer.Argument(...)) -> None: def discover_citing(paper_id: str = typer.Argument(...)) -> None:
"""List papers that cite PAPER_ID.""" """List papers that cite PAPER_ID."""
@@ -362,7 +441,15 @@ def synthesis_leads(
"--lib-path", "--lib-path",
help=( help=(
"Path to a C++ source tree. Enables improvement leads (@cite-aware) and " "Path to a C++ source tree. Enables improvement leads (@cite-aware) and "
"code-vs-corpus gap detection. Without it only connection + topic gaps run." "code-vs-corpus gap detection."
),
),
topic: list[str] | None = typer.Option( # noqa: B008
None,
"--topic",
help=(
"Topic to check for coverage gaps (repeatable). Opt-in: without --topic "
"no coverage-map gaps run (avoids per-paper false positives, audit R-8)."
), ),
), ),
output_dir: Optional[str] = typer.Option( # noqa: UP045 output_dir: Optional[str] = typer.Option( # noqa: UP045
@@ -384,7 +471,7 @@ def synthesis_leads(
llm = default_llm_client() llm = default_llm_client()
connections = find_connections(top_k=settings.synthesis_top_k, llm=llm) connections = find_connections(top_k=settings.synthesis_top_k, llm=llm)
gaps = find_gaps(lib_path=lib_path, llm=llm) gaps = find_gaps(lib_path=lib_path, llm=llm, topics=topic or None)
improvements = ( improvements = (
find_improvements(lib_path, top_k=settings.synthesis_top_k, llm=llm) if lib_path else [] find_improvements(lib_path, top_k=settings.synthesis_top_k, llm=llm) if lib_path else []
) )
@@ -419,9 +506,7 @@ def synthesis_conjectures(
conjectures = propose_conjectures(top_k=settings.synthesis_top_k, llm=llm) conjectures = propose_conjectures(top_k=settings.synthesis_top_k, llm=llm)
write_leads(conjectures, leads_dir) write_leads(conjectures, leads_dir)
typer.echo( typer.echo(f"Conjectures (UNVERIFIED) written to {leads_dir}/conjectures/: {len(conjectures)}")
f"Conjectures (UNVERIFIED) written to {leads_dir}/conjectures/: {len(conjectures)}"
)
@synth_app.command("report") @synth_app.command("report")
@@ -454,13 +539,188 @@ def synthesis_report(
typer.echo("") typer.echo("")
typer.echo("=" * 72) typer.echo("=" * 72)
typer.echo( typer.echo(f"CONJECTURES ({len(conj_files)}) → {conj_dir} [UNVERIFIED — never in wiki/]")
f"CONJECTURES ({len(conj_files)}) → {conj_dir} "
"[UNVERIFIED — never in wiki/]"
)
typer.echo("=" * 72) typer.echo("=" * 72)
if not conj_files: if not conj_files:
typer.echo("(none)") typer.echo("(none)")
for f in conj_files: for f in conj_files:
first = f.read_text(encoding="utf-8").splitlines()[0] first = f.read_text(encoding="utf-8").splitlines()[0]
typer.echo(f" {first}") typer.echo(f" {first}")
# ---------------------------------------------------------------------------
# F-16 — quality sub-commands
# ---------------------------------------------------------------------------
@quality_app.command("run")
def quality_run(
paper_id: Optional[str] = typer.Option( # noqa: UP045
None,
"--paper-id",
help="Restrict pass to one paper (omit to process all papers).",
),
) -> None:
"""Apply quality gate + section classification to existing chunks.
Iterates over chunks in the database, removes those that fail the
quality thresholds, and back-fills the ``section`` column for the rest.
"""
from codex.config import get_settings
from codex.db import get_conn
from codex.quality import run_quality_pass
settings = get_settings()
with get_conn() as conn:
stats = run_quality_pass(paper_id=paper_id, conn=conn, settings=settings)
scope = f"paper {paper_id}" if paper_id else "all papers"
typer.echo(
f"Quality pass ({scope}): "
f"{stats['kept']} kept, "
f"{stats['removed']} removed, "
f"{stats['tagged']} section-tagged"
)
# ---------------------------------------------------------------------------
# F-15 — graph sub-commands
# ---------------------------------------------------------------------------
@graph_app.command("report")
def graph_report(
top_n: int = typer.Option(10, "--top-n", "-n", help="Number of hub papers to show."),
output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
) -> None:
"""Show citation graph report: top hub papers, dangling citations, cluster summary."""
from codex.config import get_settings
from codex.db import get_conn
from codex.graph import (
build_citation_graph,
citation_pagerank,
citing_coverage,
dangling_citations,
)
settings = get_settings()
with get_conn() as conn:
graph = build_citation_graph(conn)
paper_rows = conn.execute("SELECT id, title FROM papers").fetchall()
known_ids = {row["id"] for row in paper_rows}
titles = {row["id"]: row["title"] for row in paper_rows}
if graph.number_of_nodes() == 0:
typer.echo("Citation graph is empty — ingest some papers first.")
return
n_papers = len(known_ids)
pr = citation_pagerank(graph)
hubs = sorted(pr.items(), key=lambda x: -x[1])[:top_n]
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
warning = (
f"only {n_papers} ingested papers; recommended >= {settings.graph_min_corpus_size} "
"for meaningful ranking"
if small_corpus
else None
)
if output_json:
typer.echo(
json.dumps(
{
"nodes": graph.number_of_nodes(),
"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)
"hubs": [
{"paper_id": pid, "title": titles.get(pid), "pagerank": score}
for pid, score in hubs
],
"dangling_count": len(dangling),
"dangling": dangling,
},
indent=2,
)
)
return
if warning:
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"Citing coverage: {n_citing}/{n_papers} papers with out-edges ({coverage:.0%})")
typer.echo("")
typer.echo(f"TOP-{top_n} HUB PAPERS (PageRank)")
typer.echo("-" * 48)
for pid, score in hubs:
# in-KB hubs show their title; dangling (OpenAlex-id) hubs are flagged (U-1)
label = titles.get(pid) or "(dangling — not ingested)"
typer.echo(f" {score:.4f} {pid} {label}")
typer.echo("")
typer.echo(f"DANGLING CITATIONS ({len(dangling)} cited but not ingested)")
typer.echo("-" * 48)
for pid in dangling[:top_n]:
typer.echo(f" {pid}")
if len(dangling) > top_n:
typer.echo(f" … and {len(dangling) - top_n} more")
@graph_app.command("related")
def graph_related(
paper_id: str = typer.Argument(..., help="Paper ID to find related papers for."),
min_shared: int = typer.Option(
2, "--min-shared", help="Minimum shared references for bibliographic coupling."
),
) -> None:
"""List bibliographically related papers (shared references ≥ min-shared)."""
from codex.db import get_conn
from codex.graph import build_citation_graph, find_related
with get_conn() as conn:
graph = build_citation_graph(conn)
related = find_related(paper_id, graph, min_shared=min_shared)
if not related:
typer.echo(f"No papers with ≥ {min_shared} shared references found for {paper_id}.")
return
typer.echo(
f"Papers related to {paper_id} (bibliographic coupling, ≥ {min_shared} shared refs):"
)
for pid in related:
typer.echo(f" {pid}")
@graph_app.command("cocited")
def graph_cocited(
paper_id: str = typer.Argument(..., help="Paper ID to find co-cited papers for."),
) -> None:
"""List papers frequently co-cited with PAPER_ID (graph co-citation)."""
from codex.db import get_conn
from codex.graph import build_citation_graph, find_co_cited
with get_conn() as conn:
graph = build_citation_graph(conn)
results = find_co_cited(paper_id, graph)
if not results:
typer.echo(f"No papers co-cited with {paper_id}.")
return
typer.echo(f"Papers co-cited with {paper_id} (count desc):")
for pid, count in results:
typer.echo(f" {count:>3}× {pid}")

View File

@@ -9,7 +9,7 @@ from __future__ import annotations
from functools import lru_cache from functools import lru_cache
from pydantic import AliasChoices, Field from pydantic import AliasChoices, Field, SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -26,14 +26,25 @@ class Settings(BaseSettings):
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Database # Database
# ------------------------------------------------------------------ # ------------------------------------------------------------------
database_url: str = Field( database_url: SecretStr = Field(
default="postgresql://researcher:change_me@localhost:5432/papers", default=SecretStr("postgresql://researcher:change_me@localhost:5432/papers"),
description=( description=(
"libpq-compatible connection string consumed by psycopg. " "libpq-compatible connection string consumed by psycopg. "
"Example: postgresql://user:pass@host:5432/dbname" "Example: postgresql://user:pass@host:5432/dbname"
), ),
) )
migration_database_url: SecretStr | None = Field(
default=None,
description=(
"Optional privileged connection string used by `codex migrate` to apply "
"schema DDL. The app's database_url is typically a least-privilege DML "
"role that cannot CREATE/ALTER owner-held tables (raises "
"InsufficientPrivilege); point this at an owner/superuser connection. "
"Falls back to database_url when unset."
),
)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# External services # External services
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -109,7 +120,7 @@ class Settings(BaseSettings):
description=( description=(
"Ollama base URL for wiki synthesis. " "Ollama base URL for wiki synthesis. "
"When None, falls back to OLLAMA_BASE_URL " "When None, falls back to OLLAMA_BASE_URL "
"(http://192.168.178.103:11434 for the Jetson). " "(http://jetson.local:11434 for the Jetson). "
"Set WIKI_LLM_URL to override." "Set WIKI_LLM_URL to override."
), ),
) )
@@ -137,7 +148,7 @@ class Settings(BaseSettings):
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# F-09 Rich Parsing # F-09 Rich Parsing
# ------------------------------------------------------------------ # ------------------------------------------------------------------
mathpix_app_id: str | None = Field( mathpix_app_id: SecretStr | None = Field(
default=None, default=None,
description=( description=(
"MathPix App ID for cloud formula extraction. " "MathPix App ID for cloud formula extraction. "
@@ -145,7 +156,7 @@ class Settings(BaseSettings):
), ),
) )
mathpix_app_key: str | None = Field( mathpix_app_key: SecretStr | None = Field(
default=None, default=None,
description=( description=(
"MathPix App Key for cloud formula extraction. " "MathPix App Key for cloud formula extraction. "
@@ -191,9 +202,7 @@ class Settings(BaseSettings):
synthesis_llm_url: str | None = Field( synthesis_llm_url: str | None = Field(
default=None, default=None,
description=( description=("Ollama base URL for synthesis. When None, falls back to OLLAMA_BASE_URL."),
"Ollama base URL for synthesis. When None, falls back to OLLAMA_BASE_URL."
),
) )
synthesis_top_k: int = Field( synthesis_top_k: int = Field(
@@ -248,7 +257,7 @@ class Settings(BaseSettings):
description="Bind port for HTTP transport (ignored for stdio).", description="Bind port for HTTP transport (ignored for stdio).",
) )
mcp_auth_token: str | None = Field( mcp_auth_token: SecretStr | None = Field(
default=None, default=None,
description=( description=(
"Bearer token required when mcp_transport='http'. " "Bearer token required when mcp_transport='http'. "
@@ -256,6 +265,75 @@ class Settings(BaseSettings):
), ),
) )
# ------------------------------------------------------------------
# F-16 Chunk Quality Gate
# ------------------------------------------------------------------
chunk_min_chars: int = Field(
default=60,
gt=0,
description=(
"Minimum character count for a chunk to pass the quality gate. "
"Chunks shorter than this value are discarded before embedding."
),
)
chunk_min_alpha_ratio: float = Field(
default=0.40,
ge=0.0,
le=1.0,
description=(
"Minimum fraction of alphabetic characters in a chunk. "
"Chunks with a lower ratio are treated as OCR artefacts and discarded."
),
)
chunk_max_bib_score: float = Field(
default=0.70,
ge=0.0,
le=1.0,
description=(
"Maximum bibliography heuristic score before a chunk is rejected. "
"Score is based on DOI density, 'et al.' and year-in-brackets patterns."
),
)
# ------------------------------------------------------------------
# F-15 Literature Graph
# ------------------------------------------------------------------
graph_cite_boost_alpha: float = Field(
default=0.3,
ge=0.0,
le=1.0,
description=(
"Weight of the PageRank citation-boost in hybrid search. "
"boosted_distance = distance / (1 + alpha * pagerank_score) — dividing "
"lowers the cosine distance of high-PageRank papers (lower = closer). "
"Set to 0.0 to disable the boost without removing the flag."
),
)
graph_min_corpus_size: int = Field(
default=15,
gt=0,
description=(
"Minimum number of ingested papers for citation_pagerank to yield "
"meaningful rankings. Below this threshold a warning is logged and "
"uniform scores are returned."
),
)
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

@@ -36,7 +36,7 @@ def get_conn() -> Generator[psycopg.Connection[psycopg.rows.DictRow], None, None
""" """
settings = get_settings() settings = get_settings()
with psycopg.connect( with psycopg.connect(
settings.database_url, settings.database_url.get_secret_value(),
row_factory=psycopg.rows.dict_row, row_factory=psycopg.rows.dict_row,
) as conn: ) as conn:
yield conn yield conn
@@ -45,11 +45,16 @@ def get_conn() -> Generator[psycopg.Connection[psycopg.rows.DictRow], None, None
def apply_schema(conn: psycopg.Connection[psycopg.rows.DictRow]) -> None: def apply_schema(conn: psycopg.Connection[psycopg.rows.DictRow]) -> None:
"""Idempotently apply ``infra/schema.sql`` to the connected database. """Idempotently apply ``infra/schema.sql`` to the connected database.
Safe to call on every startup — all DDL statements use Safe to call repeatedly — every statement is ``CREATE … IF NOT EXISTS`` or
``CREATE … IF NOT EXISTS``. ``ADD COLUMN IF NOT EXISTS``, so re-application is a no-op.
Requires a connection whose role can run DDL (owns / can CREATE + ALTER the
tables). The least-privilege application role is typically DML-only and will
raise ``InsufficientPrivilege``; use a privileged connection — see
``codex migrate`` and ``MIGRATION_DATABASE_URL`` (audit M-1).
Args: Args:
conn: An open psycopg connection (obtained via :func:`get_conn`). conn: An open psycopg connection with DDL privileges.
""" """
schema_path = Path(__file__).parent.parent / "infra" / "schema.sql" schema_path = Path(__file__).parent.parent / "infra" / "schema.sql"
sql = schema_path.read_text(encoding="utf-8") sql = schema_path.read_text(encoding="utf-8")

View File

@@ -1,22 +1,32 @@
"""Discovery queries over the citation graph stored in PostgreSQL.""" """Discovery queries over the citation graph stored in PostgreSQL.
Every query resolves ``citations.cited_id`` to a canonical ``papers.id`` through
the shared :data:`codex.graph.RESOLVED_CITATIONS_SQL`, so this module and the
citation-graph layer agree on what counts as "ingested". Previously this module
compared the raw OpenAlex ``cited_id`` against ``papers.id`` (a DOI/arXiv id),
which mis-reported already-ingested papers as discovery leads (audit C-1).
"""
from __future__ import annotations from __future__ import annotations
from codex.db import get_conn from codex.db import get_conn
from codex.graph import RESOLVED_CITATIONS_SQL
def discovery_leads(limit: int = 20) -> list[dict[str, int | str]]: def discovery_leads(limit: int = 20) -> list[dict[str, int | str]]:
"""Return papers referenced by already-ingested papers but not yet collected. """Return papers referenced by already-ingested papers but not yet collected.
Returns list of dicts with keys: Returns list of dicts with keys:
cited_id (str) — arXiv ID, DOI, or OpenAlex W-ID of the target cited_id (str) — canonical id of the referenced (not-yet-ingested) work
pull (int) — how many already-ingested papers cite this target pull (int) — how many already-ingested papers cite this target
Ordered by pull DESC, limited to `limit` rows. Ordered by pull DESC, limited to `limit` rows. References that resolve to a
paper already in the corpus are excluded — they are not leads.
""" """
sql = """ sql = f"""
WITH resolved AS ({RESOLVED_CITATIONS_SQL})
SELECT cited_id, count(*) AS pull SELECT cited_id, count(*) AS pull
FROM citations FROM resolved
WHERE cited_id NOT IN (SELECT id FROM papers) WHERE cited_id NOT IN (SELECT id FROM papers)
GROUP BY cited_id GROUP BY cited_id
ORDER BY pull DESC ORDER BY pull DESC
@@ -29,7 +39,10 @@ def discovery_leads(limit: int = 20) -> list[dict[str, int | str]]:
def citing_papers(paper_id: str) -> list[str]: def citing_papers(paper_id: str) -> list[str]:
"""Return IDs of all papers that cite `paper_id` (in-graph reverse citations).""" """Return IDs of all papers that cite `paper_id` (in-graph reverse citations)."""
sql = "SELECT citing_id FROM citations WHERE cited_id = %(paper_id)s" sql = f"""
WITH resolved AS ({RESOLVED_CITATIONS_SQL})
SELECT citing_id FROM resolved WHERE cited_id = %(paper_id)s
"""
with get_conn() as conn: with get_conn() as conn:
rows = conn.execute(sql, {"paper_id": paper_id}).fetchall() rows = conn.execute(sql, {"paper_id": paper_id}).fetchall()
return [row["citing_id"] for row in rows] return [row["citing_id"] for row in rows]
@@ -37,7 +50,10 @@ def citing_papers(paper_id: str) -> list[str]:
def cited_by(paper_id: str) -> list[str]: def cited_by(paper_id: str) -> list[str]:
"""Return IDs of all papers that `paper_id` cites (forward citations).""" """Return IDs of all papers that `paper_id` cites (forward citations)."""
sql = "SELECT cited_id FROM citations WHERE citing_id = %(paper_id)s" sql = f"""
WITH resolved AS ({RESOLVED_CITATIONS_SQL})
SELECT cited_id FROM resolved WHERE citing_id = %(paper_id)s
"""
with get_conn() as conn: with get_conn() as conn:
rows = conn.execute(sql, {"paper_id": paper_id}).fetchall() rows = conn.execute(sql, {"paper_id": paper_id}).fetchall()
return [row["cited_id"] for row in rows] return [row["cited_id"] for row in rows]
@@ -49,10 +65,11 @@ def cocited_papers(paper_id: str, limit: int = 10) -> list[dict[str, int | str]]
A paper X is co-cited with `paper_id` if some paper cites both. A paper X is co-cited with `paper_id` if some paper cites both.
Returns list of dicts: {paper_id: str, co_citations: int}, ordered DESC. Returns list of dicts: {paper_id: str, co_citations: int}, ordered DESC.
""" """
sql = """ sql = f"""
WITH resolved AS ({RESOLVED_CITATIONS_SQL})
SELECT c2.cited_id AS paper_id, count(*) AS co_citations SELECT c2.cited_id AS paper_id, count(*) AS co_citations
FROM citations c1 FROM resolved c1
JOIN citations c2 ON c1.citing_id = c2.citing_id JOIN resolved c2 ON c1.citing_id = c2.citing_id
WHERE c1.cited_id = %(paper_id)s WHERE c1.cited_id = %(paper_id)s
AND c2.cited_id != %(paper_id)s AND c2.cited_id != %(paper_id)s
GROUP BY c2.cited_id GROUP BY c2.cited_id

View File

@@ -1,4 +1,4 @@
"""Hybrid dense + sparse embeddings via BGE-M3 (ADR-0002). """Dense (+ optional sparse) embeddings via BGE-M3 (ADR-0002).
Uses :class:`FlagEmbedding.BGEM3FlagModel` for encoding because the Uses :class:`FlagEmbedding.BGEM3FlagModel` for encoding because the
``return_dense`` / ``return_sparse`` kwargs are part of the FlagEmbedding ``return_dense`` / ``return_sparse`` kwargs are part of the FlagEmbedding
@@ -7,6 +7,11 @@ vanilla ``SentenceTransformer`` load of ``BAAI/bge-m3`` (same weights,
same model); sparse output is a list of ``{token_id: weight}`` dicts per same model); sparse output is a list of ``{token_id: weight}`` dicts per
text. text.
Status: only the **dense** path is wired into ingest/search today; the search
"hybrid" is dense + Postgres FTS. :meth:`Embedder.encode_sparse` / :meth:`encode`
are provided for a future sparse-retrieval layer but are not yet consumed by the
pipeline (audit C-14).
Notes for callers Notes for callers
----------------- -----------------
* Empty input is handled explicitly — no model call is issued. * Empty input is handled explicitly — no model call is issued.

209
codex/graph.py Normal file
View File

@@ -0,0 +1,209 @@
"""Citation graph analytics (F-15).
Builds an in-memory NetworkX DiGraph from the ``citations`` table and
provides PageRank, bibliographic coupling, co-citation, and dangling-
citation queries. The graph is ephemeral — rebuilt per call, < 1 s for
≤ 100 papers.
Graceful degradation
--------------------
* ``citation_pagerank`` returns uniform scores when the graph has fewer
than 5 nodes, and logs a warning.
* All functions accept an empty graph without raising.
"""
from __future__ import annotations
import logging
from typing import Any, cast
import networkx as nx
logger = logging.getLogger(__name__)
_MIN_PAGERANK_NODES = 5
# Single source of truth for resolving ``citations.cited_id`` to a canonical
# paper id. ``cited_id`` stores OpenAlex IDs while ``papers.id`` stores DOIs /
# arXiv ids, so an in-KB reference cited by its OpenAlex id does not match
# ``papers.id`` directly. This resolves it to the canonical ``papers.id`` (via
# ``papers.openalex_id``, then the ``paper_identifiers`` alias table); dangling
# references keep their raw id. Both :func:`build_citation_graph` and
# :mod:`codex.discover` query through this so the "what counts as ingested" rule
# cannot drift between them again (audit C-1). Trusted constant — no user input.
RESOLVED_CITATIONS_SQL = """
SELECT c.citing_id,
COALESCE(p.id, pi.paper_id, c.cited_id) AS cited_id
FROM citations c
LEFT JOIN papers p ON p.openalex_id = c.cited_id
LEFT JOIN paper_identifiers pi
ON pi.openalex_id = c.cited_id AND p.id IS NULL
"""
def build_citation_graph(conn: Any) -> nx.DiGraph:
"""Load the ``citations`` table into a directed graph.
Nodes are paper IDs (strings). An edge ``citing → cited`` means
the citing paper references the cited paper. Both ingested papers
and dangling targets (cited but not yet ingested) appear as nodes.
``cited_id`` in the citations table stores OpenAlex IDs; ``papers.id``
stores DOIs. A LEFT JOIN resolves cited papers that are in the KB to
their canonical DOI key via ``papers.openalex_id``. Dangling references
(not in KB) keep their raw OpenAlex ID.
Parameters
----------
conn:
Open psycopg connection (dict-row factory assumed).
Returns
-------
nx.DiGraph with every (citing_id, cited_id) pair as an edge.
"""
rows = conn.execute(RESOLVED_CITATIONS_SQL).fetchall()
g: nx.DiGraph = nx.DiGraph()
for row in rows:
g.add_edge(row["citing_id"], row["cited_id"])
logger.debug(
"Built citation graph: %d nodes, %d edges", g.number_of_nodes(), g.number_of_edges()
)
return g
def citation_pagerank(graph: nx.DiGraph, *, damping: float = 0.85) -> dict[str, float]:
"""Compute PageRank over the citation graph.
Parameters
----------
graph:
DiGraph from :func:`build_citation_graph`.
damping:
PageRank damping factor (default 0.85).
Returns
-------
``{paper_id: score}`` dict. Scores sum to approximately 1.0.
When ``graph`` has fewer than :data:`_MIN_PAGERANK_NODES` (5) nodes,
returns a uniform distribution and logs a warning — the graph is too
sparse for the random-walk model to converge meaningfully. The
``damping`` parameter is ignored in this branch. The user-facing
corpus-size threshold (default 15) lives in
:attr:`codex.config.Settings.graph_min_corpus_size` and is surfaced
by ``codex graph report``.
"""
n = graph.number_of_nodes()
if n == 0:
return {}
if n < _MIN_PAGERANK_NODES:
logger.warning(
"citation_pagerank: only %d nodes — corpus too small for meaningful ranking "
"(need ≥ %d). Returning uniform scores.",
n,
_MIN_PAGERANK_NODES,
)
uniform = 1.0 / n
return {node: uniform for node in graph.nodes()}
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]:
"""Bibliographic coupling: papers sharing ≥ ``min_shared`` references with ``paper_id``.
Two papers are bibliographically coupled when they cite the same sources.
The more shared references, the more likely they treat related topics.
Parameters
----------
paper_id:
Source paper whose references form the seed set.
graph:
DiGraph from :func:`build_citation_graph`.
min_shared:
Minimum number of shared references to be included.
Returns
-------
List of paper IDs ordered by shared-reference count DESC.
"""
if paper_id not in graph:
return []
# papers cited by paper_id
references: set[str] = set(graph.successors(paper_id))
if not references:
return []
shared: dict[str, int] = {}
for ref in references:
# other papers that also cite this reference
for co_citer in graph.predecessors(ref):
if co_citer == paper_id:
continue
shared[co_citer] = shared.get(co_citer, 0) + 1
return [
pid for pid, count in sorted(shared.items(), key=lambda x: -x[1]) if count >= min_shared
]
def find_co_cited(paper_id: str, graph: nx.DiGraph) -> list[tuple[str, int]]:
"""Co-citation: papers frequently cited alongside ``paper_id``.
A paper X is co-cited with ``paper_id`` when some third paper cites both.
Returns
-------
``[(paper_id, count)]`` ordered by count DESC.
"""
if paper_id not in graph:
return []
# papers that cite paper_id
citers: set[str] = set(graph.predecessors(paper_id))
if not citers:
return []
co_cited: dict[str, int] = {}
for citer in citers:
for other in graph.successors(citer):
if other == paper_id:
continue
co_cited[other] = co_cited.get(other, 0) + 1
return sorted(co_cited.items(), key=lambda x: -x[1])
def dangling_citations(graph: nx.DiGraph, known_ids: set[str]) -> list[str]:
"""Return graph nodes not present in ``known_ids`` (cited but not yet ingested).
Complements :func:`codex.discover.discovery_leads` with graph context:
the returned IDs are reachable in the citation graph but have no paper
record in the DB.
Parameters
----------
graph:
DiGraph from :func:`build_citation_graph`.
known_ids:
Set of paper IDs that exist in the ``papers`` table.
Returns
-------
List of dangling paper IDs (unordered).
"""
return [node for node in graph.nodes() if node not in known_ids]

View File

@@ -2,18 +2,19 @@
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
import numpy as np import numpy as np
import psycopg
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.embed import get_embedder from codex.embed import get_embedder
from codex.models import Citation, Paper from codex.models import Citation, Paper
from codex.sources import openalex, semanticscholar from codex.quality import is_quality_chunk, section_label
from codex.sources import arxiv, crossref, openalex, semanticscholar
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -46,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,
@@ -80,7 +188,6 @@ def ingest_paper(
# --------------------------------------------------------------- # ---------------------------------------------------------------
paper: Paper | None = openalex.fetch_paper(paper_id) paper: Paper | None = openalex.fetch_paper(paper_id)
if paper is None: if paper is None:
# Detect arXiv IDs: numeric pattern like "2301.07041" or "arxiv:..." prefix # Detect arXiv IDs: numeric pattern like "2301.07041" or "arxiv:..." prefix
pid_lower = paper_id.lower() pid_lower = paper_id.lower()
@@ -88,16 +195,36 @@ 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}")
if paper is None: if paper is None:
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
# use and what papers.id is contracted to hold (arXiv id or DOI). Pin it to the
# caller's id (audit C-7), but normalize it the same way cited-ids are (strip a
# https://doi.org/ or doi: prefix, lower-case DOIs) so a non-bare caller cannot
# store a URL-form papers.id that would defeat DQ-5's ON CONFLICT (id) idempotency
# or the `paper.id.startswith("10.")` recovery gates below. arXiv/W ids pass
# 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:
paper.bibkey = _make_bibkey(paper) paper.bibkey = _make_bibkey(paper)
@@ -117,9 +244,7 @@ def ingest_paper(
# --------------------------------------------------------------- # ---------------------------------------------------------------
# 3. Upsert paper (INSERT … ON CONFLICT (id) DO UPDATE SET …) # 3. Upsert paper (INSERT … ON CONFLICT (id) DO UPDATE SET …)
# --------------------------------------------------------------- # ---------------------------------------------------------------
with get_conn() as conn: upsert_sql = """
conn.execute(
"""
INSERT INTO papers (id, openalex_id, bibkey, title, authors, year, abstract, INSERT INTO papers (id, openalex_id, bibkey, title, authors, year, abstract,
source_path, abstract_emb) source_path, abstract_emb)
VALUES (%(id)s, %(openalex_id)s, %(bibkey)s, %(title)s, %(authors)s, %(year)s, VALUES (%(id)s, %(openalex_id)s, %(bibkey)s, %(title)s, %(authors)s, %(year)s,
@@ -133,7 +258,16 @@ def ingest_paper(
abstract = EXCLUDED.abstract, abstract = EXCLUDED.abstract,
source_path = COALESCE(EXCLUDED.source_path, papers.source_path), source_path = COALESCE(EXCLUDED.source_path, papers.source_path),
abstract_emb = EXCLUDED.abstract_emb abstract_emb = EXCLUDED.abstract_emb
""", """
with get_conn() as conn:
# The id-keyed upsert does not catch a papers.bibkey UNIQUE collision (two
# same-author/year papers generate the same key). On such a collision retry
# with an a/b/c… suffix instead of failing the whole ingest (audit C-15).
base_bibkey = paper.bibkey
for attempt in range(27):
try:
conn.execute(
upsert_sql,
{ {
"id": paper.id, "id": paper.id,
"openalex_id": paper.openalex_id, "openalex_id": paper.openalex_id,
@@ -146,6 +280,41 @@ def ingest_paper(
"abstract_emb": abstract_emb, "abstract_emb": abstract_emb,
}, },
) )
break
except psycopg.errors.UniqueViolation as exc:
msg = str(exc).lower()
conn.rollback()
if "openalex" in msg:
# Two different papers.id claim the same OpenAlex work — a real data
# conflict, not something to auto-rename. Surface it clearly instead
# of the raw psycopg error (companion gap to C-15's bibkey case).
raise ValueError(
f"openalex_id {paper.openalex_id!r} is already attached to a "
f"different paper; ingesting '{paper.id}' would duplicate it — "
"resolve the conflicting paper first."
) from exc
if base_bibkey is None or "bibkey" not in msg or attempt == 26:
raise
paper.bibkey = f"{base_bibkey}{chr(ord('a') + attempt)}"
logger.warning(
"bibkey %r already exists; ingesting '%s' as %r instead",
base_bibkey,
paper.id,
paper.bibkey,
)
# Register openalex_id in paper_identifiers (alias table).
# Every ingest records the primary openalex_id so the graph JOIN
# resolves it even if papers.openalex_id is later updated.
if paper.openalex_id:
conn.execute(
"""
INSERT INTO paper_identifiers (paper_id, openalex_id)
VALUES (%s, %s)
ON CONFLICT DO NOTHING
""",
(paper.id, paper.openalex_id),
)
# --------------------------------------------------------------- # ---------------------------------------------------------------
# 4. Parse + chunk + embed source file (if provided) # 4. Parse + chunk + embed source file (if provided)
@@ -157,48 +326,74 @@ 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)
chunks_text = chunk_text(text) if text else [] # Quality-gate the pairs (keeps each chunk aligned with its section title).
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, ord_idx, content, chunk_embeddings[ord_idx].tolist()) (
for ord_idx, content in enumerate(chunks_text) paper.id,
ord_idx,
content,
chunk_embeddings[ord_idx].tolist(),
# .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, (title, content) in enumerate(kept_pairs)
] ]
with conn.cursor() as cur: with conn.cursor() as cur:
cur.executemany( cur.executemany(
"INSERT INTO chunks (paper_id, ord, content, embedding)" "INSERT INTO chunks (paper_id, ord, content, embedding, section)"
" VALUES (%s, %s, %s, %s)", " VALUES (%s, %s, %s, %s, %s)",
chunk_rows, chunk_rows,
) )
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]
@@ -207,11 +402,19 @@ def ingest_paper(
# OpenAlex returns citing_id=openalex_id, but papers.id uses the DOI. # OpenAlex returns citing_id=openalex_id, but papers.id uses the DOI.
# Rewrite citing_id to paper.id so the FK constraint is satisfied. # Rewrite citing_id to paper.id so the FK constraint is satisfied.
api_citations = [ api_citations = [
Citation(citing_id=paper.id, cited_id=c.cited_id, context=c.context) Citation(citing_id=paper.id, cited_id=c.cited_id, context=c.context) for c in raw
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()
@@ -267,7 +470,10 @@ def ingest_paper(
VALUES (%s, %s, %s, %s, %s) VALUES (%s, %s, %s, %s, %s)
""", """,
[ [
(f.paper_id, f.page, f.raw_latex, f.context, f.eq_label) # paper.id, not f.paper_id: the extractor derives its
# paper_id from the PDF filename stem, which need not match
# papers.id and would violate the FK (audit C-10).
(paper.id, f.page, f.raw_latex, f.context, f.eq_label)
for f in formulas for f in formulas
], ],
) )
@@ -281,7 +487,8 @@ def ingest_paper(
VALUES (%s, %s, %s, %s) VALUES (%s, %s, %s, %s)
""", """,
[ [
(fig.paper_id, fig.page, fig.caption, fig.image_path) # paper.id, not fig.paper_id (filename stem) — see C-10 above.
(paper.id, fig.page, fig.caption, fig.image_path)
for fig in figures for fig in figures
], ],
) )

View File

@@ -24,6 +24,7 @@ Transport
from __future__ import annotations from __future__ import annotations
import logging import logging
import secrets
from mcp.server.fastmcp import FastMCP from mcp.server.fastmcp import FastMCP
from mcp.server.transport_security import TransportSecuritySettings from mcp.server.transport_security import TransportSecuritySettings
@@ -121,7 +122,6 @@ def wiki_read(concept_slug: str) -> dict[str, object]:
directory or the requested page does not exist. directory or the requested page does not exist.
""" """
try: try:
import json
from pathlib import Path from pathlib import Path
from codex.config import get_settings from codex.config import get_settings
@@ -135,16 +135,12 @@ def wiki_read(concept_slug: str) -> dict[str, object]:
markdown = page_path.read_text(encoding="utf-8") markdown = page_path.read_text(encoding="utf-8")
# Collect cited bibkeys from the compile state # Collect the actual cited bibkeys from the page's inline [BibKey #loc]
state_path = wiki_dir / ".compile-state.json" # citations (audit C-3: this previously returned the concept slug itself,
sources: list[str] = [] # not the cited sources).
if state_path.exists(): from codex.wiki import _parse_claims
try:
state: dict[str, str] = json.loads(state_path.read_text(encoding="utf-8")) sources = sorted({claim.bibkey for claim in _parse_claims(markdown)})
if concept_slug in state:
sources = [concept_slug]
except (json.JSONDecodeError, OSError):
pass
return {"markdown": markdown, "sources": sources} return {"markdown": markdown, "sources": sources}
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
@@ -298,12 +294,13 @@ def _require_http_token() -> str:
from codex.config import get_settings from codex.config import get_settings
settings = get_settings() settings = get_settings()
if not settings.mcp_auth_token: token = settings.mcp_auth_token.get_secret_value() if settings.mcp_auth_token else ""
if not token:
raise RuntimeError( raise RuntimeError(
"HTTP transport requires MCP_AUTH_TOKEN to be set. " "HTTP transport requires MCP_AUTH_TOKEN to be set. "
"Set the environment variable or add it to .env." "Set the environment variable or add it to .env."
) )
return settings.mcp_auth_token return token
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -337,7 +334,9 @@ def main() -> None:
self, request: Request, call_next: RequestResponseEndpoint self, request: Request, call_next: RequestResponseEndpoint
) -> Response: ) -> Response:
auth = request.headers.get("Authorization", "") auth = request.headers.get("Authorization", "")
if auth != f"Bearer {token}": # Constant-time comparison to avoid a token-length/equality timing
# side-channel (audit S-2).
if not secrets.compare_digest(auth, f"Bearer {token}"):
return Response("Unauthorized", status_code=401) return Response("Unauthorized", status_code=401)
return await call_next(request) return await call_next(request)

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

@@ -302,8 +302,10 @@ def extract_formulas(
""" """
settings = get_settings() settings = get_settings()
app_id = mathpix_app_id or settings.mathpix_app_id or "" settings_id = settings.mathpix_app_id.get_secret_value() if settings.mathpix_app_id else ""
app_key = mathpix_app_key or settings.mathpix_app_key or "" settings_key = settings.mathpix_app_key.get_secret_value() if settings.mathpix_app_key else ""
app_id = mathpix_app_id or settings_id
app_key = mathpix_app_key or settings_key
if app_id and app_key: if app_id and app_key:
logger.info("Using MathPix backend for %s", pdf_path) logger.info("Using MathPix backend for %s", pdf_path)

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

248
codex/quality.py Normal file
View File

@@ -0,0 +1,248 @@
"""Chunk quality filtering and section classification (F-16).
Three independent quality signals are applied at ingest time:
1. **Length** — structural; no content required.
2. **Alpha-ratio** — OCR artefacts have high non-alpha character density.
3. **Bib-score** — DOI + "et al." + (YYYY) patterns co-occur almost only
in reference list entries.
Section classification is rule-based (no LLM) and runs on the first 200
characters of each chunk. The retroactive ``run_quality_pass`` function
can be called from the CLI to back-fill existing chunks.
"""
from __future__ import annotations
import logging
import re
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from codex.config import Settings
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Section classification patterns (checked in order; first match wins)
# ---------------------------------------------------------------------------
_SECTION_PATTERNS: list[tuple[str, re.Pattern[str]]] = [
("abstract", re.compile(r"^\s*(abstract|zusammenfassung|r[eé]sum[eé])\b", re.I)),
("intro", re.compile(r"^\s*(introduction|einleitung|1\.\s)", re.I)),
("theorem", re.compile(r"^\s*(theorem|lemma|proposition|corollary|definition)\b", re.I)),
("proof", re.compile(r"^\s*(proof\b|beweis\b|proof\s+of\b)", re.I)),
("bibliography", re.compile(r"^\s*(references|bibliography|bibliographie|literatur)\b", re.I)),
]
_DOI_RE = re.compile(r"10\.\d{4,}/\S+")
_ET_AL_RE = re.compile(r"\bet\s+al\b", re.I)
_YEAR_BRACKETS_RE = re.compile(r"\(\d{4}\)")
# ---------------------------------------------------------------------------
# Bibliography heuristic score
# ---------------------------------------------------------------------------
def _bib_score(text: str) -> float:
"""Return a [0..1] heuristic for how bibliography-like a chunk is.
Three signals contribute:
* DOI occurrences (weight 3)
* "et al." occurrences (weight 2)
* year-in-brackets occurrences (weight 1)
The raw signal is normalised against a rough word-count proxy so that
long chunks with occasional references don't get flagged.
"""
if not text:
return 0.0
word_count = max(len(text.split()), 1)
doi_hits = len(_DOI_RE.findall(text))
etal_hits = len(_ET_AL_RE.findall(text))
year_hits = len(_YEAR_BRACKETS_RE.findall(text))
raw = (doi_hits * 3 + etal_hits * 2 + year_hits) / max(word_count / 10, 1)
return min(raw, 1.0)
# ---------------------------------------------------------------------------
# Quality predicate
# ---------------------------------------------------------------------------
def is_quality_chunk(text: str, *, settings: Settings) -> bool:
"""Return True when *text* passes all three quality thresholds.
Checks (all configurable via :class:`codex.config.Settings`):
1. ``chunk_min_chars`` — character count floor.
2. ``chunk_min_alpha_ratio`` — minimum fraction of alphabetic chars.
3. ``chunk_max_bib_score`` — bibliography heuristic ceiling.
"""
if len(text) < settings.chunk_min_chars:
return False
alpha_ratio = sum(c.isalpha() for c in text) / max(len(text), 1)
if alpha_ratio < settings.chunk_min_alpha_ratio:
return False
return _bib_score(text) <= settings.chunk_max_bib_score
# ---------------------------------------------------------------------------
# Section classification
# ---------------------------------------------------------------------------
def classify_section(text: str) -> str:
"""Classify a chunk's section using rule-based regex matching.
Inspects only the first 200 characters. Returns one of:
``abstract``, ``intro``, ``theorem``, ``proof``, ``bibliography``, ``body``.
"""
snippet = text[:200]
for section_name, pattern in _SECTION_PATTERNS:
if pattern.search(snippet):
return section_name
# Fallback: bibliography by DOI density even without a header
if _bib_score(text) > 0.5:
return "bibliography"
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
# ---------------------------------------------------------------------------
def filter_chunks(chunks: list[str], *, settings: Settings) -> list[str]:
"""Return only the chunks that pass all quality filters.
Logs the keep ratio at DEBUG level.
"""
kept = [c for c in chunks if is_quality_chunk(c, settings=settings)]
logger.debug(
"Quality filter: %d/%d chunks kept (%.0f%%)",
len(kept),
len(chunks),
100 * len(kept) / max(len(chunks), 1),
)
return kept
# ---------------------------------------------------------------------------
# Retroactive DB pass
# ---------------------------------------------------------------------------
def run_quality_pass(
*,
paper_id: str | None = None,
conn: Any,
settings: Settings,
) -> dict[str, int]:
"""Apply quality filter + section classification to existing chunks in DB.
For each chunk:
* Fails quality → DELETE.
* Passes quality → UPDATE ``section`` with :func:`classify_section`.
Parameters
----------
paper_id:
When provided, restrict the pass to chunks for this paper only.
conn:
Open psycopg connection (dict-row factory assumed).
settings:
Application settings for quality thresholds.
Returns
-------
dict with keys ``kept``, ``removed``, ``tagged`` (= kept).
"""
if paper_id is not None:
rows = conn.execute(
"SELECT id, content FROM chunks WHERE paper_id = %(pid)s",
{"pid": paper_id},
).fetchall()
else:
rows = conn.execute("SELECT id, content FROM chunks").fetchall()
kept = 0
removed = 0
for row in rows:
chunk_id = row["id"]
content = row["content"]
if not is_quality_chunk(content, settings=settings):
conn.execute("DELETE FROM chunks WHERE id = %(id)s", {"id": chunk_id})
removed += 1
else:
section = classify_section(content)
conn.execute(
"UPDATE chunks SET section = %(section)s WHERE id = %(id)s",
{"section": section, "id": chunk_id},
)
kept += 1
conn.commit()
logger.info("Quality pass done: %d kept, %d removed, %d section-tagged", kept, removed, kept)
return {"kept": kept, "removed": removed, "tagged": kept}

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
---------- ----------
@@ -38,48 +128,64 @@ def fetch_source(arxiv_id: str) -> str | None:
file is present (signals Nougat fallback). file is present (signals Nougat fallback).
""" """
url = f"{_BASE}/src/{arxiv_id}" url = f"{_BASE}/src/{arxiv_id}"
try:
response = httpx.get(url, timeout=60, follow_redirects=True) response = httpx.get(url, timeout=60, follow_redirects=True)
except httpx.RequestError:
raise
if response.status_code == 404: if response.status_code == 404:
logger.debug("arXiv 404 for source id=%s", arxiv_id) logger.debug("arXiv 404 for source id=%s", arxiv_id)
return None return 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)
if f is None:
return None
return f.read().decode("utf-8", errors="replace")
except tarfile.TarError as exc:
logger.warning("Failed to open tar archive for %s: %s", arxiv_id, exc)
return None return None
return None # unreachable but satisfies type checker # No \documentclass anywhere → fall back to the largest .tex.
if primary_name is None:
primary_name = max(tex_files, key=lambda k: len(tex_files[k]))
# 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
def fetch_pdf_url(arxiv_id: str) -> str: def fetch_pdf_url(arxiv_id: str) -> str:

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

@@ -14,6 +14,7 @@ import logging
import threading import threading
import time import time
from typing import Any from typing import Any
from urllib.parse import quote
import httpx import httpx
from tenacity import retry, retry_if_exception, stop_after_attempt, wait_exponential from tenacity import retry, retry_if_exception, stop_after_attempt, wait_exponential
@@ -77,7 +78,9 @@ def fetch_references(paper_id: str) -> list[Citation]:
list[Citation] list[Citation]
One Citation per reference, with optional context snippet. One Citation per reference, with optional context snippet.
""" """
url = f"{_BASE_GRAPH}/paper/{paper_id}/references" # quote the id so a legacy-arXiv "/" (e.g. arXiv:math/0603097) doesn't break
# the URL path; keep ":" for the arXiv:/DOI: scheme prefix (audit R-5).
url = f"{_BASE_GRAPH}/paper/{quote(paper_id, safe=':')}/references"
params: dict[str, Any] = {"fields": "externalIds,contexts"} params: dict[str, Any] = {"fields": "externalIds,contexts"}
try: try:
response = _get(url, params=params) response = _get(url, params=params)
@@ -87,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", {})
@@ -96,16 +103,31 @@ def fetch_references(paper_id: str) -> list[Citation]:
context: str | None = contexts[0] if contexts else None context: str | None = contexts[0] if contexts else None
cited_id: str = ( cited_id: str = (
external_ids.get("DOI") external_ids.get("DOI") or external_ids.get("ArXiv") or cited_paper.get("paperId") or ""
or external_ids.get("ArXiv")
or cited_paper.get("paperId")
or ""
) )
if cited_id: if cited_id:
citations.append(Citation(citing_id=paper_id, cited_id=cited_id, context=context)) citations.append(Citation(citing_id=paper_id, cited_id=cited_id, context=context))
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.
@@ -121,7 +143,7 @@ def fetch_recommendations(paper_id: str, limit: int = 20) -> list[str]:
list[str] list[str]
List of recommended paper IDs. List of recommended paper IDs.
""" """
url = f"{_BASE_RECS}/papers/forpaper/{paper_id}" url = f"{_BASE_RECS}/papers/forpaper/{quote(paper_id, safe=':')}"
params: dict[str, Any] = {"limit": limit} params: dict[str, Any] = {"limit": limit}
try: try:
response = _get(url, params=params) response = _get(url, params=params)

View File

@@ -248,9 +248,23 @@ def _grounded_ratio(claims: list[Claim]) -> float:
return n_grounded / len(claims) return n_grounded / len(claims)
def _make_lead_id(seq: int) -> str: _KIND_PREFIX: dict[LeadKind, str] = {
"""Format a sequential lead id like ``L-0001``.""" "connection": "C",
return f"L-{seq:04d}" "gap": "G",
"improvement": "I",
"conjecture": "X",
}
def _make_lead_id(seq: int, kind: LeadKind) -> str:
"""Format a per-kind sequential lead id like ``L-C-0001``.
Each synthesis stage numbers its leads from 1, so without a kind prefix a
connection, a gap, and an improvement all produce ``L-0001`` and clobber
one another in the shared ``grounded/`` directory (audit C-11). The kind
prefix namespaces the ids so they stay distinct.
"""
return f"L-{_KIND_PREFIX[kind]}-{seq:04d}"
def _safe_llm_generate(llm: LLMClient, prompt: str, model: str) -> str: def _safe_llm_generate(llm: LLMClient, prompt: str, model: str) -> str:
@@ -296,7 +310,7 @@ def _finalise_grounded_lead(
return None return None
marked = _mark_ungrounded(body, claims) marked = _mark_ungrounded(body, claims)
return Lead( return Lead(
id=_make_lead_id(seq), id=_make_lead_id(seq, kind),
kind=kind, kind=kind,
title=title, title=title,
body=marked, body=marked,
@@ -365,11 +379,7 @@ def find_connections(
continue continue
seen_pairs.add(pair) # type: ignore[arg-type] seen_pairs.add(pair) # type: ignore[arg-type]
pair_chunks = [ pair_chunks = [c for c in chunks if c.get("bibkey") in {bibkey, other}]
c
for c in chunks
if c.get("bibkey") in {bibkey, other}
]
prompt = _CONNECTION_PROMPT.format(chunks_block=_format_chunks(pair_chunks)) prompt = _CONNECTION_PROMPT.format(chunks_block=_format_chunks(pair_chunks))
raw = _safe_llm_generate(llm, prompt, settings.synthesis_llm_model) raw = _safe_llm_generate(llm, prompt, settings.synthesis_llm_model)
if not raw.strip(): if not raw.strip():
@@ -406,11 +416,12 @@ def find_gaps(
Two complementary signals are used: Two complementary signals are used:
* **Topic coverage map** — for each topic in ``topics`` (or, if * **Topic coverage map** — for each **explicitly-requested** topic in
``None``, the union of paper titles), retrieve chunks and check ``topics``, retrieve chunks and check bibkey coverage. A topic covered by
bibkey coverage. A topic covered by < ``synthesis_gap_min_coverage`` < ``synthesis_gap_min_coverage`` bibkeys is a gap candidate and the LLM is
bibkeys is reported as a gap candidate and the LLM is asked to asked to articulate it. Defaulting to paper titles flagged almost every
articulate it. paper as a gap (a title retrieves mostly its own single bibkey), so the
coverage map is opt-in now; pass ``topics`` / CLI ``--topic`` (audit R-8).
* **Code-vs-corpus gap** — when ``lib_path`` is given, any @cite * **Code-vs-corpus gap** — when ``lib_path`` is given, any @cite
bibkey scanned from code that is NOT present in the corpus is bibkey scanned from code that is NOT present in the corpus is
flagged. This catches the "cited in code, never ingested" case. flagged. This catches the "cited in code, never ingested" case.
@@ -419,9 +430,9 @@ def find_gaps(
seq = 1 seq = 1
leads: list[Lead] = [] leads: list[Lead] = []
# Coverage-map probes are the explicit topics only — no default-to-titles,
# which turned every paper into a spurious gap (audit R-8).
probe_topics: list[str] = list(topics or []) probe_topics: list[str] = list(topics or [])
if not probe_topics:
probe_topics = [title for _, title in _list_paper_titles(db_conn)]
known_bibkeys = {bk for bk, _ in _list_paper_titles(db_conn)} known_bibkeys = {bk for bk, _ in _list_paper_titles(db_conn)}
@@ -432,7 +443,7 @@ def find_gaps(
# No coverage at all → record the gap directly (LLM not strictly needed). # No coverage at all → record the gap directly (LLM not strictly needed).
leads.append( leads.append(
Lead( Lead(
id=_make_lead_id(seq), id=_make_lead_id(seq, "gap"),
kind="gap", kind="gap",
title=f"Gap: corpus has no material on '{topic}'", title=f"Gap: corpus has no material on '{topic}'",
body=( body=(
@@ -485,7 +496,7 @@ def find_gaps(
for bibkey in sorted(missing): for bibkey in sorted(missing):
leads.append( leads.append(
Lead( Lead(
id=_make_lead_id(seq), id=_make_lead_id(seq, "gap"),
kind="gap", kind="gap",
title=f"Gap: code cites '{bibkey}' but corpus does not contain it", title=f"Gap: code cites '{bibkey}' but corpus does not contain it",
body=( body=(
@@ -662,7 +673,7 @@ def propose_conjectures(
continue continue
leads.append( leads.append(
Lead( Lead(
id=_make_lead_id(seq), id=_make_lead_id(seq, "conjecture"),
kind="conjecture", kind="conjecture",
title=title, title=title,
body=body, body=body,
@@ -763,10 +774,11 @@ def write_leads(leads: list[Lead], leads_dir: str) -> None:
* Grounded leads → ``leads_dir/grounded/<id>.md`` * Grounded leads → ``leads_dir/grounded/<id>.md``
* Conjectures → ``leads_dir/conjectures/<id>.md`` (HARD invariant) * Conjectures → ``leads_dir/conjectures/<id>.md`` (HARD invariant)
The function ALSO maintains an append-only ``leads_dir/INDEX.md``: The function ALSO rebuilds ``leads_dir/INDEX.md`` from the on-disk files on
a previously-recorded id is kept on subsequent runs even if it is every run (see :func:`_update_index`): it reflects whatever lead files
overwritten with a newer body. The two sections (grounded / currently exist under ``grounded/`` and ``conjectures/``, listed as two
conjectures) are visibly separated. visibly separated sections. It is not append-only — a deleted lead file
drops out of the index on the next run.
Notes Notes
----- -----

View File

@@ -186,8 +186,15 @@ def _retrieve_chunks(
).fetchall() ).fetchall()
# Filter reference-list chunks: skip chunks whose content looks like a bibliography # Filter reference-list chunks: skip chunks whose content looks like a bibliography
# (heuristic: > 60 % of lines match "^\[\d+\]" or "^[A-Z][a-z]+,?\s+[A-Z]\."). # (heuristic: > 60 % of lines look like "[12]" refs or "Surname, I." authors).
ref_pattern = re.compile(r"^\s*(\[\d+\]|[A-Z][a-z]+,?\s+[A-Z]\.)", re.MULTILINE) # The author alternative excludes common math-structure words ("Theorem A.",
# "Lemma B.") so theorem-dense chunks aren't mistaken for a reference list (R-2).
ref_pattern = re.compile(
r"^\s*(\[\d+\]|(?!(?:Theorem|Lemma|Proposition|Corollary|Definition|Proof|"
r"Section|Figure|Fig|Table|Equation|Eq|Remark|Example|Chapter)\b)"
r"[A-Z][a-z]+,?\s+[A-Z]\.)",
re.MULTILINE,
)
filtered: list[dict[str, Any]] = [] filtered: list[dict[str, Any]] = []
for row in rows: for row in rows:
content: str = row["content"] content: str = row["content"]
@@ -240,13 +247,46 @@ def _parse_claims(markdown: str) -> list[Claim]:
_STOPWORDS: frozenset[str] = frozenset( _STOPWORDS: frozenset[str] = frozenset(
{ {
"the", "a", "an", "in", "on", "of", "to", "is", "are", "was", "were", "the",
"and", "or", "but", "for", "with", "this", "that", "it", "we", "they", "a",
"be", "as", "at", "by", "from", "has", "have", "not", "which", "an",
"in",
"on",
"of",
"to",
"is",
"are",
"was",
"were",
"and",
"or",
"but",
"for",
"with",
"this",
"that",
"it",
"we",
"they",
"be",
"as",
"at",
"by",
"from",
"has",
"have",
"not",
"which",
} }
) )
_SENTENCE_SPLIT_RE = re.compile(r"[.!?]") # Split on sentence-ending punctuation only when followed by whitespace/end, so a
# decimal like "1.5" does not create a spurious sentence boundary (audit R-1).
_SENTENCE_SPLIT_RE = re.compile(r"[.!?]+(?=\s|$)")
# Punctuation stripped from token edges before grounding comparison — sentence/
# clause marks and quotes, NOT math delimiters like ()=+ (audit R-1).
_EDGE_PUNCT = ".,;:!?\"'"
def _last_sentence(text: str) -> str: def _last_sentence(text: str) -> str:
@@ -265,8 +305,16 @@ def _last_sentence(text: str) -> str:
def _content_words(text: str) -> list[str]: def _content_words(text: str) -> list[str]:
"""Return lowercased tokens with stopwords removed.""" """Return lowercased content tokens, edge-punctuation-stripped, stopwords removed.
return [w for w in text.lower().split() if w not in _STOPWORDS]
Stripping leading/trailing sentence punctuation makes grounding robust to
surface variation ("volume," vs "volume") so a faithful paraphrase is not
marked ungrounded over a comma (audit R-1). Math delimiters (``()=+``) are
preserved. Claim and chunk tokens are normalised identically, so matching
stays consistent.
"""
tokens = (w.strip(_EDGE_PUNCT) for w in text.lower().split())
return [w for w in tokens if w and w not in _STOPWORDS]
def _run_grounding_guard( def _run_grounding_guard(
@@ -295,8 +343,9 @@ def _run_grounding_guard(
for chunk in chunks: for chunk in chunks:
bk = str(chunk.get("bibkey") or "") bk = str(chunk.get("bibkey") or "")
if bk: if bk:
# Content words of the chunk (stopwords removed, lowercased) # Content words of the chunk, space-padded so a 5-gram match is bounded
cw = " ".join(_content_words(chunk["content"])) # by whole tokens, not a substring bleeding across words (audit C-6).
cw = " " + " ".join(_content_words(chunk["content"])) + " "
bib_index.setdefault(bk, []).append(cw) bib_index.setdefault(bk, []).append(cw)
for claim in claims: for claim in claims:
@@ -318,7 +367,7 @@ def _run_grounding_guard(
found = False found = False
for i in range(len(content) - 4): # content-5-grams for i in range(len(content) - 4): # content-5-grams
gram = " ".join(content[i : i + 5]) gram = " " + " ".join(content[i : i + 5]) + " " # padded: token-boundary match
if any(gram in src for src in sources): if any(gram in src for src in sources):
found = True found = True
break break
@@ -360,12 +409,8 @@ def _detect_conflicts(
for bk2 in bibkeys[i + 1 :]: for bk2 in bibkeys[i + 1 :]:
# Check if any chunk from bk1 contains a conflict keyword # Check if any chunk from bk1 contains a conflict keyword
# and any chunk from bk2 also does — heuristic signal only # and any chunk from bk2 also does — heuristic signal only
bk1_has_conflict = any( bk1_has_conflict = any(_CONFLICT_KEYWORDS.search(c) for c in by_bib[bk1])
_CONFLICT_KEYWORDS.search(c) for c in by_bib[bk1] bk2_has_conflict = any(_CONFLICT_KEYWORDS.search(c) for c in by_bib[bk2])
)
bk2_has_conflict = any(
_CONFLICT_KEYWORDS.search(c) for c in by_bib[bk2]
)
if bk1_has_conflict and bk2_has_conflict: if bk1_has_conflict and bk2_has_conflict:
conflicts.append((slug, bk1, bk2)) conflicts.append((slug, bk1, bk2))
@@ -379,6 +424,13 @@ def _detect_conflicts(
_INLINE_CODE_RE = re.compile(r"`[^`]+`") _INLINE_CODE_RE = re.compile(r"`[^`]+`")
# LaTeX math spans — protected from cross-ref injection so a concept name inside
# a formula is not rewritten to a [[slug]] link, corrupting the LaTeX (audit R-9).
_MATH_SPAN_RE = re.compile(
r"\$\$.*?\$\$|\$[^$\n]*?\$|\\\(.*?\\\)|\\\[.*?\\\]",
re.DOTALL,
)
def _inject_cross_refs( def _inject_cross_refs(
markdown: str, markdown: str,
@@ -388,11 +440,12 @@ def _inject_cross_refs(
"""Replace occurrences of other concept titles/aliases with ``[[slug]]`` links. """Replace occurrences of other concept titles/aliases with ``[[slug]]`` links.
Only exact case-insensitive whole-word matches outside of existing Only exact case-insensitive whole-word matches outside of existing
``[[…]]`` blocks or inline code spans are replaced. ``[[…]]`` blocks, inline code spans, or LaTeX math spans are replaced.
Inline-code spans (`` `…` ``) are temporarily protected by null-byte Inline-code (`` `…` ``) and math (``$…$``, ``$$…$$``, ``\\(…\\)``,
placeholders and restored after injection. ``\\[…\\]``) spans are temporarily protected by null-byte placeholders and
restored after injection.
""" """
# Step 1: protect inline-code spans from replacement # Step 1: protect inline-code and math spans from replacement
placeholders: dict[str, str] = {} placeholders: dict[str, str] = {}
def _protect(m: re.Match[str]) -> str: def _protect(m: re.Match[str]) -> str:
@@ -401,6 +454,7 @@ def _inject_cross_refs(
return key return key
markdown = _INLINE_CODE_RE.sub(_protect, markdown) markdown = _INLINE_CODE_RE.sub(_protect, markdown)
markdown = _MATH_SPAN_RE.sub(_protect, markdown) # don't rewrite names inside LaTeX (R-9)
# Step 2: inject cross-refs on unprotected text # Step 2: inject cross-refs on unprotected text
for concept in all_concepts: for concept in all_concepts:
@@ -579,14 +633,14 @@ def compile_concept(
claims = _parse_claims(raw_output) claims = _parse_claims(raw_output)
claims = _run_grounding_guard(claims, chunks) claims = _run_grounding_guard(claims, chunks)
_all_concepts = all_concepts or [] # Graceful: formula-embedding hook (F-09) — currently a no-op (audit C-12).
raw_output = _inject_cross_refs(raw_output, _all_concepts, concept.slug)
# Graceful: try to embed formula chunks (F-09) — skip if table missing
_try_embed_formulas(concept, chunks) _try_embed_formulas(concept, chunks)
compiled_at = datetime.now(UTC) compiled_at = datetime.now(UTC)
# Mark ungrounded claims on the raw body FIRST, then inject cross-refs: a
# concept name inside a claim must not stop the ⚠ regex from matching (C-13).
markdown = _render_page_markdown(concept, raw_output, claims, compiled_at) markdown = _render_page_markdown(concept, raw_output, claims, compiled_at)
markdown = _inject_cross_refs(markdown, all_concepts or [], concept.slug)
return ConceptPage( return ConceptPage(
concept=concept, concept=concept,
@@ -598,20 +652,13 @@ def compile_concept(
def _try_embed_formulas(concept: Concept, chunks: list[dict[str, Any]]) -> None: def _try_embed_formulas(concept: Concept, chunks: list[dict[str, Any]]) -> None:
"""Attempt to look up formula chunks for the concept — graceful no-op if F-09 absent.""" """Reserved hook for embedding F-09 formula chunks into a page.
try:
from codex.db import get_conn
with get_conn() as conn: Currently a no-op — formula embedding is not implemented yet. Kept as a stable
# Check if formulas table exists seam for compile_concept's call site. The previous body issued a per-concept
row = conn.execute( DB round-trip (information_schema lookup) that did nothing (audit C-12).
"SELECT 1 FROM information_schema.tables WHERE table_name = 'formulas'" """
).fetchone() return
if row is None:
return # F-09 not present
# (Future: embed relevant raw_latex into the page)
except Exception: # noqa: BLE001
return # DB not reachable or other error — degrade gracefully
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -699,17 +746,23 @@ def compile_all(
# Detect conflicts between chunks from different bibkeys # Detect conflicts between chunks from different bibkeys
report.conflicts.extend(_detect_conflicts(concept.slug, chunks)) report.conflicts.extend(_detect_conflicts(concept.slug, chunks))
# Quarantine check: compute grounding rate # Quarantine check: compute grounding rate.
# A page with NO citations at all (total_claims == 0) carries zero grounding
# evidence — this also covers the LLM-outage case where compile_concept
# returns an empty page — and must NOT be published (audit C-4). Previously
# the `total_claims > 0` guard let such pages fall through to wiki/.
total_claims = len(page.claims) total_claims = len(page.claims)
grounded_claims = sum(1 for c in page.claims if c.grounded) grounded_claims = sum(1 for c in page.claims if c.grounded)
grounding_rate = grounded_claims / total_claims if total_claims > 0 else 0.0 grounding_rate = grounded_claims / total_claims if total_claims > 0 else 0.0
if total_claims > 0 and grounding_rate < settings.wiki_min_grounding_rate: if total_claims == 0 or grounding_rate < settings.wiki_min_grounding_rate:
# Quarantine: write to wiki/draft/ instead of wiki/ # Quarantine and do NOT update the compile-state, so a transient failure
# (e.g. LLM outage) is retried on the next run instead of being frozen as
# "compiled". An empty body is not worth a draft file — just record it.
if page.markdown.strip():
draft_dir = wiki_dir / "draft" draft_dir = wiki_dir / "draft"
draft_dir.mkdir(parents=True, exist_ok=True) draft_dir.mkdir(parents=True, exist_ok=True)
page_path = draft_dir / f"{concept.slug}.md" (draft_dir / f"{concept.slug}.md").write_text(page.markdown, encoding="utf-8")
page_path.write_text(page.markdown, encoding="utf-8")
report.quarantined.append(concept.slug) report.quarantined.append(concept.slug)
else: else:
# Write page to wiki/ # Write page to wiki/

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 (`jetson.local`) is online. Re-open the tunnel with keepalive:
`ssh -o ServerAliveInterval=15 -f -N -L 5433:localhost:5432 alfred@jetson.local`.
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

@@ -0,0 +1,204 @@
# ADR-F15 — Literature Graph / Citation PageRank
**Status:** IMPL — GO after live-corpus spike 2026-06-15. See **Audit Update
(2026-06-15)** at the end: the ID-format mismatch is now resolved in code
(audit C-1, C-7) and the Spike Result numbers below predate that resolution.
**Owners:** F-15 Worker (Sonnet)
**Last updated:** 2026-06-15 (audit addendum)
---
## Context
F-15 adds a citation graph layer on top of the corpus DB. The goals are:
1. **Hub detection** — identify which papers are most central in the citation
network (PageRank) so that `codex search paper --cite-boost` can
up-weight them in dense retrieval.
2. **Bibliographic coupling / co-citation** — surface related papers that
share many references or are frequently cited together.
3. **Dangling-citation discovery** — enumerate works cited by the corpus but
not yet ingested, serving as an acquisition queue.
Primary risk: **degenerate PageRank** on a small, sparse corpus producing
scores so flat that the boost has no practical effect or — worse — distorts
results by promoting low-quality dangling works.
---
## Spike Result (2026-06-15)
Run on the live Jetson corpus: **29 ingested papers, 590 citation edges,
495 total graph nodes, 478 dangling nodes**.
### Hub-Paper Ranking
| Rank | OpenAlex ID | Year | Authors | Title | PR |
|------|-------------|------|---------|-------|----|
| 1 | W1974956622 | 1993 | Pinkall, Polthier | Computing Discrete Minimal Surfaces and Their Conjugates | 0.002282 |
| 2 | W1964119857 | 1962 | Ford, Fulkerson | Flows in Networks | 0.002228 |
| 3 | W312477465 | 1968 | Trudinger | Remarks on conformal deformation of Riemannian structures | 0.002204 |
| 4 | W1595775335 | 1960 | Yamabe | On a deformation of Riemannian structures on compact manifolds | 0.002204 |
| 5 | W2023551584 | 1996 | Cooper, Rivin | Combinatorial scalar curvature and rigidity of ball packings | 0.002204 |
| 6 | W2159531493 | 1984 | Schoen | Conformal deformation of a Riemannian metric to constant scalar curvature | 0.002204 |
| 7 | **W2163787581** | **2007** | **Bobenko, Springborn** | **A Discrete LaplaceBeltrami Operator for Simplicial Surfaces** | **0.002195** |
| 8 | W3099917509 | 2012 | Mercat | Discrete Riemann Surfaces and the Ising Model | 0.002190 |
| 9 | W2021879624 | 2008 | Mullen, Tong, et al. | Spectral Conformal Parameterization | 0.002164 |
| 10 | W1976584051 | 2002 | Desbrun, Meyer, et al. | Intrinsic Parameterizations of Surface Meshes | 0.002159 |
### Validation Against Manual Expectation
| Criterion | Target | Result |
|-----------|--------|--------|
| Bobenko or Springborn in top-10 hubs | ✓ expected hub | **PASS — Bobenko & Springborn 2007 at rank 7** |
| Pinkall & Polthier 1993 prominent | ✓ foundational DGP paper | **PASS — rank 1** |
| Score flatness acceptable | < 2× spread over top-10 | PASS 5.7 % spread (0.002282 0.002159) |
| Graceful small-corpus warning | shown when < 15 in-KB papers | **PASS — shown at 29 in-KB papers** (< `graph_min_corpus_size=15` not triggered; Springborn 2008 is not in-KB so not visible as "ingested hub") |
**GO** PageRank produces a plausible field-specific ranking. The top hits
(Pinkall/Polthier, Yamabe, Schoen, Bobenko/Springborn) are exactly the
foundational works a DGP researcher would expect to see elevated.
---
## Decision
### Graph Construction
`build_citation_graph` builds an in-memory `nx.DiGraph` from the `citations`
table: `citing_id → cited_id`. No schema migration needed.
- `citing_id` stores DOIs (matches `papers.id`).
- `cited_id` stores OpenAlex IDs (external references).
**Consequence:** in the current corpus, ingested papers do not appear as
in-degree targets of each other inter-KB citations are structurally
invisible because the `cited_id` column uses OpenAlex IDs while `papers.id`
uses DOIs. The graph is therefore bipartite-like (29 DOI-nodes
478 OpenAlex-nodes), and all PageRank mass concentrates on the dangling
OpenAlex-nodes cited by many of our 29 papers.
This is acceptable for the `--cite-boost` use-case: dangling hub papers are
*foundational works* our corpus cites repeatedly, so boosting search results
that match them is desirable.
### PageRank Graceful Degradation
`citation_pagerank` returns a **uniform distribution** when the graph has
fewer than 5 nodes (`_MIN_PAGERANK_NODES`) and logs a warning.
The CLI `graph report` emits a separate user-visible warning when the count
of in-KB papers is below `graph_min_corpus_size` (default 15) because at
that point the *hub ordering* (which uses PageRank over all 495 nodes) is
still valid, but its *discriminative power* is limited by the few citing
sources.
At 29 ingested papers, `graph_min_corpus_size=15` is satisfied; the score
spread is narrow (5.7 %) but the ranking order is meaningful.
### cite-boost Formula
```
boosted_distance = distance / (1.0 + alpha * pr_score)
```
`alpha = 0.3` (config: `graph_cite_boost_alpha`). Dividing reduces cosine
distance for high-PR papers (lower = closer in pgvector). Multiplying
would invert the boost this was the critical bug corrected during the
F-15 review gate.
At `alpha=0.3` and the observed PR scores (~0.002), the maximum boost is
approximately 0.06 % per unit score effectively a tie-breaker on equally
relevant papers rather than a hard re-ranking. This is intentional: we do
not want citation authority to override semantic relevance for queries that
genuinely target niche papers.
### Dangling Citations as Acquisition Queue
`dangling_citations(graph, known_ids)` returns the 478 OpenAlex IDs that
are cited but not ingested. `codex graph report` surfaces these so the
researcher can prioritize which papers to add to the corpus next.
The top hubs (rank 110) are the highest-value acquisition targets: adding
Pinkall & Polthier 1993, Yamabe 1960, and Schoen 1984 would improve both
retrieval quality and PageRank discrimination.
---
## Fallback
If the corpus has < `_MIN_PAGERANK_NODES` (5) nodes: `citation_pagerank`
returns uniform scores; `--cite-boost` has no effect (each paper is boosted
equally); no crash.
If `--cite-boost` is omitted: search works as F-03/F-04 baseline.
If the `citations` table is empty: graph has 0 nodes; `graph report` prints
"Graph is empty" and exits 0; all other commands degrade gracefully.
---
## Known Limitation: ID-Format Mismatch
> **RESOLVED (audit 2026-06-15) — see Audit Update below.** This no longer holds:
> `build_citation_graph` resolves OpenAlex `cited_id`s to the canonical
> `papers.id` via the shared `RESOLVED_CITATIONS_SQL` (C-1) and ingest stores
> canonical ids (C-7), so inter-KB citations *are* now edges. Original text kept
> for the record.
`cited_id` uses OpenAlex IDs; `papers.id` uses DOIs. Cross-citations
between ingested papers are therefore not represented as graph edges.
This is a D-05-class issue (same root cause as the `citing_id`/`openalex_id`
FK mismatch fixed in PR #9). A future migration could add an `openalex_id`
column to `papers` and populate `cited_id` cross-links; but the benefit is
small while the corpus is < 100 papers.
---
## Consequences
- R-41: `build_citation_graph` in-memory DiGraph, no schema change
- R-42: `citation_pagerank` + `codex search paper --cite-boost`
- R-43: `find_related` (bibliographic coupling) + `find_co_cited`
- R-44: `codex graph report [--top-n N] [--json]`
- R-45: graceful degradation < 5 nodes: uniform scores, warning
- R-46: **this document** GO; Bobenko+Springborn 2007 at rank 7 validates hub detection; score flatness expected at corpus size 29; increase to > 100 papers for stronger PageRank differentiation
---
## Audit Update (2026-06-15)
A post-hoc audit of the shipped F-15 code found that the live-corpus spike above
was measured on a graph the current code no longer produces:
- **ID-format mismatch resolved.** `build_citation_graph` resolves OpenAlex
`cited_id`s to the canonical `papers.id` through the shared
`RESOLVED_CITATIONS_SQL`, now also used by `codex.discover` so the two cannot
diverge (audit C-1). Ingest stores the caller's canonical id as `papers.id`
instead of OpenAlex's URL-form doi (audit C-7). Inter-KB citations are
therefore represented as edges — the "Known Limitation" above is obsolete.
- **Spike re-measured after migration (2026-06-15).** The corpus was re-ingested
onto canonical bare ids (29 papers, all bare — verified `url_form_ids = 0`), and
the graph re-run on the post-migration corpus:
| Metric | Original spike (pre-fix) | Post-migration (re-measured) |
|--------|--------------------------|------------------------------|
| Ingested papers | 29 | 29 |
| Graph nodes | 495 | 489 |
| Graph edges | 590 | 590 |
| Dangling nodes | 478 | 472 |
| Top-10 PR spread | 5.7 % | 5.4 % |
| Rank-1 hub | W1974956622 (dangling) | **Pinkall/Polthier 1993 — in-KB (resolved)** |
| Bobenko/Springborn 2007 | rank 7 | rank 7 |
The resolution fix's signature is visible: Pinkall/Polthier 1993 now resolves to
its canonical DOI and rises to **rank 1 as an in-KB hub** (previously a dangling
OpenAlex node). Bobenko/Springborn 2007 holds rank 7, and the foundational works
(Pinkall/Polthier, Yamabe, Schoen, Bobenko/Springborn) still surface. **GO
re-affirmed** on the shipping topology.
Migration footnotes: two papers initially survived in URL form because the
id-keyed upsert did not catch a `papers.openalex_id` collision, and `2305.10988`
failed on a `papers.bibkey` collision with `2310.17529` (both →
`BobenkoLutz2023`). Same upsert gap — resolved by the bibkey-retry (audit C-15)
plus a delete + re-ingest of the two stragglers; corpus is now 29/29 canonical.

View File

@@ -0,0 +1,616 @@
# AUDIT — Full Repository (iterative)
**Auditor:** Audit-Loop (Opus)
**Started:** 2026-06-15
**Scope:** entire `main` tree, audited iteratively module-by-module.
**Companion doc:** [`AUDIT-2026-06-15-loop-1.md`](AUDIT-2026-06-15-loop-1.md) —
the F-15 / dev-loop-1 findings (S-1…U-4). This document continues the **same
finding-ID register** (so `C-4` here follows `C-3` there) and covers the rest of
the repo.
> Severity: **HIGH** fix before relying · **MED** fix soon · **LOW** polish ·
> **INFO** accepted/no-action. Findings only — remediation planned separately.
## Coverage tracker
| Part | Area | Status |
|------|------|--------|
| (loop-1) | F-15 graph, db/schema, ingest, discover, mcp, provenance-scan, cli(search+graph), config(graph) | ✅ documented |
| **A** | Grounding guard (`wiki.py` `_parse_claims`/`_run_grounding_guard`, quarantine) — shared by `synthesis.py` | ✅ documented |
| **B** | `sources/` (arxiv, openalex, semanticscholar) | ✅ documented |
| **C** | `parsing/` (grobid, nougat, mathpix, figures, tex) | ✅ documented |
| **D** | `synthesis.py` orchestration (find_connections/gaps/improvements, conjectures, write_leads) | ✅ documented |
| **E** | `wiki.py` rest (retrieve, compile_concept, cross-refs, index/log, embed) | ✅ documented |
| **F** | `embed.py`, `quality.py`, `models.py` | ✅ documented |
| **G** | `cli.py` rest (ingest/wiki/synthesis/provenance commands), `config.py` rest | ✅ documented |
---
## Part A — Grounding guard (the "zero-fact-leak" core)
The guard lives in `wiki.py` (`_parse_claims:216`, `_run_grounding_guard:299`)
and is imported by `synthesis.py:57-59`, so **one implementation gates both**
the wiki pages and the synthesis leads. A claim is "grounded" iff the **last
sentence** before its `[BibKey #loc]` citation contains a run of 5 consecutive
non-stopword tokens that appears as a substring of any chunk of the same bibkey.
### C-4 — Zero-claim pages bypass quarantine and publish as "compiled" · **HIGH**
- **Evidence:** `wiki.py:730``if total_claims > 0 and grounding_rate < threshold:`
quarantines; the `else` (`:737-742`) **publishes to `wiki/` and records the
hash** as successfully compiled.
- **Bug:** when the LLM output has **no parseable citations** (`total_claims == 0`,
so `grounding_rate == 0.0`), the `total_claims > 0` guard is false → it takes
the *publish* branch. The quarantine gate is skipped exactly when grounding
evidence is **weakest** (zero citations).
- **Trigger paths:** (a) the LLM emits uncited prose; (b) **LLM outage** — the
module header guarantees "LLM unavailable → empty ConceptPage, no crash"
(`wiki.py:13-14`), so a transient Ollama outage writes near-empty stub pages to
`wiki/`, marks them compiled, and stores their hash. On the next
`changed_only` run the hash matches → the stub is **skipped forever** until a
forced recompile. A research wiki silently fills with empty/uncited pages.
### C-5 — "Every claim is grounded" holds only for the last cited sentence · **MED**
- **Evidence:** `_CLAIM_RE` (`wiki.py:211-213`) captures `text = [^\[]+?` (the
span back to the previous `]`/start); `_last_sentence` (`:279-291`) then keeps
only the final sentence; `_run_grounding_guard` checks that sentence only.
- **Two holes in the guarantee:**
1. **Paragraph passengers** — any sentence that is *not* the last one before a
citation is never grounding-checked and never `⚠`-marked. "Hallucination one.
Hallucination two. Grounded tail [Bib]." passes with only the tail verified.
2. **Uncited text is invisible** — a sentence with no `[BibKey]` is not a Claim
at all (`_parse_claims` only yields cited spans), so it is never grounded
nor marked. The LLM can emit fully uncited assertions into a published page.
- **Impact:** the effective invariant is "*the last sentence immediately before
each citation has lexical overlap with the cited paper*" — materially weaker
than the N-02 "zero-fact-leak enforcement" framing. The module **header is
honest** ("Substring-Match MVP", `wiki.py:4,13`); the commit/ADR language is
not. Recommend aligning the claim or strengthening the guard (check every
sentence; flag uncited sentences).
### C-6 — Substring match does not enforce the documented "5 consecutive tokens" · **LOW**
- **Evidence:** `wiki.py:347-349``gram = " ".join(content[i:i+5])`;
`if any(gram in src for src in sources)` where `src` is the space-joined
content-word string of the chunk.
- **Issue:** a *substring* test on the joined string lets the first/last gram
token match as a **prefix/suffix of a longer chunk word** (e.g. gram
`"cat dog …"` substring-matches inside `"scat dogma …"`). The docstring claims
matching "as 5 consecutive non-stopword **tokens**" — true sequence matching
would compare token lists with a sliding window (or add boundary sentinels).
Low real-world impact at 5-gram length, but the claimed invariant is not what
the code enforces.
### R-1 — Tokenizer brittleness causes false-negative over-quarantine · **MED**
- **Evidence:** `_content_words` (`wiki.py:294-296`) lowercases and `.split()`s on
whitespace only — **no punctuation stripping**. `_last_sentence` splits on
`.!?` (`:276`).
- **Impact:** punctuation stays glued to tokens, so a faithful paraphrase grounds
only on *exact* surface form — `"volume,"``"volume"`, `"L(gamma1)"` must
match character-for-character, and a trailing decimal like `"V = 1.5"` makes
`_last_sentence` return the fragment `"5"` (split at the `.`), which then has
`< 5` content words → **forced ungrounded** (`:339-344`). Math-heavy and terse
claims are systematically marked ungrounded even when verbatim, inflating the
ungrounded ratio and pushing real pages into quarantine (and into C-4's
blind spot when it drives `total_claims` interactions). The passing test
`test_grounding_guard_…` only succeeds because its fixture reproduces the chunk
text *exactly*.
### R-2 — Reference-list filter can drop theorem-dense chunks · **LOW**
- **Evidence:** `_retrieve_chunks` (`wiki.py:188-199`) skips a chunk when
`>60 %` of its lines match `^\s*(\[\d+\]|[A-Z][a-z]+,?\s+[A-Z]\.)`.
- **Issue:** the second alternative (`[A-Z][a-z]+,?\s+[A-Z]\.`) also matches prose
like `"Theorem A."`, `"Lemma B."`, `"Section C."`. A chunk formatted as a list
of theorems/lemmas can exceed 60 % and be discarded as a "reference list" —
silently dropping exactly the mathematical content the wiki wants.
### R-3 — Conflict detection has near-zero precision · **LOW**
- **Evidence:** `_detect_conflicts` (`wiki.py:367-…`) flags a pair `(bk1, bk2)` if
**each** has *any* chunk containing a hedging keyword
(`but|however|in contrast|…`, `_CONFLICT_KEYWORDS:361-364`).
- **Issue:** it never checks the keywords relate to a shared topic or to each
other. In a corpus where most papers contain "however"/"but" somewhere, this
flags ~O(n²) pairs as "conflicting" — noise surfaced in `CompileReport`.
Labeled "heuristic signal only", but as-is it is not actionable.
**Part A net:** 1 HIGH (C-4), 2 MED (C-5, R-1), 3 LOW (C-6, R-2, R-3). The guard
is a reasonable lexical MVP, but (a) it has a concrete publish-bypass bug for
zero-citation output, and (b) the "zero-fact-leak" framing overstates what a
last-sentence substring check can guarantee.
---
## Part B — `sources/` (external API clients)
Solid foundations: all three clients set httpx timeouts; OpenAlex and S2 retry
429/5xx with exponential back-off (tenacity); S2 enforces a ≥1 req/s floor with a
monotonic clock under a lock. The findings are about **ID format consistency**
and a couple of dead/inert paths.
### C-7 — `papers.id` is stored in OpenAlex *URL* form, breaking the bare-ID contract · **HIGH**
- **Evidence:** `openalex.py:94``_map_paper` sets
`id = data.get("doi") or data.get("id") or ""`. Real OpenAlex returns `doi` as
a **full URL** (`https://doi.org/10.x`) and `id` as `https://openalex.org/W…`
(the repo's own test confirms the URL form for `openalex_id`,
`test_openalex.py:92`). `ingest.py:82` takes `paper.id` from this unchanged and
upserts it as `papers.id` (`ingest.py:138`).
- **Why it's currently masked:** the test fixture `_SAMPLE_WORK["doi"]` is a
**bare** DOI `"10.1145/3592430"` (`test_openalex.py:18`) — not the URL form real
OpenAlex emits — and **no test asserts `paper.id`** at all. So the suite is
green while production stores `papers.id = "https://doi.org/10.1145/3592430"`.
- **Impact:**
1. **Bare-ID lookups fail.** `ingest_all.sh` ingests `"10.1145/1964921.1964997"`,
but `papers.id` becomes the URL; `codex graph related 10.1145/…`,
`discover citing 10.1145/…`, search-by-id, etc. find nothing.
2. **Second ID-mismatch axis (compounds C-1).** `cited_id` from the S2/GROBID
paths is a **bare** DOI/arXiv (`semanticscholar.py:98-99`,
`ingest.py:187`), so a bare-DOI reference can never equal a URL-form
`papers.id` — bare-DOI cross-citations stay "dangling" regardless of the
F-15 W-ID resolution (which only matches `papers.openalex_id`, the URL form).
- **Action:** confirm with `SELECT id FROM papers LIMIT 5` on the Jetson DB; if
URL-form, normalize `papers.id` to a canonical bare form at ingest (and the
`cited_id` writers to match), then add a representative fixture + `paper.id`
assertion (see T-3).
### T-3 — OpenAlex test fixture is unrepresentative and skips `paper.id` · **MED**
- **Evidence:** `_SAMPLE_WORK["doi"] = "10.1145/3592430"` (bare) vs the real
`https://doi.org/…` URL form; `test_fetch_paper_*` assert title/year/authors/
abstract/`openalex_id` but **never** `paper.id`.
- **Impact:** the id-mapping — the exact surface of C-7 — is untested, and the
fixture actively hides the URL-form behaviour. Any normalization fix needs this
closed first or it will regress invisibly.
### R-5 — S2 `fetch_references` breaks on legacy arXiv IDs containing `/` · **LOW**
- **Evidence:** `semanticscholar.py:80` interpolates `paper_id` into the path
`…/paper/{paper_id}/references`. The ingest fallback passes `arXiv:math/0603097`
(`ingest.py:91`) — the embedded `/` yields a malformed path → 404 →
`fetch_references` silently returns `[]`. Legacy-arXiv papers that miss the
OpenAlex path get **zero references** with no error.
### R-4 — `arxiv.fetch_source` no-op try/except + no retry · **LOW**
- **Evidence:** `arxiv.py:41-44``try: httpx.get(...) except httpx.RequestError: raise`
catches and immediately re-raises, doing nothing. Unlike OpenAlex/S2, the arXiv
client has **no** retry/back-off, so a transient network blip aborts ingest.
Minor resilience asymmetry.
### C-9 — `semanticscholar.fetch_recommendations` is unreachable app code · **LOW**
- **Evidence:** referenced only by `tests/sources/test_semanticscholar.py`; no
`codex/` module or CLI calls it (`grep fetch_recommendations`). Tested but
never wired — dead feature surface (decide: wire a `discover recommend`
command, or drop).
**Part B net:** 1 HIGH (C-7, ID-form contract — the big one; confirm on live DB),
1 MED (T-3, fixture masks it), 3 LOW (R-4, R-5, C-9). The retry/timeout/rate-limit
hygiene is genuinely good; the weakness is the **un-normalized, heterogeneous ID
space** (URL vs bare, W-ID vs DOI vs arXiv vs S2-hash) threaded through `papers.id`
and `citations.cited_id` — the same root cause behind C-1 and the F-15 ID dance.
---
## Part C — `parsing/` (tex, grobid, nougat, mathpix, figures)
> **Scope reality:** the live corpus is ingested entirely as **`.txt`**
> (`ingest_all.sh` maps every paper to a `*.txt` file), so the `.tex`-clean,
> GROBID, Nougat, Mathpix, and Figures paths are **latent** — only
> `tex.chunk_text` + `quality.*` run on the live data. The findings below are
> real but mostly fire only once `.tex`/`.pdf --rich` ingest is used.
>
> **Not a finding:** the broad `except Exception` in `mathpix.py:109,151` and
> `figures.py:125,152` are *justified* per-item graceful degradation
> (`fitz.open` fails → return empty; pix2tex/image fails → skip that item), not
> swallowed bugs.
### C-10 — Formulas/figures key on the PDF *filename*, not `paper.id` · **MED** (latent, `.pdf --rich`)
- **Evidence:** `figures.py:119` and the mathpix extractor set
`paper_id = Path(pdf_path).stem`. `ingest.py:289,303` insert
`f.paper_id` / `fig.paper_id` **unchanged** into `formulas`/`figures`, whose
schema declares `paper_id TEXT REFERENCES papers(id)` (`schema.sql:97,115`).
- **Impact:** the filename stem (e.g. `springborn-2008-weighted-delaunay…`) will
not equal `papers.id` (an OpenAlex URL/bare id — see C-7), so the FK insert
**fails and aborts rich ingest**, or (without FK enforcement) writes orphaned
rows. Identity is derived from the *file*, not the *paper*. Fix: pass `paper.id`
into the extractors, or have ingest overwrite `f.paper_id = paper.id` before insert.
### R-7 — `_clean_latex` misses `\(…\)` / `\[…\]` math + strips all math from chunks · **LOW** (`.tex` path)
- **Evidence:** `tex.py:21-56` strips `%comment`, `$…$`, `$$…$$`,
`equation`/`align` envs, `\cite/\label/\ref` — but **not** the `\(…\)` / `\[…\]`
math delimiters. Papers using those leave raw `\( … \)` markup in the chunk text.
- **Impact:** (a) leftover math markup pollutes embeddings; (b) *all* math is
removed from chunk text, so chunk-level semantic search can never match formula
content — it relies entirely on the separate formula FTS (F-09), which is itself
latent here. Acceptable as a separation-of-concerns choice, but worth stating.
### R-6 — PDF-path external calls are unwrapped / un-retried · **LOW** (`.pdf` path)
- **Evidence:** `ingest.py:185` calls `extract_references()` outside any
`try`; GROBID (`grobid.py:54,133`) and the mathpix HTTP calls `raise_for_status`
with **no** retry/back-off (unlike OpenAlex/S2). A transient GROBID/Mathpix
5xx aborts the whole PDF ingest.
- **INFO:** `grobid.py:56` uses stdlib `ET.fromstring` on the server's XML.
Fine for a trusted self-hosted GROBID; switch to `defusedxml` if it is ever
pointed at an untrusted endpoint.
**Part C net:** 1 MED (C-10), 2 LOW (R-6, R-7), all **latent** for the current
`.txt` corpus. `nougat.py` was skimmed only (54 lines, `.pdf`-only wrapper) — no
deep review yet; flagged in the ledger. Parsing hygiene is otherwise good.
> **Request (a) — second audit pass — is now complete:** §8 of the loop-1 doc
> (grounding guard ✓ Part A, `sources/` ✓ Part B, `parsing/` ✓ Part C) is closed.
> Remaining whole-repo parts DG are the broader sweep.
---
## Part D — `synthesis.py` orchestration
The lead pipeline (connections → gaps → improvements → conjectures) reuses the
Part-A grounding guard for the three grounded kinds and degrades gracefully on
LLM/DB failure (`_safe_llm_generate`/`_list_paper_titles` swallow + log). The
conjecture invariants are genuinely solid (status hard-set `"unverified"`,
mandatory falsification path or the lead is dropped, written to `conjectures/`
only). Two real defects and a doc contradiction:
### C-11 — Lead-ID collision silently overwrites synthesis output · **HIGH**
- **Evidence:** every stage restarts its counter at `seq = 1`
(`synthesis.py:341` connections, `:415` gaps, `:530` improvements,
`:634` conjectures) and ids are `L-0001, L-0002, …` (`_make_lead_id`). The CLI
concatenates the grounded kinds — `all_leads = connections + gaps + improvements`
(`cli.py:421`) — and `write_leads` routes **all** grounded kinds to the *same*
`grounded/<id>.md` (`synthesis.py:783-796`), writing in list order.
- **Bug:** connection `L-0001`, gap `L-0001`, improvement `L-0001` all target
`grounded/L-0001.md`; later writes overwrite earlier → **improvements clobber
gaps clobber connections** at each number. Surviving files =
`max(#connections, #gaps, #improvements)`, **not** the sum. With 5/3/4 leads you
get 5 files, not 12; connections are lost first. Silent data loss of the
synthesis engine's core output, masked because "files do get written".
- **Fix direction:** namespace ids by kind (`L-C-0001` / `L-G-0001` / `L-I-0001`)
or use a single shared counter across stages.
### R-8 — Default-topic gap detection is a false-positive generator · **MED**
- **Evidence:** `find_gaps` defaults `probe_topics` to paper **titles** when
`topics is None` (`synthesis.py:418-420`); a title query retrieves mostly its
*own* paper's chunks → `bibkeys_for_topic` ≈ 1, which is `< synthesis_gap_min_coverage`
(`:450-453`) → the topic is flagged as a gap and the LLM is asked to articulate
one.
- **Impact:** nearly every ingested paper becomes a spurious "Gap: '<title>'
covered by only 1 bibkey(s)" candidate — the LLM invents a gap for material that
is actually well covered. The grounding guard catches some, but the default
signal is mostly noise. (User-supplied `--topics` avoid this.)
### D-4 — `write_leads` vs `_update_index` docstrings contradict each other · **LOW**
- **Evidence:** `write_leads` (`synthesis.py:762-765`) claims it "maintains an
**append-only** `INDEX.md`: a previously-recorded id is kept on subsequent runs",
but `_update_index` (`:802-803`) states — and implements — a "**full rebuild**,
not append-only" by globbing the on-disk dirs.
- **Impact:** there is no append-only / id-retention logic; the index purely
reflects current disk state. The guarantee in the `write_leads` docstring is
false (and interacts with C-11: overwritten ids simply vanish from the index).
**Part D net:** 1 HIGH (C-11, silent lead overwrite), 1 MED (R-8, gap noise),
1 LOW (D-4, doc contradiction). The conjecture path is exemplary; the grounded
path's persistence layer is where it breaks.
---
## Part E — `wiki.py` rest (compile, cross-refs, state, index/log)
Good: `yaml.safe_load`, deterministic `_chunk_hash` (sorted by chunk id) driving a
sound incremental-skip, append-only `log.md`, inline-code protection during
cross-ref injection. The issues are math-corruption, a dead step, and a marking
order bug.
### R-9 — Cross-ref injection corrupts LaTeX math · **MED**
- **Evidence:** `_inject_cross_refs` (`wiki.py:426`) protects only inline-code
spans (`_INLINE_CODE_RE`); it then runs whole-word, case-insensitive title/alias
`[[slug]]` replacement over everything else (`:429-438`). **Math spans
(`$…$`, `$$…$$`, `\(…\)`) are not protected.**
- **Impact:** the synthesis prompt explicitly asks for LaTeX `$ … $`
(`wiki.py:495`), so bodies contain math. A concept title/alias that appears
inside a formula (e.g. a concept named "Energy" inside `$E_{\text{Energy}}$`,
or a single-word alias) is rewritten to `[[slug]]`, corrupting the LaTeX. The
risk scales with how common the concept titles/aliases are as math tokens.
### C-12 — `_try_embed_formulas` is a no-op that still costs a DB round-trip per concept · **LOW**
- **Evidence:** `wiki.py:623-637` opens a connection, queries
`information_schema.tables` for `formulas`, and returns — the actual embed is
`"(Future: …)"`. Called unconditionally per concept (`compile_concept:609`).
- **Impact:** step 4 of `compile_concept`'s docstring ("Embed formula chunks")
does nothing, and each concept compile pays an extra DB connection + query for
no effect. Placeholder masquerading as a feature.
### C-13 — Ungrounded-claim marking can miss claims mutated by cross-ref injection · **LOW**
- **Evidence:** order in `compile_concept` — claims parsed from `raw_output`
(`:602-603`), then `raw_output` is rewritten by `_inject_cross_refs` (`:606`),
then `_render_page_markdown` (`:612`) re-finds the *original* `claim.text` by
regex in the **injected** body (`:544-552`).
- **Impact:** if an ungrounded claim's text contained a term that got replaced
with `[[slug]]`, the original text no longer matches → the `⚠` mark is silently
not applied. (Also note: wiki's marking lacks the `(?<!⚠ )` guard that
synthesis's `_mark_ungrounded` has — two divergent mark implementations.)
### R-10 — Non-atomic compile-state write · **LOW**
- **Evidence:** `_save_compile_state` (`wiki.py:475-477`) writes
`.compile-state.json` in place. A crash mid-write corrupts it; `_load`
then catches `JSONDecodeError``{}` → every concept recompiles. Graceful but
loses incrementality. Write to a temp file + `os.replace` for atomicity.
### R-11 — `load_concepts` raises `KeyError` on malformed YAML · **LOW**
- **Evidence:** `entry["slug"]` / `entry["title"]` (`wiki.py:135-136`) are
unguarded; a concepts.yaml entry missing a key crashes the whole compile with a
bare `KeyError` rather than a actionable message.
> **Minor:** `compile_all` builds the index from `state.keys()` (`:745`), which
> accumulates slugs across runs — a concept removed from `concepts.yaml` lingers
> in `index.md`/state until manually pruned. Low.
**Part E net:** 1 MED (R-9, math corruption), 4 LOW (C-12, C-13, R-10, R-11). The
incremental-compile machinery is sound; the rendering/cross-ref layer has the rough
edges. `OllamaClient.generate` (no retry, 120 s timeout, `raise` on error) feeds
directly into **C-4** (its exception → empty page → published), so C-4's blast
radius includes every transient Ollama hiccup.
---
## Part F — `embed.py`, `quality.py`, `models.py`
The best-built corner of the repo. `embed.py` L2-normalises correctly (cosine ⇒
dot product), guards empty input and zero-norm rows, and caches a process
singleton. `quality.py` thresholds are all config-driven, `run_quality_pass` is
CLI-wired (`cli.py:513-517`) and atomic (single commit). `models.py` dataclasses
mirror the schema cleanly. Two dead/low-signal items and one corroboration:
### C-14 — The sparse-embedding ("hybrid") path is dead code · **LOW**
- **Evidence:** `Embedder.encode_sparse` / `encode` / `_coerce_sparse` are called
nowhere in `codex/` (grep for `encode_sparse` / `.encode(` finds only
`str.encode` in `wiki.py:455`). Only `encode_dense` is wired (ingest, search,
wiki). There is no sparse column in the schema.
- **Impact:** the `embed.py` header and ADR-0002 advertise "**Hybrid dense +
sparse** embeddings via BGE-M3" as a headline, but the sparse half is
unreachable — the actual "hybrid" elsewhere is dense + Postgres FTS. Either wire
sparse retrieval or drop the capability + correct the docs.
### R-12 — `classify_section` is low-signal as fed · **LOW**
- **Evidence:** `classify_section` matches section headers in the **first 200
chars** (`quality.py:103-106`), but `tex.chunk_text` produces overlapping
*word-window* chunks that almost never begin at a header. So most chunks fall
through to `"body"` (or the DOI-density `"bibliography"` fallback).
- **Impact:** the `chunks.section` column is mostly `"body"` — limited value for
any section-aware retrieval that depends on it. Not wrong, just weak signal
given the chunker.
### Corroboration of C-7 (not a new finding)
- `models.Paper.id` docstring (`models.py:20-21`) states the canonical id is "an
arXiv ID … **or a DOI (e.g. `10.1145/3592430`)**" — the **bare** form. Production
fills it from OpenAlex in **URL** form (C-7), so the code violates its own model
contract. This makes C-7 a documented-contract breach, not a judgement call.
**Part F net:** 2 LOW (C-14, R-12) + a C-7 corroboration. These three modules are
clean; no correctness defects.
---
## Part G — `cli.py` rest + `config.py`
Most CLI commands are thin Typer wrappers delegating to already-audited modules
(`ingest`, `search`, `discover`, `wiki`, `synthesis`, `provenance`, `quality`,
`graph`). Settings are pydantic-validated with sensible bounds (`gt=0`, `0..1`).
Findings are config-coupling and output hygiene.
### C-15 — `embedding_dim` is a footgun decoupled from the hardcoded schema · **MED**
- **Evidence:** `embedding_dim` is configurable (`config.py:67-74`, default 1024)
and `.env.example`/`schema.sql:7` invite other models (Qwen3 1024, **Jina v4
2048**). But `schema.sql` hardcodes `vector(1024)` on every embedding column
(`:25,38,102,119`), and `Embedder` derives the real output dim from the *model*,
using the `dim` setting only for empty-input zero-vectors (`embed.py:50,89`).
- **Impact:** set `EMBEDDING_DIM=2048` (or pick a non-1024 model) and inserts
fail on a pgvector dimension mismatch — silently for empty abstracts
(`(1,2048)` zero-vector into `vector(1024)`), structurally for real vectors.
Nothing couples the knob to the DDL; the setting reads as supported but is not.
### R-13 — `export_bib` does no BibTeX escaping and forces `@article` · **LOW**
- **Evidence:** `provenance.py:174-181` interpolates `title`/`author`/`year` raw
into `@article{…}`; no escaping/brace-balancing of BibTeX specials
(`{ } \ % # $ _ &`).
- **Impact:** a title with an unbalanced brace or a stray special char produces a
malformed `.bib` entry that breaks Doxygen/BibTeX. Also every paper is emitted
as `@article` although the corpus includes theses/books/chapters
(`ingest_all.sh`) — bibliographically wrong (functional for `@cite` keys though).
### S-4 — Secrets stored as plain `str`, not `SecretStr` · **LOW**
- **Evidence:** `database_url`, `mathpix_app_key`, `mcp_auth_token`
(`config.py:29,148,249`) are plain `str`. Any `repr(Settings())` or debug log of
the settings object prints the DB password / API key / bearer token in clear.
- **Impact:** log/exception-dump leakage surface (complements S-1). Use
`pydantic.SecretStr` for credential fields.
### R-14 — `mcp_transport` enum not enforced · **LOW**
- **Evidence:** `config.py:229-236` documents "stdio or http" but no validator;
`main()` (`mcp_server.py:323`) treats anything non-`http` as stdio. A typo
(`htttp`) silently starts stdio instead of failing loudly.
### D-5 — Inconsistent `validation_alias` on `nougat_url` only · **LOW**
- **Evidence:** `nougat_url` adds `AliasChoices("NOUGAT_URL", "nougat_url")`
(`config.py:47`) that no other field has — redundant under
`case_sensitive=False`, and the inconsistency invites confusion.
> **Corroboration of C-7:** `cli.py:45` ingest does **no** id normalization and
> echoes `result.paper_id` (the URL-form id) straight back — no normalization
> layer exists at any tier (source → ingest → CLI).
**Part G net:** 1 MED (C-15, dim/schema footgun), 4 LOW (R-13, S-4, R-14, D-5).
CLI/config are otherwise solid and well-documented.
---
# Executive Summary — whole-repo audit complete (loop-1 + Parts AG)
**Tally: 45 findings — 8 HIGH, 11 MED, 26 LOW/INFO** (after live-DB verification:
**7 HIGH, 12 MED** — M-1 reclassified, see the verification section at the bottom).
Test suite 330 green, `ruff`/`mypy` clean. The codebase is well-structured and idiomatic; the defects
cluster into five root themes, not random scatter.
### The 8 HIGH findings
| ID | Theme | One-liner |
|----|-------|-----------|
| S-1 | secrets | live DB password in un-ignored `.env.jetson-ingest` (mitigated this session) |
| M-1 | migration | `paper_identifiers` has no migration path; `schema.sql` not re-applyable, `apply_schema` never called |
| C-1 | identity | `discover.py` keeps the ID-mismatch bug F-15 fixed in `graph.py` (over-reports ingested papers as leads) |
| C-7 | identity | `papers.id` stored in OpenAlex **URL** form, violating the bare-ID contract (breaks lookups + cross-citation match) |
| C-4 | silent failure | zero-citation/LLM-outage wiki pages **bypass quarantine** and publish as "compiled" |
| C-11 | silent data loss | synthesis leads collide on `L-000N` across stages → improvements overwrite gaps overwrite connections |
| D-1 | doc integrity | ADR-F15 GO rests on spike numbers measured **before** the shipped resolution fix |
| T-1 | verification | the F-15 resolution SQL (the substance of the last 2 commits) has **zero** real-DB coverage |
### Five root-cause themes
1. **Un-normalized identity (the dominant theme).** C-1, C-7, C-10, M-1, T-3,
and most of the F-15 ID dance are one problem: a paper's identity exists in
many un-reconciled forms (bare vs URL · DOI vs arXiv vs OpenAlex-W vs S2-hash
vs PDF-filename) with **no canonical normalization layer**. One normalization
boundary at ingest would retire ~5 HIGH/MED findings at once.
2. **"Green but unverified."** T-1/T-2/T-3 — the most error-prone code (resolution
SQL, migrations, id-mapping) is mocked or tested with unrepresentative fixtures,
so real bugs (C-4, C-7, C-11) sit behind 330 passing tests.
3. **Silent failure / data loss under degradation.** C-4 (publish empty), C-11
(overwrite leads), R-1 (over-quarantine) corrupt *outputs* instead of erroring
— the worst failure mode for a research tool whose value is trust.
4. **Overstated guarantees.** D-1 (stale ADR), D-2/D-4 (doc↔code drift), C-5
("zero-fact-leak" ≫ a last-sentence substring check), C-14 ("hybrid" sparse is
dead) — the docs/commit-language claim more than the code delivers.
5. **Latent rich-path debt.** C-10, R-6, R-7 are real but dormant because the live
corpus is `.txt`; they detonate on the first `.pdf --rich` ingest.
### Suggested remediation sequencing (themes, not a plan — planning deferred)
- **First:** S-1 (rotate credential — secret already protected), M-1 (migration
path), C-7+C-1 (one identity-normalization layer), T-1 (real-DB test) — these
unblock trust in the live corpus.
- **Then:** C-4 + C-11 (stop silent output corruption), D-1 (re-validate ADR-F15).
- **Then:** MED batch (R-1, R-8, R-9, C-15, C-10) and the LOW polish.
### Coverage honesty
Every `codex/` module was read. After the follow-up pass **all are deep-reviewed**
(`nougat.py` included — see Runtime Validation). Runtime state that was initially
*inferred from code* (C-7 id-form, M-1 table existence, and the C-4/C-11
behaviours) has since been **empirically confirmed** — see the *Live DB
Verification* and *Runtime Validation* sections below.
---
# Live DB Verification — 2026-06-15 (read-only, via existing SSH tunnel)
Ran read-only queries against the live Jetson corpus (`papers=36`,
`citations=811`). This **confirms the two identity HIGHs with hard numbers** and
**corrects an over-statement in M-1**.
### C-7 — CONFIRMED ✅ (severity stands: HIGH)
`papers.id` form distribution: **`doi_url=35`, `bare_arxiv=1`, everything else 0**
(of 36). Samples: `https://doi.org/10.48550/arxiv.math/0603097`,
`https://doi.org/10.48550/arxiv.2310.17529`. Even arXiv papers are stored as their
arXiv-DOI **URL**, never the bare `math/0603097` / `2301.x` the schema, `models.Paper`,
and `ingest_all.sh` assume. The single bare id is the one paper OpenAlex 404'd
(fell to `ingest.py:94`). → bare-ID lookups fail for 35/36 papers. Confirmed.
### C-1 — CONFIRMED ✅ with impact (severity stands: HIGH)
- `citations.cited_id` is uniformly **`oa_url=811`** (all OpenAlex URLs) — the live
corpus was ingested via the OpenAlex path, so the heterogeneous bare-DOI/S2 mix
theorised in Part B is **not present here** (it would appear only via S2/GROBID,
i.e. `.pdf`/fallback ingest).
- **`discover.py` wrongly flags 13 already-ingested papers as "dangling / not
ingested"** (their `cited_id` is an OpenAlex URL whose paper *is* in the corpus
via `openalex_id`). Meanwhile **`graph.py` correctly resolves 57 cross-citation
edges** via the `openalex_id` JOIN. The two "dangling" implementations
measurably disagree — exactly the C-1 divergence, now quantified.
### M-1 — REFINED ⤵ (downgrade acute severity; structural finding stands)
`paper_identifiers` **exists on the live DB with 39 rows** — the catastrophic
"graph JOIN crashes with relation-does-not-exist" scenario **does not occur**;
the table was applied (manually or via DB re-create). The *structural* gap stands:
`apply_schema` is still non-idempotent and never invoked, so the **next** schema
change has no migration path — but the acute "live graph is broken" framing was
**over-stated**. Re-rank M-1 from HIGH → **MED** (latent migration debt, not an
active outage).
### C-2 — PARTIALLY REFUTED ⤵
`paper_identifiers` has **39 rows vs ~35 papers-with-openalex_id**, so ~4 papers
carry a diverging alias → the `pi` JOIN is **not** fully inert (it resolves a
handful of cases the `p` JOIN can't). Keep as LOW/INFO: the table earns its keep
for a few papers, though the cost/benefit at this corpus size is still marginal.
### Net adjustment to the tally
HIGH **8 → 7** (M-1 → MED). C-7 and C-1 are now **confirmed**, not inferred — they
should lead remediation. The dominant root theme (**un-normalized identity**) is
empirically validated: 35/36 ids in the "wrong" form, 13 papers mis-classified by
the older code path, 57 edges rescued by the newer one.
---
# Runtime Validation — 2026-06-15 (C-4 and C-11 reproduced end-to-end)
The two HIGH findings flagged "code-certain, runtime-confirmable" were exercised
against the **real** code paths (injected fake LLM; no Ollama, no DB needed).
### C-11 — CONFIRMED ✅ (lead-ID collision → silent loss; stays HIGH)
Reproduced the exact CLI assembly (`cli.py:421` `connections + gaps +
improvements`, each stage numbered from `L-0001`): **4 distinct leads** (2
connection, 1 gap, 1 improvement) → `write_leads` produced **2 files**.
`grounded/L-0001.md` survived as the **improvement** ("IMPROVEMENT-one"); the
connection and gap `L-0001` were silently overwritten. **2 of 4 leads lost.**
### C-4 — CONFIRMED ✅ (zero-claim page bypasses quarantine; stays HIGH)
Drove the real `compile_all` with a fake LLM returning confident **uncited** prose
(→ 0 parseable claims). With `wiki_min_grounding_rate = 0.5` and an actual
grounding rate of `0.0`, the page was **published to `wiki/` and marked
compiled** (`report.compiled=['test-concept']`, `report.quarantined=[]`) — not
quarantined to `wiki/draft/`. The `total_claims > 0` guard (`wiki.py:730`) is the
exact bypass; the bug fires precisely when grounding evidence is weakest.
### `nougat.py` — now fully reviewed (no new finding)
Folds into **R-6** (PDF-path resilience). Detail: `pdf_to_markdown` retries **only**
`httpx.ConnectError` with `wait_fixed(0)` (no 5xx retry, no back-off) and is
unwrapped at `ingest.py:183` — a Nougat 5xx aborts PDF ingest.
**Closure:** every `codex/` module is deep-reviewed; the two runtime-confirmable
HIGHs are empirically reproduced. The **audit / finding-identification pass is
complete**. Remediation planning is the open next phase.
---
# Migration Incident — 2026-06-15 (M-1 confirmed live, new privilege dimension)
While running the C-7 re-ingest migration (`infra/reingest_canonical_ids.sh`),
M-1 manifested exactly as predicted — and exposed a dimension the static audit
did **not** capture.
**What happened.** The helper ran `TRUNCATE papers CASCADE` (corpus wiped to 0),
then `ingest_all.sh` failed on the first chunk insert:
`UndefinedColumn: column "section" of relation "chunks" does not exist`. The live
`chunks` table predates the F-16 `section` column, and nothing had ever applied
that DDL to the live DB (M-1: `apply_schema` is never called; `schema.sql` is not
re-applyable). The ingest code was ahead of the live schema.
**New finding — privilege separation (extends M-1).** The fix
`ALTER TABLE chunks ADD COLUMN IF NOT EXISTS section TEXT` then failed with
`InsufficientPrivilege: must be owner of table chunks`. The live ownership:
| table | owner |
|-------|-------|
| papers, chunks, citations, code_links, formulas, figures | `postgres` |
| paper_identifiers | `researcher` (app user) |
The app role `researcher` has DML + `TRUNCATE` but **not** DDL on the
postgres-owned core tables. So a `codex migrate` / `apply_schema` invoked with the
application's `DATABASE_URL` would fail on every `ALTER` / `CREATE INDEX` against
those tables. **Implication for the M-1 fix:** making `schema.sql` idempotent is
necessary but *not sufficient* — the migration must run under a privileged role
(owner/superuser), via a separate admin connection, not the least-privilege app
user. This is now part of M-1's remediation scope.
**Consequences observed.**
- Corpus left wiped (papers = 0) until the owner adds the column and re-ingest is
re-run — a destructive-then-fail sequence.
- Helper hardened (`fix(infra)` on `fix/audit-wave-2`): a **preflight** verifies
`chunks.section` exists *before* the `TRUNCATE`, aborting early with the
owner-level `ALTER` to run, so a schema gap can no longer cost the corpus.
**Unblock (owner action):**
`sudo -u postgres psql -d papers -c "ALTER TABLE chunks ADD COLUMN IF NOT EXISTS section TEXT;"`
then re-run `bash ingest_all.sh`.
This incident is the strongest possible validation of M-1 and should anchor its
Wave-3 fix: **idempotent `schema.sql` + a `codex migrate` command that connects as
a privileged role**, plus a documented owner/grant model for the app user.

View File

@@ -0,0 +1,227 @@
# AUDIT — Development Loop 1 (main branch)
**Auditor:** Audit-Loop (Opus)
**Date:** 2026-06-15
**Scope:** entire `main` branch (HEAD `5dfa6e4`)
**Method:** static review of source + schema + tests, branch-wide pattern scan
(`ruff`, `mypy`, defect-class greps), full test run (`330 passed`).
> Status legend — **HIGH** = fix before relying on the feature ·
> **MED** = fix soon, correctness/maintainability · **LOW** = polish ·
> **INFO** = accepted risk / no action, documented for the record.
This document **only records findings**. Remediation planning is a separate
step (next phase of the audit loop).
---
## 0. Headline
The most recent dev-loop work (**F-15 citation graph** + the follow-up fixes
`cited_id→papers.id` resolution and the `paper_identifiers` alias table) is
**functionally green but unverified where it matters**: the SQL that *is* the
fix has zero real-database coverage, the live-corpus validation in the ADR
predates the fix, and the same ID-mismatch bug the fix solves still lives in a
parallel code path (`discover.py`). Separately, a **live DB password sits in an
un-ignored file** and the new table has **no migration path onto the live DB**.
Test suite: **330 passed**, `ruff check` clean, `ruff format --check` clean,
`mypy` clean on reviewed modules.
---
## 1. Security
### S-1 — Live DB credential in an un-ignored file · **HIGH**
- **Evidence:** `.env.jetson-ingest` contains
`DATABASE_URL=postgresql://researcher:05761b87…@localhost:5433/papers`.
`.gitignore` ignores only the literal `.env` (line: `# Environment secrets — NEVER commit``.env`).
`git check-ignore .env.jetson-ingest`**NOT IGNORED**.
- **Impact:** one `git add -A && git commit` leaks a real Postgres password to
history. The file is currently untracked (`??`), so the leak has **not**
happened yet — this is a near-miss, not an incident.
- **Note:** the same gap exposes any future `.env.<profile>` file.
### S-2 — Non-constant-time token comparison (MCP HTTP) · **LOW**
- **Evidence:** `codex/mcp_server.py:340``if auth != f"Bearer {token}":`.
- **Impact:** theoretical timing side-channel on the Bearer token. Low on a
trusted LAN, but trivially fixable with `secrets.compare_digest`.
### S-3 — DNS-rebinding protection disabled · **INFO (accepted)**
- **Evidence:** `codex/mcp_server.py:35-37`,
`TransportSecuritySettings(enable_dns_rebinding_protection=False)` (commit `d29dcf2`).
- **Assessment:** documented and mitigated — HTTP transport requires a Bearer
token (`_TokenAuth`) and is firewalled to LAN IPs (UFW). A rebound browser
request lacks the token. Residual risk only if the firewall assumption fails.
No action; recorded so the trade-off stays visible.
---
## 2. Correctness
### C-1 — `discover.py` still has the ID-format-mismatch bug F-15 fixed · **HIGH**
- **Evidence:** `codex/discover.py:20`
`WHERE cited_id NOT IN (SELECT id FROM papers)`; same pattern in
`cocited_papers` (`discover.py:52-61`), `citing_papers`, `cited_by`.
- **Root cause:** `citations.cited_id` is a **mix** of OpenAlex W-IDs (OpenAlex
API path, `ingest.py:229-231``sources/openalex.py:155`) and DOIs/arXiv
(GROBID/S2 paths). `papers.id` is the canonical DOI/arXiv. An ingested paper
cited by its **OpenAlex ID** is *not* in `(SELECT id FROM papers)`, so it is
wrongly reported as a discovery lead.
- **Impact:** `codex discover leads`, `codex discover cocited`, and the MCP
`discover_leads` tool over-report — foundational papers the user has **already
ingested** show up as "cited but not ingested". This is exactly the
D-05-class issue the F-15 graph path resolves via the
`papers.openalex_id` JOIN (`graph.py:48-56`) — but the fix was **not
propagated** to `discover.py`. Two divergent "dangling" implementations now
disagree on what "ingested" means.
### C-2 — `paper_identifiers` JOIN is effectively inert · **MED**
- **Evidence:** `graph.py:50-56` second LEFT JOIN; populated only at
`ingest.py:153-161`, which inserts **the same** `paper.openalex_id` that
`papers.openalex_id` already holds.
- **Impact:** because ingest never records an alias that differs from
`papers.openalex_id`, the `pi` JOIN can only resolve what the `p` JOIN already
resolves. It adds value **only** in the narrow case where a paper's
`openalex_id` changes across re-ingests (old alias retained via
`ON CONFLICT DO NOTHING`). The table + unique index + seed INSERT + migration
burden buy near-zero behaviour today — speculative generality. No test
exercises an alias that diverges from `papers.openalex_id`.
### C-3 — MCP `wiki_read` returns the slug as its own "sources" · **MED**
- **Evidence:** `codex/mcp_server.py:138-149` — comment says "Collect cited
bibkeys", code sets `sources = [concept_slug]`.
- **Impact:** the `sources` field is a placeholder masquerading as data; any MCP
client trusting it to list cited bibkeys gets the concept's own slug.
---
## 3. Documentation / Code Drift
### D-1 — ADR-F15 validation predates the shipped graph · **HIGH (integrity)**
- **Evidence:** ADR-F15 (commit `a1a6f45`) lands **before** the resolution fixes
`664906f` (`cited_id→papers.id`) and `5dfa6e4` (`paper_identifiers`).
- **Stale claims:**
- "Spike Result": *478 dangling nodes*, specific PageRank scores,
*Bobenko/Springborn rank 7*, *5.7 % spread* — all measured on the
**pre-resolution** (bipartite DOI→OpenAlex) graph. Post-fix, in-KB cited
papers resolve to DOIs, so dangling count and PageRank distribution change.
- "Decision → Graph Construction": "inter-KB citations are structurally
invisible" — **now false**.
- "Known Limitation: ID-Format Mismatch": "cross-citations… not represented"
**now resolved by the code**, contradicting the section.
- **Impact:** the **GO** decision rests on numbers the shipping code no longer
produces. Validation must be re-run on the post-fix graph (or the ADR marked
validation-pending).
### D-2 — `graph_cite_boost_alpha` doc describes the *fixed* bug · **MED**
- **Evidence:** `config.py:296-300` describes
`Final score = dense_score * (1 + alpha * pagerank_score)` — a **multiply**.
Actual code (`cli.py:107`) divides distance:
`boosted = distance / (1.0 + alpha * pr_score)`. The ADR explicitly states the
multiply form was the "critical bug corrected during the F-15 review gate".
- **Impact:** the config docstring still documents the inverted (buggy) formula.
### D-3 — `paper_identifiers` tagged "F-17" in schema, shipped as F-15 fix · **LOW**
- **Evidence:** `infra/schema.sql:129` comment "F-17 Paper identifier aliases",
but the table is introduced by the F-15 graph follow-up (`5dfa6e4`) and the
ADR-F15 work. Feature-tag inconsistency; ADR-F15 doesn't mention the table at all.
---
## 4. Schema / Migration
### M-1 — New table has no migration path onto the live DB · **HIGH (operational)**
- **Evidence chain:**
1. `apply_schema` docstring (`db.py:46-49`) claims "all DDL statements use
CREATE … IF NOT EXISTS" — **false**: `papers`, `chunks`, `citations`,
`code_links` and their indexes use bare `CREATE TABLE/INDEX`
(`schema.sql:16,33,56,68,30,41,…`). Only F-09/F-16/F-17 DDL is idempotent.
2. `apply_schema` is **never called** anywhere in the app — no `init`/`migrate`
CLI command (`grep apply_schema` → only `db.py` + a mocked unit test).
3. `ingest_all.sh` does **not** apply the schema; it only runs `codex ingest`.
- **Impact:** re-running `infra/schema.sql` against the **existing** Jetson DB
fails on the first `CREATE TABLE papers` (already exists) and rolls back the
whole script in one implicit transaction — so the new `paper_identifiers`
table (and its seed INSERT) is **never created** on a DB that predates it.
`build_citation_graph` then fails with `relation "paper_identifiers" does not
exist` on the live corpus. The migration step is currently manual and undocumented.
---
## 5. Test Integrity
### T-1 — The F-15 resolution SQL has zero real coverage · **HIGH**
- **Evidence:** every test in `tests/graph/test_graph.py` and
`tests/graph/test_cli.py` builds the graph from a `MagicMock` conn
(`_make_conn`, `_make_conn_with_paper_ids`). `test_cross_citation_uses_doi_when_in_kb`
(`test_graph.py:83-99`) **pre-resolves** the rows in Python and asserts the
graph builds — it tests the mock, not the `COALESCE(p.id, pi.paper_id, …)` +
two LEFT JOINs that *are* the fix.
- **Impact:** the substance of the two most recent commits is unverified. 330
green tests give false confidence about the resolution logic, the `p.id IS NULL`
correlated guard, and the `paper_identifiers` unique-index behaviour. Needs an
integration test against a real (or `pytest`-managed) Postgres.
### T-2 — `apply_schema` idempotency untested · **MED**
- **Evidence:** `tests/scaffold/test_db.py:24-32` mocks the conn and only checks
`execute`/`commit` are called. The (false) idempotency claim in M-1 is never
exercised.
---
## 6. Design / Usability
### U-1 — `graph report` prints bare IDs, no titles · **LOW**
- `cli.py:582-588` emits `score <id>` and bare dangling IDs — a mix of DOIs and
`W…` OpenAlex IDs with no title/year. The ADR's readable author/title table is
not what the CLI produces; output is hard to interpret.
### U-2 — `--cite-boost` can only reorder within the top-`limit` page · **LOW**
- `cli.py:84-94` applies `ORDER BY … LIMIT limit` in SQL **before** the boost
(`cli.py:96-109`). A high-PR paper just outside the semantic cut can never be
pulled in. With α=0.3 and PR≈0.002 the effect is ~0.06 % — effectively a
within-page tie-breaker. Consistent with ADR intent, but worth stating as a
limitation (the flag does far less than "weight results by PageRank" implies).
### U-3 — `graph report --json` drops the small-corpus warning · **LOW**
- `cli.py:557-570` returns before the `graph_min_corpus_size` warning
(`cli.py:572-577`). JSON consumers get neither the warning nor a flag for it.
### U-4 — `graph.find_co_cited` implemented + tested but not wired to a CLI · **LOW**
- `graph.find_co_cited` (`graph.py:142`) is unit-tested but exposed by no `graph`
sub-command (only the separate SQL-based `discover cocited` is wired). ADR-F15
R-43's co-citation is only half-surfaced.
---
## 7. State of Tree (INFO — no defect)
- The uncommitted diff (`wiki.py`, `synthesis.py`, `semanticscholar.py`,
`tests/synthesis/test_cli.py`, `tests/wiki/test_compile.py`) is a **legitimate
`ruff format` normalization** to `line-length=100`. `ruff format --check`
reports all 25 files formatted *with* these changes applied, meaning the
**committed** code was non-compliant. These edits should be **committed**, not
reverted. (Not a finding; clarifies the working-tree churn.)
---
## 8. Coverage of this audit (honesty ledger)
**Deep-reviewed:** `graph.py`, `db.py`, `infra/schema.sql`, `ingest.py`,
`discover.py`, `provenance.py` (scan), `mcp_server.py`, `cli.py` (search +
graph), `config.py` (graph settings), F-15 tests, ADR-F15; branch-wide pattern
scan (SQL injection, bare except, eval/exec/subprocess, secrets, timeouts).
**Not yet deep-reviewed (no findings asserted):**
- `synthesis.py` grounding guard / claim parser (`_run_grounding_guard`,
`_parse_claims`) — the "zero-fact-leak (N-02)" core. Prompts reviewed; guard
logic not yet traced.
- `wiki.py` grounding guard + conflict detection (only the format diff seen).
- `sources/` (arxiv, openalex, semanticscholar) beyond citation shapes.
- `parsing/` (grobid, nougat, mathpix, figures, tex) — error handling in
`mathpix.py`/`figures.py` uses broad `except Exception` without `noqa`
(`mathpix.py:109,151`, `figures.py:125,152`) — flagged for a later pass.
- `embed.py`, `quality.py`, `models.py`.
A second pass should close these before the audit is declared complete.

View File

@@ -0,0 +1,575 @@
# DATA-QUALITY AUDIT — codex knowledge base (handoff for a fresh session)
**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
of the audit conversation — everything needed to continue is in this file.
---
## Why this exists — the second audit axis
The code/pipeline audit (`AUDIT-2026-06-15-loop-1.md`, `-full-repo.md`) verified the
**loader is correct**: IDs are canonical, the citation graph is consistent, the
ingest pipeline does what it claims. It did **not** ask whether the *information
in the database is actually good*. A correct loader can faithfully load thin,
incomplete, or unrepresentative data. This document is that second axis — a
**data-quality** assessment of the live corpus, independent of code correctness.
---
## How to reach the data (self-contained)
- **Live DB:** Jetson PostgreSQL via an SSH tunnel (open it first):
```
ssh -f -N -L 5433:localhost:5432 alfred@jetson.local
```
- **Credentials:** `.env.jetson-ingest` (gitignored) holds `DATABASE_URL`
(`postgresql://researcher:…@localhost:5433/papers`). The `researcher` role is
DML-only (read freely; no DDL).
- **Run probes:** `set -a; source .env.jetson-ingest; set +a` then
`PYTHONPATH=. .venv/bin/python` with `psycopg` + `psycopg.rows.dict_row`.
Reusable domain helpers: `codex.quality` (`is_quality_chunk`, `_bib_score`,
`classify_section`), `codex.graph`, `codex.discover`.
- **Corpus snapshot (2026-06-15, post canonical-id migration):**
**29 papers, 1507 chunks, 590 citations.**
---
## Baseline — what is already GOOD (measured, no action needed)
- **Chunk content is clean.** Re-running the F-16 quality gate over all stored
chunks: **0 / 1507 fail** `is_quality_chunk` (none too-short, none low-alpha /
OCR-ish, none bibliography-like). The gate did its job on the `.txt` ingest.
- **Chunk sizing healthy.** chars: min 478 / median 2913 / max 4154; **0 exact
duplicate** chunk bodies.
- **`section` column is low-signal** (98 % `body`; 13 `bibliography`, 9 `theorem`,
2 `intro`, 1 `proof`). This confirms code-audit **R-12** — word-window chunks
rarely start at a section header. Not a data defect; the column is just weak.
---
## Findings (open investigation items)
### 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
comes from only ~17 papers. The whole F-15 layer (PageRank / coupling /
co-citation / discovery leads) therefore runs on ~59 % of the corpus.
- **Sharpening — it is mostly a source-coverage limit, not an ingest bug:**
**11 of the 12** zero-citation papers *do* have an `openalex_id`, i.e. ingest
called `openalex.fetch_citations(openalex_id)` and OpenAlex returned an **empty
`referenced_works`** list. Only `1911.00966` has no `openalex_id` (OpenAlex
404 → arXiv/S2 fallback, which also yielded nothing).
- **Zero-citation ids:** `1005.2698`, `1505.01341`, `1911.00966` (no oa),
`2206.13461`, `2305.10988`, `2310.17529`, `2601.22903`, `math/0001176`,
`math/0503219`, `math/0603097`, `10.14279/depositonce-20357`,
`10.14279/depositonce-5415`. (Pattern: arXiv preprints + the two `depositonce`
theses — works OpenAlex indexes without parsed references.)
- **To investigate next:**
1. Confirm it is OpenAlex coverage, not a `fetch_citations` bug: for 23 of the
ids, hit `GET https://api.openalex.org/works/<openalex_id>` and check whether
`referenced_works` is genuinely empty server-side.
2. If genuinely empty: enrich via the **Semantic Scholar references** path
(`semanticscholar.fetch_references("arXiv:<id>")`) as a *supplement* for
OpenAlex-empty papers — S2 often has references where OpenAlex doesn't. Note
S2 cited_ids are bare DOI/arXiv, so they flow through the existing
`RESOLVED_CITATIONS_SQL` resolver (audit C-1) fine.
3. Decide whether the F-15 `graph_min_corpus_size` warning should also flag *low
citing-paper coverage*, not just paper count.
- **Acceptance:** either (a) citation coverage materially improves after an S2
supplement, or (b) documented as an inherent OpenAlex-coverage limit with the
graph caveated accordingly.
### 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:**
- **No abstract (3):** `10.1007/978-3-642-17413-1_7`,
`10.1007/s00454-019-00132-8`, `1911.00966`. These get a **zero-vector**
`abstract_emb`, so paper-level semantic search can't place them.
- **No bibkey / year (1):** `1911.00966` only — and its authors array is empty
too. This paper is **fully degraded** (OpenAlex 404 → `Paper(id, title="")`
fallback): no abstract, no bibkey, no year, empty authors, no citations. With
no bibkey it cannot be `@cite`d and is invisible to wiki grounding (which keys
on bibkey).
- **To investigate next:**
1. `1911.00966` — confirm the arXiv id is correct and whether OpenAlex/S2 has it
under a different id (DOI?); if recoverable, re-ingest to populate metadata.
If not, decide: keep as a degraded node or drop.
2. The 2 no-abstract DOIs — check if OpenAlex has an `abstract_inverted_index`
that the mapper missed, or if the abstract is genuinely absent upstream.
- **Acceptance:** every paper has at least a bibkey + non-zero abstract embedding,
or the exceptions are documented with rationale.
### 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
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
`.txt` (`PAPERS_DIR=/Users/tarikmoussa/Desktop/ConformalLabpp/papers/txt`), so
the `.tex`/`.pdf` paths never ran.
- **To investigate next:** for a sample of ~5 papers, compare
`len("".join(stored chunks))` against `len(source.txt)` (coverage %), and
eyeball the first/last chunk vs the file head/tail. Flag papers where coverage
is low (content lost) — F-16 dropping >X % of a paper is a signal.
- **Acceptance:** sampled papers retain ≳ the expected fraction of source text;
no paper is silently gutted by the quality gate.
### 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
chunks + a working index don't guarantee useful retrieval.
- **To investigate next:** run a handful of domain queries through the actual
search path (`codex search paper "<q>"` and the chunk-level MCP `search`), e.g.
"discrete conformal map", "circle packing rigidity", "combinatorial Yamabe
flow", "discrete Laplace-Beltrami operator", and judge whether the top-5 hits
are on-topic and point at the right papers. Cross-check a couple against
`--cite-boost` to see if the boost helps or hurts on this corpus.
- **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.
### 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://jetson.local: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
1. ~~**DQ-1**~~**DONE 2026-06-16** (live DB at 920 citations, 27/29 citing
coverage; remedy wired into ingest).
2. ~~**DQ-4**~~**DONE 2026-06-16** (P0 `search paper` crash fixed; relevance
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.
DQ-1..DQ-4 are *data*/assessment work; DQ-5 + roadmap R-A..R-D add **new code**
(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.
---
## Appendix — reproduce the baseline scan
```python
import os, psycopg, statistics
from collections import Counter, defaultdict
from psycopg.rows import dict_row
from codex.config import Settings
from codex.quality import is_quality_chunk, _bib_score
s = Settings()
with psycopg.connect(os.environ["DATABASE_URL"], row_factory=dict_row) as c:
papers = c.execute("SELECT id, bibkey, title, abstract, year, authors, openalex_id FROM papers").fetchall()
chunks = c.execute("SELECT paper_id, content, section FROM chunks").fetchall()
cit = c.execute("SELECT citing_id, count(*) n FROM citations GROUP BY citing_id").fetchall()
# zero-citation papers (DQ-1)
cited = {r["citing_id"] for r in cit}
zero = [p["id"] for p in papers if p["id"] not in cited]
# metadata gaps (DQ-2): p["abstract"] empty / p["bibkey"] None / not p["authors"]
# F-16 re-gate (baseline): [ch for ch in chunks if not is_quality_chunk(ch["content"], settings=s)]
# section dist (R-12): Counter(ch["section"] or "(null)" for ch in chunks)
```

View File

@@ -0,0 +1,59 @@
# Cold-start session prompt — evaluate open points · review · release
Paste the block below into a fresh Claude Code session (it assumes no memory of prior work).
---
You are picking up **codex-py** cold. The data-quality roadmap (**DQ-1DQ-5 + R-AR-F**)
was merged to `main` via **PR #16** (merge commit `5aa2f7d`). Your job this session, in
order: **(1) evaluate the remaining open points, (2) code-review the released state,
(3) cut a release.** Read the two handoff docs first, then work the tasks. Ask before any
destructive action (branch deletion) or before publishing the release.
## Orient yourself first
- Repo: `/Users/tarikmoussa/Desktop/codex-py`, branch `main` (at the PR #16 merge).
- Read: `docs/SESSION-2026-06-17-open-items.md` (open items + branch-cleanup lists),
`docs/audit/DATA-QUALITY-2026-06-15.md` (DQ-1DQ-5 + R-AR-F resolution blocks),
`docs/infra/jetson-ssh-stability.md` (infra TODO), `docs/infra/grobid-jetson.md`.
- Live DB: Jetson Postgres via SSH tunnel
`ssh -o ServerAliveInterval=15 -f -N -L 5433:localhost:5432 alfred@jetson.local`;
creds in `.env.jetson-ingest`. **The tunnel is chronically flaky** — prefer short,
idempotent ops; for long live runs use tmux **on the Jetson** (no tunnel needed there).
- Tests: `PYTHONPATH=. .venv/bin/python -m pytest -q`. **Important:** importing `codex`
eagerly loads the embedding libs (~2 min) so the suite is slow, and this sandbox
auto-backgrounds + reaps long commands. **Launch ONE pytest run and issue no other
commands until it notifies you** — that lets it finish (polling its output file with a
read-only Read is fine; a new Bash command reaps it). Baseline after PR #16: 408 green.
- Remote: `origin` = `git.eulernest.eu/user2595/codex-py`. Use the **gitea MCP** for
PRs/tags/releases (tools `mcp__gitea-eulernest__*`).
## Task 1 — Evaluate open points
- **Branch cleanup:** confirm these are fully merged into `main`, then propose deletion
(local + remote): `feat/tex-ingest`, `feat/crossref-references`,
`feat/graph-coverage-warning`, `feat/section-labels`, `chore/infra-ssh-stability`,
`audit/loop-1`, `integration/roadmap`; plus pre-session stale: `feat/F-15-literature-graph`,
`feat/F-16-chunk-quality`, `fix/audit-wave-1/2/3`, `scratch/spike-embed`.
- **Deferred review findings** (intentionally left in PR #16 — see the `fix(review)`
commit): #3 (`chunk_sections` drops pre-`\section` text), #8 (script id-normalizer
duplication), #9 (arXiv cited-id case), #11 (gzip heuristic), #12 (title truncation).
Re-assess whether any is worth doing now.
- **Infra TODO:** tmux-on-Jetson for stable long ops — documented, not implemented.
- **DQ-6** (upstream PDF→`.txt` fidelity) — flagged, believed mooted by R-A/R-C; confirm.
- If the tunnel is up, a quick corpus scan for any new data-quality gap.
## Task 2 — Review
- Run `/code-review` on `main` (the released state) and confirm no regressions.
- Confirm the full suite is green (`pytest -q`, honoring the launch-and-wait rule), plus
`ruff check`, `ruff format --check`, `mypy codex/ scripts/`.
## Task 3 — Release
- Only after the review + suite are green.
- Bump the version in `pyproject.toml` (currently `0.1.0`; the roadmap is a substantial
feature set → `0.2.0`). Commit on a branch (do not commit straight to `main`) and PR it,
or tag the merge commit directly if that matches the team's flow.
- Write release notes summarizing DQ-1DQ-5 + R-AR-F + the audit-remediation reconcile;
corpus impact: **citations 590→1075, citing coverage 29/29, section signal 10%→95%**.
- Create the tag + Gitea release via the gitea MCP; confirm it's published.
Deliverable: a short report of (1) the open-point decisions, (2) the review outcome,
(3) the published release tag/URL.

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@jetson.local` — 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@jetson.local
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@jetson.local
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@jetson.local:~/codex-infra/
ssh alfred@jetson.local
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@jetson.local 'docker start papers-grobid' # ~30s model load
curl -s --retry 30 --retry-delay 2 --retry-connrefused \
http://jetson.local:8070/api/isalive # wait for: true
# ... run R-A from the Mac ...
PYTHONPATH=. python scripts/ra_grobid_backfill.py --write
ssh alfred@jetson.local '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://jetson.local: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://jetson.local: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://jetson.local: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@jetson.local & # 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@jetson.local`,
`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:

141
infra/reingest_canonical_ids.sh Executable file
View File

@@ -0,0 +1,141 @@
#!/usr/bin/env bash
# One-time migration to canonical paper IDs (audit C-7).
#
# Before the C-7 fix, ingest stored OpenAlex's URL-form doi/id as papers.id
# (e.g. "https://doi.org/10.48550/arxiv.math/0603097"). After the fix, ingest
# keys papers.id on the bare caller id ("math/0603097"). The existing rows keep
# their old URL-form primary keys, so a plain re-ingest would DUPLICATE every
# paper (new bare-id row alongside the old URL-id row). This script wipes the
# corpus and rebuilds it on canonical ids.
#
# DESTRUCTIVE: `TRUNCATE papers CASCADE` removes papers + chunks + citations +
# formulas + figures + paper_identifiers (code_links.paper_id is set NULL). All
# content is rebuilt by ingest_all.sh, which re-fetches OpenAlex and re-embeds
# (minutes for ~36 papers).
#
# Prerequisites: SSH tunnel on :5433 (see ingest_all.sh) and .env.jetson-ingest.
# Usage: bash infra/reingest_canonical_ids.sh
set -euo pipefail
CODEX_DIR="$(cd "$(dirname "$0")/.." && pwd)"
ENV_FILE="$CODEX_DIR/.env.jetson-ingest"
PY="$CODEX_DIR/.venv/bin/python"
TUNNEL_PORT=5433
# ── 1. Preconditions ─────────────────────────────────────────────────────────
if ! nc -z localhost "$TUNNEL_PORT" 2>/dev/null; then
echo "ERROR: SSH tunnel not active on :$TUNNEL_PORT"
echo "Run: ssh -f -N -L 5433:localhost:5432 alfred@jetson.local"
exit 1
fi
[[ -f "$ENV_FILE" ]] || { echo "ERROR: $ENV_FILE missing"; exit 1; }
[[ -x "$PY" ]] || { echo "ERROR: venv python not found at $PY (run uv sync)"; exit 1; }
# Load DATABASE_URL into the environment (not echoed — keeps the password out of logs).
set -a; source "$ENV_FILE"; set +a
# run_sql <SQL>: execute one statement via psycopg, print any result rows.
# Reads DATABASE_URL from the environment; suppresses the DSN on error.
run_sql() {
"$PY" - "$1" <<'PYEOF'
import os, sys, psycopg
from psycopg.rows import dict_row
try:
with psycopg.connect(os.environ["DATABASE_URL"], row_factory=dict_row, connect_timeout=10) as c:
cur = c.execute(sys.argv[1])
if cur.description:
for row in cur.fetchall():
print(" " + " ".join(f"{k}={v!r}" for k, v in row.items()))
c.commit()
except Exception as e:
print(f" DB ERROR: {type(e).__name__} (details suppressed to avoid DSN leak)")
sys.exit(1)
PYEOF
}
# ── 2. BEFORE snapshot ───────────────────────────────────────────────────────
echo "── BEFORE migration ──────────────────────────────────────────"
run_sql "SELECT count(*) AS papers, count(*) FILTER (WHERE starts_with(id, 'https://')) AS url_form_ids FROM papers"
echo " sample ids:"
run_sql "SELECT id FROM papers ORDER BY added_at LIMIT 3"
# ── 2b. Preflight: verify the schema is re-ingestable BEFORE wiping anything ──
# ingest writes chunks.section (F-16). On a live DB that predates it the column
# is missing, and the app user cannot ALTER tables owned by 'postgres' (audit
# M-1). Check FIRST so we never TRUNCATE and then fail mid-re-ingest.
echo "── Preflight: schema readiness ───────────────────────────────"
SECTION_OK="$("$PY" - <<'PYEOF'
import os, psycopg
try:
with psycopg.connect(os.environ["DATABASE_URL"], connect_timeout=10) as c:
cols = [r[0] for r in c.execute(
"SELECT column_name FROM information_schema.columns WHERE table_name='chunks'").fetchall()]
print("yes" if "section" in cols else "no")
except Exception:
print("error")
PYEOF
)"
if [[ "$SECTION_OK" != "yes" ]]; then
echo "ABORT — schema not ready: chunks.section is missing (audit M-1)."
echo "The app user cannot add it (core tables are owned by 'postgres'). Sync the"
echo "schema with a privileged role, then re-run this script. Either:"
echo ""
echo " (a) MIGRATION_DATABASE_URL=postgresql://postgres:PASS@HOST:PORT/papers \\"
echo " \"$CODEX_DIR/.venv/bin/codex\" migrate # applies schema.sql idempotently"
echo " (b) sudo -u postgres psql -d papers \\"
echo " -c \"ALTER TABLE chunks ADD COLUMN IF NOT EXISTS section TEXT;\""
echo ""
echo "Nothing was changed — no TRUNCATE was issued."
exit 1
fi
echo " chunks.section present — schema OK."
# ── 3. Confirmation gate (destructive) ───────────────────────────────────────
echo ""
echo "This TRUNCATEs papers CASCADE (papers/chunks/citations/formulas/figures/"
echo "paper_identifiers) and re-ingests via ingest_all.sh. The DB content is"
echo "rebuilt from scratch."
read -r -p "Type 'MIGRATE' to proceed: " confirm
[[ "$confirm" == "MIGRATE" ]] || { echo "Aborted — nothing changed."; exit 1; }
# ── 4. Wipe ──────────────────────────────────────────────────────────────────
echo "── TRUNCATE papers CASCADE ───────────────────────────────────"
run_sql "TRUNCATE papers CASCADE"
echo " wiped."
# ── 5. Re-ingest on canonical ids ────────────────────────────────────────────
echo "── Re-ingest (ingest_all.sh) ─────────────────────────────────"
bash "$CODEX_DIR/ingest_all.sh"
# ── 6. AFTER snapshot + live verification ────────────────────────────────────
echo "── AFTER migration ───────────────────────────────────────────"
echo " C-7 — url_form_ids should now be 0; sample ids should be bare:"
run_sql "SELECT count(*) AS papers, count(*) FILTER (WHERE starts_with(id, 'https://')) AS url_form_ids FROM papers"
run_sql "SELECT id FROM papers ORDER BY added_at LIMIT 3"
# C-1 must be verified through the REAL resolver-based discovery_leads(), not a
# raw cited_id check: cited_id/openalex_id stay in OpenAlex form, so the raw
# "cited_id NOT IN papers.id" count is non-zero by design — the resolver is what
# excludes ingested papers. Check that no ingested paper leaks into the leads.
echo " C-1 — real discovery_leads() must contain no already-ingested paper:"
PYTHONPATH="$CODEX_DIR" "$PY" - <<'PYEOF'
import os, psycopg
from psycopg.rows import dict_row
from codex.discover import discovery_leads
try:
leads = discovery_leads(limit=100000)
with psycopg.connect(os.environ["DATABASE_URL"], row_factory=dict_row, connect_timeout=10) as c:
ingested = {r["id"] for r in c.execute("SELECT id FROM papers").fetchall()}
ingested |= {
r["openalex_id"]
for r in c.execute("SELECT openalex_id FROM papers WHERE openalex_id IS NOT NULL").fetchall()
}
leaked = sum(1 for lead in leads if lead["cited_id"] in ingested)
print(f" leads={len(leads)} ingested-papers-leaked-into-leads={leaked}")
except Exception as e:
print(f" DB ERROR: {type(e).__name__} (details suppressed)")
PYEOF
echo ""
echo "✓ Migration complete."
echo " Expected: url_form_ids=0 (C-7 verified) and leaked=0 (C-1 verified) above."
echo " Next: re-run 'codex graph report' and refresh the ADR-F15 spike table (audit D-1)."

View File

@@ -13,7 +13,7 @@ CREATE EXTENSION IF NOT EXISTS vector;
-- --------------------------------------------------------------------- -- ---------------------------------------------------------------------
-- Layer 1: Papers + full-text chunks -- Layer 1: Papers + full-text chunks
-- --------------------------------------------------------------------- -- ---------------------------------------------------------------------
CREATE TABLE papers ( CREATE TABLE IF NOT EXISTS papers (
id TEXT PRIMARY KEY, -- canonical ID: arXiv ID or DOI id TEXT PRIMARY KEY, -- canonical ID: arXiv ID or DOI
openalex_id TEXT UNIQUE, -- e.g. W2741809807 openalex_id TEXT UNIQUE, -- e.g. W2741809807
bibkey TEXT UNIQUE, -- BibTeX key -> .bib + Doxygen @cite bibkey TEXT UNIQUE, -- BibTeX key -> .bib + Doxygen @cite
@@ -27,10 +27,10 @@ CREATE TABLE papers (
); );
-- HNSW index for fast approximate nearest-neighbour search on abstracts. -- HNSW index for fast approximate nearest-neighbour search on abstracts.
CREATE INDEX papers_abstract_emb_idx CREATE INDEX IF NOT EXISTS papers_abstract_emb_idx
ON papers USING hnsw (abstract_emb vector_cosine_ops); ON papers USING hnsw (abstract_emb vector_cosine_ops);
CREATE TABLE chunks ( CREATE TABLE IF NOT EXISTS chunks (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
paper_id TEXT REFERENCES papers(id) ON DELETE CASCADE, paper_id TEXT REFERENCES papers(id) ON DELETE CASCADE,
ord INT NOT NULL, -- position within the paper ord INT NOT NULL, -- position within the paper
@@ -38,13 +38,13 @@ CREATE TABLE chunks (
embedding vector(1024) embedding vector(1024)
); );
CREATE INDEX chunks_emb_idx CREATE INDEX IF NOT EXISTS chunks_emb_idx
ON chunks USING hnsw (embedding vector_cosine_ops); ON chunks USING hnsw (embedding vector_cosine_ops);
CREATE INDEX chunks_paper_idx ON chunks (paper_id); CREATE INDEX IF NOT EXISTS chunks_paper_idx ON chunks (paper_id);
-- Sparse / keyword hits for exact mathematical terminology (hybrid search). -- Sparse / keyword hits for exact mathematical terminology (hybrid search).
-- Dense (above) + full-text (below) combined = robust against math terms. -- Dense (above) + full-text (below) combined = robust against math terms.
CREATE INDEX chunks_fts_idx CREATE INDEX IF NOT EXISTS chunks_fts_idx
ON chunks USING gin (to_tsvector('english', content)); ON chunks USING gin (to_tsvector('english', content));
-- --------------------------------------------------------------------- -- ---------------------------------------------------------------------
@@ -53,19 +53,19 @@ CREATE INDEX chunks_fts_idx
-- edges to not-yet-ingested papers are intentionally preserved — -- edges to not-yet-ingested papers are intentionally preserved —
-- those are your discovery leads. -- those are your discovery leads.
-- --------------------------------------------------------------------- -- ---------------------------------------------------------------------
CREATE TABLE citations ( CREATE TABLE IF NOT EXISTS citations (
citing_id TEXT REFERENCES papers(id) ON DELETE CASCADE, citing_id TEXT REFERENCES papers(id) ON DELETE CASCADE,
cited_id TEXT NOT NULL, -- arXiv/DOI/OpenAlex ID of the target cited_id TEXT NOT NULL, -- arXiv/DOI/OpenAlex ID of the target
context TEXT, -- optional: citation context (S2) context TEXT, -- optional: citation context (S2)
PRIMARY KEY (citing_id, cited_id) PRIMARY KEY (citing_id, cited_id)
); );
CREATE INDEX citations_cited_idx ON citations (cited_id); CREATE INDEX IF NOT EXISTS citations_cited_idx ON citations (cited_id);
-- --------------------------------------------------------------------- -- ---------------------------------------------------------------------
-- Layer 3: Provenance (the "cleanly couple" goal) -- Layer 3: Provenance (the "cleanly couple" goal)
-- --------------------------------------------------------------------- -- ---------------------------------------------------------------------
CREATE TABLE code_links ( CREATE TABLE IF NOT EXISTS code_links (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
symbol TEXT NOT NULL, -- e.g. 'dec::hodge_star' or 'file.cpp:120' symbol TEXT NOT NULL, -- e.g. 'dec::hodge_star' or 'file.cpp:120'
paper_id TEXT REFERENCES papers(id) ON DELETE SET NULL, paper_id TEXT REFERENCES papers(id) ON DELETE SET NULL,
@@ -74,8 +74,8 @@ CREATE TABLE code_links (
added_at TIMESTAMPTZ DEFAULT now() added_at TIMESTAMPTZ DEFAULT now()
); );
CREATE INDEX code_links_symbol_idx ON code_links (symbol); CREATE INDEX IF NOT EXISTS code_links_symbol_idx ON code_links (symbol);
CREATE INDEX code_links_paper_idx ON code_links (paper_id); CREATE INDEX IF NOT EXISTS code_links_paper_idx ON code_links (paper_id);
-- --------------------------------------------------------------------- -- ---------------------------------------------------------------------
-- Example query: discovery leads -- Example query: discovery leads
@@ -122,3 +122,24 @@ CREATE TABLE IF NOT EXISTS figures (
CREATE INDEX IF NOT EXISTS figures_emb_idx CREATE INDEX IF NOT EXISTS figures_emb_idx
ON figures USING hnsw (embedding vector_cosine_ops); ON figures USING hnsw (embedding vector_cosine_ops);
CREATE INDEX IF NOT EXISTS figures_paper_idx ON figures (paper_id); CREATE INDEX IF NOT EXISTS figures_paper_idx ON figures (paper_id);
-- F-16 Chunk Quality Gate: section classification
ALTER TABLE chunks ADD COLUMN IF NOT EXISTS section TEXT;
-- F-17 Paper identifier aliases: one paper → many OpenAlex IDs
-- (arXiv preprint ID + journal published ID + any future version)
-- Each OpenAlex ID maps to exactly one paper (UNIQUE on openalex_id).
-- Populated automatically by ingest; add extra aliases manually.
CREATE TABLE IF NOT EXISTS paper_identifiers (
paper_id TEXT NOT NULL REFERENCES papers(id) ON DELETE CASCADE,
openalex_id TEXT NOT NULL,
PRIMARY KEY (paper_id, openalex_id)
);
CREATE UNIQUE INDEX IF NOT EXISTS paper_identifiers_oa_idx
ON paper_identifiers (openalex_id);
-- Seed from existing papers.openalex_id
INSERT INTO paper_identifiers (paper_id, openalex_id)
SELECT id, openalex_id FROM papers WHERE openalex_id IS NOT NULL
ON CONFLICT DO NOTHING;

116
ingest_all.sh Executable file
View File

@@ -0,0 +1,116 @@
#!/usr/bin/env bash
# Batch ingest — writes directly to Jetson PostgreSQL via SSH tunnel.
#
# Prerequisites:
# ssh -f -N -L 5433:localhost:5432 alfred@jetson.local
# uv sync (done once)
#
# Usage: bash ingest_all.sh 2>&1 | tee ingest_all.log
set -euo pipefail
PAPERS_DIR="/Users/tarikmoussa/Desktop/ConformalLabpp/papers/txt"
CODEX_DIR="$(cd "$(dirname "$0")" && pwd)"
ENV_FILE="$CODEX_DIR/.env.jetson-ingest"
TUNNEL_PORT=5433
# ── 1. Verify SSH tunnel is active ───────────────────────────────────────────
if ! nc -z localhost "$TUNNEL_PORT" 2>/dev/null; then
echo "ERROR: SSH tunnel not active on port $TUNNEL_PORT"
echo "Run: ssh -f -N -L 5433:localhost:5432 alfred@jetson.local"
exit 1
fi
echo "✓ SSH tunnel active on port $TUNNEL_PORT"
# ── 1b. Clear macOS UF_HIDDEN flag on editable-install .pth files ─────────────
# Python 3.13 skips .pth files with this flag set (new in 3.13); uv sets it.
# --no-sync below prevents uv from re-syncing (which would re-set the flag).
SP="$CODEX_DIR/.venv/lib/python3.13/site-packages"
for pth in "$SP"/_editable_impl_codex*.pth; do
[[ -f "$pth" ]] && chflags nohidden "$pth" 2>/dev/null || true
done
echo "✓ Cleared hidden flags on editable .pth files"
# ── 2. Load env (DATABASE_URL → Jetson via tunnel) ───────────────────────────
# pydantic-settings reads DATABASE_URL directly from the environment.
# DOTENV_PATH is not supported; export each var explicitly.
set -a; source "$ENV_FILE"; set +a
export DATABASE_URL GROBID_URL OLLAMA_BASE_URL EMBEDDING_MODEL EMBEDDING_DIM OPENALEX_MAILTO
echo "✓ DATABASE_URL: $DATABASE_URL"
# ── 3. Paper ID → source file mapping ────────────────────────────────────────
# Format: "PAPER_ID|filename.txt"
# SKIP: farkas-kra-1992-riemann-surfaces.txt (textbook, no OpenAlex entry)
declare -a PAPERS=(
# arXiv papers
"math/0603097|springborn-2008-weighted-delaunay-hyperideal.txt"
"1005.2698|bobenko-pinkall-springborn-2015-discrete-conformal-maps.txt"
"math/0306167|luo-2004-combinatorial-yamabe-flow.txt"
"math/0203250|bobenko-springborn-2004-circle-patterns.txt"
"math/0503219|bobenko-springborn-2007-discrete-laplace-beltrami.txt"
"2310.17529|bobenko-lutz-2025-non-euclidean-dce.txt"
"2305.10988|bobenko-lutz-2024-decorated-conformal-maps.txt"
"2206.13461|lutz-2023-canonical-tessellations.txt"
"2601.22903|bowers-bowers-lutz-2026-koebe-rigidity.txt"
"1911.00966|pinkall-springborn-2021-liouville.txt"
"1505.01341|born-bucking-springborn-2015-quasiconformal.txt"
"0906.1560|glickenstein-2011-discrete-conformal-variations.txt"
"math/0001176|rivin-schlenker-2000-schlafli-formula-arxiv-preprint.txt"
# DOI papers
"10.1007/s00454-019-00132-8|springborn-2020-ideal-hyperbolic-polyhedra.txt"
"10.1145/1964921.1964997|alexa-wardetzky-2011-discrete-laplacians-polygonal.txt"
"10.1111/cgf.13931|bunge-herholz-kazhdan-botsch-2020-polygon-laplacian.txt"
"10.1145/3450626.3459763|gillespie-springborn-crane-2021-discrete-conformal-equivalence.txt"
"10.1145/3306346.3323042|sharp-soliman-crane-2019-navigating-intrinsic-triangulations.txt"
"10.1145/3197517.3201367|soliman-slepcv-crane-2018-optimal-cone-singularities.txt"
"10.1145/3132705|sawhney-crane-2017-boundary-first-flattening.txt"
"10.1145/2767000|knoppel-crane-pinkall-schroder-2015-stripe-patterns.txt"
"10.5555/1070432.1070581|erickson-whittlesey-2005-greedy-homotopy-homology.txt"
"10.1145/1185657.1185665|desbrun-kanso-tong-2006-discrete-differential-forms.txt"
"10.1080/10586458.1993.10504266|pinkall-polthier-1993-computing-discrete-minimal-surfaces.txt"
"10.1007/s11040-021-09394-2|bobenko-bucking-2021-period-matrices.txt"
"10.14279/depositonce-20357|lutz-2024-thesis.txt"
"10.14279/depositonce-5415|sechelmann-2016-thesis.txt"
# Book chapters (txt contains full book; ID = cited chapter)
"10.1007/0-387-29555-0_13|precopa-molnar-eds-2006-non-euclidean-geometries-book.txt"
"10.1007/978-3-642-17413-1_7|bobenko-klein-eds-2011-computational-approach-riemann-surfaces-book.txt"
)
# ── 4. Ingest loop ────────────────────────────────────────────────────────────
OK=0; FAIL=0; SKIP=0
FAILED_IDS=()
cd "$CODEX_DIR"
for entry in "${PAPERS[@]}"; do
PAPER_ID="${entry%%|*}"
FILENAME="${entry##*|}"
SOURCE="$PAPERS_DIR/$FILENAME"
if [[ ! -f "$SOURCE" ]]; then
echo "SKIP (file missing): $FILENAME"
(( SKIP++ )) || true
continue
fi
echo ""
echo "── Ingesting: $PAPER_ID"
echo " source: $FILENAME"
if PYTHONPATH="$CODEX_DIR" "$CODEX_DIR/.venv/bin/codex" ingest "$PAPER_ID" --source "$SOURCE"; then
(( OK++ )) || true
else
echo "FAILED: $PAPER_ID"
FAILED_IDS+=("$PAPER_ID")
(( FAIL++ )) || true
fi
done
# ── 5. Summary ────────────────────────────────────────────────────────────────
echo ""
echo "════════════════════════════════════════"
echo "Ingest complete: $OK OK, $FAIL FAILED, $SKIP SKIPPED"
if [[ ${#FAILED_IDS[@]} -gt 0 ]]; then
echo "Failed IDs:"
for id in "${FAILED_IDS[@]}"; do echo " $id"; done
fi
echo "════════════════════════════════════════"

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://jetson.local: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

@@ -3,7 +3,7 @@
NOT a unit test. Not maintained. Run by hand against live infrastructure: NOT a unit test. Not maintained. Run by hand against live infrastructure:
DB: postgresql://researcher:change_me@localhost:5432/papers (podman, Mac) DB: postgresql://researcher:change_me@localhost:5432/papers (podman, Mac)
LLM: http://192.168.178.103:11434 (Jetson, qwen2.5:7b) LLM: http://jetson.local:11434 (Jetson, qwen2.5:7b)
Goal: emit 3-5 grounded leads (connection + gap) and a couple of Goal: emit 3-5 grounded leads (connection + gap) and a couple of
conjectures, then eyeball: conjectures, then eyeball:
@@ -25,7 +25,7 @@ from pathlib import Path
# Wire up the live infra without polluting ~/.env # Wire up the live infra without polluting ~/.env
os.environ.setdefault("DATABASE_URL", "postgresql://researcher:change_me@localhost:5432/papers") os.environ.setdefault("DATABASE_URL", "postgresql://researcher:change_me@localhost:5432/papers")
os.environ.setdefault("OLLAMA_BASE_URL", "http://192.168.178.103:11434") os.environ.setdefault("OLLAMA_BASE_URL", "http://jetson.local:11434")
os.environ.setdefault("SYNTHESIS_LLM_MODEL", "qwen2.5:7b") os.environ.setdefault("SYNTHESIS_LLM_MODEL", "qwen2.5:7b")
os.environ.setdefault("SYNTHESIS_TOP_K", "12") os.environ.setdefault("SYNTHESIS_TOP_K", "12")

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:

0
tests/graph/__init__.py Normal file
View File

256
tests/graph/test_cli.py Normal file
View File

@@ -0,0 +1,256 @@
"""CLI tests for `codex graph` sub-commands (F-15)."""
from __future__ import annotations
from contextlib import contextmanager
from unittest.mock import MagicMock, patch
import networkx as nx
import numpy as np
from typer.testing import CliRunner
from codex.cli import app
runner = CliRunner()
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_conn_cm(conn: MagicMock):
@contextmanager
def _cm():
yield conn
return _cm
def _make_conn_with_paper_ids(*paper_ids: str) -> MagicMock:
"""Mock conn that returns paper rows for SELECT id, title FROM papers."""
conn = MagicMock()
conn.execute.return_value.fetchall.return_value = [
{"id": pid, "title": f"Title of {pid}"} for pid in paper_ids
]
return conn
def _sample_graph() -> nx.DiGraph:
"""A→B, A→C, B→C, B→D, C→D — 5 nodes, D is dangling (not in papers)."""
g = nx.DiGraph()
g.add_edges_from([("A", "B"), ("A", "C"), ("B", "C"), ("B", "D"), ("C", "D")])
return g
# ---------------------------------------------------------------------------
# codex graph report
# ---------------------------------------------------------------------------
class TestGraphReport:
def _run(self, graph=None, known_ids=("A", "B", "C"), extra_args=()):
if graph is None:
graph = _sample_graph()
conn = _make_conn_with_paper_ids(*known_ids)
with (
patch("codex.db.get_conn", side_effect=_make_conn_cm(conn)),
patch("codex.graph.build_citation_graph", return_value=graph),
):
return runner.invoke(app, ["graph", "report", *extra_args])
def test_exit_zero(self):
assert self._run().exit_code == 0
def test_shows_hub_section(self):
assert "HUB" in self._run().output.upper()
def test_shows_dangling_section(self):
assert "DANGLING" in self._run().output.upper()
def test_dangling_node_in_output(self):
# D is cited but not in known_ids
assert "D" in self._run().output
def test_json_output_valid(self):
import json
graph = _sample_graph()
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=graph),
):
result = runner.invoke(app, ["graph", "report", "--json"])
assert result.exit_code == 0
data = json.loads(result.output)
assert "hubs" in data
assert "dangling" in data
assert "nodes" in data
assert data["dangling_count"] == 1
def test_empty_graph_message(self):
conn = _make_conn_with_paper_ids()
with (
patch("codex.db.get_conn", side_effect=_make_conn_cm(conn)),
patch("codex.graph.build_citation_graph", return_value=nx.DiGraph()),
):
result = runner.invoke(app, ["graph", "report"])
assert result.exit_code == 0
assert "empty" in result.output.lower()
def test_top_n_respected(self):
# Build graph with 8 nodes, ask for top 3
g = nx.DiGraph()
for i in range(8):
g.add_edge(f"P{i}", "HUB")
conn = _make_conn_with_paper_ids(*[f"P{i}" for i in range(8)], "HUB")
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", "--top-n", "3"])
assert result.exit_code == 0
def test_small_corpus_warning_shown(self):
# known_ids has only 3 papers (< graph_min_corpus_size default 15)
result = self._run(known_ids=("A", "B", "C"))
assert result.exit_code == 0
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
# ---------------------------------------------------------------------------
class TestGraphRelated:
def _run(self, paper_id: str, graph=None, extra_args=()):
if graph is None:
graph = _sample_graph()
conn = MagicMock()
with (
patch("codex.db.get_conn", side_effect=_make_conn_cm(conn)),
patch("codex.graph.build_citation_graph", return_value=graph),
):
return runner.invoke(app, ["graph", "related", paper_id, *extra_args])
def test_exit_zero_known_paper(self):
assert self._run("A").exit_code == 0
def test_unknown_paper_graceful(self):
result = self._run("UNKNOWN_ID")
assert result.exit_code == 0
assert "No papers" in result.output
def test_no_results_message_when_min_shared_high(self):
result = self._run("A", _sample_graph(), extra_args=("--min-shared", "99"))
assert "No papers" in result.output
def test_related_papers_shown(self):
# A cites {B,C}; B also cites C → shared=1 with A
result = self._run("A", _sample_graph(), extra_args=("--min-shared", "1"))
assert result.exit_code == 0
# B shares C with A; should appear
assert "B" in result.output
# ---------------------------------------------------------------------------
# codex search paper --cite-boost
# ---------------------------------------------------------------------------
class TestSearchCiteBoost:
def _make_two_paper_conn(self, pr_winner: str) -> MagicMock:
"""Two papers A and B at equal distance; pr_winner has a high PageRank score."""
conn = MagicMock()
conn.execute.return_value.fetchall.return_value = [
{"id": "A", "title": "Paper A", "year": 2020, "distance": 0.5},
{"id": "B", "title": "Paper B", "year": 2021, "distance": 0.5},
]
return conn
def test_cite_boost_exits_zero(self):
conn = MagicMock()
conn.execute.return_value.fetchall.return_value = [
{"id": "A", "title": "Paper A", "year": 2020, "distance": 0.2},
]
graph = _sample_graph()
def _fake_encode(texts):
return np.zeros((len(texts), 1024), dtype="float32")
with (
patch("codex.db.get_conn", side_effect=_make_conn_cm(conn)),
patch("codex.embed.get_embedder") as mock_emb,
patch("codex.graph.build_citation_graph", return_value=graph),
):
mock_emb.return_value.encode_dense.side_effect = _fake_encode
result = runner.invoke(app, ["search", "paper", "hodge star", "--cite-boost"])
assert result.exit_code == 0
def test_cite_boost_promotes_high_pr_paper(self):
"""High-PR paper should appear first (lower boosted distance) via divide formula."""
import networkx as nx
# Build a graph where A has high PageRank (many papers cite A)
g = nx.DiGraph()
for i in range(10):
g.add_edge(f"P{i}", "A") # A is heavily cited → high PR
g.add_edge("P0", "B") # B is barely cited → low PR
conn = MagicMock()
conn.execute.return_value.fetchall.return_value = [
{"id": "A", "title": "Hub", "year": 2020, "distance": 0.5},
{"id": "B", "title": "Niche", "year": 2021, "distance": 0.5},
]
def _fake_encode(texts):
return np.zeros((len(texts), 1024), dtype="float32")
with (
patch("codex.db.get_conn", side_effect=_make_conn_cm(conn)),
patch("codex.embed.get_embedder") as mock_emb,
patch("codex.graph.build_citation_graph", return_value=g),
):
mock_emb.return_value.encode_dense.side_effect = _fake_encode
result = runner.invoke(app, ["search", "paper", "query", "--cite-boost"])
assert result.exit_code == 0
lines = [ln for ln in result.output.strip().splitlines() if "Hub" in ln or "Niche" in ln]
assert len(lines) == 2
# A (Hub) should appear before B (Niche) — lower boosted distance
hub_line = next(line for line in lines if "Hub" in line)
niche_line = next(line for line in lines if "Niche" in line)
assert lines.index(hub_line) < lines.index(niche_line)

311
tests/graph/test_graph.py Normal file
View File

@@ -0,0 +1,311 @@
"""Tests for codex.graph — citation graph analytics (F-15)."""
from __future__ import annotations
from unittest.mock import MagicMock
import networkx as nx
import pytest
from codex.graph import (
build_citation_graph,
citation_pagerank,
citing_coverage,
dangling_citations,
find_co_cited,
find_related,
)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
def _make_conn(rows: list[dict]) -> MagicMock:
conn = MagicMock()
conn.execute.return_value.fetchall.return_value = rows
return conn
def _citation_rows(*pairs: tuple[str, str]) -> list[dict]:
return [{"citing_id": a, "cited_id": b} for a, b in pairs]
def _small_graph() -> nx.DiGraph:
"""
A→C, A→D, B→C, B→D, B→E, C→D
Nodes: A B C D E (5 nodes ingested)
"""
rows = _citation_rows(
("A", "C"),
("A", "D"),
("B", "C"),
("B", "D"),
("B", "E"),
("C", "D"),
)
return build_citation_graph(_make_conn(rows))
# ---------------------------------------------------------------------------
# build_citation_graph
# ---------------------------------------------------------------------------
class TestBuildCitationGraph:
def test_empty_db_returns_empty_graph(self):
g = build_citation_graph(_make_conn([]))
assert isinstance(g, nx.DiGraph)
assert g.number_of_nodes() == 0
assert g.number_of_edges() == 0
def test_nodes_and_edges_populated(self):
rows = _citation_rows(("P1", "P2"), ("P1", "P3"), ("P2", "P3"))
g = build_citation_graph(_make_conn(rows))
assert set(g.nodes()) == {"P1", "P2", "P3"}
assert g.number_of_edges() == 3
def test_edge_direction(self):
rows = _citation_rows(("citing", "cited"))
g = build_citation_graph(_make_conn(rows))
assert g.has_edge("citing", "cited")
assert not g.has_edge("cited", "citing")
def test_duplicate_edges_collapsed(self):
rows = _citation_rows(("A", "B"), ("A", "B"))
g = build_citation_graph(_make_conn(rows))
assert g.number_of_edges() == 1
def test_dangling_nodes_included(self):
rows = _citation_rows(("P1", "not_in_papers"))
g = build_citation_graph(_make_conn(rows))
assert "not_in_papers" in g.nodes()
def test_cross_citation_uses_doi_when_in_kb(self):
# Simulate DB COALESCE: cited_id already resolved to papers.id (DOI)
# for in-KB papers; dangling references keep their OpenAlex ID.
rows = [
{
"citing_id": "https://doi.org/10.1234/paper-a",
"cited_id": "https://doi.org/10.5678/paper-b",
},
{
"citing_id": "https://doi.org/10.5678/paper-b",
"cited_id": "https://openalex.org/W999",
},
]
g = build_citation_graph(_make_conn(rows))
assert g.has_edge("https://doi.org/10.1234/paper-a", "https://doi.org/10.5678/paper-b")
assert g.has_edge("https://doi.org/10.5678/paper-b", "https://openalex.org/W999")
assert g.number_of_nodes() == 3
# ---------------------------------------------------------------------------
# citation_pagerank
# ---------------------------------------------------------------------------
class TestCitationPagerank:
def test_empty_graph_returns_empty_dict(self):
g = nx.DiGraph()
assert citation_pagerank(g) == {}
def test_scores_sum_to_one(self):
g = _small_graph()
pr = citation_pagerank(g)
assert abs(sum(pr.values()) - 1.0) < 1e-6
def test_hub_node_has_highest_score(self):
# D is pointed to by A, B, C → should rank highest
g = _small_graph()
pr = citation_pagerank(g)
assert pr["D"] == max(pr.values())
def test_all_nodes_present(self):
g = _small_graph()
pr = citation_pagerank(g)
assert set(pr.keys()) == set(g.nodes())
def test_small_graph_returns_uniform_scores(self):
# 3 nodes < _MIN_PAGERANK_NODES (5) → uniform
rows = _citation_rows(("A", "B"), ("B", "C"))
g = build_citation_graph(_make_conn(rows))
pr = citation_pagerank(g)
assert len(pr) == 3
scores = list(pr.values())
assert all(abs(s - scores[0]) < 1e-9 for s in scores)
def test_small_graph_logs_warning(self, caplog: pytest.LogCaptureFixture):
import logging
rows = _citation_rows(("A", "B"), ("B", "C"))
g = build_citation_graph(_make_conn(rows))
with caplog.at_level(logging.WARNING, logger="codex.graph"):
citation_pagerank(g)
assert any("corpus too small" in rec.message for rec in caplog.records)
def test_custom_damping(self):
g = _small_graph()
pr_85 = citation_pagerank(g, damping=0.85)
pr_50 = citation_pagerank(g, damping=0.50)
# Different damping → different scores (not all equal)
assert pr_85 != pr_50
# ---------------------------------------------------------------------------
# find_related (bibliographic coupling)
# ---------------------------------------------------------------------------
class TestFindRelated:
def _coupling_graph(self) -> nx.DiGraph:
"""
A cites: X Y Z
B cites: X Y
C cites: X
D cites: W (no overlap with A)
"""
rows = _citation_rows(
("A", "X"),
("A", "Y"),
("A", "Z"),
("B", "X"),
("B", "Y"),
("C", "X"),
("D", "W"),
)
return build_citation_graph(_make_conn(rows))
def test_unknown_paper_returns_empty(self):
g = self._coupling_graph()
assert find_related("UNKNOWN", g) == []
def test_paper_with_no_refs_returns_empty(self):
# X has no outgoing edges → no references to couple on
g = self._coupling_graph()
assert find_related("X", g) == []
def test_related_by_two_shared_refs(self):
g = self._coupling_graph()
related = find_related("A", g, min_shared=2)
assert "B" in related
def test_not_related_below_threshold(self):
g = self._coupling_graph()
related = find_related("A", g, min_shared=2)
assert "C" not in related # only 1 shared ref
def test_ordered_by_shared_count_desc(self):
g = self._coupling_graph()
related = find_related("A", g, min_shared=1)
# B shares 2 refs, C shares 1 → B before C
assert related.index("B") < related.index("C")
def test_self_not_in_results(self):
g = self._coupling_graph()
related = find_related("A", g, min_shared=1)
assert "A" not in related
# ---------------------------------------------------------------------------
# find_co_cited
# ---------------------------------------------------------------------------
class TestFindCoCited:
def _co_citation_graph(self) -> nx.DiGraph:
"""
P1 cites: A, B, C
P2 cites: A, B
P3 cites: A
"""
rows = _citation_rows(
("P1", "A"),
("P1", "B"),
("P1", "C"),
("P2", "A"),
("P2", "B"),
("P3", "A"),
)
return build_citation_graph(_make_conn(rows))
def test_unknown_paper_returns_empty(self):
g = self._co_citation_graph()
assert find_co_cited("UNKNOWN", g) == []
def test_paper_with_no_citers_returns_empty(self):
# C has citers but let's test a node that has no predecessors
rows = _citation_rows(("P1", "X"))
g = build_citation_graph(_make_conn(rows))
# "X" is cited once by P1, but P1 has no other citations
# X is co-cited with nothing
result = find_co_cited("X", g)
assert result == []
def test_most_co_cited_first(self):
g = self._co_citation_graph()
result = find_co_cited("A", g)
# B is co-cited twice (by P1 and P2); C once (by P1 only)
ids = [r[0] for r in result]
assert ids[0] == "B"
def test_self_not_included(self):
g = self._co_citation_graph()
result = find_co_cited("A", g)
assert all(pid != "A" for pid, _ in result)
def test_counts_correct(self):
g = self._co_citation_graph()
result = dict(find_co_cited("A", g))
assert result["B"] == 2 # P1 and P2 both cite A and B
assert result["C"] == 1 # only P1 cites A and C
# ---------------------------------------------------------------------------
# dangling_citations
# ---------------------------------------------------------------------------
class TestDanglingCitations:
def test_empty_graph(self):
assert dangling_citations(nx.DiGraph(), set()) == []
def test_all_known_returns_empty(self):
rows = _citation_rows(("A", "B"), ("B", "C"))
g = build_citation_graph(_make_conn(rows))
assert dangling_citations(g, {"A", "B", "C"}) == []
def test_identifies_dangling(self):
rows = _citation_rows(("A", "B"), ("A", "external_doi"))
g = build_citation_graph(_make_conn(rows))
dangling = dangling_citations(g, {"A", "B"})
assert "external_doi" in dangling
assert "A" not in dangling
assert "B" not in dangling
def test_empty_known_set(self):
rows = _citation_rows(("A", "B"))
g = build_citation_graph(_make_conn(rows))
dangling = dangling_citations(g, set())
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,7 +10,9 @@ 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 pytest import pytest
from codex.ingest import IngestResult, _make_bibkey, ingest_paper from codex.ingest import IngestResult, _make_bibkey, ingest_paper
@@ -106,13 +108,99 @@ 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:
"""papers.id is the caller-supplied id, not OpenAlex's URL-form id (audit C-7).
Real OpenAlex returns the doi/id as a full URL; ingest must key the paper on
the bare id the caller passed (matching ingest scripts and CLI lookups), and
keep the OpenAlex work id only as openalex_id.
"""
oa_paper = Paper(
id="https://doi.org/10.48550/arxiv.math/0603097", # URL form, as OpenAlex emits
title="A Test Paper",
openalex_id="https://openalex.org/W4301005794",
authors=["Alice", "Bob"],
year=2008,
abstract="Test abstract.",
)
mock_conn = MagicMock()
with (
patch("codex.ingest.openalex.fetch_paper", return_value=oa_paper),
patch("codex.ingest.openalex.fetch_citations", return_value=[]),
patch("codex.ingest.get_embedder", return_value=_fake_embedder()),
patch("codex.ingest.get_conn", side_effect=_make_conn_cm(mock_conn)),
):
result = ingest_paper("math/0603097")
papers_params = mock_conn.execute.call_args_list[0][0][1]
assert papers_params["id"] == "math/0603097", "papers.id must be the caller's bare id"
assert papers_params["openalex_id"] == "https://openalex.org/W4301005794"
assert result.paper_id == "math/0603097"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 2. test_ingest_paper_source_tex # 2. test_ingest_paper_source_tex
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
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.")
@@ -122,15 +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),
): ):
result = ingest_paper(paper.id, source_path=str(tex_file)) result = ingest_paper(paper.id, source_path=str(tex_file))
@@ -145,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)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -173,11 +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.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))
@@ -193,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
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -245,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()
@@ -254,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
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -283,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)),
): ):
@@ -318,9 +598,11 @@ def test_ingest_paper_rich_calls_formula_and_figure_parsers(tmp_path: Any) -> No
mock_conn.executemany = MagicMock() mock_conn.executemany = MagicMock()
mock_conn.commit = MagicMock() mock_conn.commit = MagicMock()
fake_formula = FormulaChunk(paper_id=paper.id, page=1, raw_latex=r"\alpha", context="test") # Extractors derive paper_id from the PDF filename stem ("paper"), which differs
# from papers.id — ingest must insert papers.id, not this stem (audit C-10).
fake_formula = FormulaChunk(paper_id="paper", page=1, raw_latex=r"\alpha", context="test")
fake_figure = FigureChunk( fake_figure = FigureChunk(
paper_id=paper.id, page=1, image_path="/tmp/fig.png", caption="Figure 1." paper_id="paper", page=1, image_path="/tmp/fig.png", caption="Figure 1."
) )
mock_settings = MagicMock() mock_settings = MagicMock()
@@ -347,6 +629,16 @@ def test_ingest_paper_rich_calls_formula_and_figure_parsers(tmp_path: Any) -> No
assert result.formulas_upserted == 1 assert result.formulas_upserted == 1
assert result.figures_upserted == 1 assert result.figures_upserted == 1
# C-10: formula/figure rows must be keyed on papers.id, not the extractor's
# filename-stem paper_id, or the FK to papers(id) is violated.
cursor = mock_conn.cursor.return_value.__enter__.return_value
inserted_paper_ids = {
row[0] for call in cursor.executemany.call_args_list for row in call[0][1]
}
assert inserted_paper_ids == {paper.id}, (
f"formula/figure inserts must use papers.id, got {inserted_paper_ids}"
)
def test_ingest_paper_no_rich_skips_parsers(tmp_path: Any) -> None: def test_ingest_paper_no_rich_skips_parsers(tmp_path: Any) -> None:
"""When rich=False (default), formula and figure parsers are NOT called.""" """When rich=False (default), formula and figure parsers are NOT called."""
@@ -480,6 +772,156 @@ 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:
"""A papers.bibkey UNIQUE collision is retried with an a/b/c suffix (audit C-15)."""
paper = _make_paper() # bibkey auto-generates to "AliceBob2023"
mock_conn = MagicMock()
state = {"first": True}
def execute_side_effect(sql: str, params: Any = None) -> MagicMock:
# The first papers INSERT collides on the bibkey; the retry must succeed.
if "INSERT INTO papers" in sql and state["first"]:
state["first"] = False
raise psycopg.errors.UniqueViolation(
'duplicate key value violates unique constraint "papers_bibkey_key"'
)
return MagicMock()
mock_conn.execute.side_effect = execute_side_effect
with (
patch("codex.ingest.openalex.fetch_paper", return_value=paper),
patch("codex.ingest.openalex.fetch_citations", return_value=[]),
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)
papers_inserts = [
c for c in mock_conn.execute.call_args_list if "INSERT INTO papers" in c[0][0]
]
assert len(papers_inserts) == 2, "expected one retry after the bibkey collision"
assert papers_inserts[0][0][1]["bibkey"] == "AliceBob2023"
assert papers_inserts[1][0][1]["bibkey"] == "AliceBob2023a"
mock_conn.rollback.assert_called_once()
def test_ingest_openalex_collision_raises_clear_error() -> None:
"""An openalex_id UNIQUE collision raises a clear, actionable error (audit C-15).
Unlike a bibkey clash (auto-suffixed), a shared openalex_id means two papers
claim the same OpenAlex work — a real data conflict that must surface, not the
raw psycopg error.
"""
paper = _make_paper()
mock_conn = MagicMock()
def execute_side_effect(sql: str, params: Any = None) -> MagicMock:
if "INSERT INTO papers" in sql:
raise psycopg.errors.UniqueViolation(
'duplicate key value violates unique constraint "papers_openalex_id_key"'
)
return MagicMock()
mock_conn.execute.side_effect = execute_side_effect
with (
patch("codex.ingest.openalex.fetch_paper", return_value=paper),
patch("codex.ingest.openalex.fetch_citations", return_value=[]),
patch("codex.ingest.get_embedder", return_value=_fake_embedder()),
patch("codex.ingest.get_conn", side_effect=_make_conn_cm(mock_conn)),
pytest.raises(ValueError, match="openalex_id"),
):
ingest_paper(paper.id)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@@ -12,6 +12,7 @@ from __future__ import annotations
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import pytest import pytest
from pydantic import SecretStr
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# _require_http_token # _require_http_token
@@ -37,7 +38,7 @@ def test_require_http_token_returns_token_when_set() -> None:
from codex.mcp_server import _require_http_token from codex.mcp_server import _require_http_token
mock_settings = MagicMock() mock_settings = MagicMock()
mock_settings.mcp_auth_token = "super-secret-token" mock_settings.mcp_auth_token = SecretStr("super-secret-token")
with patch("codex.config.get_settings", return_value=mock_settings): with patch("codex.config.get_settings", return_value=mock_settings):
result = _require_http_token() result = _require_http_token()
@@ -97,7 +98,7 @@ def test_main_http_with_token_calls_uvicorn() -> None:
mock_settings = MagicMock() mock_settings = MagicMock()
mock_settings.mcp_transport = "http" mock_settings.mcp_transport = "http"
mock_settings.mcp_auth_token = "test-token" mock_settings.mcp_auth_token = SecretStr("test-token")
mock_settings.mcp_host = "127.0.0.1" mock_settings.mcp_host = "127.0.0.1"
mock_settings.mcp_port = 8765 mock_settings.mcp_port = 8765

View File

@@ -10,6 +10,7 @@ from typing import Any
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import pytest import pytest
from pydantic import SecretStr
from codex.models import FormulaChunk from codex.models import FormulaChunk
@@ -295,8 +296,8 @@ class TestRouteSelection:
def test_mathpix_selected_when_creds_present(self) -> None: def test_mathpix_selected_when_creds_present(self) -> None:
"""extract_formulas calls MathPix backend when both creds are set.""" """extract_formulas calls MathPix backend when both creds are set."""
mock_settings = MagicMock() mock_settings = MagicMock()
mock_settings.mathpix_app_id = "my_id" mock_settings.mathpix_app_id = SecretStr("my_id")
mock_settings.mathpix_app_key = "my_key" mock_settings.mathpix_app_key = SecretStr("my_key")
mock_settings.pix2tex_fallback = True mock_settings.pix2tex_fallback = True
with ( with (

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

View File

@@ -0,0 +1,302 @@
"""Tests for codex.quality — chunk quality gate and section classification (F-16)."""
from __future__ import annotations
from unittest.mock import MagicMock
from codex.quality import (
_bib_score,
_clean_title,
classify_section,
filter_chunks,
is_quality_chunk,
run_quality_pass,
section_label,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _settings(min_chars: int = 60, min_alpha: float = 0.40, max_bib: float = 0.70):
s = MagicMock()
s.chunk_min_chars = min_chars
s.chunk_min_alpha_ratio = min_alpha
s.chunk_max_bib_score = max_bib
return s
GOOD_TEXT = (
"In differential geometry, the discrete LaplaceBeltrami operator "
"generalises the smooth operator to polyhedral surfaces by using cotangent "
"weights. This discretisation preserves key spectral properties."
)
BIB_TEXT = (
"Bobenko, A. and Pinkall, U. (2015) Discrete differential geometry. "
"doi:10.1145/2010324.1964978 Smith et al. (2018) Surface remeshing. "
"doi:10.1007/s00211-019-01082-w Jones et al. (2020) doi:10.1145/1234567.890123"
)
# ---------------------------------------------------------------------------
# _bib_score
# ---------------------------------------------------------------------------
class TestBibScore:
def test_empty_string(self):
assert _bib_score("") == 0.0
def test_clean_prose_is_low(self):
assert _bib_score(GOOD_TEXT) < 0.10
def test_bib_like_text_is_high(self):
assert _bib_score(BIB_TEXT) > 0.70
def test_single_doi_short_text_spikes(self):
text = "See doi:10.1145/2010324.1964978 for details."
# 8 words, 1 DOI (weight 3) → raw = 3 / max(0.8, 1) = 3.0 → capped 1.0
assert _bib_score(text) == 1.0
def test_score_capped_at_one(self):
# _DOI_RE requires \d{4,} — use a valid registrant length
heavy = "10.1145/abcdef " * 20
assert _bib_score(heavy) == 1.0
# ---------------------------------------------------------------------------
# is_quality_chunk
# ---------------------------------------------------------------------------
class TestIsQualityChunk:
def test_passes_good_text(self):
assert is_quality_chunk(GOOD_TEXT, settings=_settings()) is True
def test_fails_too_short(self):
assert is_quality_chunk("Short.", settings=_settings(min_chars=60)) is False
def test_fails_low_alpha_ratio(self):
# Mostly digits and symbols
junk = "1234 5678 9012 3456 7890 |||| ==== ---- ____ .... ;;;; 0000 9999 8888 7777"
assert is_quality_chunk(junk, settings=_settings(min_alpha=0.40)) is False
def test_fails_high_bib_score(self):
assert is_quality_chunk(BIB_TEXT, settings=_settings(max_bib=0.70)) is False
def test_bib_threshold_is_strict_gt(self):
# A text that scores exactly at the threshold should still pass (> not >=)
s = _settings(max_bib=1.0)
assert is_quality_chunk(GOOD_TEXT, settings=s) is True
def test_exactly_min_chars_passes(self):
# check is `len < min_chars`, so exactly at the threshold passes
text = "a" * 60
assert is_quality_chunk(text, settings=_settings(min_chars=60)) is True
def test_one_below_min_chars_fails(self):
text = "a" * 59
assert is_quality_chunk(text, settings=_settings(min_chars=60)) is False
# ---------------------------------------------------------------------------
# classify_section
# ---------------------------------------------------------------------------
class TestClassifySection:
def test_abstract_header(self):
assert classify_section("Abstract\n\nThis paper presents...") == "abstract"
def test_abstract_german(self):
assert classify_section("Zusammenfassung\nDiese Arbeit...") == "abstract"
def test_intro_header(self):
assert classify_section("Introduction\nOver the past decade...") == "intro"
def test_intro_numbered(self):
assert classify_section("1. Introduction\nWe study...") == "intro"
def test_theorem_header(self):
assert classify_section("Theorem 3.1 (Existence). Let M be...") == "theorem"
def test_lemma_header(self):
assert classify_section("Lemma 2. If f is smooth then...") == "theorem"
def test_proof_header(self):
assert classify_section("Proof. We proceed by induction...") == "proof"
def test_bibliography_header(self):
assert classify_section("References\n[1] Bobenko, A....") == "bibliography"
def test_bibliography_german(self):
assert classify_section("Literatur\n[1] Müller, H....") == "bibliography"
def test_body_fallback(self):
assert classify_section("The cotangent weight for edge (i,j) is...") == "body"
def test_doi_density_fallback_to_bibliography(self):
# No "References" header but heavy DOI density → bibliography
dense = BIB_TEXT
assert classify_section(dense) == "bibliography"
def test_only_first_200_chars_checked(self):
# Pattern in position >200 should NOT be matched as section header
prefix = "x" * 201
text = prefix + "\nAbstract"
assert classify_section(text) == "body"
# ---------------------------------------------------------------------------
# filter_chunks
# ---------------------------------------------------------------------------
class TestFilterChunks:
def test_empty_list(self):
assert filter_chunks([], settings=_settings()) == []
def test_all_good(self):
chunks = [GOOD_TEXT, GOOD_TEXT + " More content here."]
assert filter_chunks(chunks, settings=_settings()) == chunks
def test_all_bad(self):
chunks = ["x", "y"]
assert filter_chunks(chunks, settings=_settings()) == []
def test_mixed(self):
chunks = [GOOD_TEXT, "Short.", GOOD_TEXT]
result = filter_chunks(chunks, settings=_settings())
assert len(result) == 2
assert all(c == GOOD_TEXT for c in result)
# ---------------------------------------------------------------------------
# run_quality_pass
# ---------------------------------------------------------------------------
def _make_mock_conn(rows: list[dict]) -> MagicMock:
"""Return a mock psycopg connection whose execute().fetchall() returns rows."""
conn = MagicMock()
conn.execute.return_value.fetchall.return_value = rows
return conn
class TestRunQualityPass:
def test_removes_bad_chunk(self):
conn = _make_mock_conn([{"id": 1, "content": "x"}])
stats = run_quality_pass(conn=conn, settings=_settings())
assert stats["removed"] == 1
assert stats["kept"] == 0
# The DELETE must have been called with chunk id=1
delete_calls = [c for c in conn.execute.call_args_list if "DELETE" in str(c)]
assert len(delete_calls) == 1
def test_tags_good_chunk(self):
conn = _make_mock_conn([{"id": 2, "content": GOOD_TEXT}])
stats = run_quality_pass(conn=conn, settings=_settings())
assert stats["kept"] == 1
assert stats["tagged"] == 1
assert stats["removed"] == 0
# UPDATE must have been called
update_calls = [c for c in conn.execute.call_args_list if "UPDATE" in str(c)]
assert len(update_calls) == 1
def test_mixed_batch(self):
rows = [
{"id": 1, "content": "x"},
{"id": 2, "content": GOOD_TEXT},
{"id": 3, "content": "y"},
{"id": 4, "content": GOOD_TEXT + " extra words here."},
]
conn = _make_mock_conn(rows)
stats = run_quality_pass(conn=conn, settings=_settings())
assert stats["removed"] == 2
assert stats["kept"] == 2
assert stats["tagged"] == 2
def test_paper_id_scopes_query(self):
conn = _make_mock_conn([])
run_quality_pass(paper_id="2301.07041", conn=conn, settings=_settings())
first_call = conn.execute.call_args_list[0]
sql = first_call[0][0]
assert "paper_id" in sql
def test_all_papers_query_has_no_filter(self):
conn = _make_mock_conn([])
run_quality_pass(paper_id=None, conn=conn, settings=_settings())
first_call = conn.execute.call_args_list[0]
sql = first_call[0][0]
assert "paper_id" not in sql
def test_commit_is_called(self):
conn = _make_mock_conn([])
run_quality_pass(conn=conn, settings=_settings())
conn.commit.assert_called_once()
def test_section_label_stored_correctly(self):
conn = _make_mock_conn([{"id": 5, "content": "Abstract\n\n" + GOOD_TEXT}])
run_quality_pass(conn=conn, settings=_settings())
update_calls = [c for c in conn.execute.call_args_list if "UPDATE" in str(c)]
assert len(update_calls) == 1
kwargs = update_calls[0][0][1]
assert kwargs["section"] == "abstract"
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

@@ -8,7 +8,10 @@ from codex.config import Settings, get_settings
def test_settings_defaults() -> None: def test_settings_defaults() -> None:
"""Settings() is constructable without env vars; expected defaults are set.""" """Settings() is constructable without env vars; expected defaults are set."""
s = Settings() s = Settings()
assert s.database_url == "postgresql://researcher:change_me@localhost:5432/papers" assert (
s.database_url.get_secret_value()
== "postgresql://researcher:change_me@localhost:5432/papers"
)
assert s.embedding_model == "BAAI/bge-m3" assert s.embedding_model == "BAAI/bge-m3"
assert s.embedding_dim == 1024 assert s.embedding_dim == 1024
@@ -20,7 +23,7 @@ def test_settings_env_override(monkeypatch: object) -> None:
mp: pytest.MonkeyPatch = monkeypatch # type: ignore[assignment] mp: pytest.MonkeyPatch = monkeypatch # type: ignore[assignment]
mp.setenv("DATABASE_URL", "postgresql://x:y@h:5432/db") mp.setenv("DATABASE_URL", "postgresql://x:y@h:5432/db")
s = Settings() s = Settings()
assert s.database_url == "postgresql://x:y@h:5432/db" assert s.database_url.get_secret_value() == "postgresql://x:y@h:5432/db"
def test_get_settings_is_singleton() -> None: def test_get_settings_is_singleton() -> None:

View File

@@ -33,3 +33,40 @@ def test_apply_schema_executes_sql() -> None:
mock_conn.execute.assert_called_once() mock_conn.execute.assert_called_once()
mock_conn.commit.assert_called_once() mock_conn.commit.assert_called_once()
def test_schema_sql_is_idempotent() -> None:
"""Every CREATE TABLE/INDEX in infra/schema.sql uses IF NOT EXISTS (audit M-1, T-2).
Re-applying schema.sql must be a safe no-op so the migration can run
repeatedly against an existing database.
"""
import re
from pathlib import Path
schema = (Path(__file__).resolve().parents[2] / "infra" / "schema.sql").read_text()
# Drop line comments so commented-out example queries don't count.
code = "\n".join(line.split("--", 1)[0] for line in schema.splitlines())
non_idempotent = re.findall(
r"CREATE\s+(?:UNIQUE\s+)?(?:TABLE|INDEX)\s+(?!IF\s+NOT\s+EXISTS)(\w+)",
code,
re.IGNORECASE,
)
assert not non_idempotent, f"schema.sql has non-idempotent CREATE statements: {non_idempotent}"
def test_migrate_command_reports_privilege_error() -> None:
"""`codex migrate` surfaces InsufficientPrivilege with guidance and exits 1 (M-1)."""
import psycopg
from typer.testing import CliRunner
from codex.cli import app
def boom(*args: object, **kwargs: object) -> object:
raise psycopg.errors.InsufficientPrivilege("must be owner of table chunks")
with patch("psycopg.connect", boom):
result = CliRunner().invoke(app, ["migrate"])
assert result.exit_code == 1
assert "MIGRATION_DATABASE_URL" in result.output

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,9 @@ from codex.sources.openalex import _resolve_id
_SAMPLE_WORK = { _SAMPLE_WORK = {
"id": "https://openalex.org/W2741809807", "id": "https://openalex.org/W2741809807",
"doi": "10.1145/3592430", # 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",
"title": "Conformal Prediction: A Review", "title": "Conformal Prediction: A Review",
"publication_year": 2023, "publication_year": 2023,
"authorships": [ "authorships": [
@@ -67,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
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -85,6 +168,12 @@ 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"]

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

View File

@@ -55,9 +55,7 @@ def isolated_leads_dir(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path:
monkeypatch.setattr("codex.cli.get_settings", lambda: settings, raising=False) monkeypatch.setattr("codex.cli.get_settings", lambda: settings, raising=False)
monkeypatch.setattr("codex.synthesis.get_settings", lambda: settings) monkeypatch.setattr("codex.synthesis.get_settings", lambda: settings)
monkeypatch.setattr( monkeypatch.setattr("codex.config.get_settings", lambda: settings, raising=False)
"codex.config.get_settings", lambda: settings, raising=False
)
return leads_path return leads_path

View File

@@ -112,10 +112,35 @@ def test_find_gaps_coverage_map_low_coverage_triggers_llm(
"For an ideal tetrahedron with dihedral angles the hyperbolic volume is " "For an ideal tetrahedron with dihedral angles the hyperbolic volume is "
"V = L(gamma1) + L(gamma2) + L(gamma3). [Springborn2008 #chunk 16]\n" "V = L(gamma1) + L(gamma2) + L(gamma3). [Springborn2008 #chunk 16]\n"
) )
leads = find_gaps(llm=StubLLM(grounded_body)) # Coverage gaps are opt-in (audit R-8): pass the topic explicitly.
leads = find_gaps(llm=StubLLM(grounded_body), topics=["hyperbolic volume formula"])
assert any(lead.kind == "gap" for lead in leads) assert any(lead.kind == "gap" for lead in leads)
def test_find_gaps_no_coverage_gaps_without_explicit_topics(
monkeypatch: pytest.MonkeyPatch,
mock_paper_titles: None,
mock_settings: Path,
) -> None:
"""Without explicit topics, paper titles do NOT auto-generate coverage gaps (R-8)."""
sparse_chunks: list[dict[str, Any]] = [
{
"id": 10,
"paper_id": "springborn-2008",
"ord": 16,
"content": "the hyperbolic volume is V = L(gamma1) + L(gamma2) + L(gamma3)",
"bibkey": "Springborn2008",
},
]
monkeypatch.setattr(
"codex.synthesis._retrieve_chunks",
lambda queries, top_k: [dict(c) for c in sparse_chunks],
)
# No lib_path, no topics → the coverage map must not run despite sparse coverage.
leads = find_gaps(llm=StubLLM("a gap. [Springborn2008 #chunk 16]"))
assert leads == []
def test_find_gaps_explicit_topics_override_default( def test_find_gaps_explicit_topics_override_default(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
mock_paper_titles: None, mock_paper_titles: None,

View File

@@ -76,7 +76,7 @@ def test_finalise_grounded_lead_succeeds_when_grounded(
) )
assert lead is not None assert lead is not None
assert lead.kind == "connection" assert lead.kind == "connection"
assert lead.id == "L-0001" assert lead.id == "L-C-0001" # kind-prefixed id (audit C-11)
assert lead.confidence >= 0.5 assert lead.confidence >= 0.5
assert lead.status == "unverified" assert lead.status == "unverified"
assert lead.suggested_validation == "" assert lead.suggested_validation == ""
@@ -148,7 +148,8 @@ def test_find_gaps_topic_with_no_chunks(
) -> None: ) -> None:
"""When retrieval returns no chunks, a gap lead is emitted directly.""" """When retrieval returns no chunks, a gap lead is emitted directly."""
monkeypatch.setattr("codex.synthesis._retrieve_chunks", lambda queries, top_k: []) monkeypatch.setattr("codex.synthesis._retrieve_chunks", lambda queries, top_k: [])
leads = find_gaps(llm=StubLLM("")) # Coverage gaps are opt-in now (audit R-8): pass an explicit topic.
leads = find_gaps(llm=StubLLM(""), topics=["nonexistent topic"])
assert len(leads) >= 1 assert len(leads) >= 1
assert all(lead.kind == "gap" for lead in leads) assert all(lead.kind == "gap" for lead in leads)
# Direct gap leads carry a validation hint (re-ingest) # Direct gap leads carry a validation hint (re-ingest)

View File

@@ -92,9 +92,21 @@ def test_lead_empty_provenance_raises() -> None:
def test_lead_id_format_helper() -> None: def test_lead_id_format_helper() -> None:
"""The internal _make_lead_id helper produces zero-padded L-XXXX strings.""" """_make_lead_id produces a kind-prefixed, zero-padded L-<K>-XXXX string."""
from codex.synthesis import _make_lead_id from codex.synthesis import _make_lead_id
assert _make_lead_id(1) == "L-0001" assert _make_lead_id(1, "connection") == "L-C-0001"
assert _make_lead_id(42) == "L-0042" assert _make_lead_id(42, "gap") == "L-G-0042"
assert _make_lead_id(9999) == "L-9999" assert _make_lead_id(9999, "improvement") == "L-I-9999"
assert _make_lead_id(1, "conjecture") == "L-X-0001"
def test_lead_id_namespaced_per_kind_no_collision() -> None:
"""Regression for audit C-11: each stage numbers from 1, so without a kind
prefix connection/gap/improvement all collide on L-0001 and overwrite one
another in grounded/. The prefix must keep same-seq ids distinct.
"""
from codex.synthesis import _make_lead_id
ids = {_make_lead_id(1, k) for k in ("connection", "gap", "improvement", "conjecture")}
assert len(ids) == 4, f"same-seq ids must stay distinct across kinds, got {ids}"

View File

@@ -201,9 +201,7 @@ def test_grounding_guard_last_sentence_grounded_earlier_hallucination_ignored()
This test verifies that a claim whose text = the last sentence grounds correctly. This test verifies that a claim whose text = the last sentence grounds correctly.
""" """
# The last sentence of the claim text is directly grounded in the fixture chunk. # The last sentence of the claim text is directly grounded in the fixture chunk.
grounded_sentence = ( grounded_sentence = "the hyperbolic volume is V = L(gamma1) + L(gamma2) + L(gamma3)"
"the hyperbolic volume is V = L(gamma1) + L(gamma2) + L(gamma3)"
)
claim = Claim( claim = Claim(
text=grounded_sentence, text=grounded_sentence,
bibkey="Springborn2008", bibkey="Springborn2008",
@@ -215,6 +213,47 @@ def test_grounding_guard_last_sentence_grounded_earlier_hallucination_ignored()
) )
def test_grounding_guard_robust_to_trailing_comma() -> None:
"""A faithful 5-word claim grounds despite a trailing comma (audit R-1).
The chunk says "... the volume function V0 is strictly concave on the angle
domain." Before edge-punctuation stripping, the claim token "concave," != the
chunk token "concave", so the only content-5-gram failed to match and the
claim was wrongly marked ungrounded.
"""
claim = Claim(
text="the volume function V0 is strictly concave,",
bibkey="Springborn2008",
locator="chunk 16",
)
result = _run_grounding_guard([claim], FIXTURE_CHUNKS)
assert result[0].grounded is True
def test_grounding_guard_no_substring_bleed_across_tokens() -> None:
"""A 5-gram must match whole tokens, not bleed into a longer chunk word (audit C-6).
The chunk contains 'subset'; a claim whose first token is 'set' must NOT ground
by substring-matching the suffix of 'subset'.
"""
chunks = [
{
"id": 99,
"paper_id": "p",
"ord": 0,
"bibkey": "TestBib",
"content": "the subset conformal map energy functional is minimized here",
}
]
claim = Claim(
text="the set conformal map energy functional",
bibkey="TestBib",
locator="chunk 0",
)
result = _run_grounding_guard([claim], chunks)
assert result[0].grounded is False
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# _inject_cross_refs # _inject_cross_refs
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -276,6 +315,19 @@ def test_inject_cross_refs_skips_inline_code() -> None:
assert "[[circle-packing]]" in result # bare occurrence replaced assert "[[circle-packing]]" in result # bare occurrence replaced
def test_inject_cross_refs_skips_math_spans() -> None:
"""Concept names inside LaTeX math are NOT rewritten to [[slug]] links (audit R-9)."""
concepts = [
Concept(slug="energy", title="Energy", aliases=[]),
FIXTURE_CONCEPT,
]
text = "Prose Energy here. Math $x = Energy^2$ and inline \\(Energy\\) stay literal."
result = _inject_cross_refs(text, concepts, current_slug="lobachevsky-function")
assert "$x = Energy^2$" in result # display/inline $...$ math untouched
assert "\\(Energy\\)" in result # \(...\) math untouched
assert "Prose [[energy]] here." in result # prose occurrence still linked
def test_inject_cross_refs_full_list_even_when_single_concept() -> None: def test_inject_cross_refs_full_list_even_when_single_concept() -> None:
"""Cross-ref injection uses the full concept list, not just the compiled concept.""" """Cross-ref injection uses the full concept list, not just the compiled concept."""
concepts = [ concepts = [
@@ -506,6 +558,47 @@ def test_log_append_records_skipped(
assert "circle-packing" in content assert "circle-packing" in content
# ---------------------------------------------------------------------------
# Audit C-4: zero-citation pages must NOT bypass the quarantine
# ---------------------------------------------------------------------------
def test_zero_citation_page_is_quarantined_not_published(
mock_retrieve: None,
mock_formulas: None,
mock_settings: Path,
) -> None:
"""A confident-but-uncited LLM response yields zero parseable claims
(grounding_rate 0.0). It must be quarantined — not written to wiki/ and
marked compiled. Regression for audit C-4: the old ``total_claims > 0``
guard let zero-claim (and LLM-outage empty) pages fall through to wiki/.
"""
from codex.wiki import compile_all
(mock_settings / "concepts.yaml").write_text(
textwrap.dedent(
"""\
concepts:
- slug: lobachevsky-function
title: Lobachevsky Function
aliases: [Milnor Lobachevsky]
"""
),
encoding="utf-8",
)
# No [BibKey] citations anywhere → zero claims → grounding_rate 0.0.
uncited = MockLLM(
"The Lobachevsky function is obviously central to all of geometry. "
"Every result follows from it. This paragraph cites nothing at all."
)
report = compile_all(changed_only=False, llm=uncited, output_dir=str(mock_settings))
assert "lobachevsky-function" in report.quarantined
assert "lobachevsky-function" not in report.compiled
assert not (mock_settings / "lobachevsky-function.md").exists()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Idempotency: second compile without chunk change → no rewrite # Idempotency: second compile without chunk change → no rewrite
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -523,9 +616,7 @@ def test_idempotent_no_rewrite_when_unchanged(
call_count = 0 call_count = 0
original_compile = compile_concept original_compile = compile_concept
def counting_compile( def counting_compile(concept: Concept, chunks: list[Any], **kwargs: Any) -> ConceptPage:
concept: Concept, chunks: list[Any], **kwargs: Any
) -> ConceptPage:
nonlocal call_count nonlocal call_count
call_count += 1 call_count += 1
return original_compile(concept, chunks, **kwargs) return original_compile(concept, chunks, **kwargs)
@@ -569,9 +660,7 @@ def test_force_all_recompiles_even_when_unchanged(
call_count = 0 call_count = 0
original_compile = compile_concept original_compile = compile_concept
def counting_compile( def counting_compile(concept: Concept, chunks: list[Any], **kwargs: Any) -> ConceptPage:
concept: Concept, chunks: list[Any], **kwargs: Any
) -> ConceptPage:
nonlocal call_count nonlocal call_count
call_count += 1 call_count += 1
return original_compile(concept, chunks, **kwargs) return original_compile(concept, chunks, **kwargs)