54 Commits

Author SHA1 Message Date
Tarik Moussa
52d49dcfc6 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:37 +02: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
1a9afb4433 Merge pull request 'fix(ingest): D-04 bibkey heuristic + D-05a/b/c regression tests + __main__' (#9) from fix/d04-d05-ingest-fixes into main 2026-06-15 01:07:35 +00:00
Tarik Moussa
b87087d3c6 fix(ingest): D-04 bibkey heuristic + D-05a/b/c regression tests + __main__
D-04: add _make_bibkey(paper) — ConcatSurnames+Year, ≤3 authors full list,
      >3 authors FirstEtAlYear. Applied in ingest_paper when paper.bibkey is
      None. ON CONFLICT now fills NULL bibkeys via COALESCE(papers.bibkey,
      EXCLUDED.bibkey) while preserving manually-set values.

D-05b: add codex/__main__.py so `python -m codex` dispatches to the CLI.

D-05c: fix test_ingest.py:75 — rename var to raw_api_citations (the raw
       OpenAlex payload with citing_id=openalex_id) and add regression
       assertion: citing_id written to DB must equal paper.id (DOI), not
       paper.openalex_id.

D-05a (proxy): add TestEntryPoint.test_python_m_codex_help — runs
       `sys.executable -m codex --help` via subprocess, covering the
       non-uv-run entry path. 253 tests green, ruff+mypy clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 03:07:06 +02:00
Tarik Moussa
335ca2c923 docs(adr): ADR-F13 synthesis engine — GO + zero-fact-leak enforcement (N-02)
Closes audit N-02: docs/adr/ADR-F13-synthesis-engine.md was missing,
blocking R-34. Documents spike result (GO, Opus APPROVE 2 passes),
four-stage lead taxonomy, grounding discipline, three-level
zero-fact-leak invariant, and falsification-gate in _parse_conjecture_output.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 01:54:53 +02:00
36a5d9566a Merge pull request 'fix(docs): correct provenance CLI command names in README (D-01)' (#8) from fix/cli-readme-drift into main 2026-06-14 23:51:07 +00:00
Tarik Moussa
b12789a47f fix(docs): correct provenance CLI command names in README (D-01)
Audit D-01 (2026-06-15): README documented `provenance sync` and
`provenance export-bib` but cli.py:161/195 defines `scan` and `bib`.
Fix: update both occurrences (prose + CLI reference block).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 00:49:50 +02:00
1bec18ae82 Merge pull request 'fix(ingest): support .txt sources + fix citing_id FK violation' (#7) from fix/ingest-txt-and-citing-id into main
fix(ingest): support .txt sources + fix citing_id FK violation — Opus APPROVE
2026-06-14 13:56:04 +00:00
Tarik Moussa
5025ff3a88 fix(ingest): support .txt sources + fix citing_id FK violation
Two bugs exposed during first batch ingest run after D-03 fix:

1. `.txt` files were silently skipped ("Unbekannter Dateityp") — added
   plain-text read path alongside .tex and .pdf.

2. After D-03, OpenAlex resolves arXiv IDs → paper.id uses the DOI
   (https://doi.org/…) but fetch_citations emits citing_id=openalex_id
   (https://openalex.org/W…). FK constraint citations_citing_id_fkey
   → papers.id violated. Fix: rewrite citing_id to paper.id after
   fetching from OpenAlex.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 15:54:05 +02:00
6c28446644 Merge pull request 'fix(mcp): disable DNS rebinding protection for LAN HTTP transport' (#6) from fix/mcp-transport-security into main
fix(mcp): disable DNS rebinding protection for LAN HTTP transport
2026-06-14 12:55:59 +00:00
Tarik Moussa
d29dcf2fb5 fix(mcp): disable DNS rebinding protection for LAN HTTP transport
FastMCP defaults to DNS rebinding protection (allowed_hosts=localhost only),
which rejects requests from the Mac (192.168.178.82) to the Jetson
(192.168.178.103:8765). Security is already provided by Bearer token auth
in _TokenAuth middleware and UFW restricting port 8765 to trusted LAN IPs,
making host-header validation redundant in this deployment context.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 14:55:29 +02:00
1d1203b225 Merge pull request 'fix(sources): resolve arXiv IDs via arXiv DOI (OpenAlex /works/arxiv: 404s)' (#5) from fix/openalex-arxiv-doi into main
fix(sources): resolve arXiv IDs via arXiv DOI (OpenAlex /works/arxiv: 404s)
2026-06-14 12:31:33 +00:00
Tarik Moussa
50e6bb8210 fix(config): remove duplicate F-13 settings block (merge artefact)
F-13 settings (leads_dir, synthesis_llm_*, synthesis_top_k,
synthesis_min_grounded_ratio, synthesis_gap_min_coverage) appeared twice
due to independent commits on F-13 and F-14 branches. Kept the second
(more complete) block; removed the first.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 08:59:05 +02:00
Tarik Moussa
b2ef8f2575 docs(mcp): client registration (Claude Code / Desktop)
Documents MCP server setup for Claude Code (.mcp.json) and Claude Desktop
(claude_desktop_config.json), HTTP transport config, and available tools table.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 08:55:49 +02:00
Tarik Moussa
b803811701 feat(mcp): FastMCP server exposing read-only KB tools (stdio)
Implements F-14: thin FastMCP wrapper over existing codex domain modules.
Seven read-only tools: search, ask, wiki_read, wiki_list, discover_leads,
provenance_verify, synthesis_browse. All optional-feature tools degrade
gracefully to {"error": "feature not available"} instead of crashing.
Adds mcp[cli]>=1.0 dependency and codex-mcp console_script entry-point.
28 new tests across test_server, test_search, test_readonly, test_graceful,
test_http_auth — all green; 0 regressions in existing 172 tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 08:55:49 +02:00
Tarik Moussa
59763105a5 fix(synthesis): enforce non-empty provenance + fix gap lead + clean index docstring
- Lead.__post_init__: raises ValueError on empty provenance list
- find_gaps: no-chunk path now carries topic-marker Provenance instead of []
- _update_index: docstring corrected (rebuilds, not append-only)
- test_model: test_lead_empty_provenance_raises covers the new invariant

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 08:52:35 +02:00
Tarik Moussa
8cf0cc7e01 feat(synthesis): Lead/Provenance model + grounded leads + conjecture generator
- codex/synthesis.py: Lead/Provenance dataclasses, find_connections/gaps/improvements,
  propose_conjectures (status=unverified, quarantined in leads/conjectures/),
  write_leads (grounded→leads/grounded/, conjectures→leads/conjectures/ HARD invariant)
- codex/cli.py: synthesis leads/conjectures/report command group
- codex/config.py: F-13 settings (leads_dir, synthesis_llm_*, synthesis_top_k,
  synthesis_min_grounded_ratio, synthesis_gap_min_coverage)
- tests/synthesis/: 42 tests covering model, grounding, conjecture invariant, quarantine, CLI
- spike/: F-13 spike script + output (live run attempted)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-14 08:37:42 +02:00
Tarik Moussa
967e6baa59 feat(wiki): ADR-F12-wiki-compile.md — GO decision + grounding measurement
Document Spike result (grounding ~0.95, hallucination ≤5% on 3 concepts),
content-5-gram guard design, quarantine mechanism, LLM graceful degradation,
cross-ref injection rules, and conflict detection MVP.
R-27 satisfied.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 05:11:06 +02:00
Tarik Moussa
7d338d29ca fix(wiki): sentence-level claim granularity + content-5-gram grounding guard
Replace 3-gram substring match with:
- _last_sentence(): only the last sentence before a citation is checked
  (prevents a hallucinated paragraph grounding via a phrase at its end)
- _content_words(): stopwords removed from both claim and chunk before
  n-gram comparison (prevents bypass via "the discrete conformal map")
- Content-5-gram: require 5 consecutive non-stopword tokens from last
  sentence to appear in the chunk's content-word stream
- Claims with <5 content words in last sentence → ungrounded by default

Add adversarial tests: stopword-3-gram bypass → grounded=False;
legitimate content-5-gram → grounded=True.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 05:11:06 +02:00
Tarik Moussa
408e4886bb fix(wiki): basic conflict detection in CompileReport
Add _detect_conflicts() using adversative keyword heuristic (but,
however, in contrast, …) between chunks of different bibkeys; results
populate CompileReport.conflicts and appear in wiki/log.md.
Also add CompileReport.quarantined field (used by Fix 2).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 05:11:06 +02:00
Tarik Moussa
ef46d14aed fix(wiki): protect inline-code spans from cross-ref injection
_inject_cross_refs temporarily replaces backtick spans with null-byte
placeholders before injecting [[slug]] cross-refs, then restores them.
This prevents concept titles inside `code` from being rewritten.
Add tests for inline-code protection and full-list cross-ref injection.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 03:58:02 +02:00
Tarik Moussa
0202a2266e fix(wiki): pass chunks explicitly to compile_concept (single retrieve)
compile_concept now accepts chunks as a positional parameter; compile_all
retrieves chunks once and passes them directly, avoiding double-retrieve.
Also separates all_concepts (full list) from compile_concepts (filtered),
so cross-ref injection always uses the complete concept list regardless of
--concept filter (Fix 6 + Fix 8).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 03:58:02 +02:00
Tarik Moussa
60e40f6f36 fix(wiki): exclude URL-shaped bibkeys from claim parsing
_parse_claims now skips matches where bibkey contains spaces or starts
with "http", preventing Markdown hyperlinks like [text](https://...) from
being misidentified as citations. Add two tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 03:58:02 +02:00
Tarik Moussa
89228a1f62 fix(wiki): LLM error handling — graceful degradation on Ollama unavailable
Wrap llm.generate() in compile_concept with a broad try/except;
log a warning and return an empty ConceptPage (no crash) when the
LLM endpoint is unreachable. Add logging module import.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 03:58:02 +02:00
946a80418e Merge pull request 'feat(F-09): rich parsing — formula + figure extraction' (#1) from feat/F-09-rich-parsing into main
feat(F-09): rich parsing — formula + figure extraction
2026-06-14 01:52:23 +00:00
Tarik Moussa
cd42e9abc8 fix(F-09): address review-gate findings — resource leak + idempotency
- mathpix.py + figures.py: wrap fitz page-loop in try/finally so
  doc.close() is guaranteed even on exception (no file-handle leak)
- ingest.py: DELETE FROM formulas/figures WHERE paper_id before re-insert
  so --rich re-ingest is idempotent (BIGSERIAL has no natural UNIQUE key;
  ON CONFLICT DO NOTHING was a no-op)

Gate: 158 passed, ruff clean, mypy clean

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 02:23:21 +02:00
Tarik Moussa
1df9be6563 feat(F-09): rich parsing — formula + figure extraction
- codex/parsing/mathpix.py: pix2tex (local, CPU) primary + MathPix API
  optional; bbox heuristic h>15px, math-char-count>5; singleton model cache
- codex/parsing/figures.py: pymupdf embedded-image extraction → PNG;
  caption detection via proximity + "Figure/Fig./Abbildung" prefix
- codex/models.py: FormulaChunk + FigureChunk dataclasses (R-10/R-11)
- codex/ingest.py: --rich flag wires formula+figure extraction post-ingest
- codex/cli.py: search_app sub-typer (paper + formula subcommands),
  --rich flag on ingest; wiki_app from F-12 preserved intact
- codex/config.py: mathpix_app_id/key, pix2tex_fallback, figures_dir
- infra/schema.sql: formulas + figures tables with HNSW pgvector indexes
- pyproject.toml: pymupdf>=1.24, pix2tex>=0.1.4
- tests/parsing/test_mathpix.py + test_figures.py: 31 tests (mock pix2tex
  + MathPix HTTP, real pymupdf on synthetic PDF)

Gate: 158 passed, ruff clean, mypy clean (20 files)
Requirements: R-10 R-11 R-12 R-13 R-14 → done

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 01:16:24 +02:00
Tarik Moussa
d2f9141a5c fix(cli): remove F-09 spillover from F-12 branch, keep only wiki group
cli.py and config.py were accidentally contaminated with uncommitted F-09
changes (search_app restructure, rich ingest, mathpix/figures settings).
This restores both files to main-baseline + F-12-only additions:
- cli.py: wiki_app group (compile/list/check), restore @app.command search
- config.py: wiki_dir, wiki_llm_model, wiki_llm_url, wiki_top_k only

All 127 tests green, ruff+mypy clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 20:28:22 +02:00
Tarik Moussa
4d361fd8dc feat(cli): wiki compile/list/check command group (F-12)
Adds codex wiki compile/list/check Typer subgroup to cli.py.
Adds wiki_dir, wiki_llm_model, wiki_llm_url, wiki_top_k settings
to config.py (F-12 section only).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 20:22:55 +02:00
Tarik Moussa
aee00515f4 feat(wiki): concept schema loader + concepts.yaml (F-12)
Adds wiki/concepts.yaml (5 curated seeds: discrete-conformal-map,
circle-packing, lobachevsky-function, discrete-yamabe-flow,
hyperideal-tetrahedron) and codex/wiki.py with full load_concepts()
implementation (Concept dataclass, YAML parser, graceful empty list).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 20:22:51 +02:00
Tarik Moussa
c6eaf999a1 chore(env): add NOUGAT_URL to .env.example (F-11 / D-02)
Documents the Nougat HTTP server endpoint. Resolves D-02 drift finding.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 20:14:07 +02:00
Tarik Moussa
456dea102b chore(claude): add project harness config (permissions for uv/ruff/mypy/podman)
codex-py hatte kein .claude/ — F-xx-Worker liefen mit nackten globalen Settings
und wurden bei jedem uv/ruff/mypy/pytest/git-Befehl geprompted. settings.json
spiegelt den knowledge-base-Stil (acceptEdits + harter deny-Block), zugeschnitten
auf den Python/uv-Stack + podman (DB) + ollama. settings.local.json gitignored.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 09:03:41 +02:00
Tarik Moussa
c8b127bae0 fix(sources): resolve arXiv IDs via arXiv DOI (OpenAlex /works/arxiv: 404s)
OpenAlex' /works/{id}-Pfad akzeptiert die arxiv:-Namespace-Form nicht (404).
_resolve_id löst arXiv-IDs jetzt auf die arXiv-DOI 10.48550/arXiv.<id> auf —
empirisch verifiziert für modern (1005.2698) und legacy (math/0603097) IDs.
Behebt D-03 (Erstingest: alle Metadaten leer, weil fetch_paper 404 gab).

- _resolve_id: arxiv:-Präfix strippen, arXiv-DOI bauen
- Tests: arxiv (modern/legacy/prefixed) erwarten jetzt doi:10.48550/arXiv.*
- 19 Tests grün, ruff + mypy sauber

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 23:14:52 +02:00
Tarik Moussa
d48db47a7d feat: merge F-08 scaffold + integration tests (config, db, models, pipeline smoke) 2026-06-05 07:43:09 +02:00
Tarik Moussa
65918cc80a feat(tests): scaffold + integration smoke tests (config, db, models, pipeline)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 07:40:25 +02:00
Tarik Moussa
01199c5e46 feat: merge F-07 CLI (Typer commands: ingest, search, discover, provenance, ask-stub) 2026-06-05 07:35:24 +02:00
Tarik Moussa
4c177a0d29 fix(cli): use datetime() for CodeLink.added_at in tests (mypy strict)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 07:34:51 +02:00
Tarik Moussa
2acb2ee213 feat(cli): wire all commands via Typer (ingest, search, discover, provenance, ask-stub)
- Implement all CLI commands as specified in F-07: ingest, search, and discover subcommands (leads, citing, cited-by, cocited), and provenance subcommands (scan, add-link, links, bib).
- Add ask command stub that exits with code 1 (not yet implemented).
- Implement comprehensive tests with mocked dependencies (no real DB/API calls).

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-06-05 07:22:03 +02:00
Tarik Moussa
c65fe19c24 feat: merge F-06 discover + provenance (graph queries, @cite scan, bib export) 2026-06-05 07:15:58 +02:00
Tarik Moussa
7675f63188 feat(discover,provenance): graph discovery queries + @cite scan + code_links + bib export
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 07:13:01 +02:00
Tarik Moussa
1e10736185 feat: merge F-05 ingest pipeline (idempotent upsert, sources+parsing+embed) 2026-06-05 07:08:29 +02:00
Tarik Moussa
5344975bb1 feat(ingest): end-to-end idempotent ingest pipeline
Implements codex/ingest.py with:
- OpenAlex primary / Semantic Scholar fallback metadata fetch
- Abstract dense embedding (zero-vector for missing abstracts)
- Idempotent paper upsert (ON CONFLICT DO UPDATE)
- Source file parsing (.tex via latex_to_text, .pdf via nougat + grobid)
- Chunk deletion + bulk re-insert with dense embeddings
- Citation merge from OpenAlex/S2 API and GROBID PDF refs (dedup via set)
- 7 tests covering basic, tex, pdf, not-found, idempotent, arxiv-fallback,
  and no-abstract scenarios (all mocked, offline)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 07:04:12 +02:00
Tarik Moussa
fbf8949579 feat: merge F-04 embed layer (BGE-M3 dense+sparse via FlagEmbedding) 2026-06-05 06:56:17 +02:00
Tarik Moussa
c33fd9d697 feat: merge F-03 parsing layer (LaTeX, Nougat, GROBID)
Review-Gate: APPROVE (Opus, cold, 2 passes)
23 tests, ruff+mypy clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 06:48:49 +02:00
Tarik Moussa
d5726ed94d feat: merge F-02 sources layer (OpenAlex, SemanticScholar, arXiv)
Review-Gate: APPROVE (Opus, cold, 2 passes)
19 tests, ruff+mypy clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 06:48:49 +02:00
Tarik Moussa
52737abe3d fix(sources): correct OpenAlex ID prefixing, citations endpoint, S2 rate-limit
Review-Gate findings:
- openalex: bare DOIs/arXiv IDs need doi:/arxiv: prefix (bare IDs 404);
  add _resolve_id(); fix fetch_citations to use referenced_works field
  instead of non-existent /works/{id}/references endpoint.
- semanticscholar: wait_fixed(1) was inter-retry only; add per-request
  monotonic rate-limiter (_rate_limit()) before each httpx call.
  Add _is_retryable() filter so 404s don't burn 5 retry slots.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 23:49:35 +02:00
Tarik Moussa
1698f7dcad feat(embed): BGE-M3 dense+sparse via FlagEmbedding (ADR-0002)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 23:48:06 +02:00
Tarik Moussa
d1ffea1c1c fix(parsing): case-insensitive arXiv idno match; fix tmp_path types; add extract_structure test
Review-Gate finding: GROBID emits type="arXiv" (camel-case), not "arxiv".
XPath literal match silently returned empty strings for all real responses.
Fix: iterate idno elements and compare .lower() == "arxiv".
Test fixture updated to reflect real GROBID output (type="arXiv").

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 23:47:18 +02:00
Tarik Moussa
8eca4ebe12 fix(deps): add flagembedding>=1.2 for BGE-M3 hybrid encode (ADR-0002)
SentenceTransformer.encode() lacks return_dense/return_sparse kwargs;
BGEM3FlagModel provides the correct hybrid API.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 23:42:04 +02:00
Tarik Moussa
291a66dd38 fix(parsing): add root conftest.py so pytest resolves codex package
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 23:34:34 +02:00
Tarik Moussa
b7f19b6e2c feat(sources): OpenAlex, SemanticScholar, arXiv API clients
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 23:31:15 +02:00
Tarik Moussa
c6a428d335 feat(parsing): LaTeX, Nougat, GROBID parsers with chunking
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 23:30:58 +02:00
88 changed files with 14733 additions and 99 deletions

21
.claude/README.md Normal file
View File

@@ -0,0 +1,21 @@
# .claude/ — Harness-Config für codex-py
`settings.json` ist **committed** (team-weit): jede Worker-Session in diesem Repo
bekommt dieselben Permissions. Persönliche Abweichungen gehören in
`settings.local.json` (gitignored).
## Was die Permissions tun
- **`defaultMode: acceptEdits`** — Edits/Writes ohne Prompt (Worker schreiben Code
in ihrem F-XX-Paket frei). Disjunkte Ownership je Paket (siehe knowledge-base
`STATE.md`); Konflikte löst der Leader bei der Integration.
- **allow** — die Dev-Toolchain (`uv`, `ruff`, `mypy`, `pytest`, `python`), git
read+workflow, `podman` (lokale pgvector-DB), `ollama` (lokales LLM), `curl`
(API-Tests, z. B. OpenAlex) und gängige Read-Utils.
- **deny** — `git push --force`/`-f`, `git clean -fd(x)`, `sudo rm`, katastrophale
`rm -rf /`/`~`. Hart blockiert. Alles übrige (z. B. `git reset --hard`) ist
*nicht* allow-gelistet → es fragt nach.
## Hinweis
Gespiegelt am Stil der knowledge-base-`.claude/`, aber auf den Python/uv-Stack
zugeschnitten. Tests laufen via `uv run pytest`; falls `uv run` die Umgebung
neu-synct, ist `.venv/bin/python` ebenfalls allow-gelistet.

80
.claude/settings.json Normal file
View File

@@ -0,0 +1,80 @@
{
"$schema": "https://json.schemastore.org/claude-code-settings.json",
"env": {
"CLAUDE_VERBOSE": "1"
},
"outputStyle": "Explanatory",
"permissions": {
"defaultMode": "acceptEdits",
"allow": [
"Bash(uv:*)",
"Bash(ruff:*)",
"Bash(mypy:*)",
"Bash(pytest:*)",
"Bash(python -m pytest:*)",
"Bash(python3 -m pytest:*)",
"Bash(python:*)",
"Bash(python3:*)",
"Bash(.venv/bin/python:*)",
"Bash(podman:*)",
"Bash(ollama:*)",
"Bash(codespell:*)",
"Bash(shellcheck:*)",
"Bash(git status:*)",
"Bash(git diff:*)",
"Bash(git log:*)",
"Bash(git show:*)",
"Bash(git branch:*)",
"Bash(git ls-remote:*)",
"Bash(git ls-files:*)",
"Bash(git remote:*)",
"Bash(git rev-parse:*)",
"Bash(git rev-list:*)",
"Bash(git merge-base:*)",
"Bash(git diff-tree:*)",
"Bash(git describe:*)",
"Bash(git tag:*)",
"Bash(git add:*)",
"Bash(git commit:*)",
"Bash(git switch:*)",
"Bash(git checkout:*)",
"Bash(git restore:*)",
"Bash(git stash:*)",
"Bash(git fetch:*)",
"Bash(git pull:*)",
"Bash(git push:*)",
"Bash(git merge:*)",
"Bash(git rebase:*)",
"Bash(git worktree:*)",
"Bash(git init:*)",
"Bash(git mv:*)",
"Bash(ls:*)",
"Bash(find:*)",
"Bash(grep:*)",
"Bash(rg:*)",
"Bash(wc:*)",
"Bash(cat:*)",
"Bash(head:*)",
"Bash(tail:*)",
"Bash(awk:*)",
"Bash(jq:*)",
"Bash(curl:*)",
"Bash(mkdir:*)",
"Bash(cp:*)",
"Bash(test:*)",
"Bash(echo:*)",
"Bash(true)"
],
"deny": [
"Bash(git push --force:*)",
"Bash(git push -f:*)",
"Bash(git clean -fdx:*)",
"Bash(git clean -fd:*)",
"Bash(rm -rf /)",
"Bash(rm -rf /*)",
"Bash(rm -rf ~)",
"Bash(rm -rf ~/*)",
"Bash(sudo rm:*)"
]
}
}

View File

@@ -23,3 +23,12 @@ EMBEDDING_DIM=1024
# E-mail address for the OpenAlex Polite Pool (faster rate limits).
# Required by OpenAlex ToS when making automated requests.
OPENALEX_MAILTO=you@example.com
# Nougat HTTP-Server (Jetson: http://192.168.178.103:8080 | lokal: http://localhost:8080)
# Used by `codex ingest <id> --rich` for full-PDF Mathpix Markdown output.
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

3
.gitignore vendored
View File

@@ -33,3 +33,6 @@ htmlcov/
.idea/
.vscode/
*.swp
# Claude Code — settings.json IS committed (team-wide); only personal overrides ignored
.claude/settings.local.json

View File

@@ -81,8 +81,8 @@ Maps C++ symbols (qualified names or `file.cpp:line` references) to the
papers they implement. The workflow:
1. Tag C++ source with `@cite <bibkey>` in Doxygen comments.
2. Run `codex provenance sync --lib-path <path>` to scan and resolve tags.
3. Run `codex provenance export-bib <out.bib>` to generate a `.bib` file
2. Run `codex provenance scan --lib-path <path>` to scan and resolve tags.
3. Run `codex provenance bib <out.bib>` to generate a `.bib` file
containing **only the cited subset** of your collection.
The exported `.bib` is a derived view of the master catalogue — regenerate
@@ -97,13 +97,79 @@ codex ingest <id> # ingest one paper by arXiv ID or DOI
codex ingest-file <ids.txt> # bulk ingest from a file of IDs
codex search "<query>" [--hybrid]
codex discover leads
codex provenance sync --lib-path <path>
codex provenance export-bib <out.bib>
codex provenance scan --lib-path <path>
codex provenance bib <out.bib>
codex ask "<question>" # optional LLM Q&A via Ollama
```
---
## MCP Server (F-14)
codex exposes its KB as read-only MCP tools for LLM clients (Claude Code, Claude Desktop, etc.).
### Start (stdio)
```bash
# directly
python -m codex.mcp_server
# or via the installed script
codex-mcp
```
### Client registration
**Claude Code** — add to `.mcp.json` in your project root:
```json
{
"mcpServers": {
"codex": {
"command": "uv",
"args": ["run", "codex-mcp"],
"cwd": "/path/to/codex-py"
}
}
}
```
**Claude Desktop** — add to `~/Library/Application Support/Claude/claude_desktop_config.json`:
```json
{
"mcpServers": {
"codex": {
"command": "uv",
"args": ["--directory", "/path/to/codex-py", "run", "codex-mcp"]
}
}
}
```
### HTTP transport (optional, config-gated)
```bash
MCP_TRANSPORT=http MCP_AUTH_TOKEN=<secret> codex-mcp
```
HTTP transport requires `MCP_AUTH_TOKEN` — the server refuses to start without it.
Requests must carry `Authorization: Bearer <token>`.
### Available tools (read-only)
| Tool | Description |
|---|---|
| `search` | Chunk-level hybrid search (dense BGE-M3) |
| `ask` | RAG answer (returns "not available" until implemented) |
| `wiki_read` | Compiled concept page (F-12) |
| `wiki_list` | All concept pages + freshness |
| `discover_leads` | Dangling-citation leads |
| `provenance_verify` | Scan C++ sources for @cite tags |
| `synthesis_browse` | Leads/conjectures from leads/ (F-13) |
---
## Development
```bash

3
codex/__main__.py Normal file
View File

@@ -0,0 +1,3 @@
from codex.cli import app
app()

View File

@@ -1,8 +1,615 @@
"""codex CLI — commands wired to domain modules."""
from __future__ import annotations
import json
from datetime import UTC, datetime
from typing import Optional
import typer
app = typer.Typer(help="codex — personal knowledge base for scientific papers.")
discover_app = typer.Typer(help="Discovery queries over the citation graph.")
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.")
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
search_app = typer.Typer(
help="Search: semantic paper search and formula full-text search.",
invoke_without_command=True,
)
app.add_typer(discover_app, name="discover")
app.add_typer(prov_app, name="provenance")
app.add_typer(wiki_app, name="wiki")
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.callback()
def _main() -> None:
"""codex CLI — commands land here in F-07."""
@app.command()
def ingest(
paper_id: str = typer.Argument(..., help="arXiv ID, DOI, or OpenAlex W-ID"),
source: Optional[str] = typer.Option(None, "--source", "-s", help="Path to .tex or .pdf"), # noqa: UP045
rich: bool = typer.Option( # noqa: A002
False,
"--rich",
help="Extract formulas and figures (requires PDF source, F-09).",
),
) -> None:
"""Ingest a paper into the knowledge base."""
from codex.ingest import ingest_paper
result = ingest_paper(paper_id, source_path=source, rich=rich)
typer.echo(
f"Ingested {result.paper_id}: {result.chunks_upserted} chunks, "
f"{result.citations_upserted} citations, "
f"{result.formulas_upserted} formulas, "
f"{result.figures_upserted} figures"
)
# ---------------------------------------------------------------------------
# F-09: Search command group
# ---------------------------------------------------------------------------
@search_app.callback(invoke_without_command=True)
def search_callback(ctx: typer.Context) -> None:
"""Search subcommands: ``paper`` for semantic search, ``formula`` for LaTeX FTS."""
if ctx.invoked_subcommand is None:
typer.echo(ctx.get_help())
raise typer.Exit(0)
@search_app.command("paper")
def search_paper(
query: str = typer.Argument(..., help="Natural-language search query"),
limit: int = typer.Option(10, "--limit", "-n", help="Number of results"),
cite_boost: bool = typer.Option(
False,
"--cite-boost",
help="Weight results by citation PageRank (F-15). Graceful when corpus < 5 papers.",
),
) -> None:
"""Semantic similarity search over paper abstracts."""
from codex.config import get_settings
from codex.db import get_conn
from codex.embed import get_embedder
emb = get_embedder().encode_dense([query])[0].tolist()
with get_conn() as conn:
rows = conn.execute(
"""
SELECT id, title, year,
abstract_emb <-> %(emb)s AS distance
FROM papers
WHERE abstract_emb IS NOT NULL
ORDER BY abstract_emb <-> %(emb)s
LIMIT %(limit)s
""",
{"emb": emb, "limit": limit},
).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:
typer.echo(f"[{row['distance']:.3f}] {row['id']} ({row['year']}) — {row['title']}")
@search_app.command("formula")
def search_formula(
query: str = typer.Argument(..., help="LaTeX snippet or keyword to search in formulas"),
limit: int = typer.Option(10, "--limit", "-n", help="Number of results"),
) -> None:
"""Full-text search over extracted LaTeX formulas (F-09)."""
from codex.db import get_conn
with get_conn() as conn:
rows = conn.execute(
"""
SELECT paper_id, page, raw_latex, context,
ts_rank(to_tsvector('english', raw_latex || ' ' || coalesce(context, '')),
plainto_tsquery('english', %(query)s)) AS rank
FROM formulas
WHERE to_tsvector('english', raw_latex || ' ' || coalesce(context, ''))
@@ plainto_tsquery('english', %(query)s)
ORDER BY rank DESC
LIMIT %(limit)s
""",
{"query": query, "limit": limit},
).fetchall()
if not rows:
typer.echo("No matching formulas found.")
return
for row in rows:
typer.echo(
f"[{row['rank']:.3f}] {row['paper_id']} p.{row['page']} {row['raw_latex'][:80]}"
)
@discover_app.command("leads")
def discover_leads(
limit: int = typer.Option(20, "--limit", "-n"),
) -> None:
"""Show discovery leads: cited papers not yet ingested, ranked by pull."""
from codex.discover import discovery_leads
for item in discovery_leads(limit=limit):
typer.echo(f"{item['pull']:>4}× {item['cited_id']}")
@discover_app.command("citing")
def discover_citing(paper_id: str = typer.Argument(...)) -> None:
"""List papers that cite PAPER_ID."""
from codex.discover import citing_papers
for pid in citing_papers(paper_id):
typer.echo(pid)
@discover_app.command("cited-by")
def discover_cited_by(paper_id: str = typer.Argument(...)) -> None:
"""List papers that PAPER_ID cites."""
from codex.discover import cited_by
for pid in cited_by(paper_id):
typer.echo(pid)
@discover_app.command("cocited")
def discover_cocited(
paper_id: str = typer.Argument(...),
limit: int = typer.Option(10, "--limit", "-n"),
) -> None:
"""List papers frequently co-cited with PAPER_ID."""
from codex.discover import cocited_papers
for item in cocited_papers(paper_id, limit=limit):
typer.echo(f"{item['co_citations']:>4}× {item['paper_id']}")
@prov_app.command("scan")
def prov_scan(root_dir: str = typer.Argument(..., help="Root directory to scan")) -> None:
"""Scan C++ sources for @cite tags."""
from codex.provenance import scan_cite_tags
hits = scan_cite_tags(root_dir)
typer.echo(json.dumps(hits, indent=2))
@prov_app.command("add-link")
def prov_add_link(
symbol: str = typer.Argument(...),
paper_id: Optional[str] = typer.Option(None, "--paper-id"), # noqa: UP045
role: Optional[str] = typer.Option(None, "--role"), # noqa: UP045
note: Optional[str] = typer.Option(None, "--note"), # noqa: UP045
) -> None:
"""Add a code → paper link."""
from codex.provenance import add_code_link
link = add_code_link(symbol, paper_id=paper_id, role=role, note=note)
typer.echo(f"Created code_link id={link.id}: {symbol}{paper_id}")
@prov_app.command("links")
def prov_links(
paper_id: Optional[str] = typer.Option(None, "--paper-id"), # noqa: UP045
) -> None:
"""List code → paper links."""
from codex.provenance import list_code_links
for link in list_code_links(paper_id=paper_id):
typer.echo(f"[{link.id}] {link.symbol}{link.paper_id} ({link.role})")
@prov_app.command("bib")
def prov_bib(
paper_ids: Optional[list[str]] = typer.Argument(None), # noqa: UP045, B008
output: Optional[str] = typer.Option( # noqa: UP045
None, "--output", "-o", help="Write to file instead of stdout"
),
) -> None:
"""Export papers as BibTeX."""
from codex.provenance import export_bib
bib = export_bib(paper_ids if paper_ids else None)
if output:
from pathlib import Path
Path(output).write_text(bib, encoding="utf-8")
typer.echo(f"Wrote {output}")
else:
typer.echo(bib)
@app.command()
def ask(question: str = typer.Argument(..., help="Question to answer")) -> None:
"""Ask a question (RAG — not yet implemented)."""
typer.echo("ask: not yet implemented", err=True)
raise typer.Exit(1)
# ---------------------------------------------------------------------------
# F-12: Wiki command group
# ---------------------------------------------------------------------------
@wiki_app.command("compile")
def wiki_compile(
concept: Optional[str] = typer.Option( # noqa: UP045
None, "--concept", help="Compile only this concept slug."
),
all_concepts: bool = typer.Option(
False, "--all", help="Force full recompile (ignore change detection)."
),
top_k: int = typer.Option(0, "--top-k", help="Override config.wiki_top_k (0 = use config)."),
output_dir: Optional[str] = typer.Option( # noqa: UP045
None, "--output-dir", help="Override config.wiki_dir."
),
) -> None:
"""Compile concept pages from retrieved chunks via local LLM.
By default only recompiles concepts whose source chunks have changed
(hash-based incremental). Use --all to force full recompile.
"""
from codex.wiki import compile_all
report = compile_all(
changed_only=not all_concepts,
top_k=top_k if top_k > 0 else None,
concept_filter=concept,
output_dir=output_dir,
)
if report.compiled:
typer.echo(f"Compiled: {', '.join(report.compiled)}")
if report.skipped:
typer.echo(f"Skipped (unchanged): {', '.join(report.skipped)}")
if report.ungrounded:
typer.echo(f"⚠ Ungrounded claims: {len(report.ungrounded)}", err=True)
for slug, text in report.ungrounded:
typer.echo(f" [{slug}] {text[:100]}", err=True)
@wiki_app.command("list")
def wiki_list(
output_dir: Optional[str] = typer.Option( # noqa: UP045
None, "--output-dir", help="Override config.wiki_dir."
),
) -> None:
"""List compiled concept pages with freshness information."""
import json as _json
from pathlib import Path
from codex.config import get_settings
settings = get_settings()
wiki_dir = Path(output_dir or settings.wiki_dir)
state_path = wiki_dir / ".compile-state.json"
state: dict[str, str] = {}
if state_path.exists():
try:
state = _json.loads(state_path.read_text(encoding="utf-8"))
except (_json.JSONDecodeError, OSError):
state = {}
pages = sorted(wiki_dir.glob("*.md"))
if not pages:
typer.echo("No compiled pages found.")
return
for page in pages:
if page.name in ("index.md", "log.md"):
continue
slug = page.stem
hash_val = state.get(slug, "")[:8]
content = page.read_text(encoding="utf-8")
n_claims = content.count("[")
n_ungrounded = content.count("")
mtime = datetime.fromtimestamp(page.stat().st_mtime, tz=UTC).strftime("%Y-%m-%d %H:%M")
typer.echo(
f"{slug:40s} last={mtime} hash={hash_val} claims≈{n_claims} ⚠={n_ungrounded}"
)
@wiki_app.command("check")
def wiki_check(
output_dir: Optional[str] = typer.Option( # noqa: UP045
None, "--output-dir", help="Override config.wiki_dir."
),
) -> None:
"""Check all compiled pages for ungrounded claims.
Exits with code 0 if all claims are grounded, 1 if any are ungrounded.
Suitable for CI pipelines.
"""
from pathlib import Path
from codex.config import get_settings
settings = get_settings()
wiki_dir = Path(output_dir or settings.wiki_dir)
pages = sorted(wiki_dir.glob("*.md"))
found_ungrounded = False
for page in pages:
if page.name in ("index.md", "log.md"):
continue
content = page.read_text(encoding="utf-8")
if "" in content:
typer.echo(f"⚠ Ungrounded claim(s) in: {page.name}", err=True)
found_ungrounded = True
# Check for quarantined pages in wiki/draft/
draft_dir = wiki_dir / "draft"
draft_pages = sorted(draft_dir.glob("*.md")) if draft_dir.exists() else []
if draft_pages:
n = len(draft_pages)
typer.echo(
f"{n} page(s) quarantined in wiki/draft/ (grounding rate below threshold)",
err=True,
)
for dp in draft_pages:
typer.echo(f" - {dp.name}", err=True)
raise typer.Exit(2)
if found_ungrounded:
raise typer.Exit(1)
else:
typer.echo("All claims grounded.")
# ---------------------------------------------------------------------------
# F-13: Synthesis command group
# ---------------------------------------------------------------------------
@synth_app.command("leads")
def synthesis_leads(
lib_path: Optional[str] = typer.Option( # noqa: UP045
None,
"--lib-path",
help=(
"Path to a C++ source tree. Enables improvement leads (@cite-aware) and "
"code-vs-corpus gap detection. Without it only connection + topic gaps run."
),
),
output_dir: Optional[str] = typer.Option( # noqa: UP045
None, "--output-dir", help="Override config.leads_dir."
),
) -> None:
"""Generate grounded leads (stages 2-4) and write them to leads/grounded/."""
from codex.config import get_settings
from codex.synthesis import (
default_llm_client,
find_connections,
find_gaps,
find_improvements,
write_leads,
)
settings = get_settings()
leads_dir = output_dir or settings.leads_dir
llm = default_llm_client()
connections = find_connections(top_k=settings.synthesis_top_k, llm=llm)
gaps = find_gaps(lib_path=lib_path, llm=llm)
improvements = (
find_improvements(lib_path, top_k=settings.synthesis_top_k, llm=llm) if lib_path else []
)
all_leads = connections + gaps + improvements
write_leads(all_leads, leads_dir)
typer.echo(
f"Grounded leads written to {leads_dir}/grounded/: "
f"{len(connections)} connection · {len(gaps)} gap · {len(improvements)} improvement"
)
@synth_app.command("conjectures")
def synthesis_conjectures(
output_dir: Optional[str] = typer.Option( # noqa: UP045
None, "--output-dir", help="Override config.leads_dir."
),
) -> None:
"""Generate stage-5 conjecture leads — quarantined to leads/conjectures/.
INVARIANT (F-13 HARD): conjectures are NEVER written to wiki/. They
require a ``suggested_validation`` spike before they may be promoted.
"""
from codex.config import get_settings
from codex.synthesis import default_llm_client, propose_conjectures, write_leads
settings = get_settings()
leads_dir = output_dir or settings.leads_dir
llm = default_llm_client()
conjectures = propose_conjectures(top_k=settings.synthesis_top_k, llm=llm)
write_leads(conjectures, leads_dir)
typer.echo(f"Conjectures (UNVERIFIED) written to {leads_dir}/conjectures/: {len(conjectures)}")
@synth_app.command("report")
def synthesis_report(
output_dir: Optional[str] = typer.Option( # noqa: UP045
None, "--output-dir", help="Override config.leads_dir."
),
) -> None:
"""Show grounded leads and conjectures — visibly separated."""
from pathlib import Path
from codex.config import get_settings
settings = get_settings()
leads_dir = Path(output_dir or settings.leads_dir)
grounded_dir = leads_dir / "grounded"
conj_dir = leads_dir / "conjectures"
grounded_files = sorted(grounded_dir.glob("*.md")) if grounded_dir.exists() else []
conj_files = sorted(conj_dir.glob("*.md")) if conj_dir.exists() else []
typer.echo("=" * 72)
typer.echo(f"GROUNDED LEADS ({len(grounded_files)}) → {grounded_dir}")
typer.echo("=" * 72)
if not grounded_files:
typer.echo("(none)")
for f in grounded_files:
first = f.read_text(encoding="utf-8").splitlines()[0]
typer.echo(f" {first}")
typer.echo("")
typer.echo("=" * 72)
typer.echo(f"CONJECTURES ({len(conj_files)}) → {conj_dir} [UNVERIFIED — never in wiki/]")
typer.echo("=" * 72)
if not conj_files:
typer.echo("(none)")
for f in conj_files:
first = f.read_text(encoding="utf-8").splitlines()[0]
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, dangling_citations
settings = get_settings()
with get_conn() as conn:
graph = build_citation_graph(conn)
known_ids = {row["id"] for row in conn.execute("SELECT id FROM papers").fetchall()}
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))
if output_json:
typer.echo(
json.dumps(
{
"nodes": graph.number_of_nodes(),
"edges": graph.number_of_edges(),
"hubs": [{"paper_id": pid, "pagerank": score} for pid, score in hubs],
"dangling_count": len(dangling),
"dangling": dangling,
},
indent=2,
)
)
return
if n_papers < settings.graph_min_corpus_size:
typer.echo(
f"Warning: only {n_papers} ingested papers "
f"(recommended ≥ {settings.graph_min_corpus_size} for meaningful ranking).",
err=True,
)
typer.echo(f"Graph: {graph.number_of_nodes()} nodes, {graph.number_of_edges()} edges")
typer.echo("")
typer.echo(f"TOP-{top_n} HUB PAPERS (PageRank)")
typer.echo("-" * 48)
for pid, score in hubs:
typer.echo(f" {score:.4f} {pid}")
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}")

View File

@@ -9,7 +9,7 @@ from __future__ import annotations
from functools import lru_cache
from pydantic import Field
from pydantic import AliasChoices, Field
from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -42,6 +42,12 @@ class Settings(BaseSettings):
description="Base URL of the GROBID HTTP API (containerised).",
)
nougat_url: str = Field(
default="http://localhost:8080",
validation_alias=AliasChoices("NOUGAT_URL", "nougat_url"),
description="Base URL of the Nougat OCR HTTP API (containerised).",
)
ollama_base_url: str = Field(
default="http://localhost:11434",
description="Base URL of the local Ollama endpoint (optional Q&A layer).",
@@ -78,6 +84,232 @@ class Settings(BaseSettings):
),
)
# ------------------------------------------------------------------
# F-12 Wiki-Compile
# ------------------------------------------------------------------
wiki_dir: str = Field(
default="wiki/",
description=(
"Directory where compiled wiki pages are written. "
"Relative paths are resolved from the current working directory."
),
)
wiki_llm_model: str = Field(
default="qwen2.5:7b",
description=(
"Ollama model name used for wiki synthesis. "
"Must be available at the configured Ollama endpoint (WIKI_LLM_URL or "
"OLLAMA_BASE_URL). Default: qwen2.5:7b (qwen-light profile on Jetson)."
),
)
wiki_llm_url: str | None = Field(
default=None,
description=(
"Ollama base URL for wiki synthesis. "
"When None, falls back to OLLAMA_BASE_URL "
"(http://192.168.178.103:11434 for the Jetson). "
"Set WIKI_LLM_URL to override."
),
)
wiki_top_k: int = Field(
default=12,
gt=0,
description=(
"Number of top chunks retrieved per concept for wiki synthesis. "
"Higher values improve recall at the cost of a larger LLM prompt."
),
)
wiki_min_grounding_rate: float = Field(
default=0.5,
ge=0.0,
le=1.0,
description=(
"Minimum fraction of claims that must be grounded for a page to be written "
"to wiki/<slug>.md. Pages below this threshold are quarantined to "
"wiki/draft/<slug>.md instead."
),
)
# ------------------------------------------------------------------
# F-09 Rich Parsing
# ------------------------------------------------------------------
mathpix_app_id: str | None = Field(
default=None,
description=(
"MathPix App ID for cloud formula extraction. "
"When None, pix2tex local OCR is used as fallback."
),
)
mathpix_app_key: str | None = Field(
default=None,
description=(
"MathPix App Key for cloud formula extraction. "
"Must be set together with MATHPIX_APP_ID."
),
)
pix2tex_fallback: bool = Field(
default=True,
description=(
"Enable pix2tex (local LaTeX OCR) as fallback when MathPix credentials "
"are absent. Set to False to disable formula extraction entirely without creds."
),
)
figures_dir: str = Field(
default="figures/",
description=(
"Directory where extracted figure images are written. "
"Relative paths are resolved from the current working directory."
),
)
# ------------------------------------------------------------------
# F-13 Synthesis / Lead-Engine
# ------------------------------------------------------------------
leads_dir: str = Field(
default="leads/",
description=(
"Directory where synthesis leads are written. "
"Grounded leads land in <leads_dir>/grounded/, conjectures in "
"<leads_dir>/conjectures/. INVARIANT: conjectures NEVER touch wiki/."
),
)
synthesis_llm_model: str = Field(
default="qwen2.5:7b",
description=(
"Ollama model name used for synthesis (connection / gap / improvement / "
"conjecture). Defaults to the qwen-light profile on the Jetson."
),
)
synthesis_llm_url: str | None = Field(
default=None,
description=("Ollama base URL for synthesis. When None, falls back to OLLAMA_BASE_URL."),
)
synthesis_top_k: int = Field(
default=20,
gt=0,
description=(
"Number of top chunks retrieved per synthesis probe. Higher values "
"improve recall at the cost of a larger LLM prompt."
),
)
synthesis_min_grounded_ratio: float = Field(
default=0.5,
ge=0.0,
le=1.0,
description=(
"Minimum fraction of claims that must be grounded for a grounded lead "
"(connection / gap / improvement) to be emitted. Leads below this "
"threshold are dropped rather than written to leads/grounded/."
),
)
synthesis_gap_min_coverage: int = Field(
default=2,
gt=0,
description=(
"A topic is considered a 'gap' candidate when it is covered by fewer "
"than this many distinct bibkeys in the retrieved top-K."
),
)
# ------------------------------------------------------------------
# F-14 MCP Server
# ------------------------------------------------------------------
mcp_transport: str = Field(
default="stdio",
description=(
"Transport protocol for the MCP server. "
"Allowed values: 'stdio' (default) or 'http'. "
"HTTP transport requires MCP_AUTH_TOKEN to be set."
),
)
mcp_host: str = Field(
default="127.0.0.1",
description="Bind host for HTTP transport (ignored for stdio).",
)
mcp_port: int = Field(
default=8765,
gt=0,
description="Bind port for HTTP transport (ignored for stdio).",
)
mcp_auth_token: str | None = Field(
default=None,
description=(
"Bearer token required when mcp_transport='http'. "
"If HTTP transport is requested but this is None, the server refuses to start."
),
)
# ------------------------------------------------------------------
# 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. "
"Final score = dense_score * (1 + alpha * pagerank_score). "
"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."
),
)
@lru_cache(maxsize=1)
def get_settings() -> Settings:

64
codex/discover.py Normal file
View File

@@ -0,0 +1,64 @@
"""Discovery queries over the citation graph stored in PostgreSQL."""
from __future__ import annotations
from codex.db import get_conn
def discovery_leads(limit: int = 20) -> list[dict[str, int | str]]:
"""Return papers referenced by already-ingested papers but not yet collected.
Returns list of dicts with keys:
cited_id (str) — arXiv ID, DOI, or OpenAlex W-ID of the target
pull (int) — how many already-ingested papers cite this target
Ordered by pull DESC, limited to `limit` rows.
"""
sql = """
SELECT cited_id, count(*) AS pull
FROM citations
WHERE cited_id NOT IN (SELECT id FROM papers)
GROUP BY cited_id
ORDER BY pull DESC
LIMIT %(limit)s
"""
with get_conn() as conn:
rows = conn.execute(sql, {"limit": limit}).fetchall()
return [{"cited_id": row["cited_id"], "pull": row["pull"]} for row in rows]
def citing_papers(paper_id: str) -> list[str]:
"""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"
with get_conn() as conn:
rows = conn.execute(sql, {"paper_id": paper_id}).fetchall()
return [row["citing_id"] for row in rows]
def cited_by(paper_id: str) -> list[str]:
"""Return IDs of all papers that `paper_id` cites (forward citations)."""
sql = "SELECT cited_id FROM citations WHERE citing_id = %(paper_id)s"
with get_conn() as conn:
rows = conn.execute(sql, {"paper_id": paper_id}).fetchall()
return [row["cited_id"] for row in rows]
def cocited_papers(paper_id: str, limit: int = 10) -> list[dict[str, int | str]]:
"""Return papers frequently co-cited with `paper_id`.
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.
"""
sql = """
SELECT c2.cited_id AS paper_id, count(*) AS co_citations
FROM citations c1
JOIN citations c2 ON c1.citing_id = c2.citing_id
WHERE c1.cited_id = %(paper_id)s
AND c2.cited_id != %(paper_id)s
GROUP BY c2.cited_id
ORDER BY co_citations DESC
LIMIT %(limit)s
"""
with get_conn() as conn:
rows = conn.execute(sql, {"paper_id": paper_id, "limit": limit}).fetchall()
return [{"paper_id": row["paper_id"], "co_citations": row["co_citations"]} for row in rows]

155
codex/embed.py Normal file
View File

@@ -0,0 +1,155 @@
"""Hybrid dense + sparse embeddings via BGE-M3 (ADR-0002).
Uses :class:`FlagEmbedding.BGEM3FlagModel` for encoding because the
``return_dense`` / ``return_sparse`` kwargs are part of the FlagEmbedding
API — not ``sentence-transformers``. The dense output is identical to a
vanilla ``SentenceTransformer`` load of ``BAAI/bge-m3`` (same weights,
same model); sparse output is a list of ``{token_id: weight}`` dicts per
text.
Notes for callers
-----------------
* Empty input is handled explicitly — no model call is issued.
* Dense vectors are L2-normalised row-wise so cosine similarity reduces
to a dot product.
* :func:`get_embedder` returns a process-wide singleton so the model is
loaded at most once per Python process.
"""
from __future__ import annotations
import numpy as np
import torch
from FlagEmbedding import BGEM3FlagModel
class Embedder:
"""Thin wrapper over :class:`BGEM3FlagModel` with a stable API.
The wrapper hides the FlagEmbedding-specific encode-dict and exposes
three operations relevant to the ingestion pipeline:
* :meth:`encode_dense` — dense float32 matrix, L2-normalised.
* :meth:`encode_sparse` — list of ``{token_id: weight}`` dicts.
* :meth:`encode` — both in a single forward pass.
"""
def __init__(
self,
model_name: str = "BAAI/bge-m3",
dim: int = 1024,
device: str | None = None,
batch_size: int = 32,
) -> None:
resolved_device = device or ("cuda" if torch.cuda.is_available() else "cpu")
self._model = BGEM3FlagModel(
model_name,
use_fp16=False,
devices=[resolved_device],
)
self.dim = dim
self.batch_size = batch_size
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
@staticmethod
def _l2_normalise(matrix: np.ndarray) -> np.ndarray:
"""Return ``matrix`` with every row scaled to unit L2 norm.
Zero-rows are left untouched (we divide by 1 rather than 0 to
avoid ``nan``s — the BGE-M3 encoder never emits zero vectors in
practice, but the guard keeps the function total).
"""
norms = np.linalg.norm(matrix, axis=1, keepdims=True)
norms = np.where(norms == 0.0, 1.0, norms)
return (matrix / norms).astype(np.float32)
@staticmethod
def _coerce_sparse(weights: list[dict[int | str, float]]) -> list[dict[int, float]]:
"""Cast token ids to ``int`` and weights to ``float``.
FlagEmbedding returns ids as strings in some versions and as
ints in others; we normalise to ``int`` so downstream code can
rely on a stable key type.
"""
return [
{int(token_id): float(weight) for token_id, weight in row.items()} for row in weights
]
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def encode_dense(self, texts: list[str]) -> np.ndarray:
"""Encode ``texts`` to a dense, L2-normalised float32 matrix.
Returns a ``(0, dim)`` zero-matrix for an empty input.
"""
if not texts:
return np.zeros((0, self.dim), dtype=np.float32)
result = self._model.encode(
texts,
batch_size=self.batch_size,
return_dense=True,
return_sparse=False,
)
dense = np.asarray(result["dense_vecs"], dtype=np.float32)
return self._l2_normalise(dense)
def encode_sparse(self, texts: list[str]) -> list[dict[int, float]]:
"""Encode ``texts`` to a list of sparse ``{token_id: weight}`` dicts.
Returns ``[]`` for an empty input.
"""
if not texts:
return []
result = self._model.encode(
texts,
batch_size=self.batch_size,
return_dense=False,
return_sparse=True,
)
return self._coerce_sparse(result["lexical_weights"])
def encode(self, texts: list[str]) -> tuple[np.ndarray, list[dict[int, float]]]:
"""Encode ``texts`` to dense **and** sparse in one forward pass.
Returns ``(zeros((0, dim)), [])`` for an empty input.
"""
if not texts:
return np.zeros((0, self.dim), dtype=np.float32), []
result = self._model.encode(
texts,
batch_size=self.batch_size,
return_dense=True,
return_sparse=True,
)
dense = self._l2_normalise(np.asarray(result["dense_vecs"], dtype=np.float32))
sparse = self._coerce_sparse(result["lexical_weights"])
return dense, sparse
# ---------------------------------------------------------------------------
# Process-wide singleton
# ---------------------------------------------------------------------------
_embedder: Embedder | None = None
def get_embedder() -> Embedder:
"""Return the process-wide :class:`Embedder` singleton.
The first call constructs the embedder using values from
:class:`codex.config.Settings`; subsequent calls return the cached
instance. Tests can reset the cache by setting
``codex.embed._embedder`` back to ``None``.
"""
global _embedder
if _embedder is None:
from codex.config import Settings
s = Settings()
_embedder = Embedder(model_name=s.embedding_model, dim=s.embedding_dim)
return _embedder

173
codex/graph.py Normal file
View File

@@ -0,0 +1,173 @@
"""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
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.
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("SELECT citing_id, cited_id FROM citations").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 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]

304
codex/ingest.py Normal file
View File

@@ -0,0 +1,304 @@
"""End-to-end idempotent ingest pipeline for a single paper."""
from __future__ import annotations
import contextlib
import logging
from dataclasses import dataclass
from pathlib import Path
import numpy as np
from codex.config import get_settings
from codex.db import get_conn
from codex.embed import get_embedder
from codex.models import Citation, Paper
from codex.quality import classify_section, filter_chunks
from codex.sources import openalex, semanticscholar
logger = logging.getLogger(__name__)
@dataclass
class IngestResult:
paper_id: str
chunks_upserted: int
citations_upserted: int
formulas_upserted: int = 0
figures_upserted: int = 0
def _make_bibkey(paper: Paper) -> str | None:
"""Generate a BibTeX-style key from paper metadata.
Format: ConcatSurnames + Year (e.g. "BobenkoPinkallSpringborn2015").
Surnames are the last whitespace-separated token of each author name.
>3 authors: FirstSurname + "EtAl" + Year.
Returns None when authors or year are missing.
"""
if not paper.authors or not paper.year:
return None
surnames = [name.split()[-1] for name in paper.authors if name.strip()]
if not surnames:
return None
year = str(paper.year)
if len(surnames) <= 3:
return "".join(surnames) + year
return surnames[0] + "EtAl" + year
def ingest_paper(
paper_id: str,
source_path: str | None = None,
rich: bool = False,
) -> IngestResult:
"""Idempotent ingest of one paper.
Parameters
----------
paper_id:
An arXiv ID (``"2301.07041"``), DOI (``"10.1145/…"``), or
OpenAlex W-ID (``"W2741809807"``).
source_path:
Optional path to a local ``.tex`` or ``.pdf`` file.
If given, the file is parsed into text chunks and stored.
Supports:
- ``.tex`` → :func:`codex.parsing.tex.latex_to_text` + chunk
- ``.pdf`` → :func:`codex.parsing.nougat.pdf_to_markdown` + chunk
+ :func:`codex.parsing.grobid.extract_references` for refs
rich:
When True and *source_path* is a PDF, also extract formulas via
:func:`codex.parsing.mathpix.extract_formulas` and figures via
:func:`codex.parsing.figures.extract_figures` (F-09).
Returns
-------
IngestResult
Counts of upserted chunks, citations, formulas, and figures.
"""
# ---------------------------------------------------------------
# 1. Fetch metadata (OpenAlex primary, SemanticScholar fallback)
# ---------------------------------------------------------------
paper: Paper | None = openalex.fetch_paper(paper_id)
if paper is None:
# Detect arXiv IDs: numeric pattern like "2301.07041" or "arxiv:..." prefix
pid_lower = paper_id.lower()
looks_like_arxiv = pid_lower.startswith("arxiv:") or (
len(paper_id) > 4 and paper_id[4:5] == "." and paper_id[:4].isdigit()
)
if looks_like_arxiv:
arxiv_key = paper_id if pid_lower.startswith("arxiv:") else f"arXiv:{paper_id}"
with contextlib.suppress(Exception):
semanticscholar.fetch_references(arxiv_key)
paper = Paper(id=paper_id, title="")
else:
raise ValueError(f"Paper not found: {paper_id}")
if paper is None:
raise ValueError(f"Paper not found: {paper_id}")
# Auto-generate bibkey when source has none (D-04).
if paper.bibkey is None:
paper.bibkey = _make_bibkey(paper)
# ---------------------------------------------------------------
# 2. Embed abstract (dense only — schema has one vector column)
# ---------------------------------------------------------------
embedder = get_embedder()
abstract_text = paper.abstract or ""
if abstract_text:
abstract_emb_arr: np.ndarray = embedder.encode_dense([abstract_text])
abstract_emb: list[float] = abstract_emb_arr[0].tolist()
else:
dim = embedder.dim
abstract_emb = [0.0] * dim
# ---------------------------------------------------------------
# 3. Upsert paper (INSERT … ON CONFLICT (id) DO UPDATE SET …)
# ---------------------------------------------------------------
with get_conn() as conn:
conn.execute(
"""
INSERT INTO papers (id, openalex_id, bibkey, title, authors, year, abstract,
source_path, abstract_emb)
VALUES (%(id)s, %(openalex_id)s, %(bibkey)s, %(title)s, %(authors)s, %(year)s,
%(abstract)s, %(source_path)s, %(abstract_emb)s)
ON CONFLICT (id) DO UPDATE SET
openalex_id = EXCLUDED.openalex_id,
bibkey = COALESCE(papers.bibkey, EXCLUDED.bibkey),
title = EXCLUDED.title,
authors = EXCLUDED.authors,
year = EXCLUDED.year,
abstract = EXCLUDED.abstract,
source_path = COALESCE(EXCLUDED.source_path, papers.source_path),
abstract_emb = EXCLUDED.abstract_emb
""",
{
"id": paper.id,
"openalex_id": paper.openalex_id,
"bibkey": paper.bibkey,
"title": paper.title,
"authors": paper.authors,
"year": paper.year,
"abstract": paper.abstract,
"source_path": source_path,
"abstract_emb": abstract_emb,
},
)
# ---------------------------------------------------------------
# 4. Parse + chunk + embed source file (if provided)
# DELETE existing chunks for this paper first, then bulk INSERT
# ---------------------------------------------------------------
chunks_upserted = 0
pdf_citations: list[Citation] = []
if source_path is not None:
from codex.parsing.grobid import extract_references
from codex.parsing.nougat import pdf_to_markdown
from codex.parsing.tex import chunk_text, latex_to_text
suffix = Path(source_path).suffix.lower()
text = ""
if suffix == ".tex":
text = latex_to_text(Path(source_path).read_text())
elif suffix == ".txt":
text = Path(source_path).read_text(encoding="utf-8", errors="replace")
elif suffix == ".pdf":
text = pdf_to_markdown(source_path)
# Also extract GROBID refs
grobid_refs = extract_references(source_path)
for ref in grobid_refs:
cited_id = ref.get("doi") or ref.get("arxiv_id")
if cited_id:
pdf_citations.append(Citation(citing_id=paper.id, cited_id=cited_id))
else:
logger.warning("Unbekannter Dateityp: %s — kein Text-Parsing", source_path)
raw_chunks = chunk_text(text) if text else []
chunks_text = filter_chunks(raw_chunks, settings=get_settings())
# Delete existing chunks for this paper
conn.execute("DELETE FROM chunks WHERE paper_id = %s", (paper.id,))
if chunks_text:
# Embed all chunks in one batch
chunk_embeddings = embedder.encode_dense(chunks_text)
chunk_rows = [
(
paper.id,
ord_idx,
content,
chunk_embeddings[ord_idx].tolist(),
classify_section(content),
)
for ord_idx, content in enumerate(chunks_text)
]
with conn.cursor() as cur:
cur.executemany(
"INSERT INTO chunks (paper_id, ord, content, embedding, section)"
" VALUES (%s, %s, %s, %s, %s)",
chunk_rows,
)
chunks_upserted = len(chunk_rows)
# ---------------------------------------------------------------
# 5. Fetch citations (OpenAlex if openalex_id, else S2)
# Insert with ON CONFLICT (citing_id, cited_id) DO NOTHING
# ---------------------------------------------------------------
api_citations: list[Citation]
if paper.openalex_id:
raw = openalex.fetch_citations(paper.openalex_id)
# OpenAlex returns citing_id=openalex_id, but papers.id uses the DOI.
# Rewrite citing_id to paper.id so the FK constraint is satisfied.
api_citations = [
Citation(citing_id=paper.id, cited_id=c.cited_id, context=c.context) for c in raw
]
else:
api_citations = semanticscholar.fetch_references(paper_id)
# Merge API citations and GROBID PDF citations; dedup via set
all_citations_set: set[tuple[str, str]] = set()
merged_citations: list[Citation] = []
for cit in api_citations + pdf_citations:
key = (cit.citing_id, cit.cited_id)
if key not in all_citations_set:
all_citations_set.add(key)
merged_citations.append(cit)
if merged_citations:
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 merged_citations],
)
citations_upserted = len(merged_citations)
conn.commit()
# ---------------------------------------------------------------
# 6. F-09 Rich Parsing: formulas + figures (PDF only)
# ---------------------------------------------------------------
formulas_upserted = 0
figures_upserted = 0
if rich and source_path is not None and Path(source_path).suffix.lower() == ".pdf":
from codex.parsing.figures import extract_figures
from codex.parsing.mathpix import extract_formulas
settings = get_settings()
formulas = extract_formulas(source_path)
figures = extract_figures(source_path, output_dir=settings.figures_dir)
if formulas or figures:
with get_conn() as conn2:
with conn2.cursor() as cur:
# Delete-before-insert keeps re-ingest idempotent (BIGSERIAL has no
# natural UNIQUE key, so ON CONFLICT DO NOTHING never fires).
cur.execute("DELETE FROM formulas WHERE paper_id = %s", (paper.id,))
cur.execute("DELETE FROM figures WHERE paper_id = %s", (paper.id,))
if formulas:
with conn2.cursor() as cur:
cur.executemany(
"""
INSERT INTO formulas (paper_id, page, raw_latex, context, eq_label)
VALUES (%s, %s, %s, %s, %s)
""",
[
(f.paper_id, f.page, f.raw_latex, f.context, f.eq_label)
for f in formulas
],
)
formulas_upserted = len(formulas)
if figures:
with conn2.cursor() as cur:
cur.executemany(
"""
INSERT INTO figures (paper_id, page, caption, image_path)
VALUES (%s, %s, %s, %s)
""",
[
(fig.paper_id, fig.page, fig.caption, fig.image_path)
for fig in figures
],
)
figures_upserted = len(figures)
conn2.commit()
return IngestResult(
paper_id=paper.id,
chunks_upserted=chunks_upserted,
citations_upserted=citations_upserted,
formulas_upserted=formulas_upserted,
figures_upserted=figures_upserted,
)

359
codex/mcp_server.py Normal file
View File

@@ -0,0 +1,359 @@
"""MCP server — read-only KB tools exposed via FastMCP (F-14).
This module is a *thin facade* over existing codex domain modules.
No new domain logic lives here — each tool delegates to the relevant
module and reshapes the result into a JSON-serialisable dict/list.
Graceful degradation contract
------------------------------
Every tool that depends on an optional feature (wiki, leads/synthesis,
provenance-verify) catches all exceptions and returns
``{"error": "feature not available"}`` instead of crashing.
Transport
---------
* **stdio** (default):
``python -m codex.mcp_server`` or ``codex-mcp``
* **HTTP** (config-gated):
Set ``MCP_TRANSPORT=http`` *and* ``MCP_AUTH_TOKEN=<token>`` in your
environment (or ``.env``). The server refuses to start without a
token — no unauthenticated HTTP endpoints are ever exposed.
"""
from __future__ import annotations
import logging
from mcp.server.fastmcp import FastMCP
from mcp.server.transport_security import TransportSecuritySettings
logger = logging.getLogger(__name__)
# DNS rebinding protection disabled: the server is guarded by Bearer token auth
# (mcp_server.py _TokenAuth middleware) and UFW firewall (trusted LAN IPs only).
mcp = FastMCP(
"codex",
transport_security=TransportSecuritySettings(enable_dns_rebinding_protection=False),
)
# ---------------------------------------------------------------------------
# Tool: search
# ---------------------------------------------------------------------------
@mcp.tool()
def search(query: str, limit: int = 10) -> list[dict[str, object]]:
"""Chunk-level hybrid search (dense BGE-M3 + FTS) over the KB.
Returns one dict per hit:
``{bibkey, paper_id, locator, score, snippet}``.
"""
try:
from codex.db import get_conn
from codex.embed import get_embedder
embedder = get_embedder()
dense_vec = embedder.encode_dense([query])[0].tolist()
sql = """
SELECT
c.id,
c.paper_id,
c.ord,
c.content,
p.bibkey,
c.embedding <-> %(emb)s::vector AS dist
FROM chunks c
JOIN papers p ON p.id = c.paper_id
WHERE c.embedding IS NOT NULL
AND p.bibkey IS NOT NULL
ORDER BY c.embedding <-> %(emb)s::vector
LIMIT %(limit)s
"""
with get_conn() as conn:
rows = conn.execute(sql, {"emb": dense_vec, "limit": limit}).fetchall()
results: list[dict[str, object]] = []
for row in rows:
results.append(
{
"bibkey": row["bibkey"],
"paper_id": row["paper_id"],
"locator": f"chunk {row['ord']}",
"score": float(1.0 - row["dist"]), # convert distance → similarity
"snippet": row["content"][:300],
}
)
return results
except Exception as exc: # noqa: BLE001
logger.warning("search tool error: %s", exc)
return [{"error": str(exc)}]
# ---------------------------------------------------------------------------
# Tool: ask
# ---------------------------------------------------------------------------
@mcp.tool()
def ask(question: str) -> dict[str, object]:
"""RAG answer over the KB.
Returns ``{"answer": str}`` when implemented,
or ``{"error": "not available"}`` until a RAG layer is added.
"""
# RAG not yet implemented (CLI parity with codex ask)
return {"error": "not available"}
# ---------------------------------------------------------------------------
# Tool: wiki_read
# ---------------------------------------------------------------------------
@mcp.tool()
def wiki_read(concept_slug: str) -> dict[str, object]:
"""Compiled concept page (F-12) as ``{markdown, sources}``.
Gracefully returns ``{"error": "feature not available"}`` when the wiki
directory or the requested page does not exist.
"""
try:
import json
from pathlib import Path
from codex.config import get_settings
settings = get_settings()
wiki_dir = Path(settings.wiki_dir)
page_path = wiki_dir / f"{concept_slug}.md"
if not page_path.exists():
return {"error": "feature not available"}
markdown = page_path.read_text(encoding="utf-8")
# Collect cited bibkeys from the compile state
state_path = wiki_dir / ".compile-state.json"
sources: list[str] = []
if state_path.exists():
try:
state: dict[str, str] = json.loads(state_path.read_text(encoding="utf-8"))
if concept_slug in state:
sources = [concept_slug]
except (json.JSONDecodeError, OSError):
pass
return {"markdown": markdown, "sources": sources}
except Exception as exc: # noqa: BLE001
logger.warning("wiki_read tool error: %s", exc)
return {"error": "feature not available"}
# ---------------------------------------------------------------------------
# Tool: wiki_list
# ---------------------------------------------------------------------------
@mcp.tool()
def wiki_list() -> list[dict[str, object]]:
"""All compiled concept pages with freshness metadata.
Returns a list of ``{slug, mtime, hash_prefix}`` dicts.
Gracefully returns ``[]`` when the wiki directory does not exist.
"""
try:
import json
from datetime import UTC, datetime
from pathlib import Path
from codex.config import get_settings
settings = get_settings()
wiki_dir = Path(settings.wiki_dir)
if not wiki_dir.exists():
return []
state_path = wiki_dir / ".compile-state.json"
state: dict[str, str] = {}
if state_path.exists():
try:
state = json.loads(state_path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
state = {}
pages = sorted(wiki_dir.glob("*.md"))
result: list[dict[str, object]] = []
for page in pages:
if page.name in ("index.md", "log.md"):
continue
slug = page.stem
hash_prefix = state.get(slug, "")[:8]
mtime = datetime.fromtimestamp(page.stat().st_mtime, tz=UTC).isoformat()
result.append({"slug": slug, "mtime": mtime, "hash_prefix": hash_prefix})
return result
except Exception as exc: # noqa: BLE001
logger.warning("wiki_list tool error: %s", exc)
return []
# ---------------------------------------------------------------------------
# Tool: discover_leads
# ---------------------------------------------------------------------------
@mcp.tool()
def discover_leads(limit: int = 20) -> list[dict[str, object]]:
"""Dangling-citation discovery leads — papers cited but not yet ingested.
Returns ``[{cited_id, pull}]`` ordered by citation count descending.
"""
try:
from codex.discover import discovery_leads as _leads
raw = _leads(limit=limit)
return [{"cited_id": row["cited_id"], "pull": int(row["pull"])} for row in raw]
except Exception as exc: # noqa: BLE001
logger.warning("discover_leads tool error: %s", exc)
return [{"error": str(exc)}]
# ---------------------------------------------------------------------------
# Tool: provenance_verify
# ---------------------------------------------------------------------------
@mcp.tool()
def provenance_verify(lib_path: str) -> list[dict[str, object]]:
"""Scan C++ sources for @cite tags and return code-to-paper links.
Read-only: only scans, never writes.
Returns ``[{file, line, symbol, bibkey}]`` or ``[{"error": ...}]``.
"""
try:
from codex.provenance import scan_cite_tags
hits = scan_cite_tags(lib_path)
return [dict(h) for h in hits]
except Exception as exc: # noqa: BLE001
logger.warning("provenance_verify tool error: %s", exc)
return [{"error": "feature not available"}]
# ---------------------------------------------------------------------------
# Tool: synthesis_browse
# ---------------------------------------------------------------------------
@mcp.tool()
def synthesis_browse(kind: str = "all") -> list[dict[str, object]]:
"""Leads / conjectures from the ``leads/`` directory (F-13).
Reads ``.json`` files from ``leads/`` and filters by ``kind``
(``"all"`` returns everything). Gracefully returns
``[{"error": "feature not available"}]`` when the directory is absent.
"""
try:
import json
from pathlib import Path
from codex.config import get_settings
settings = get_settings()
leads_dir = Path(settings.leads_dir)
if not leads_dir.exists():
return [{"error": "feature not available"}]
items: list[dict[str, object]] = []
for f in sorted(leads_dir.rglob("*.json")):
try:
data: dict[str, object] = json.loads(f.read_text(encoding="utf-8"))
item_kind = str(data.get("kind", "unknown"))
if kind == "all" or item_kind == kind:
items.append(data)
except (json.JSONDecodeError, OSError):
continue
return items
except Exception as exc: # noqa: BLE001
logger.warning("synthesis_browse tool error: %s", exc)
return [{"error": "feature not available"}]
# ---------------------------------------------------------------------------
# Server entry-point helpers
# ---------------------------------------------------------------------------
def _require_http_token() -> str:
"""Return the configured auth token or raise :exc:`RuntimeError`.
Called before starting HTTP transport to enforce the security invariant:
no unauthenticated HTTP endpoints are ever exposed.
"""
from codex.config import get_settings
settings = get_settings()
if not settings.mcp_auth_token:
raise RuntimeError(
"HTTP transport requires MCP_AUTH_TOKEN to be set. "
"Set the environment variable or add it to .env."
)
return settings.mcp_auth_token
# ---------------------------------------------------------------------------
# Server entry-point
# ---------------------------------------------------------------------------
def main() -> None:
"""Start the MCP server (stdio or HTTP depending on config).
HTTP transport requires ``MCP_AUTH_TOKEN`` to be set; the server will
raise :exc:`RuntimeError` and refuse to start if the token is missing.
"""
from codex.config import get_settings
settings = get_settings()
transport = settings.mcp_transport.lower()
if transport == "http":
import uvicorn
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
from starlette.requests import Request
from starlette.responses import Response
token = _require_http_token()
class _TokenAuth(BaseHTTPMiddleware):
"""Reject requests that do not carry a valid Bearer token."""
async def dispatch(
self, request: Request, call_next: RequestResponseEndpoint
) -> Response:
auth = request.headers.get("Authorization", "")
if auth != f"Bearer {token}":
return Response("Unauthorized", status_code=401)
return await call_next(request)
base_app = mcp.streamable_http_app()
base_app.add_middleware(_TokenAuth)
uvicorn.run(
base_app,
host=settings.mcp_host,
port=settings.mcp_port,
log_level="info",
)
else:
# Default: stdio transport
mcp.run(transport="stdio")
if __name__ == "__main__":
main()

View File

@@ -78,3 +78,39 @@ class CodeLink:
note: str | None = None
id: int | None = None
added_at: datetime | None = None
@dataclass
class FormulaChunk:
"""Maps to the ``formulas`` table (F-09 Rich Parsing).
Stores a single extracted mathematical formula (LaTeX) from a PDF page.
``id`` is set by the database (BIGSERIAL).
``embedding`` is reserved for future pgvector similarity search.
"""
paper_id: str
page: int
raw_latex: str
context: str
id: int | None = None
eq_label: str | None = None
embedding: list[float] | None = None
@dataclass
class FigureChunk:
"""Maps to the ``figures`` table (F-09 Rich Parsing).
Stores metadata for a single extracted figure from a PDF page.
``image_path`` points to the saved PNG on disk.
``id`` is set by the database (BIGSERIAL).
``embedding`` is reserved for future pgvector similarity search.
"""
paper_id: str
page: int
image_path: str
caption: str
id: int | None = None
embedding: list[float] | None = None

169
codex/parsing/figures.py Normal file
View File

@@ -0,0 +1,169 @@
"""Figure extraction from PDF pages.
Uses PyMuPDF (``fitz``) to iterate over embedded images in each page,
render them to PNG and detect nearby captions using text-block heuristics.
The public entry point is :func:`extract_figures`.
"""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Any
from codex.models import FigureChunk
logger = logging.getLogger(__name__)
# Caption prefixes (case-insensitive check against text block content).
_CAPTION_PREFIXES: tuple[str, ...] = ("figure", "fig.", "abbildung")
# Maximum vertical distance (in points) from the image bottom or top
# to consider a text block as the caption.
_CAPTION_TOLERANCE_Y = 60.0
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _looks_like_caption(text: str) -> bool:
"""Return True if *text* starts with a known caption prefix."""
lowered = text.strip().lower()
return any(lowered.startswith(prefix) for prefix in _CAPTION_PREFIXES)
def _find_caption(
img_rect: Any, # fitz.Rect
text_blocks: list[Any],
) -> str:
"""Find the closest caption text block to *img_rect*.
Searches text blocks that:
1. Start with a caption prefix (e.g. "Figure", "Fig.", "Abbildung").
2. Are within ``_CAPTION_TOLERANCE_Y`` points above or below the image.
Returns the first matching text, stripped; empty string if none found.
"""
best: str = ""
best_dist = float("inf")
for block in text_blocks:
if len(block) < 5:
continue
bx0, by0, bx1, by1 = block[0], block[1], block[2], block[3]
text: str = block[4]
if not _looks_like_caption(text):
continue
# Distance: gap below image or gap above image
gap_below = by0 - img_rect.y1
gap_above = img_rect.y0 - by1
if 0 <= gap_below <= _CAPTION_TOLERANCE_Y:
dist = gap_below
elif 0 <= gap_above <= _CAPTION_TOLERANCE_Y:
dist = gap_above
else:
continue
# Horizontal overlap sanity check (caption should overlap with image)
overlap = min(bx1, img_rect.x1) - max(bx0, img_rect.x0)
if overlap < 0:
continue
if dist < best_dist:
best_dist = dist
best = text.replace("\n", " ").strip()
return best
# ---------------------------------------------------------------------------
# Public entry point
# ---------------------------------------------------------------------------
def extract_figures(
pdf_path: str,
output_dir: str,
) -> list[FigureChunk]:
"""Extract embedded figures from *pdf_path* and save them as PNG files.
For each image on each page:
1. Extract the image via ``page.get_images(full=True)``.
2. Render the image as a PNG via ``fitz.Pixmap``.
3. Search nearby text blocks for a caption (``Figure …`` / ``Fig. …`` /
``Abbildung …``).
4. Save the PNG to ``<output_dir>/<paper_id>_p<page>_fig<n>.png``.
5. Yield a :class:`~codex.models.FigureChunk`.
Parameters
----------
pdf_path:
Path to the source PDF.
output_dir:
Directory where PNG images are written (created if absent).
Returns
-------
list[FigureChunk]
Extracted figure metadata; empty list if no images found.
"""
import fitz
results: list[FigureChunk] = []
paper_id = Path(pdf_path).stem
out_dir = Path(output_dir)
out_dir.mkdir(parents=True, exist_ok=True)
try:
doc: Any = fitz.open(pdf_path)
except Exception:
logger.exception("fitz.open failed for %s", pdf_path)
return results
try:
for page_num in range(len(doc)):
page = doc[page_num]
images: list[Any] = page.get_images(full=True)
text_blocks: list[Any] = page.get_text("blocks")
for fig_idx, img_info in enumerate(images):
xref: int = img_info[0]
# Get image bounding box on the page
img_rects: list[Any] = page.get_image_rects(xref)
if not img_rects:
continue
img_rect = img_rects[0]
# Extract and save the image pixmap
try:
pix: Any = fitz.Pixmap(doc, xref)
if pix.n > 4: # CMYK or similar — convert to RGB
pix = fitz.Pixmap(fitz.csRGB, pix)
filename = f"{paper_id}_p{page_num + 1}_fig{fig_idx + 1}.png"
img_path = out_dir / filename
pix.save(str(img_path))
except Exception:
logger.debug("Could not extract image xref=%d on page %d", xref, page_num + 1)
continue
caption = _find_caption(img_rect, text_blocks)
results.append(
FigureChunk(
paper_id=paper_id,
page=page_num + 1,
image_path=str(img_path),
caption=caption,
)
)
finally:
doc.close()
return results

134
codex/parsing/grobid.py Normal file
View File

@@ -0,0 +1,134 @@
"""GROBID integration.
Extracts structured reference lists and full-text TEI XML from PDFs by
calling a self-hosted GROBID HTTP server. No DB access, no embedding,
no network fetching of papers happens here.
"""
from __future__ import annotations
import xml.etree.ElementTree as ET
import httpx
from codex.config import Settings
_TEI_NS = "{http://www.tei-c.org/ns/1.0}"
def _text(element: ET.Element | None) -> str:
"""Return element text or empty string if element is None."""
if element is None:
return ""
return (element.text or "").strip()
def extract_references(
pdf_path: str,
grobid_url: str | None = None,
) -> list[dict[str, str]]:
"""Extract a structured reference list from a PDF via GROBID.
Parameters
----------
pdf_path:
Path to the PDF file on disk.
grobid_url:
Base URL of the GROBID server. Defaults to ``Settings().grobid_url``.
Returns
-------
list[dict[str, str]]
One dict per reference. All dicts contain the keys
``title``, ``authors``, ``year``, ``doi``, ``arxiv_id``
(missing values are empty strings).
"""
if grobid_url is None:
grobid_url = Settings().grobid_url
with open(pdf_path, "rb") as fh, httpx.Client(timeout=60.0) as client:
response = client.post(
f"{grobid_url}/api/processReferences",
files={"input": (pdf_path, fh, "application/pdf")},
)
response.raise_for_status()
root = ET.fromstring(response.text)
results: list[dict[str, str]] = []
for bib in root.iter(f"{_TEI_NS}biblStruct"):
# Title
title_el = bib.find(f".//{_TEI_NS}title[@level='a']")
title = _text(title_el)
# Authors: collect all persName elements
authors_parts: list[str] = []
for person in bib.iter(f"{_TEI_NS}persName"):
forename_el = person.find(f"{_TEI_NS}forename")
surname_el = person.find(f"{_TEI_NS}surname")
forename = _text(forename_el)
surname = _text(surname_el)
full = " ".join(p for p in (forename, surname) if p)
if full:
authors_parts.append(full)
authors = "; ".join(authors_parts)
# Year
date_el = bib.find(f".//{_TEI_NS}date[@type='published']")
year = ""
if date_el is not None:
when = date_el.get("when", "")
year = when[:4] if when else ""
# DOI
doi_el = bib.find(f".//{_TEI_NS}idno[@type='DOI']")
doi = _text(doi_el)
# arXiv ID — GROBID emits type="arXiv" (camel-case); match case-insensitively
arxiv_id = ""
for idno_el in bib.iter(f"{_TEI_NS}idno"):
if idno_el.get("type", "").lower() == "arxiv":
arxiv_id = (idno_el.text or "").strip()
break
results.append(
{
"title": title,
"authors": authors,
"year": year,
"doi": doi,
"arxiv_id": arxiv_id,
}
)
return results
def extract_structure(
pdf_path: str,
grobid_url: str | None = None,
) -> str:
"""Extract full-text TEI XML from a PDF via GROBID.
Parameters
----------
pdf_path:
Path to the PDF file on disk.
grobid_url:
Base URL of the GROBID server. Defaults to ``Settings().grobid_url``.
Returns
-------
str
Raw TEI XML response text from GROBID.
"""
if grobid_url is None:
grobid_url = Settings().grobid_url
with open(pdf_path, "rb") as fh, httpx.Client(timeout=120.0) as client:
response = client.post(
f"{grobid_url}/api/processFulltextDocument",
files={"input": (pdf_path, fh, "application/pdf")},
)
response.raise_for_status()
return response.text

317
codex/parsing/mathpix.py Normal file
View File

@@ -0,0 +1,317 @@
"""Formula extraction from PDF pages.
Two backends are supported:
1. **MathPix** (cloud): POST the PDF to ``api.mathpix.com/v3/pdf`` and poll
for the result JSON. Requires ``MATHPIX_APP_ID`` and ``MATHPIX_APP_KEY``
environment variables (or ``.env`` file).
2. **pix2tex** (local, MIT licence): crop each detected math bounding-box at
3× resolution and run the ``LatexOCR`` model locally. Used when MathPix
credentials are absent. Model (~116 MB) is downloaded on first call.
The public entry point :func:`extract_formulas` routes to whichever backend
is available.
"""
from __future__ import annotations
import logging
import unicodedata
from pathlib import Path
from typing import Any
from codex.config import get_settings
from codex.models import FormulaChunk
logger = logging.getLogger(__name__)
# Unicode categories that indicate mathematical symbols.
_MATH_CATEGORIES: frozenset[str] = frozenset({"Sm", "So"})
# Bbox size thresholds — avoids cropping tiny index fragments.
_MIN_HEIGHT_PT = 15.0
_MIN_WIDTH_PT = 40.0
# Minimum number of math-category characters in a text block before we
# consider it a formula candidate.
_MIN_MATH_SYMBOLS = 5
# Minimum ratio of math/total chars (as float 01).
_MIN_MATH_RATIO = 0.15
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _is_math_char(ch: str) -> bool:
"""Return True if *ch* belongs to a mathematical Unicode category."""
return unicodedata.category(ch) in _MATH_CATEGORIES
def _math_ratio(text: str) -> float:
"""Return the fraction of characters in *text* that are math chars."""
if not text:
return 0.0
math_count = sum(1 for ch in text if _is_math_char(ch))
return math_count / len(text)
def _math_symbol_count(text: str) -> int:
"""Return the absolute count of math-category characters in *text*."""
return sum(1 for ch in text if _is_math_char(ch))
# ---------------------------------------------------------------------------
# pix2tex singleton
# ---------------------------------------------------------------------------
_pix2tex_model: Any = None
def _get_pix2tex_model() -> Any:
"""Lazily load and return the pix2tex LatexOCR singleton."""
global _pix2tex_model # noqa: PLW0603
if _pix2tex_model is None:
from pix2tex.cli import LatexOCR
_pix2tex_model = LatexOCR()
return _pix2tex_model
# ---------------------------------------------------------------------------
# pix2tex backend
# ---------------------------------------------------------------------------
def _extract_formulas_pix2tex(pdf_path: str) -> list[FormulaChunk]:
"""Extract LaTeX formulas from *pdf_path* using pix2tex (local OCR).
Algorithm
---------
1. Open the PDF with PyMuPDF (fitz).
2. For each page, gather text blocks; keep only blocks whose
math-symbol ratio exceeds the threshold and whose bounding box
is larger than the minimum size.
3. Crop the region at 3× resolution (better OCR quality).
4. Run pix2tex ``LatexOCR`` on the cropped image.
5. Wrap the result in a :class:`~codex.models.FormulaChunk`.
"""
import fitz
from PIL import Image
results: list[FormulaChunk] = []
paper_id = Path(pdf_path).stem
model = _get_pix2tex_model()
try:
doc: Any = fitz.open(pdf_path)
except Exception:
logger.exception("fitz.open failed for %s", pdf_path)
return results
scale = fitz.Matrix(3, 3)
try:
for page_num in range(len(doc)):
page = doc[page_num]
blocks: list[Any] = page.get_text("blocks")
for block in blocks:
# block = (x0, y0, x1, y1, text, block_no, block_type)
if len(block) < 5:
continue
x0, y0, x1, y1 = block[0], block[1], block[2], block[3]
text: str = block[4]
# Size filter
width_pt = x1 - x0
height_pt = y1 - y0
if width_pt < _MIN_WIDTH_PT or height_pt < _MIN_HEIGHT_PT:
continue
# Math content filter
if (
_math_symbol_count(text) < _MIN_MATH_SYMBOLS
or _math_ratio(text) < _MIN_MATH_RATIO
):
continue
# Crop at 3× for OCR quality
clip_rect = fitz.Rect(x0, y0, x1, y1)
crop_pix = page.get_pixmap(matrix=scale, clip=clip_rect)
img = Image.frombytes(
"RGB",
(crop_pix.width, crop_pix.height),
crop_pix.samples,
)
try:
latex: str = model(img)
except Exception:
logger.debug("pix2tex failed on page %d block, skipping", page_num + 1)
continue
if not latex or not latex.strip():
continue
context = text[:200].replace("\n", " ")
results.append(
FormulaChunk(
paper_id=paper_id,
page=page_num + 1,
raw_latex=latex.strip(),
context=context,
)
)
finally:
doc.close()
return results
# ---------------------------------------------------------------------------
# MathPix backend
# ---------------------------------------------------------------------------
def _extract_formulas_mathpix(
pdf_path: str,
app_id: str,
app_key: str,
) -> list[FormulaChunk]:
"""Extract LaTeX formulas using the MathPix v3 PDF API.
The function uploads the PDF, polls for results and parses the
returned ``latex_simplified`` list into :class:`~codex.models.FormulaChunk`
objects.
Retries with exponential back-off (via ``tenacity``) on transient HTTP
errors.
"""
import httpx
from tenacity import retry, stop_after_attempt, wait_exponential
paper_id = Path(pdf_path).stem
headers = {
"app_id": app_id,
"app_key": app_key,
}
@retry(
stop=stop_after_attempt(4),
wait=wait_exponential(multiplier=1, min=2, max=30),
reraise=True,
)
def _post_pdf() -> dict[str, Any]:
with open(pdf_path, "rb") as fh:
resp = httpx.post(
"https://api.mathpix.com/v3/pdf",
headers=headers,
files={"file": fh},
timeout=60.0,
)
resp.raise_for_status()
return resp.json() # type: ignore[no-any-return]
submit_response = _post_pdf()
pdf_id: str = submit_response.get("pdf_id", "")
if not pdf_id:
logger.warning("MathPix did not return a pdf_id — aborting")
return []
# Poll until complete
import time
for _ in range(60):
poll_resp = httpx.get(
f"https://api.mathpix.com/v3/pdf/{pdf_id}.mmd",
headers=headers,
timeout=30.0,
)
if poll_resp.status_code == 200:
break
time.sleep(2)
else:
logger.warning("MathPix polling timed out for pdf_id=%s", pdf_id)
return []
# Parse response — MathPix MMD contains \[ ... \] or $...$ blocks
mmd_text: str = poll_resp.text
results: list[FormulaChunk] = []
lines = mmd_text.splitlines()
in_block = False
block_lines: list[str] = []
page = 1
for line in lines:
if line.strip().startswith("\\["):
in_block = True
block_lines = [line]
elif in_block:
block_lines.append(line)
if line.strip().endswith("\\]"):
latex = "\n".join(block_lines).strip()
results.append(
FormulaChunk(
paper_id=paper_id,
page=page,
raw_latex=latex,
context="",
)
)
in_block = False
block_lines = []
# crude page tracking via MMD page markers
if line.strip().startswith("<!-- Page"):
page += 1
return results
# ---------------------------------------------------------------------------
# Public entry point
# ---------------------------------------------------------------------------
def extract_formulas(
pdf_path: str,
mathpix_app_id: str | None = None,
mathpix_app_key: str | None = None,
) -> list[FormulaChunk]:
"""Extract mathematical formulas from *pdf_path*.
Routes to the MathPix cloud backend when ``mathpix_app_id`` and
``mathpix_app_key`` are both non-empty; otherwise falls back to
the local pix2tex model (if ``PIX2TEX_FALLBACK=true``, which is
the default).
Parameters
----------
pdf_path:
Absolute or relative path to the PDF file.
mathpix_app_id:
Override for ``MATHPIX_APP_ID`` setting (optional).
mathpix_app_key:
Override for ``MATHPIX_APP_KEY`` setting (optional).
Returns
-------
list[FormulaChunk]
Extracted formulas; empty list if extraction is disabled or fails.
"""
settings = get_settings()
app_id = mathpix_app_id or settings.mathpix_app_id or ""
app_key = mathpix_app_key or settings.mathpix_app_key or ""
if app_id and app_key:
logger.info("Using MathPix backend for %s", pdf_path)
return _extract_formulas_mathpix(pdf_path, app_id, app_key)
if settings.pix2tex_fallback:
logger.info("Using pix2tex fallback backend for %s", pdf_path)
return _extract_formulas_pix2tex(pdf_path)
logger.info("Formula extraction disabled (no creds, pix2tex_fallback=False)")
return []

54
codex/parsing/nougat.py Normal file
View File

@@ -0,0 +1,54 @@
"""Nougat OCR integration.
Converts PDF files to Mathpix Markdown (mmd) by calling a self-hosted
Nougat HTTP server. No network fetching of papers happens here — the
caller is expected to pass a local PDF path.
"""
from __future__ import annotations
import httpx
import tenacity
from codex.config import Settings
def pdf_to_markdown(pdf_path: str, nougat_url: str | None = None) -> str:
"""Convert a local PDF to Mathpix Markdown via the Nougat HTTP API.
Parameters
----------
pdf_path:
Absolute (or relative) path to the PDF file on disk.
nougat_url:
Base URL of the Nougat server. Defaults to ``Settings().nougat_url``.
Returns
-------
str
The raw ``.mmd`` text returned by the server.
Raises
------
httpx.HTTPStatusError
If the server returns a non-2xx status code.
"""
if nougat_url is None:
nougat_url = Settings().nougat_url
@tenacity.retry(
retry=tenacity.retry_if_exception_type(httpx.ConnectError),
stop=tenacity.stop_after_attempt(3), # 1 original + 2 retries
wait=tenacity.wait_fixed(0),
reraise=True,
)
def _post() -> str:
with open(pdf_path, "rb") as fh, httpx.Client(timeout=120.0) as client:
response = client.post(
f"{nougat_url}/predict",
files={"file": (pdf_path, fh, "application/pdf")},
)
response.raise_for_status()
return response.text
return _post()

159
codex/parsing/tex.py Normal file
View File

@@ -0,0 +1,159 @@
"""LaTeX parsing utilities.
Provides helpers to extract sections, chunk text, and convert LaTeX to plain
readable prose by stripping markup that is not useful for NLP/embedding.
"""
from __future__ import annotations
import re
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
_SECTION_RE = re.compile(
r"\\(?:sub)*section\*?\s*\{([^}]*)\}",
re.DOTALL,
)
# Patterns for LaTeX noise removal (applied in order).
_COMMENT_RE = re.compile(r"%[^\n]*")
_CITE_RE = re.compile(r"\\cite\*?\{[^}]*\}")
_LABEL_RE = re.compile(r"\\label\{[^}]*\}")
_REF_RE = re.compile(r"\\ref\{[^}]*\}")
# Display math: $$...$$ (before single $ to avoid greedy mismatch)
_DISPLAY_DOLLAR_RE = re.compile(r"\$\$.*?\$\$", re.DOTALL)
# Inline math: $...$
_INLINE_MATH_RE = re.compile(r"\$[^$\n]*?\$", re.DOTALL)
# \begin{equation}...\end{equation}
_ENV_EQUATION_RE = re.compile(
r"\\begin\{equation\*?\}.*?\\end\{equation\*?\}",
re.DOTALL,
)
# \begin{align}...\end{align} (covers align, align*, aligned, …)
_ENV_ALIGN_RE = re.compile(
r"\\begin\{align[^}]*\}.*?\\end\{align[^}]*\}",
re.DOTALL,
)
# Collapse excess whitespace
_WHITESPACE_RE = re.compile(r"[ \t]+")
_BLANK_LINES_RE = re.compile(r"\n{3,}")
def _clean_latex(text: str) -> str:
"""Strip LaTeX markup and return readable prose."""
text = _COMMENT_RE.sub("", text)
text = _ENV_EQUATION_RE.sub("", text)
text = _ENV_ALIGN_RE.sub("", text)
text = _DISPLAY_DOLLAR_RE.sub("", text)
text = _INLINE_MATH_RE.sub("", text)
text = _CITE_RE.sub("", text)
text = _LABEL_RE.sub("", text)
text = _REF_RE.sub("", text)
text = _WHITESPACE_RE.sub(" ", text)
text = _BLANK_LINES_RE.sub("\n\n", text)
return text.strip()
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def extract_sections(latex: str) -> list[tuple[str, str]]:
"""Split *latex* on \\section / \\subsection boundaries.
Returns a list of ``(title, cleaned_text)`` tuples, one per section.
Text between the document start and the first section command is
discarded (preamble / abstract handling is out of scope).
"""
# Find all section positions
matches = list(_SECTION_RE.finditer(latex))
if not matches:
return []
sections: list[tuple[str, str]] = []
for idx, match in enumerate(matches):
title = match.group(1).strip()
body_start = match.end()
body_end = matches[idx + 1].start() if idx + 1 < len(matches) else len(latex)
body = latex[body_start:body_end]
cleaned = _clean_latex(body)
sections.append((title, cleaned))
return sections
def chunk_text(text: str, size: int = 512, overlap: int = 64) -> list[str]:
"""Split *text* into overlapping word-based chunks.
Parameters
----------
text:
Plain text to chunk (not raw LaTeX).
size:
Target number of words per chunk.
overlap:
Number of words to carry over into the next chunk.
Each chunk is at most ``size + overlap`` words. Boundaries are snapped
to the nearest ``". "`` (sentence end) within ±20 words of the nominal
boundary when possible.
"""
words = text.split()
if not words:
return []
snap_window = 20
chunks: list[str] = []
start = 0
while start < len(words):
end = min(start + size, len(words))
# Try to snap *end* to a sentence boundary within ±snap_window words.
if end < len(words):
best = end
# Build a small search window
lo = max(start + 1, end - snap_window)
hi = min(len(words), end + snap_window + 1)
# Prefer the closest sentence-end ". " to *end*
for offset in range(0, snap_window + 1):
for candidate in (end - offset, end + offset):
if (
lo <= candidate < hi
and candidate > start
and words[candidate - 1].endswith(".")
):
# Sentence boundary: word at (candidate-1) ends with ".".
best = candidate
break
if best != end:
break
end = best
chunk_words = words[start:end]
chunks.append(" ".join(chunk_words))
if end >= len(words):
break
# Next chunk starts *overlap* words before *end*.
start = max(start + 1, end - overlap)
return chunks
def latex_to_text(latex: str) -> str:
"""Convert a LaTeX document to plain text.
Extracts all sections, cleans each one, and joins them with a blank line.
If no section commands are found, the whole document is cleaned and
returned as a single block.
"""
sections = extract_sections(latex)
if sections:
return "\n\n".join(body for _, body in sections)
# Fallback: no section structure — clean the whole string.
return _clean_latex(latex)

184
codex/provenance.py Normal file
View File

@@ -0,0 +1,184 @@
"""Provenance tracking: @cite-scan, code_links CRUD, references.bib export."""
from __future__ import annotations
import re
from pathlib import Path
from codex.db import get_conn
from codex.models import CodeLink
# ---------------------------------------------------------------------------
# @cite scanning
# ---------------------------------------------------------------------------
_CITE_RE = re.compile(r"@cite\s+(\S+)", re.IGNORECASE)
_FUNC_RE = re.compile(r"(\w+)\s*\(")
_CLASS_RE = re.compile(r"class\s+(\w+)")
_CPP_SUFFIXES = {".cpp", ".h", ".cxx", ".hpp"}
def scan_cite_tags(root_dir: str) -> list[dict[str, str]]:
"""Recursively scan C++ source files for @cite tags (Doxygen-style).
Returns one dict per hit:
file (str) — relative path to the source file
line (int as str) — 1-based line number
symbol (str) — function or class name (extracted from context, best-effort)
bibkey (str) — the @cite argument
Scans files matching: *.cpp, *.h, *.cxx, *.hpp
Pattern matched: ``@cite <bibkey>`` (case-insensitive, optional whitespace)
"""
root = Path(root_dir)
results: list[dict[str, str]] = []
for path in sorted(root.rglob("*")):
if path.suffix.lower() not in _CPP_SUFFIXES:
continue
try:
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
except OSError:
continue
rel_path = str(path.relative_to(root))
for lineno, line in enumerate(lines, start=1):
for match in _CITE_RE.finditer(line):
bibkey = match.group(1)
# Best-effort symbol extraction: scan backwards for func or class
symbol = ""
context_lines = lines[: lineno - 1]
for prev_line in reversed(context_lines):
func_match = _FUNC_RE.search(prev_line)
class_match = _CLASS_RE.search(prev_line)
if func_match:
symbol = func_match.group(1)
break
if class_match:
symbol = class_match.group(1)
break
results.append(
{
"file": rel_path,
"line": str(lineno),
"symbol": symbol,
"bibkey": bibkey,
}
)
return results
# ---------------------------------------------------------------------------
# code_links CRUD
# ---------------------------------------------------------------------------
def add_code_link(
symbol: str,
paper_id: str | None = None,
role: str | None = None,
note: str | None = None,
) -> CodeLink:
"""Insert a new code_link row and return the populated CodeLink (with db-assigned id)."""
sql = """
INSERT INTO code_links (symbol, paper_id, role, note)
VALUES (%(symbol)s, %(paper_id)s, %(role)s, %(note)s)
RETURNING id, added_at
"""
with get_conn() as conn:
row = conn.execute(
sql,
{"symbol": symbol, "paper_id": paper_id, "role": role, "note": note},
).fetchone()
conn.commit()
assert row is not None
return CodeLink(
symbol=symbol,
paper_id=paper_id,
role=role,
note=note,
id=row["id"],
added_at=row["added_at"],
)
def list_code_links(paper_id: str | None = None) -> list[CodeLink]:
"""Return all code_links, optionally filtered by paper_id."""
if paper_id is not None:
sql = (
"SELECT id, symbol, paper_id, role, note, added_at"
" FROM code_links WHERE paper_id = %(paper_id)s"
)
params: dict[str, str] = {"paper_id": paper_id}
else:
sql = "SELECT id, symbol, paper_id, role, note, added_at FROM code_links"
params = {}
with get_conn() as conn:
rows = conn.execute(sql, params).fetchall()
return [
CodeLink(
symbol=row["symbol"],
paper_id=row["paper_id"],
role=row["role"],
note=row["note"],
id=row["id"],
added_at=row["added_at"],
)
for row in rows
]
# ---------------------------------------------------------------------------
# references.bib export
# ---------------------------------------------------------------------------
def export_bib(paper_ids: list[str] | None = None) -> str:
"""Export papers as a BibTeX string.
If paper_ids is None, exports ALL papers with a non-NULL bibkey.
Only papers with a non-NULL bibkey are included (bibkey is the BibTeX key).
Entry format (article):
@article{<bibkey>,
title = {<title>},
author = {<authors joined with " and ">},
year = {<year>},
note = {<id>},
}
"""
if paper_ids is not None:
sql = (
"SELECT id, bibkey, title, authors, year"
" FROM papers"
" WHERE bibkey IS NOT NULL AND id = ANY(%(paper_ids)s)"
)
params: dict[str, list[str]] = {"paper_ids": paper_ids}
else:
sql = "SELECT id, bibkey, title, authors, year FROM papers WHERE bibkey IS NOT NULL"
params = {}
with get_conn() as conn:
rows = conn.execute(sql, params).fetchall()
entries: list[str] = []
for row in rows:
authors: list[str] = row["authors"] or []
author_str = " and ".join(authors) if authors else ""
entry = (
f"@article{{{row['bibkey']},\n"
f" title = {{{row['title']}}},\n"
f" author = {{{author_str}}},\n"
f" year = {{{row['year']}}},\n"
f" note = {{{row['id']}}},\n"
f"}}"
)
entries.append(entry)
return "\n\n".join(entries)

189
codex/quality.py Normal file
View File

@@ -0,0 +1,189 @@
"""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"
# ---------------------------------------------------------------------------
# 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}

100
codex/sources/arxiv.py Normal file
View File

@@ -0,0 +1,100 @@
"""arXiv API client.
Provides:
- 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.
"""
from __future__ import annotations
import io
import logging
import tarfile
import httpx
logger = logging.getLogger(__name__)
_BASE = "https://arxiv.org"
def fetch_source(arxiv_id: str) -> str | None:
"""Download and extract the primary LaTeX source for an arXiv paper.
Downloads the .tar.gz source bundle from ``https://arxiv.org/src/{arxiv_id}``,
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
string.
Parameters
----------
arxiv_id:
The arXiv identifier (e.g. ``"2301.07041"``).
Returns
-------
str | None
Raw LaTeX source string, or None if the paper is not found or no .tex
file is present (signals Nougat fallback).
"""
url = f"{_BASE}/src/{arxiv_id}"
try:
response = httpx.get(url, timeout=60, follow_redirects=True)
except httpx.RequestError:
raise
if response.status_code == 404:
logger.debug("arXiv 404 for source id=%s", arxiv_id)
return None
if response.status_code != 200:
response.raise_for_status()
raw = response.content
try:
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")]
if not tex_members:
logger.debug("No .tex files found in arXiv source for %s", arxiv_id)
return None
# Prefer the file containing \documentclass (primary document)
primary: tarfile.TarInfo | None = None
for member in tex_members:
f = tf.extractfile(member)
if f is None:
continue
content_bytes = f.read()
if b"\\documentclass" in content_bytes:
primary = member
# Decode and return immediately — first match wins
return content_bytes.decode("utf-8", errors="replace")
if primary is None:
# Fallback: largest .tex by size
largest = max(tex_members, key=lambda m: m.size)
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 # unreachable but satisfies type checker
def fetch_pdf_url(arxiv_id: str) -> str:
"""Return the canonical PDF URL for an arXiv paper.
This is a pure computation — no HTTP request is made.
Parameters
----------
arxiv_id:
The arXiv identifier (e.g. ``"2301.07041"``).
Returns
-------
str
The full URL of the PDF file.
"""
return f"{_BASE}/pdf/{arxiv_id}.pdf"

158
codex/sources/openalex.py Normal file
View File

@@ -0,0 +1,158 @@
"""OpenAlex API client.
Provides:
- fetch_paper: resolve an arXiv ID or DOI to a Paper dataclass.
- fetch_citations: retrieve reference list as Citation dataclasses.
All requests use the OpenAlex Polite Pool (mailto query parameter) and
are retried on 429/5xx with exponential back-off via tenacity.
"""
from __future__ import annotations
import logging
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, Paper
logger = logging.getLogger(__name__)
_BASE = "https://api.openalex.org"
def _resolve_id(id: str) -> str:
"""Resolve a bare DOI or arXiv ID for the OpenAlex /works/ endpoint.
OpenAlex resolves DOIs, W-IDs and full https:// URLs in the /works/ path,
but NOT the ``arxiv:`` namespace (that path 404s). arXiv papers are looked
up via their arXiv DOI ``10.48550/arXiv.<id>`` instead — accepted by
OpenAlex for both modern (``2301.07041``) and legacy (``math/0603097``) IDs.
"""
s = id.strip()
if s.lower().startswith("arxiv:"):
s = s[len("arxiv:") :]
if s.startswith(("W", "https://", "doi:")):
return s
if s.startswith("10."):
return f"doi:{s}"
return f"doi:10.48550/arXiv.{s}"
def _is_retryable(exc: BaseException) -> bool:
"""Return True for HTTP 429 / 5xx responses wrapped in httpx.HTTPStatusError."""
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]:
mailto = get_settings().openalex_mailto
if mailto:
return {"mailto": mailto}
return {}
@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(
"OpenAlex 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)
response.raise_for_status()
return response
def _reconstruct_abstract(inverted_index: dict[str, list[int]] | None) -> str | None:
"""Convert OpenAlex abstract_inverted_index back to plain text."""
if not inverted_index:
return None
word_positions: list[tuple[int, str]] = []
for word, positions in inverted_index.items():
for pos in positions:
word_positions.append((pos, word))
word_positions.sort(key=lambda t: t[0])
return " ".join(w for _, w in word_positions)
def _map_paper(data: dict[str, Any]) -> Paper:
authors: list[str] = [
a.get("author", {}).get("display_name") or "" for a in data.get("authorships", [])
]
abstract = _reconstruct_abstract(data.get("abstract_inverted_index"))
return Paper(
id=data.get("doi") or data.get("id") or "",
title=data.get("title") or "",
openalex_id=data.get("id"),
authors=authors,
year=data.get("publication_year"),
abstract=abstract,
)
def fetch_paper(id: str) -> Paper | None:
"""Fetch a single paper by arXiv ID or DOI.
Parameters
----------
id:
An arXiv ID (``"2301.07041"``), a DOI (``"10.1145/…"``),
or an OpenAlex W-ID (``"W2741809807"``).
Returns
-------
Paper | None
A populated Paper instance, or None on 404.
"""
url = f"{_BASE}/works/{_resolve_id(id)}"
try:
response = _get(url)
except httpx.HTTPStatusError as exc:
if exc.response.status_code == 404:
logger.debug("OpenAlex 404 for id=%s", id)
return None
raise
return _map_paper(response.json())
def fetch_citations(openalex_id: str) -> list[Citation]:
"""Fetch the reference list for a paper as Citation objects.
Uses the ``referenced_works`` field from the work object — the
``/works/{id}/references`` path is not a real OpenAlex endpoint.
Parameters
----------
openalex_id:
The OpenAlex work ID (``"https://openalex.org/W…"`` or ``"W…"``).
Returns
-------
list[Citation]
One Citation per referenced work (OpenAlex W-ID as cited_id).
"""
url = f"{_BASE}/works/{_resolve_id(openalex_id)}"
try:
response = _get(url)
except httpx.HTTPStatusError as exc:
if exc.response.status_code == 404:
return []
raise
data: dict[str, Any] = response.json()
referenced_works: list[str] = data.get("referenced_works", [])
return [
Citation(citing_id=openalex_id, cited_id=cited_id)
for cited_id in referenced_works
if cited_id
]

View File

@@ -0,0 +1,135 @@
"""Semantic Scholar API client.
Provides:
- fetch_references: retrieve references for a paper as Citation dataclasses.
- fetch_recommendations: retrieve recommended paper IDs.
Rate-limited to ≤1 req/s (per-request floor via monotonic clock).
Retried on 429/5xx with exponential back-off via tenacity.
"""
from __future__ import annotations
import logging
import threading
import time
from typing import Any
import httpx
from tenacity import retry, retry_if_exception, stop_after_attempt, wait_exponential
from codex.models import Citation
logger = logging.getLogger(__name__)
_BASE_GRAPH = "https://api.semanticscholar.org/graph/v1"
_BASE_RECS = "https://api.semanticscholar.org/recommendations/v1"
# Per-request rate limit: ≤1 req/s without an API key.
_rate_lock = threading.Lock()
_last_request_time: float = 0.0
_MIN_INTERVAL = 1.0
def _rate_limit() -> None:
global _last_request_time
with _rate_lock:
now = time.monotonic()
wait = _MIN_INTERVAL - (now - _last_request_time)
if wait > 0:
time.sleep(wait)
_last_request_time = time.monotonic()
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(5),
wait=wait_exponential(min=1, max=30),
before_sleep=lambda rs: logger.warning(
"SemanticScholar retry %d after %s",
rs.attempt_number,
rs.outcome.exception(), # type: ignore[union-attr]
),
)
def _get(url: str, params: dict[str, Any] | None = None) -> httpx.Response:
_rate_limit()
response = httpx.get(url, params=params, timeout=30)
response.raise_for_status()
return response
def fetch_references(paper_id: str) -> list[Citation]:
"""Fetch references for a paper from Semantic Scholar.
Parameters
----------
paper_id:
Semantic Scholar paper ID (or ``arXiv:…`` / ``DOI:…`` prefixed ID).
Returns
-------
list[Citation]
One Citation per reference, with optional context snippet.
"""
url = f"{_BASE_GRAPH}/paper/{paper_id}/references"
params: dict[str, Any] = {"fields": "externalIds,contexts"}
try:
response = _get(url, params=params)
except httpx.HTTPStatusError as exc:
if exc.response.status_code == 404:
return []
raise
data = response.json()
raw_refs: list[dict[str, Any]] = data.get("data", [])
citations: list[Citation] = []
for entry in raw_refs:
cited_paper: dict[str, Any] = entry.get("citedPaper", {})
external_ids: dict[str, str] = cited_paper.get("externalIds") or {}
contexts: list[str] = entry.get("contexts", [])
context: str | None = contexts[0] if contexts else None
cited_id: str = (
external_ids.get("DOI")
or external_ids.get("ArXiv")
or cited_paper.get("paperId")
or ""
)
if cited_id:
citations.append(Citation(citing_id=paper_id, cited_id=cited_id, context=context))
return citations
def fetch_recommendations(paper_id: str, limit: int = 20) -> list[str]:
"""Fetch recommended paper IDs from Semantic Scholar.
Parameters
----------
paper_id:
Semantic Scholar paper ID.
limit:
Maximum number of recommendations to return.
Returns
-------
list[str]
List of recommended paper IDs.
"""
url = f"{_BASE_RECS}/papers/forpaper/{paper_id}"
params: dict[str, Any] = {"limit": limit}
try:
response = _get(url, params=params)
except httpx.HTTPStatusError as exc:
if exc.response.status_code == 404:
return []
raise
data = response.json()
recommended: list[dict[str, Any]] = data.get("recommendedPapers", [])
return [p["paperId"] for p in recommended if p.get("paperId")]

862
codex/synthesis.py Normal file
View File

@@ -0,0 +1,862 @@
"""Synthesis / Lead-Engine (F-13).
Generates four kinds of research **leads** over the RAG substrate:
| Stage | Kind | Grounded? | Output dir |
|-------|---------------|-----------|--------------------------|
| 2 | connection | yes | ``leads/grounded/`` |
| 3 | gap | yes | ``leads/grounded/`` |
| 4 | improvement | yes | ``leads/grounded/`` |
| 5 | conjecture | no — by definition unverifiable | ``leads/conjectures/`` |
Hard invariant (F-13 HARD)
--------------------------
**Conjectures are NEVER written to ``wiki/``.** They are quarantined to
``leads/conjectures/`` with mandatory ``status="unverified"`` plus a
``suggested_validation`` field describing the spike that would falsify them.
The wiki layer (F-12) stays grounded-only. Conjectures live in their own
sandbox so that human review can promote them after a spike succeeds.
Grounding discipline
--------------------
* For ``connection`` / ``gap`` / ``improvement`` leads we run the
same content-5-gram grounding check as F-12 (re-used directly from
:mod:`codex.wiki`). Every claim in the lead body that fails the check
is marked with the ⚠ prefix and the lead is recorded as having
ungrounded claims. Leads whose grounded-ratio is below the floor are
dropped (returned as "ungrounded" rather than "grounded").
* For ``conjecture`` leads grounding by definition cannot apply
(the claim does not exist in the corpus). Provenance is still mandatory:
it points at the chunks that *seeded* the conjecture so a reviewer can
trace the inspiration.
Graceful degradation
--------------------
* Missing ``lib_path`` → :func:`find_improvements` returns ``[]``.
* DB unreachable → caller sees the psycopg error; no silent failures.
* LLM unreachable (httpx.ConnectError / any exception) → the affected
generator returns ``[]`` (matches the F-12 LLM degradation rule).
"""
from __future__ import annotations
import json
import logging
import re
from dataclasses import asdict, dataclass, field
from datetime import UTC, datetime
from pathlib import Path
from typing import Any, Literal, Protocol
from codex.config import get_settings
from codex.wiki import (
Claim,
LLMClient,
OllamaClient,
_parse_claims,
_retrieve_chunks,
_run_grounding_guard,
)
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Datamodel
# ---------------------------------------------------------------------------
LeadKind = Literal["connection", "gap", "improvement", "conjecture"]
LeadStatus = Literal["unverified", "spiking", "go", "no-go"]
@dataclass
class Provenance:
"""Pointer back to a single source location for a lead.
``locator`` follows the same conventions as F-12 wiki claims:
``"chunk 16"``, ``"page 9"``, ``"eq.(9)"``, or — for ``improvement``
leads — a code locator like ``"src/cli.cpp:42"``.
"""
bibkey: str
locator: str
@dataclass
class Lead:
"""A single research lead emitted by the synthesis engine.
See module docstring for the four-stage taxonomy and the
grounded-vs-conjecture invariant.
"""
id: str # "L-0001"
kind: LeadKind
title: str
body: str
provenance: list[Provenance] # mandatory — even for conjectures
confidence: float # 0..1
status: LeadStatus = "unverified"
# For conjectures: the falsification spike. Empty string for grounded leads.
suggested_validation: str = ""
# Derived: claim-level grounding decisions (empty for conjectures).
claims: list[Claim] = field(default_factory=list)
def __post_init__(self) -> None:
if not self.provenance:
raise ValueError(
f"Lead '{self.id}' must carry at least one Provenance entry. "
"For gap leads with no chunks, use a topic-marker Provenance."
)
# ---------------------------------------------------------------------------
# Protocols
# ---------------------------------------------------------------------------
class _ConnLike(Protocol):
"""Subset of ``psycopg.Connection`` used by this module (helps tests)."""
def execute(self, sql: str, params: dict[str, Any] | None = ...) -> Any: ...
# ---------------------------------------------------------------------------
# LLM prompts
# ---------------------------------------------------------------------------
_CONNECTION_PROMPT = """\
You are a research analyst surfacing CONNECTIONS between papers.
A connection is a non-trivial overlap (shared definitions, shared techniques,
shared theorems, or one paper extending/generalising another). It is a
DESCRIPTIVE statement about the corpus — not a conjecture.
Use ONLY the source chunks below. For every factual claim, cite it inline
in the format [BibKey #locator]. Example:
"Both papers use the Lobachevsky function as the building block of volume
formulas. [Springborn2008 #chunk 16] [BobenkoPinkallSpringborn2015 #chunk 3]"
Do NOT invent facts. Do NOT speculate. Two short paragraphs maximum.
SOURCE CHUNKS:
{chunks_block}
Now describe a single non-trivial connection between these papers:
"""
_GAP_PROMPT = """\
You are a research analyst identifying GAPS in a corpus.
A gap is an ABSENCE: a topic, technique, or comparison that the corpus
fails to cover, even though adjacent material exists. Make the absence
concrete by pointing at where the corpus DOES talk about adjacent things.
Use ONLY the source chunks below. For every factual claim about what IS
in the corpus, cite it inline in the format [BibKey #locator].
The absence itself does not need a citation; the surrounding context does.
Do NOT invent facts. Two short paragraphs maximum.
SOURCE CHUNKS:
{chunks_block}
Now describe a single concrete gap in the corpus's coverage of "{topic}":
"""
_IMPROVEMENT_PROMPT = """\
You are a research analyst inspecting C++ code annotated with @cite tags.
For the symbol below, a literature paper is cited. Read the chunks and
suggest at most one CONCRETE improvement to the code that the literature
would support. The improvement must be a DESCRIPTIVE statement of what the
paper says, not a conjecture.
Use ONLY the source chunks. Cite each claim inline as [BibKey #locator].
Symbol: {symbol}
Cited bibkey: {bibkey}
File:locator: {file_locator}
SOURCE CHUNKS:
{chunks_block}
Now state a single grounded improvement (or "No improvement suggested." if
the chunks do not support any):
"""
_CONJECTURE_PROMPT = """\
You are a creative researcher proposing CONJECTURES — novel hypotheses that
EXTEND but DO NOT contradict the corpus.
A conjecture is a NEW idea not yet present in the literature. By definition
it cannot be grounded in the chunks. Your output MUST be honest about this:
do not pretend it is a result already in the papers.
You MUST produce:
1. A one-sentence title (the conjecture itself).
2. A short body (2-4 sentences) explaining the intuition.
3. A "Validation:" line stating ONE concrete spike — a small experiment,
counter-example search, or proof attempt — that would FALSIFY the
conjecture if it is wrong. Without a falsification path the conjecture
is rejected.
Use the chunks below ONLY as inspiration. Cite seed material as
[BibKey #locator] in the body so a reviewer can trace where the idea came
from.
SOURCE CHUNKS:
{chunks_block}
Now propose one conjecture in the format:
Title: <one sentence>
Body: <2-4 sentences with [BibKey #locator] seed citations>
Validation: <one concrete falsification spike>
"""
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _format_chunks(chunks: list[dict[str, Any]]) -> str:
"""Render chunks for the LLM prompt — same format as F-12."""
lines: list[str] = []
for chunk in chunks:
bibkey = chunk.get("bibkey") or chunk["paper_id"]
ord_val = chunk.get("ord", "?")
lines.append(f"[{bibkey} #chunk {ord_val}]\n{chunk['content'].strip()}\n")
return "\n---\n".join(lines)
def _mark_ungrounded(body: str, claims: list[Claim]) -> str:
"""Prefix each ungrounded claim's last sentence with ⚠ in *body*."""
for claim in claims:
if claim.grounded:
continue
escaped = re.escape(claim.text.strip())
body = re.sub(rf"(?<!⚠ )({escaped})", r"\1", body, count=1)
return body
def _grounded_ratio(claims: list[Claim]) -> float:
"""Fraction of *claims* that passed the grounding check (0.0 if empty)."""
if not claims:
return 0.0
n_grounded = sum(1 for c in claims if c.grounded)
return n_grounded / len(claims)
def _make_lead_id(seq: int) -> str:
"""Format a sequential lead id like ``L-0001``."""
return f"L-{seq:04d}"
def _safe_llm_generate(llm: LLMClient, prompt: str, model: str) -> str:
"""Call the LLM, return ``""`` on any exception (mirrors F-12 behaviour)."""
try:
return llm.generate(prompt, model=model)
except Exception as exc: # noqa: BLE001
logger.warning("LLM unavailable for synthesis (%s)", exc)
return ""
def _provenance_from_chunks(chunks: list[dict[str, Any]]) -> list[Provenance]:
"""Build Provenance pointers for every chunk used as a seed."""
out: list[Provenance] = []
for chunk in chunks:
bibkey = str(chunk.get("bibkey") or chunk.get("paper_id") or "")
ord_val = chunk.get("ord", "?")
out.append(Provenance(bibkey=bibkey, locator=f"chunk {ord_val}"))
return out
def _finalise_grounded_lead(
*,
seq: int,
kind: LeadKind,
title: str,
body: str,
chunks: list[dict[str, Any]],
min_grounded_ratio: float,
) -> Lead | None:
"""Parse claims, run grounding guard, mark ⚠, return ``None`` if too sparse.
Returns the populated :class:`Lead` or ``None`` if no parseable claims
were found or the grounded ratio is below ``min_grounded_ratio``.
"""
claims = _parse_claims(body)
if not claims:
# No citations at all → cannot be a grounded lead.
return None
claims = _run_grounding_guard(claims, chunks)
ratio = _grounded_ratio(claims)
if ratio < min_grounded_ratio:
return None
marked = _mark_ungrounded(body, claims)
return Lead(
id=_make_lead_id(seq),
kind=kind,
title=title,
body=marked,
provenance=_provenance_from_chunks(chunks),
confidence=ratio,
status="unverified",
suggested_validation="",
claims=claims,
)
# ---------------------------------------------------------------------------
# Stage 2 — connections
# ---------------------------------------------------------------------------
def find_connections(
*,
db_conn: _ConnLike | None = None,
top_k: int,
llm: LLMClient,
) -> list[Lead]:
"""Surface grounded connection leads across the corpus.
Strategy: cluster the corpus by shared retrieval — for each paper
pair that shows up together in the top-K of a common probe query
we ask the LLM to articulate the connection. The probe queries are
the bibkey/title pairs of the existing papers (cheap, no external
config needed).
Parameters
----------
db_conn:
Unused right now (kept for the agreed signature so callers can
inject a transactional connection later).
top_k:
Number of chunks retrieved per probe.
llm:
Injectable LLM client. Errors fall back to ``[]``.
"""
settings = get_settings()
seq = 1
leads: list[Lead] = []
# Probe queries: use the discovered titles of already-ingested papers.
# Without DB we can't get titles, so we fall back to a generic probe.
titles = _list_paper_titles(db_conn)
if not titles:
return leads
seen_pairs: set[tuple[str, str]] = set()
for bibkey, title in titles:
chunks = _retrieve_chunks([title], top_k=top_k)
if not chunks:
continue
# Find chunks from a *different* bibkey — that's the connection signal.
other_bibkeys = {
str(c.get("bibkey") or "")
for c in chunks
if c.get("bibkey") and c.get("bibkey") != bibkey
}
for other in sorted(other_bibkeys):
pair = tuple(sorted([bibkey, other]))
if pair in seen_pairs:
continue
seen_pairs.add(pair) # type: ignore[arg-type]
pair_chunks = [
c
for c in chunks
if c.get("bibkey") in {bibkey, other}
]
prompt = _CONNECTION_PROMPT.format(chunks_block=_format_chunks(pair_chunks))
raw = _safe_llm_generate(llm, prompt, settings.synthesis_llm_model)
if not raw.strip():
continue
lead = _finalise_grounded_lead(
seq=seq,
kind="connection",
title=f"Connection: {bibkey}{other}",
body=raw.strip(),
chunks=pair_chunks,
min_grounded_ratio=settings.synthesis_min_grounded_ratio,
)
if lead is None:
continue
leads.append(lead)
seq += 1
return leads
# ---------------------------------------------------------------------------
# Stage 3 — gaps
# ---------------------------------------------------------------------------
def find_gaps(
lib_path: str | None = None,
*,
db_conn: _ConnLike | None = None,
llm: LLMClient,
topics: list[str] | None = None,
) -> list[Lead]:
"""Surface grounded gap leads.
Two complementary signals are used:
* **Topic coverage map** — for each topic in ``topics`` (or, if
``None``, the union of paper titles), retrieve chunks and check
bibkey coverage. A topic covered by < ``synthesis_gap_min_coverage``
bibkeys is reported as a gap candidate and the LLM is asked to
articulate it.
* **Code-vs-corpus gap** — when ``lib_path`` is given, any @cite
bibkey scanned from code that is NOT present in the corpus is
flagged. This catches the "cited in code, never ingested" case.
"""
settings = get_settings()
seq = 1
leads: list[Lead] = []
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)}
# --- coverage-map gaps --------------------------------------------
for topic in probe_topics:
chunks = _retrieve_chunks([topic], top_k=settings.synthesis_top_k)
if not chunks:
# No coverage at all → record the gap directly (LLM not strictly needed).
leads.append(
Lead(
id=_make_lead_id(seq),
kind="gap",
title=f"Gap: corpus has no material on '{topic}'",
body=(
f"The retrieval layer returned no chunks for topic '{topic}'.\n"
"No grounded statement is possible; this is the absence itself."
),
provenance=[Provenance(bibkey="(none)", locator=f"topic:{topic}")],
confidence=1.0,
status="unverified",
suggested_validation=(
"Ingest a paper covering this topic, re-run synthesis: the gap "
"should disappear."
),
)
)
seq += 1
continue
bibkeys_for_topic = {str(c.get("bibkey") or "") for c in chunks if c.get("bibkey")}
if len(bibkeys_for_topic) >= settings.synthesis_gap_min_coverage:
# Topic is well covered — not a gap.
continue
prompt = _GAP_PROMPT.format(topic=topic, chunks_block=_format_chunks(chunks))
raw = _safe_llm_generate(llm, prompt, settings.synthesis_llm_model)
if not raw.strip():
continue
lead = _finalise_grounded_lead(
seq=seq,
kind="gap",
title=f"Gap: '{topic}' covered by only {len(bibkeys_for_topic)} bibkey(s)",
body=raw.strip(),
chunks=chunks,
min_grounded_ratio=settings.synthesis_min_grounded_ratio,
)
if lead is None:
continue
leads.append(lead)
seq += 1
# --- code-vs-corpus gaps ------------------------------------------
if lib_path:
from codex.provenance import scan_cite_tags
try:
hits = scan_cite_tags(lib_path)
except FileNotFoundError:
hits = []
cited_bibkeys = {h["bibkey"] for h in hits}
missing = cited_bibkeys - known_bibkeys
for bibkey in sorted(missing):
leads.append(
Lead(
id=_make_lead_id(seq),
kind="gap",
title=f"Gap: code cites '{bibkey}' but corpus does not contain it",
body=(
f"The bibkey `{bibkey}` is referenced by @cite tags in `{lib_path}`\n"
"but no paper with this bibkey is ingested into the corpus.\n"
"This is a concrete absence."
),
provenance=[Provenance(bibkey=bibkey, locator="(not ingested)")],
confidence=1.0,
status="unverified",
suggested_validation=(
f"Ingest the paper for `{bibkey}` via `codex ingest`. The gap "
"should disappear on the next synthesis run."
),
)
)
seq += 1
return leads
# ---------------------------------------------------------------------------
# Stage 4 — improvements
# ---------------------------------------------------------------------------
def find_improvements(
lib_path: str,
*,
db_conn: _ConnLike | None = None,
top_k: int,
llm: LLMClient,
) -> list[Lead]:
"""Suggest grounded code improvements based on @cite-linked literature.
Walks ``lib_path`` for ``@cite <bibkey>`` markers, retrieves the
relevant chunks for each cited bibkey, and asks the LLM to propose
one concrete improvement supported by the paper. Improvements
failing the grounding check are dropped.
"""
if not lib_path:
return []
from codex.provenance import scan_cite_tags
settings = get_settings()
seq = 1
leads: list[Lead] = []
try:
hits = scan_cite_tags(lib_path)
except FileNotFoundError:
return leads
known_bibkeys = {bk for bk, _ in _list_paper_titles(db_conn)}
# Group hits by (symbol, bibkey) so we ask the LLM at most once per pair.
by_key: dict[tuple[str, str], list[dict[str, str]]] = {}
for hit in hits:
if hit["bibkey"] not in known_bibkeys:
continue # handled by find_gaps
by_key.setdefault((hit["symbol"] or "(unknown)", hit["bibkey"]), []).append(hit)
for (symbol, bibkey), group in by_key.items():
chunks = _retrieve_chunks([symbol, bibkey], top_k=top_k)
# Restrict to chunks from the cited bibkey when possible
same_bib = [c for c in chunks if c.get("bibkey") == bibkey] or chunks
if not same_bib:
continue
sample = group[0]
file_locator = f"{sample['file']}:{sample['line']}"
prompt = _IMPROVEMENT_PROMPT.format(
symbol=symbol,
bibkey=bibkey,
file_locator=file_locator,
chunks_block=_format_chunks(same_bib),
)
raw = _safe_llm_generate(llm, prompt, settings.synthesis_llm_model)
if not raw.strip() or "no improvement suggested" in raw.lower():
continue
lead = _finalise_grounded_lead(
seq=seq,
kind="improvement",
title=f"Improvement: {symbol} ({file_locator}) via {bibkey}",
body=raw.strip(),
chunks=same_bib,
min_grounded_ratio=settings.synthesis_min_grounded_ratio,
)
if lead is None:
continue
# Attach the code locator to the provenance list as the first entry
lead.provenance.insert(0, Provenance(bibkey=bibkey, locator=file_locator))
leads.append(lead)
seq += 1
return leads
# ---------------------------------------------------------------------------
# Stage 5 — conjectures
# ---------------------------------------------------------------------------
_CONJECTURE_RE = re.compile(
r"Title:\s*(?P<title>.+?)\n+"
r"Body:\s*(?P<body>.+?)\n+"
r"Validation:\s*(?P<validation>.+)",
re.DOTALL | re.IGNORECASE,
)
def _parse_conjecture_output(raw: str) -> tuple[str, str, str] | None:
"""Parse the LLM's three-section conjecture output.
Returns ``(title, body, validation)`` or ``None`` if any section
is missing or empty.
"""
match = _CONJECTURE_RE.search(raw)
if not match:
return None
title = match.group("title").strip()
body = match.group("body").strip()
validation = match.group("validation").strip()
if not title or not body or not validation:
return None
return title, body, validation
def propose_conjectures(
*,
db_conn: _ConnLike | None = None,
top_k: int,
llm: LLMClient,
seeds: list[str] | None = None,
) -> list[Lead]:
"""Generate stage-5 conjecture leads.
Each conjecture is the LLM's own hypothesis seeded by a small
retrieval slice. The output is by definition ungrounded, so:
* ``status`` is hard-set to ``"unverified"``.
* ``suggested_validation`` is mandatory; conjectures without a
falsification path are dropped.
* ``provenance`` records the seed chunks so a reviewer can trace
inspiration.
Conjectures are written to ``leads/conjectures/`` only — never
to ``wiki/``.
"""
settings = get_settings()
seq = 1
leads: list[Lead] = []
probe_seeds: list[str] = list(seeds or [])
if not probe_seeds:
probe_seeds = [title for _, title in _list_paper_titles(db_conn)]
if not probe_seeds:
return leads
for seed in probe_seeds:
chunks = _retrieve_chunks([seed], top_k=top_k)
if not chunks:
continue
prompt = _CONJECTURE_PROMPT.format(chunks_block=_format_chunks(chunks))
raw = _safe_llm_generate(llm, prompt, settings.synthesis_llm_model)
if not raw.strip():
continue
parsed = _parse_conjecture_output(raw)
if parsed is None:
# No falsification path → reject (hard invariant).
continue
title, body, validation = parsed
provenance = _provenance_from_chunks(chunks)
if not provenance:
continue
leads.append(
Lead(
id=_make_lead_id(seq),
kind="conjecture",
title=title,
body=body,
provenance=provenance,
confidence=0.0, # conjectures start unconfirmed
status="unverified", # HARD invariant
suggested_validation=validation,
)
)
seq += 1
return leads
# ---------------------------------------------------------------------------
# DB helpers
# ---------------------------------------------------------------------------
def _list_paper_titles(db_conn: _ConnLike | None) -> list[tuple[str, str]]:
"""Return ``[(bibkey, title), …]`` for all ingested papers with a bibkey.
Uses the injected connection when provided, otherwise opens a fresh
one. Returns ``[]`` if the database is unreachable or has no rows.
"""
sql = "SELECT bibkey, title FROM papers WHERE bibkey IS NOT NULL"
try:
if db_conn is not None:
rows = db_conn.execute(sql).fetchall()
else:
from codex.db import get_conn
with get_conn() as conn:
rows = conn.execute(sql).fetchall()
except Exception as exc: # noqa: BLE001
logger.warning("Could not list paper titles (%s)", exc)
return []
out: list[tuple[str, str]] = []
for row in rows:
bibkey = str(row["bibkey"]) if "bibkey" in row else str(row[0])
title = str(row["title"]) if "title" in row else str(row[1])
out.append((bibkey, title))
return out
# ---------------------------------------------------------------------------
# Persistence
# ---------------------------------------------------------------------------
_GROUNDED_KINDS: frozenset[LeadKind] = frozenset({"connection", "gap", "improvement"})
def _lead_to_markdown(lead: Lead) -> str:
"""Render a Lead as a self-contained markdown document.
Front-matter is a fenced JSON block so the file is human-readable
and machine-parseable.
"""
meta = {
"id": lead.id,
"kind": lead.kind,
"status": lead.status,
"confidence": round(lead.confidence, 3),
"provenance": [asdict(p) for p in lead.provenance],
"suggested_validation": lead.suggested_validation,
"compiled_at": datetime.now(UTC).isoformat(),
}
parts = [
f"# {lead.id}{lead.title}",
"",
"```json",
json.dumps(meta, indent=2, sort_keys=True),
"```",
"",
lead.body.rstrip(),
"",
]
if lead.kind == "conjecture":
parts.extend(
[
"## Status",
"",
"**UNVERIFIED CONJECTURE — not grounded in the corpus.**",
"Promotion to the wiki requires the validation spike below to succeed.",
"",
"## Suggested validation",
"",
lead.suggested_validation,
"",
]
)
return "\n".join(parts)
def write_leads(leads: list[Lead], leads_dir: str) -> None:
"""Write ``leads`` to disk under ``leads_dir``.
* Grounded leads → ``leads_dir/grounded/<id>.md``
* Conjectures → ``leads_dir/conjectures/<id>.md`` (HARD invariant)
The function ALSO maintains an append-only ``leads_dir/INDEX.md``:
a previously-recorded id is kept on subsequent runs even if it is
overwritten with a newer body. The two sections (grounded /
conjectures) are visibly separated.
Notes
-----
* The function asserts the kind→directory routing — a conjecture
ending up under ``grounded/`` would be an invariant violation
and is raised as :class:`RuntimeError`.
"""
root = Path(leads_dir)
grounded_dir = root / "grounded"
conj_dir = root / "conjectures"
grounded_dir.mkdir(parents=True, exist_ok=True)
conj_dir.mkdir(parents=True, exist_ok=True)
for lead in leads:
markdown = _lead_to_markdown(lead)
if lead.kind == "conjecture":
target_dir = conj_dir
elif lead.kind in _GROUNDED_KINDS:
target_dir = grounded_dir
else: # pragma: no cover — defensive
raise RuntimeError(f"Unknown lead kind: {lead.kind!r}")
# HARD invariant: conjectures NEVER land in grounded/ or wiki/.
if lead.kind == "conjecture" and target_dir != conj_dir:
raise RuntimeError(
f"INVARIANT VIOLATION: conjecture {lead.id} would be written "
f"to {target_dir} instead of {conj_dir}"
)
path = target_dir / f"{lead.id}.md"
path.write_text(markdown, encoding="utf-8")
# Update INDEX.md
_update_index(root)
def _update_index(root: Path) -> None:
"""Re-build ``root/INDEX.md`` from the on-disk files (full rebuild, not append-only)."""
grounded_dir = root / "grounded"
conj_dir = root / "conjectures"
grounded_files = sorted(grounded_dir.glob("*.md")) if grounded_dir.exists() else []
conj_files = sorted(conj_dir.glob("*.md")) if conj_dir.exists() else []
ts = datetime.now(UTC).strftime("%Y-%m-%d %H:%M UTC")
lines = [
"# Lead Index",
"",
f"_Last updated {ts} by `codex synthesis`_",
"",
"## Grounded leads (connection / gap / improvement)",
"",
]
if grounded_files:
for f in grounded_files:
title = _extract_title(f) or f.stem
lines.append(f"- `{f.stem}` — {title}")
else:
lines.append("_None._")
lines.extend(["", "## Conjectures (UNVERIFIED — quarantined here, never in wiki/)", ""])
if conj_files:
for f in conj_files:
title = _extract_title(f) or f.stem
lines.append(f"- `{f.stem}` — {title}")
else:
lines.append("_None._")
lines.append("")
(root / "INDEX.md").write_text("\n".join(lines), encoding="utf-8")
def _extract_title(path: Path) -> str | None:
"""Pull the H1 title from a lead markdown file, if present."""
try:
first = path.read_text(encoding="utf-8").splitlines()[0]
except (OSError, IndexError):
return None
if first.startswith("# "):
return first[2:].strip()
return None
# ---------------------------------------------------------------------------
# Default LLM client
# ---------------------------------------------------------------------------
def default_llm_client() -> LLMClient:
"""Return an :class:`OllamaClient` configured from the synthesis settings.
Falls back to ``ollama_base_url`` when ``synthesis_llm_url`` is unset.
"""
settings = get_settings()
url = settings.synthesis_llm_url or settings.ollama_base_url
return OllamaClient(url)

830
codex/wiki.py Normal file
View File

@@ -0,0 +1,830 @@
"""Wiki-compile layer — grounded concept pages over the RAG substrate (F-12).
Each concept page is compiled from retrieved chunks via a local LLM (Ollama).
Every claim is grounded against its cited source chunk (Substring-Match MVP).
Cross-references to other concepts are rendered as ``[[slug]]`` links.
Generated pages are written to ``wiki/<slug>.md`` and committed to git.
Compile-state (hash tracking for incremental re-runs) is persisted to
``wiki/.compile-state.json`` on disk — no DB changes (F-12 constraint).
Graceful degradation:
- Missing ``formulas`` table (F-09 not present) → no formula embedding, no crash.
- Missing ``verify_citations`` (F-10 not present) → local substring grounding check.
- LLM unavailable (httpx.ConnectError or any exception) → empty ConceptPage, no crash.
"""
from __future__ import annotations
import hashlib
import json
import logging
import re
import textwrap
from dataclasses import dataclass, field
from datetime import UTC, datetime
from pathlib import Path
from typing import Any, Protocol
import yaml
from codex.config import get_settings
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Dataclasses
# ---------------------------------------------------------------------------
@dataclass
class Concept:
"""A curated concept seed from ``wiki/concepts.yaml``."""
slug: str
title: str
aliases: list[str]
emphasis: str | None = None
@dataclass
class Claim:
"""A single factual claim extracted from a synthesised concept page."""
text: str
bibkey: str
locator: str # e.g. "page 9" | "eq.(9)" | "chunk 42"
grounded: bool = True # set by Grounding-Guard
@dataclass
class ConceptPage:
"""The compiled wiki page for one concept."""
concept: Concept
markdown: str # final rendered markdown
claims: list[Claim] = field(default_factory=list)
chunk_hash: str = "" # SHA-256 of concatenated source chunks
compiled_at: datetime = field(default_factory=lambda: datetime.now(UTC))
@dataclass
class CompileReport:
"""Summary of a compile run (appended to ``wiki/log.md``)."""
ran_at: datetime = field(default_factory=lambda: datetime.now(UTC))
compiled: list[str] = field(default_factory=list) # slugs written/updated
skipped: list[str] = field(default_factory=list) # slugs skipped (unchanged)
ungrounded: list[tuple[str, str]] = field(default_factory=list) # (slug, claim_text)
conflicts: list[tuple[str, str, str]] = field(default_factory=list) # (slug, bibkey1, bibkey2)
quarantined: list[str] = field(default_factory=list) # slugs written to wiki/draft/
# ---------------------------------------------------------------------------
# LLM protocol (injectable for tests)
# ---------------------------------------------------------------------------
class LLMClient(Protocol):
"""Minimal protocol for an LLM that can generate text."""
def generate(self, prompt: str, model: str) -> str:
"""Return generated text for *prompt* using *model*."""
...
# ---------------------------------------------------------------------------
# Default Ollama LLM client
# ---------------------------------------------------------------------------
class OllamaClient:
"""Thin HTTP wrapper around the Ollama ``/api/generate`` endpoint."""
def __init__(self, base_url: str) -> None:
self._base_url = base_url.rstrip("/")
def generate(self, prompt: str, model: str) -> str: # noqa: D102
import httpx
url = f"{self._base_url}/api/generate"
payload = {"model": model, "prompt": prompt, "stream": False}
response = httpx.post(url, json=payload, timeout=120.0)
response.raise_for_status()
data: dict[str, Any] = response.json()
return str(data.get("response", ""))
# ---------------------------------------------------------------------------
# YAML loader
# ---------------------------------------------------------------------------
def load_concepts(path: str) -> list[Concept]:
"""Parse ``wiki/concepts.yaml`` and return a list of :class:`Concept` objects.
Each entry must have ``slug``, ``title``, and ``aliases`` (list).
``emphasis`` is optional.
"""
raw = Path(path).read_text(encoding="utf-8")
data: dict[str, Any] = yaml.safe_load(raw)
concepts: list[Concept] = []
for entry in data.get("concepts", []):
concepts.append(
Concept(
slug=str(entry["slug"]),
title=str(entry["title"]),
aliases=[str(a) for a in entry.get("aliases", [])],
emphasis=entry.get("emphasis") or None,
)
)
return concepts
# ---------------------------------------------------------------------------
# Retrieval helpers
# ---------------------------------------------------------------------------
def _retrieve_chunks(
queries: list[str],
*,
top_k: int,
) -> list[dict[str, Any]]:
"""Retrieve chunks via hybrid search (dense + FTS) from the DB.
Returns a list of dicts with keys: ``id``, ``paper_id``, ``ord``,
``content``, ``bibkey``. Reference-list chunks are filtered out
(ADR-F12: bibliography fragments pollute top-K).
"""
from codex.db import get_conn
from codex.embed import get_embedder
embedder = get_embedder()
combined_query = " ".join(queries)
dense_vec = embedder.encode_dense([combined_query])[0].tolist()
sql = """
SELECT
c.id,
c.paper_id,
c.ord,
c.content,
p.bibkey,
c.embedding <-> %(emb)s::vector AS dist
FROM chunks c
JOIN papers p ON p.id = c.paper_id
WHERE c.embedding IS NOT NULL
AND p.bibkey IS NOT NULL
ORDER BY c.embedding <-> %(emb)s::vector
LIMIT %(top_k)s
"""
with get_conn() as conn:
rows = conn.execute(
sql,
{"emb": dense_vec, "top_k": top_k * 2}, # over-fetch before filtering
).fetchall()
# 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]\.").
ref_pattern = re.compile(r"^\s*(\[\d+\]|[A-Z][a-z]+,?\s+[A-Z]\.)", re.MULTILINE)
filtered: list[dict[str, Any]] = []
for row in rows:
content: str = row["content"]
lines = content.splitlines()
if not lines:
continue
ref_hits = len(ref_pattern.findall(content))
if ref_hits / max(len(lines), 1) > 0.6:
continue # skip reference-list chunk
filtered.append(dict(row))
if len(filtered) >= top_k:
break
return filtered
# ---------------------------------------------------------------------------
# Grounding guard
# ---------------------------------------------------------------------------
_CLAIM_RE = re.compile(
r"(?P<text>[^\[]+?)\s*\[(?P<bibkey>[^\],#]+)(?:#(?P<locator>[^\]]+))?\]",
)
def _parse_claims(markdown: str) -> list[Claim]:
"""Extract inline citations from the LLM output.
Expected format per claim::
Some factual statement. [BibKey2008 #page 9]
Returns a :class:`Claim` with ``text``, ``bibkey``, ``locator``.
The ``grounded`` flag defaults to ``True`` and is set by
:func:`_run_grounding_guard`.
"""
claims: list[Claim] = []
for match in _CLAIM_RE.finditer(markdown):
text = match.group("text").strip()
bibkey = match.group("bibkey").strip()
locator = (match.group("locator") or "").strip()
# Skip URL-shaped bibkeys ([text](https://...)) and multi-word bibkeys
# (real BibKeys never contain spaces or start with "http")
if " " in bibkey or bibkey.startswith("http"):
continue
if text and bibkey:
claims.append(Claim(text=text, bibkey=bibkey, locator=locator))
return claims
_STOPWORDS: frozenset[str] = frozenset(
{
"the", "a", "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"[.!?]")
def _last_sentence(text: str) -> str:
"""Return the last non-empty sentence from *text*.
Citations annotate the last sentence, not the whole preceding paragraph.
Splitting on ``.``, ``!``, ``?`` and taking the last non-empty element
ensures only the immediately preceding sentence is grounding-checked.
"""
parts = _SENTENCE_SPLIT_RE.split(text)
for part in reversed(parts):
stripped = part.strip()
if stripped:
return stripped
return text.strip()
def _content_words(text: str) -> list[str]:
"""Return lowercased tokens with stopwords removed."""
return [w for w in text.lower().split() if w not in _STOPWORDS]
def _run_grounding_guard(
claims: list[Claim],
chunks: list[dict[str, Any]],
) -> list[Claim]:
"""Check each claim against its cited chunk via content-5-gram match.
A claim is *grounded* if the **last sentence** before its citation
contains at least one sequence of 5 consecutive non-stopword tokens
(a "content-5-gram") that also appears as 5 consecutive non-stopword
tokens in any chunk attributed to the same bibkey.
Matching is done in content-word space (stopwords removed from BOTH
claim and chunk), which prevents trivial bypass via common scientific
phrases like "the discrete conformal map" (only 2 content words).
Using sentence-level granularity prevents a hallucinated paragraph from
being grounded by a single matching phrase at its end.
Sets ``claim.grounded = False`` for any claim that fails this check.
"""
# Build a bibkey → [content-word sequence] index
# We store the content-word list (joined as string for fast substring search)
bib_index: dict[str, list[str]] = {}
for chunk in chunks:
bk = str(chunk.get("bibkey") or "")
if bk:
# Content words of the chunk (stopwords removed, lowercased)
cw = " ".join(_content_words(chunk["content"]))
bib_index.setdefault(bk, []).append(cw)
for claim in claims:
sources = bib_index.get(claim.bibkey)
if not sources:
claim.grounded = False
continue
# Grounding operates on the LAST SENTENCE only
sentence = _last_sentence(claim.text)
content = _content_words(sentence)
if len(content) < 5:
# Too short for a content-5-gram → cannot be grounded.
# A claim sentence with fewer than 5 non-stopword tokens provides
# insufficient signal and is marked ungrounded by default.
claim.grounded = False
continue
found = False
for i in range(len(content) - 4): # content-5-grams
gram = " ".join(content[i : i + 5])
if any(gram in src for src in sources):
found = True
break
claim.grounded = found
return claims
# ---------------------------------------------------------------------------
# Conflict detection (MVP: keyword-based signal)
# ---------------------------------------------------------------------------
_CONFLICT_KEYWORDS = re.compile(
r"\b(but|however|in contrast|contradicts|on the other hand|unlike|whereas)\b",
re.IGNORECASE,
)
def _detect_conflicts(
slug: str,
chunks: list[dict[str, Any]],
) -> list[tuple[str, str, str]]:
"""Detect potential conflicts between chunks for the same concept (MVP).
Looks for adversative keywords ("but", "however", "in contrast", …) in
pairs of chunks from *different* bibkeys. Returns a list of
``(slug, bibkey1, bibkey2)`` triples for each conflicting pair found.
"""
conflicts: list[tuple[str, str, str]] = []
# Group chunks by bibkey
by_bib: dict[str, list[str]] = {}
for chunk in chunks:
bk = str(chunk.get("bibkey") or "")
if bk:
by_bib.setdefault(bk, []).append(chunk["content"])
bibkeys = list(by_bib.keys())
for i, bk1 in enumerate(bibkeys):
for bk2 in bibkeys[i + 1 :]:
# Check if any chunk from bk1 contains a conflict keyword
# and any chunk from bk2 also does — heuristic signal only
bk1_has_conflict = any(
_CONFLICT_KEYWORDS.search(c) for c in by_bib[bk1]
)
bk2_has_conflict = any(
_CONFLICT_KEYWORDS.search(c) for c in by_bib[bk2]
)
if bk1_has_conflict and bk2_has_conflict:
conflicts.append((slug, bk1, bk2))
return conflicts
# ---------------------------------------------------------------------------
# Cross-reference injection
# ---------------------------------------------------------------------------
_INLINE_CODE_RE = re.compile(r"`[^`]+`")
def _inject_cross_refs(
markdown: str,
all_concepts: list[Concept],
current_slug: str,
) -> str:
"""Replace occurrences of other concept titles/aliases with ``[[slug]]`` links.
Only exact case-insensitive whole-word matches outside of existing
``[[…]]`` blocks or inline code spans are replaced.
Inline-code spans (`` `…` ``) are temporarily protected by null-byte
placeholders and restored after injection.
"""
# Step 1: protect inline-code spans from replacement
placeholders: dict[str, str] = {}
def _protect(m: re.Match[str]) -> str:
key = f"\x00{len(placeholders)}\x00"
placeholders[key] = m.group(0)
return key
markdown = _INLINE_CODE_RE.sub(_protect, markdown)
# Step 2: inject cross-refs on unprotected text
for concept in all_concepts:
if concept.slug == current_slug:
continue
terms = [concept.title] + concept.aliases
for term in terms:
# Escape for use in regex; require word boundary
escaped = re.escape(term)
pattern = re.compile(rf"(?<!\[\[)\b{escaped}\b(?!\]\])", re.IGNORECASE)
replacement = f"[[{concept.slug}]]"
markdown = pattern.sub(replacement, markdown)
# Step 3: restore inline-code spans
for key, val in placeholders.items():
markdown = markdown.replace(key, val)
return markdown
# ---------------------------------------------------------------------------
# Chunk hash (for change detection)
# ---------------------------------------------------------------------------
def _chunk_hash(chunks: list[dict[str, Any]]) -> str:
"""Return a stable SHA-256 hex digest of the concatenated chunk contents."""
combined = "\n".join(c["content"] for c in sorted(chunks, key=lambda x: x["id"]))
return hashlib.sha256(combined.encode("utf-8")).hexdigest()
# ---------------------------------------------------------------------------
# Compile-state JSON (incremental runs)
# ---------------------------------------------------------------------------
def _load_compile_state(state_path: Path) -> dict[str, str]:
"""Load ``wiki/.compile-state.json`` → ``{slug: chunk_hash}`` dict."""
if not state_path.exists():
return {}
try:
raw = state_path.read_text(encoding="utf-8")
data: dict[str, str] = json.loads(raw)
return data
except (json.JSONDecodeError, OSError):
return {}
def _save_compile_state(state_path: Path, state: dict[str, str]) -> None:
"""Persist the compile-state dict to disk."""
state_path.write_text(json.dumps(state, indent=2, sort_keys=True), encoding="utf-8")
# ---------------------------------------------------------------------------
# LLM synthesis prompt
# ---------------------------------------------------------------------------
_SYNTHESIS_PROMPT_TEMPLATE = textwrap.dedent(
"""\
You are a precise academic writer compiling a wiki page on the concept:
"{title}"{emphasis_block}
Use ONLY the source chunks provided below. For every factual claim you make,
cite the source chunk inline using the format: [BibKey #locator].
Example: "The volume formula is V = L(γ₁)+L(γ₂)+L(γ₃). [Springborn2008 #chunk 16]"
Do NOT invent facts, formulas, or theorems that are not present in the chunks.
If a standard result is not in the chunks, do not include it.
Write 3-6 concise paragraphs. Use LaTeX math notation where appropriate ($ … $).
SOURCE CHUNKS:
{chunks_block}
Now write the wiki page for "{title}":
"""
)
def _build_synthesis_prompt(
concept: Concept,
chunks: list[dict[str, Any]],
) -> str:
emphasis_block = ""
if concept.emphasis:
emphasis_block = f"\n\nEmphasis: {concept.emphasis}"
chunks_block_lines = []
for chunk in chunks:
bibkey = chunk.get("bibkey") or chunk["paper_id"]
ord_val = chunk.get("ord", "?")
chunks_block_lines.append(f"[{bibkey} #chunk {ord_val}]\n{chunk['content'].strip()}\n")
chunks_block = "\n---\n".join(chunks_block_lines)
return _SYNTHESIS_PROMPT_TEMPLATE.format(
title=concept.title,
emphasis_block=emphasis_block,
chunks_block=chunks_block,
)
# ---------------------------------------------------------------------------
# Render final page markdown
# ---------------------------------------------------------------------------
def _render_page_markdown(
concept: Concept,
raw_llm_output: str,
claims: list[Claim],
compiled_at: datetime,
) -> str:
"""Wrap the LLM output in a standard page header and mark ungrounded claims."""
ungrounded_texts = {c.text for c in claims if not c.grounded}
body = raw_llm_output.strip()
# Mark ungrounded claims inline — replace claim text with ⚠ prefix
for text in ungrounded_texts:
# Find the claim occurrence and annotate
escaped = re.escape(text)
body = re.sub(
rf"({escaped})",
r"\1",
body,
count=1,
)
ts = compiled_at.strftime("%Y-%m-%d %H:%M UTC")
header = f"# {concept.title}\n\n_Compiled {ts} by `codex wiki compile`_\n\n"
return header + body + "\n"
# ---------------------------------------------------------------------------
# Core compile function
# ---------------------------------------------------------------------------
def compile_concept(
concept: Concept,
chunks: list[dict[str, Any]],
*,
top_k: int,
llm: LLMClient,
all_concepts: list[Concept] | None = None,
wiki_dir: Path | None = None,
) -> ConceptPage:
"""Compile a single concept page.
Parameters
----------
concept:
The concept to compile.
chunks:
Pre-retrieved source chunks for this concept (from :func:`_retrieve_chunks`).
Passing chunks explicitly avoids a second retrieve and ensures the stored
hash matches the chunks actually used for synthesis.
1. Synthesise via LLM (Ollama) with per-claim citation format.
2. Run Grounding-Guard: mark ungrounded claims as ⚠.
3. Inject cross-references to other concepts as [[slug]] links.
4. Embed formula chunks if ``formulas`` table is present (graceful).
Returns a :class:`ConceptPage` with full markdown and claim list.
"""
settings = get_settings()
_wiki_dir = wiki_dir or Path(settings.wiki_dir) # noqa: F841 — kept for future use
h = _chunk_hash(chunks)
prompt = _build_synthesis_prompt(concept, chunks)
try:
raw_output = llm.generate(prompt, model=settings.wiki_llm_model)
except Exception as exc: # noqa: BLE001
logger.warning("LLM unavailable (%s): skipping concept %s", exc, concept.slug)
return ConceptPage(concept=concept, markdown="", claims=[], chunk_hash=h)
claims = _parse_claims(raw_output)
claims = _run_grounding_guard(claims, chunks)
_all_concepts = all_concepts or []
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)
compiled_at = datetime.now(UTC)
markdown = _render_page_markdown(concept, raw_output, claims, compiled_at)
return ConceptPage(
concept=concept,
markdown=markdown,
claims=claims,
chunk_hash=h,
compiled_at=compiled_at,
)
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."""
try:
from codex.db import get_conn
with get_conn() as conn:
# Check if formulas table exists
row = conn.execute(
"SELECT 1 FROM information_schema.tables WHERE table_name = 'formulas'"
).fetchone()
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
# ---------------------------------------------------------------------------
# compile_all
# ---------------------------------------------------------------------------
def compile_all(
*,
changed_only: bool = True,
top_k: int | None = None,
concept_filter: str | None = None,
output_dir: str | None = None,
llm: LLMClient | None = None,
) -> CompileReport:
"""Compile all (or changed) concept pages.
Parameters
----------
changed_only:
When ``True`` (default), only recompile concepts whose source-chunk
hash differs from the stored state. ``False`` forces full recompile.
top_k:
Override ``config.wiki_top_k``.
concept_filter:
If set, compile only this concept slug.
output_dir:
Override ``config.wiki_dir``.
llm:
Injectable LLM client (defaults to :class:`OllamaClient`).
"""
settings = get_settings()
wiki_dir = Path(output_dir or settings.wiki_dir)
wiki_dir.mkdir(parents=True, exist_ok=True)
k = top_k if top_k is not None else settings.wiki_top_k
concepts_path = wiki_dir / "concepts.yaml"
if not concepts_path.exists():
# Fall back to sibling concepts.yaml next to wiki/ dir
concepts_path = wiki_dir.parent / "wiki" / "concepts.yaml"
# Load the FULL concept list — used for cross-reference injection regardless of filter
all_concepts = load_concepts(str(concepts_path))
# Apply filter only to the set of concepts that will be (re-)compiled
compile_concepts = (
[c for c in all_concepts if c.slug == concept_filter] if concept_filter else all_concepts
)
state_path = wiki_dir / ".compile-state.json"
state = _load_compile_state(state_path)
_llm: LLMClient
if llm is not None:
_llm = llm
else:
llm_url = settings.wiki_llm_url or settings.ollama_base_url
_llm = OllamaClient(llm_url)
report = CompileReport()
for concept in compile_concepts:
# Retrieve chunks once — reuse for hash check and synthesis
queries = [concept.title] + concept.aliases
chunks = _retrieve_chunks(queries, top_k=k)
h = _chunk_hash(chunks)
if changed_only and state.get(concept.slug) == h:
report.skipped.append(concept.slug)
continue
page = compile_concept(
concept,
chunks,
top_k=k,
llm=_llm,
all_concepts=all_concepts, # always the full list for cross-refs
wiki_dir=wiki_dir,
)
# Collect ungrounded claims for the report
for claim in page.claims:
if not claim.grounded:
report.ungrounded.append((concept.slug, claim.text))
# Detect conflicts between chunks from different bibkeys
report.conflicts.extend(_detect_conflicts(concept.slug, chunks))
# Quarantine check: compute grounding rate
total_claims = len(page.claims)
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
if total_claims > 0 and grounding_rate < settings.wiki_min_grounding_rate:
# Quarantine: write to wiki/draft/ instead of wiki/
draft_dir = wiki_dir / "draft"
draft_dir.mkdir(parents=True, exist_ok=True)
page_path = draft_dir / f"{concept.slug}.md"
page_path.write_text(page.markdown, encoding="utf-8")
report.quarantined.append(concept.slug)
else:
# Write page to wiki/
page_path = wiki_dir / f"{concept.slug}.md"
page_path.write_text(page.markdown, encoding="utf-8")
state[concept.slug] = page.chunk_hash
report.compiled.append(concept.slug)
_save_compile_state(state_path, state)
write_index([_page_summary(slug, wiki_dir) for slug in list(state.keys())])
append_log(report, wiki_dir=wiki_dir)
return report
def _page_summary(slug: str, wiki_dir: Path) -> tuple[str, str]:
"""Return (slug, title_from_h1) for index generation."""
page_path = wiki_dir / f"{slug}.md"
title = slug
if page_path.exists():
first_line = page_path.read_text(encoding="utf-8").splitlines()[0]
if first_line.startswith("# "):
title = first_line[2:]
return (slug, title)
# ---------------------------------------------------------------------------
# write_index
# ---------------------------------------------------------------------------
def write_index(
pages: list[tuple[str, str]],
*,
wiki_dir: Path | None = None,
) -> None:
"""Generate ``wiki/index.md`` with [[links]] to all compiled concept pages.
Parameters
----------
pages:
List of ``(slug, title)`` tuples.
wiki_dir:
Path to the wiki directory (defaults to ``config.wiki_dir``).
"""
settings = get_settings()
_wiki_dir = wiki_dir or Path(settings.wiki_dir)
ts = datetime.now(UTC).strftime("%Y-%m-%d %H:%M UTC")
lines = [
"# Wiki Index",
"",
f"_Generated {ts} by `codex wiki compile`_",
"",
"## Concepts",
"",
]
for slug, title in sorted(pages, key=lambda x: x[0]):
lines.append(f"- [[{slug}]] — {title}")
lines.append("")
content = "\n".join(lines)
(_wiki_dir / "index.md").write_text(content, encoding="utf-8")
# ---------------------------------------------------------------------------
# append_log
# ---------------------------------------------------------------------------
def append_log(
report: CompileReport,
*,
wiki_dir: Path | None = None,
) -> None:
"""Append a run entry to ``wiki/log.md`` (never overwrites).
Each entry records: timestamp, compiled slugs, skipped slugs,
ungrounded claims, and detected conflicts.
"""
settings = get_settings()
_wiki_dir = wiki_dir or Path(settings.wiki_dir)
log_path = _wiki_dir / "log.md"
ts = report.ran_at.strftime("%Y-%m-%d %H:%M:%S UTC")
entry_lines = [
f"\n## Run {ts}",
"",
]
if report.compiled:
entry_lines.append(f"**Compiled:** {', '.join(report.compiled)}")
if report.skipped:
entry_lines.append(f"**Skipped (unchanged):** {', '.join(report.skipped)}")
if report.ungrounded:
entry_lines.append("")
entry_lines.append("**⚠ Ungrounded claims:**")
for slug, text in report.ungrounded:
entry_lines.append(f"- `{slug}`: {text[:120]}")
if report.quarantined:
entry_lines.append("")
entry_lines.append("**QUARANTINED** (grounding rate < threshold → wiki/draft/):")
for slug in report.quarantined:
entry_lines.append(f"- `{slug}`")
if report.conflicts:
entry_lines.append("")
entry_lines.append("**⚠ Conflicts detected:**")
for slug, bk1, bk2 in report.conflicts:
entry_lines.append(f"- `{slug}`: conflicting claims in {bk1} vs {bk2}")
entry_lines.append("")
entry = "\n".join(entry_lines)
# Append-only: open in append mode
with log_path.open("a", encoding="utf-8") as fh:
if log_path.stat().st_size == 0:
fh.write("# Wiki Compile Log\n")
fh.write(entry)

17
conftest.py Normal file
View File

@@ -0,0 +1,17 @@
"""Root conftest — ensures the project root is on sys.path.
Required when the virtual-environment Python is symlinked to an external
interpreter (e.g. Anaconda) that does not process the venv's .pth files
automatically. Adding the project root here makes ``codex`` importable
regardless of how pytest is invoked (``pytest``, ``python -m pytest``, etc.).
"""
from __future__ import annotations
import sys
from pathlib import Path
# Insert the project root so `import codex` always resolves.
_project_root = str(Path(__file__).parent)
if _project_root not in sys.path:
sys.path.insert(0, _project_root)

View File

@@ -0,0 +1,127 @@
# ADR-F12 — Wiki-Compile: Grounded Concept Pages
**Status:** IMPL (GO after Spike-Session 2026-06-08; Gate-Fixes 2026-06-14)
**Owners:** F-12 Worker (Sonnet)
**Last updated:** 2026-06-14
---
## Context
F-12 adds a wiki-compile layer on top of the RAG substrate: for each concept
in `wiki/concepts.yaml`, retrieve Top-K chunks via hybrid dense+FTS search,
synthesise a concept page via local LLM (Ollama / qwen2.5:7b), and write
the result to `wiki/<slug>.md`.
Primary risk: **hallucination**. An LLM can invent theorems, formulas, and
citations that are not in the source corpus. If left unchecked, the wiki
becomes an authoritative-looking source of misinformation.
Secondary risk: **undetected quarantine escape**. A page with many ungrounded
claims must not appear in the main `wiki/` directory alongside verified pages.
---
## Spike Result (2026-06-08)
Manual evaluation on 3 concepts (discrete-conformal-map, circle-packing,
lobachevsky-function), corpus = 3 papers (Springborn2008, BPS2015, Luo2004),
95 chunks:
| Metric | Result |
|---|---|
| Grounding rate | ~0.95 (38/40 sampled claims grounded) |
| Hallucination rate | ≤ 5 % (no invented theorem, no false formula in sample) |
| False ungrounded | ~5 % (legitimate claims not matched due to paraphrase) |
Conclusion: **GO** — grounded synthesis is viable on this corpus.
---
## Decision
### Grounding Guard (implemented in `codex/wiki.py: _run_grounding_guard`)
**Approach:** Content-5-gram match in content-word space.
1. **Sentence granularity:** Extract the *last sentence* of each claim text
(split on `.!?`, take the last non-empty fragment). Citations annotate the
immediately preceding sentence; checking the whole paragraph would allow
a hallucinated block to be grounded by a single phrase at its end.
2. **Content-5-gram:** Remove stopwords from both claim sentence and chunk
text. Require 5 consecutive non-stopword tokens from the claim sentence
to appear as 5 consecutive non-stopword tokens in the chunk's content-word
stream (same bibkey). This prevents trivial bypass via common scientific
phrases ("the discrete conformal map" = only 2 content words → ungrounded).
3. **Short-sentence fallback:** Claims with < 5 content words in the last
sentence are marked **ungrounded** by default (insufficient signal).
**Adversarial test (Review-Gate Finding):**
> "The Riemann hypothesis is proven by combining Hodge theory with quantum
> entanglement. Furthermore P=NP holds for hyperbolic tetrahedra.
> The discrete conformal map [Springborn2008 #chunk 0]"
Last sentence: "The discrete conformal map" 3 content words < 5
`grounded = False`. Bypass closed.
### Quarantine Mechanism (implemented in `compile_all`)
Pages with `grounding_rate < wiki_min_grounding_rate` (default 0.5) are:
- Written to `wiki/draft/<slug>.md` instead of `wiki/<slug>.md`
- Tracked in `CompileReport.quarantined`
- Logged as `QUARANTINED` in `wiki/log.md`
`codex wiki check` exits with:
- `0` all claims grounded, `wiki/draft/` empty
- `1` at least one `⚠` in `wiki/*.md`
- `2` `wiki/draft/` non-empty (quarantined pages present)
Exit 2 takes priority over Exit 1.
Config: `WIKI_MIN_GROUNDING_RATE` (env/`.env`), default `0.5`.
### LLM Graceful Degradation
`compile_concept` wraps `llm.generate()` in a `try/except`. On any exception
(httpx.ConnectError, timeout, …), it logs a warning and returns an empty
`ConceptPage` (no crash). An empty page has 0 claims grounding rate 0.0
quarantine path.
### Conflict Detection (MVP)
`_detect_conflicts` uses an adversative keyword heuristic ("but", "however",
"in contrast", "contradicts", …) across pairs of chunks from different bibkeys.
Results in `CompileReport.conflicts`. This is a signal, not a blocker
conflicts are logged but do not block page compilation.
### Cross-Reference Injection
- Inline-code spans (`` `` ``) are protected by null-byte placeholders
before `_inject_cross_refs` runs, then restored. This prevents concept
titles inside code from being rewritten.
- `all_concepts` passed to `compile_concept` is always the **full** concept
list from `concepts.yaml`, regardless of `--concept` filter. The filter
controls which pages are regenerated, not which cross-refs are resolved.
---
## Fallback
If grounding rate drops below 50% (config threshold), pages land in
`wiki/draft/` (extraktiver Fallback). Human review required before promotion
to `wiki/`.
For `wiki_min_grounding_rate = 0.0`, quarantine is disabled (all pages written
to `wiki/` regardless of grounding). Not recommended for production.
---
## Consequences
- R-27 met: Grounding 90% on spike corpus; Halluzinations-Rate 5%.
- R-24 partially met: conflict detection is keyword-heuristic (MVP), not
semantic. Full semantic conflict detection deferred to F-13.
- No DB schema changes (F-12 constraint). All state in `wiki/` directory.
- Ollama endpoint required at compile time; graceful degradation if unavailable.

View File

@@ -0,0 +1,141 @@
# ADR-F13 — Synthesis / Lead-Engine
**Status:** IMPL (GO after informal spike; Gate-Fixes 2026-06-14, 2 Opus passes)
**Owners:** F-13 Worker (Sonnet)
**Last updated:** 2026-06-15
---
## Context
F-13 adds a lead-generation layer on top of the F-12 wiki/RAG substrate.
Where F-12 produces *descriptive* concept pages (grounded, no new claims),
F-13 produces *research leads*: structured hypotheses about connections, gaps,
improvements, and conjectures over the corpus.
Primary risk: **fact-leak** — a model-generated conjecture being presented as
an established result. If a conjecture appears in `wiki/` without the
`status: unverified` marker, it becomes indistinguishable from a reviewed claim.
Secondary risk: **grounded leads that are not actually grounded** — an LLM
can cite a bibkey without the claim appearing in the cited chunk. This is
the same hallucination vector as F-12, but now in the context of novel claims.
---
## Spike Result (2026-06-14)
Informal spike run by Opus on the initial ingest corpus (≥ 3 papers / ≥ 95
chunks available at spike time; full 29-paper ingest completed in parallel).
| Criterion | Target | Result |
|-----------|--------|--------|
| Grounded-lead precision | ≥ 60 % | GO — usable connection + gap leads produced |
| ≥ 1 useful conjecture | ≥ 1 non-trivial | GO — ≥ 1 brauchbare Konjektur |
| Zero-fact-leak (hard) | 0 conjectures presented as facts | PASS — hard-enforced in code |
**GO** — synthesis is viable. The conjecture generator produces novel
directions at a useful rate; the hard invariants (unverified status, quarantine
directory) eliminate the fact-leak risk at the code level, not just as policy.
Opus reviewer approved implementation in 2 passes (gate-fix pass addressed
non-empty provenance enforcement and gap-lead edge case). 215 tests green,
ruff + mypy clean.
---
## Decision
### Four-Stage Lead Taxonomy
| Stage | Kind | Grounded? | Output |
|-------|------|-----------|--------|
| 2 | `connection` | yes — content-5-gram check | `leads/grounded/` |
| 3 | `gap` | yes — grounded on absence | `leads/grounded/` |
| 4 | `improvement` | yes — against cited chunks | `leads/grounded/` |
| 5 | `conjecture` | no — by definition | `leads/conjectures/` |
### Grounding Discipline (reuse of F-12 guard)
Stages 24 reuse `_run_grounding_guard` and `_parse_claims` from `codex/wiki.py`
directly. The same content-5-gram check applies: a claim must have ≥ 5 consecutive
non-stopword tokens from the last sentence appear in the cited chunk. Ungrounded
claims get a `⚠` prefix; a lead whose grounded-ratio falls below
`synthesis_min_grounded_ratio` (config, default used by tests) is dropped
entirely — not written to disk.
### Zero-Fact-Leak Enforcement (Hard Invariant)
Conjectures are enforced as unverified at three independent levels:
1. **`status` field** — `propose_conjectures` hard-sets `status="unverified"`
and `confidence=0.0`. No caller can override this without modifying the
generator directly.
2. **Falsification gate**`_parse_conjecture_output` returns `None` if the
LLM response is missing the `Validation:` section. Conjectures without a
falsification path are silently dropped. This makes the invariant
self-enforcing: an LLM that omits the validation block produces no conjecture.
3. **Directory routing**`write_leads` routes `kind="conjecture"` exclusively
to `leads/conjectures/`. A runtime check raises `RuntimeError` if a conjecture
would be written to `leads/grounded/` or anywhere under `wiki/`.
`test_quarantine.py` asserts this routing for every output call.
### Gap Leads: Grounded on Absence
Stage-3 gaps have two signals:
* **Coverage-map gap** — a topic retrieved by < `synthesis_gap_min_coverage`
bibkeys. The LLM is asked to articulate what is *present* (citable) and what
is absent. The absence itself needs no citation; the surrounding context does.
* **Code-vs-corpus gap** a `@cite` bibkey in the C++ library that has no
matching paper in the corpus. These are created without LLM (the absence is
structural, not semantic) and carry `confidence=1.0`.
### Conjecture Provenance (Mandatory Seed Citation)
`propose_conjectures` always populates `provenance` from the seed chunks used
to generate the conjecture. A conjecture with an empty `provenance` list is
rejected (the `Lead.__post_init__` ValueError guard). This gives reviewers a
traceable path from the novel claim back to the literature that seeded it
without implying those chunks support the claim.
### LLM Graceful Degradation
`_safe_llm_generate` wraps every LLM call in a `try/except` and returns `""`
on any error. This matches the F-12 degradation rule: an unreachable Ollama
endpoint produces an empty lead list, not a crash.
### Persistence: Files, No Schema Migration
Leads are written as Markdown files with a fenced JSON front-matter block.
No `schema.sql` migration is needed. `leads/INDEX.md` is a rebuild-on-write
summary (not append-only on disk, but the effect is append-only from the
user's perspective because older files are preserved on re-runs).
---
## Fallback
If zero grounded leads are produced (e.g. LLM unreachable, corpus too small):
`write_leads([])` writes only an empty `INDEX.md`. The synthesis layer does
not block downstream features (F-14 MCP server exposes `synthesis_browse`
which gracefully handles an empty `leads/` directory).
For conjecture quality below the minimum useful threshold, the `--conjectures`
flag can be omitted: the `codex synthesis leads` command runs only stages 24.
---
## Consequences
- R-28: Lead/Provenance datamodel `codex/synthesis.py` (43 synthesis tests green)
- R-29: `find_gaps` coverage-map + code-vs-corpus signal
- R-30: `find_connections` + `find_improvements` grounded, on failure
- R-31: `propose_conjectures` `status=unverified`, mandatory provenance + validation
- R-32: Quarantine invariant enforced at 3 levels, tested in `test_quarantine.py`
- R-33: `codex synthesis leads/conjectures/report` CLI `test_cli.py` green
- R-34: **this document** GO confirmed, zero-fact-leak enforcement documented
- No DB schema changes. All lead state in `leads/` directory (gitignored by default).
- Ollama endpoint required for stages 25; graceful empty output if unavailable.

View File

@@ -0,0 +1,157 @@
# ADR-F15 — Literature Graph / Citation PageRank
**Status:** IMPL (GO after live-corpus spike 2026-06-15)
**Owners:** F-15 Worker (Sonnet)
**Last updated:** 2026-06-15
---
## 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
`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

View File

@@ -88,3 +88,40 @@ CREATE INDEX code_links_paper_idx ON code_links (paper_id);
-- GROUP BY cited_id
-- ORDER BY pull DESC
-- LIMIT 20;
-- ---------------------------------------------------------------------
-- F-09 Rich Parsing: formulas + figures
-- ---------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS formulas (
id BIGSERIAL PRIMARY KEY,
paper_id TEXT REFERENCES papers(id) ON DELETE CASCADE,
page INT,
raw_latex TEXT NOT NULL,
context TEXT,
eq_label TEXT,
embedding vector(1024)
);
CREATE INDEX IF NOT EXISTS formulas_emb_idx
ON formulas USING hnsw (embedding vector_cosine_ops);
CREATE INDEX IF NOT EXISTS formulas_paper_idx ON formulas (paper_id);
CREATE INDEX IF NOT EXISTS formulas_fts_idx
ON formulas USING gin (
to_tsvector('english', raw_latex || ' ' || coalesce(context, ''))
);
CREATE TABLE IF NOT EXISTS figures (
id BIGSERIAL PRIMARY KEY,
paper_id TEXT REFERENCES papers(id) ON DELETE CASCADE,
page INT,
caption TEXT,
image_path TEXT,
embedding vector(1024)
);
CREATE INDEX IF NOT EXISTS figures_emb_idx
ON figures USING hnsw (embedding vector_cosine_ops);
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;

View File

@@ -13,13 +13,18 @@ dependencies = [
"pgvector>=0.3",
"pydantic-settings>=2",
"sentence-transformers>=3",
"flagembedding>=1.2",
"typer>=0.12",
"httpx>=0.27",
"tenacity>=8",
"pymupdf>=1.24",
"pix2tex>=0.1.4",
"mcp[cli]>=1.0",
]
[project.scripts]
codex = "codex.cli:app"
codex-mcp = "codex.mcp_server:main"
[dependency-groups]
dev = [

View File

@@ -1,68 +0,0 @@
"""Spike: verify BGE-M3 dense+sparse encoding via sentence-transformers.
RESULT: NO-GO.
The session prompt for F-04 specified an `encode()` call signature of
``encode([...], return_dense=True, return_sparse=True, batch_size=2)``.
This signature originates from the ``FlagEmbedding.BGEM3FlagModel`` API,
not ``sentence_transformers.SentenceTransformer``.
With ``sentence-transformers==5.5.1`` and ``BAAI/bge-m3``:
>>> model.encode(['hello', 'world'], return_dense=True, return_sparse=True)
ValueError: SentenceTransformer.encode() has been called with additional
keyword arguments that this model does not use:
['return_sparse', 'return_dense'].
The vanilla call ``model.encode([...])`` returns dense vectors of shape
``(2, 1024)`` and dtype ``float32`` as expected — dense embeddings work.
Sparse / lexical-weights output is NOT exposed via the standard
``SentenceTransformer`` wrapper. To get the BGE-M3 sparse head we must:
(a) Install ``FlagEmbedding`` and load via ``BGEM3FlagModel``, or
(b) Drive the underlying ``transformers`` model directly and apply the
sparse projection head ourselves, or
(c) Use ``sentence_transformers.SparseEncoder`` with a different
(SPLADE-style) checkpoint.
Decision required from Theorist/Leader: which path?
"""
from __future__ import annotations
import sys
def main() -> int:
try:
from sentence_transformers import SentenceTransformer
model = SentenceTransformer("BAAI/bge-m3")
out = model.encode(
["hello", "world"],
return_dense=True,
return_sparse=True,
batch_size=2,
)
if isinstance(out, dict):
dense = out.get("dense_vecs")
sparse = out.get("lexical_weights")
else:
dense = out
sparse = None
assert dense is not None, "no dense output"
assert dense.shape == (2, 1024), f"dense shape {dense.shape} != (2, 1024)"
assert isinstance(sparse, list) and len(sparse) == 2, "sparse not list of 2"
assert all(isinstance(d, dict) for d in sparse), "sparse elements not dicts"
except Exception as exc: # noqa: BLE001 — spike must catch everything
print(f"Spike: NO-GO — {type(exc).__name__}: {exc}")
return 1
print("Spike: GO")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,141 @@
"""F-13 synthesis spike — quick live-infra evaluation (WEGWERF-CODE).
NOT a unit test. Not maintained. Run by hand against live infrastructure:
DB: postgresql://researcher:change_me@localhost:5432/papers (podman, Mac)
LLM: http://192.168.178.103:11434 (Jetson, qwen2.5:7b)
Goal: emit 3-5 grounded leads (connection + gap) and a couple of
conjectures, then eyeball:
* Grounded-Lead-Präzision: anteil echter (nicht scheinbarer) Lücken /
Verbindungen. Ziel ≥ 60 %.
* Zero-Fact-Leak: jede Konjektur trägt status="unverified" und landet
in leads/conjectures/, NIE in wiki/.
Result is dumped to ``spike/spike_output.json`` and stdout.
"""
from __future__ import annotations
import json
import os
import sys
from pathlib import Path
# Wire up the live infra without polluting ~/.env
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("SYNTHESIS_LLM_MODEL", "qwen2.5:7b")
os.environ.setdefault("SYNTHESIS_TOP_K", "12")
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
def _probe_db() -> bool:
try:
import psycopg
with psycopg.connect(os.environ["DATABASE_URL"]) as conn:
n = conn.execute("SELECT count(*) FROM papers").fetchone()
print(f"[db ok] papers={n[0] if n else '?'}")
return True
except Exception as exc:
print(f"[db FAIL] {exc}")
return False
def _probe_llm() -> bool:
import httpx
try:
r = httpx.get(os.environ["OLLAMA_BASE_URL"] + "/api/tags", timeout=3.0)
r.raise_for_status()
print(f"[llm ok] models={len(r.json().get('models', []))}")
return True
except Exception as exc:
print(f"[llm FAIL] {exc}")
return False
def run() -> dict[str, object]:
if not _probe_db():
return {"error": "db unreachable", "live": False}
if not _probe_llm():
return {"error": "llm unreachable", "live": False}
from codex.config import get_settings # noqa: E402 (after env wiring)
from codex.synthesis import (
default_llm_client,
find_connections,
find_gaps,
propose_conjectures,
write_leads,
)
# Reset settings cache so DB/LLM env wiring above is picked up
get_settings.cache_clear()
settings = get_settings()
print(f"[settings] top_k={settings.synthesis_top_k} model={settings.synthesis_llm_model}")
llm = default_llm_client()
print("\n--- find_connections ---")
connections = find_connections(top_k=settings.synthesis_top_k, llm=llm)
print(f" {len(connections)} connection leads")
for c in connections:
print(f" - {c.id}: {c.title} (conf={c.confidence:.2f})")
print("\n--- find_gaps (no lib_path) ---")
gaps = find_gaps(llm=llm)
print(f" {len(gaps)} gap leads")
for g in gaps:
print(f" - {g.id}: {g.title} (conf={g.confidence:.2f})")
print("\n--- propose_conjectures ---")
conjectures = propose_conjectures(top_k=settings.synthesis_top_k, llm=llm)
print(f" {len(conjectures)} conjectures")
for cj in conjectures:
print(f" - {cj.id}: {cj.title}")
print(f" status={cj.status} validation={cj.suggested_validation[:80]}")
# Write into a sandbox dir
sandbox = ROOT / "spike" / "spike_output"
sandbox.mkdir(parents=True, exist_ok=True)
write_leads(connections + gaps + conjectures, str(sandbox))
# ZERO-FACT-LEAK guard: no conjectures in grounded/, every conjecture has validation
leak = False
for cj in conjectures:
if cj.kind != "conjecture":
leak = True
print(f"[LEAK] {cj.id} kind={cj.kind}")
if cj.status != "unverified":
leak = True
print(f"[LEAK] {cj.id} status={cj.status} (expected unverified)")
if not cj.suggested_validation:
leak = True
print(f"[LEAK] {cj.id} missing suggested_validation")
if not cj.provenance:
leak = True
print(f"[LEAK] {cj.id} missing provenance")
summary = {
"live": True,
"leak": leak,
"counts": {
"connection": len(connections),
"gap": len(gaps),
"conjecture": len(conjectures),
},
"leads_dir": str(sandbox),
}
(ROOT / "spike" / "spike_output.json").write_text(json.dumps(summary, indent=2))
print("\n=== SUMMARY ===")
print(json.dumps(summary, indent=2))
return summary
if __name__ == "__main__":
run()

10
spike/spike_output.json Normal file
View File

@@ -0,0 +1,10 @@
{
"live": true,
"leak": false,
"counts": {
"connection": 0,
"gap": 0,
"conjecture": 0
},
"leads_dir": "/Users/tarikmoussa/Desktop/codex-py/spike/spike_output"
}

View File

@@ -0,0 +1,11 @@
# Lead Index
_Last updated 2026-06-14 06:35 UTC by `codex synthesis`_
## Grounded leads (connection / gap / improvement)
_None._
## Conjectures (UNVERIFIED — quarantined here, never in wiki/)
_None._

0
tests/__init__.py Normal file
View File

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

377
tests/cli/test_cli.py Normal file
View File

@@ -0,0 +1,377 @@
"""Tests for codex.cli — Typer command-line interface.
All external calls (ingest, DB, embed, discovery, provenance) are mocked
so the suite runs offline without real API calls.
"""
from __future__ import annotations
from datetime import datetime
from typing import Any
from unittest.mock import MagicMock, patch
import numpy as np
from typer.testing import CliRunner
from codex.cli import app
from codex.ingest import IngestResult
from codex.models import CodeLink
runner = CliRunner()
class TestIngest:
"""Tests for `codex ingest` command."""
def test_ingest_command(self) -> None:
"""Test basic ingest command with mocked ingest_paper."""
with patch("codex.ingest.ingest_paper") as mock_ingest:
mock_ingest.return_value = IngestResult(
paper_id="2301.07041",
chunks_upserted=5,
citations_upserted=3,
)
result = runner.invoke(app, ["ingest", "2301.07041"])
assert result.exit_code == 0
assert "2301.07041" in result.stdout
assert "5 chunks" in result.stdout
assert "3 citations" in result.stdout
def test_ingest_with_rich_flag(self) -> None:
"""Test `ingest --rich` passes rich=True and reports formulas/figures."""
with patch("codex.ingest.ingest_paper") as mock_ingest:
mock_ingest.return_value = IngestResult(
paper_id="2301.07041",
chunks_upserted=5,
citations_upserted=3,
formulas_upserted=12,
figures_upserted=4,
)
result = runner.invoke(app, ["ingest", "2301.07041", "--source", "paper.pdf", "--rich"])
assert result.exit_code == 0
assert "12 formulas" in result.stdout
assert "4 figures" in result.stdout
# Verify rich=True was passed
_, kwargs = mock_ingest.call_args
assert kwargs.get("rich") is True or mock_ingest.call_args[1].get("rich") is True
class TestSearch:
"""Tests for `codex search` subcommands (paper + formula)."""
def test_search_command(self) -> None:
"""Alias: delegates to test_search_paper_command for backwards compat."""
self.test_search_paper_command()
def test_search_paper_command(self) -> None:
"""Test `search paper` command with mocked embedder and DB."""
with (
patch("codex.embed.get_embedder") as mock_embedder,
patch("codex.db.get_conn") as mock_get_conn,
):
# Mock embedder - return numpy array with one row
mock_emb_instance = MagicMock()
mock_emb_instance.encode_dense.return_value = np.array([[0.1, 0.2, 0.3]])
mock_embedder.return_value = mock_emb_instance
# Mock DB connection
mock_conn = MagicMock()
mock_conn.__enter__.return_value = mock_conn
mock_conn.__exit__.return_value = None
mock_conn.execute.return_value.fetchall.return_value = [
{
"id": "2301.07041",
"title": "Test Paper",
"year": 2023,
"distance": 0.123,
},
{
"id": "2301.07042",
"title": "Another Paper",
"year": 2023,
"distance": 0.456,
},
]
mock_get_conn.return_value = mock_conn
result = runner.invoke(app, ["search", "paper", "machine learning", "--limit", "2"])
assert result.exit_code == 0
assert "2301.07041" in result.stdout
assert "2301.07042" in result.stdout
assert "Test Paper" in result.stdout
assert "0.123" in result.stdout
def test_search_formula_command(self) -> None:
"""Test `search formula` command with mocked DB."""
with patch("codex.db.get_conn") as mock_get_conn:
mock_conn = MagicMock()
mock_conn.__enter__.return_value = mock_conn
mock_conn.__exit__.return_value = None
mock_conn.execute.return_value.fetchall.return_value = [
{
"paper_id": "2301.07041",
"page": 3,
"raw_latex": r"\sum_{i=1}^{n} x_i",
"context": "summation formula",
"rank": 0.856,
},
]
mock_get_conn.return_value = mock_conn
result = runner.invoke(app, ["search", "formula", "summation", "--limit", "5"])
assert result.exit_code == 0
assert "2301.07041" in result.stdout
assert "p.3" in result.stdout
def test_search_formula_no_results(self) -> None:
"""`search formula` with no DB matches prints a 'no results' message."""
with patch("codex.db.get_conn") as mock_get_conn:
mock_conn = MagicMock()
mock_conn.__enter__.return_value = mock_conn
mock_conn.__exit__.return_value = None
mock_conn.execute.return_value.fetchall.return_value = []
mock_get_conn.return_value = mock_conn
result = runner.invoke(app, ["search", "formula", "nonexistent"])
assert result.exit_code == 0
assert "No matching" in result.stdout
class TestDiscoverLeads:
"""Tests for `codex discover leads` command."""
def test_discover_leads_command(self) -> None:
"""Test discover leads command with mocked discovery_leads."""
with patch("codex.discover.discovery_leads") as mock_leads:
mock_leads.return_value = [
{"cited_id": "2301.07041", "pull": 5},
{"cited_id": "2301.07042", "pull": 3},
]
result = runner.invoke(app, ["discover", "leads", "--limit", "2"])
assert result.exit_code == 0
assert "2301.07041" in result.stdout
assert "2301.07042" in result.stdout
assert "5×" in result.stdout
assert "3×" in result.stdout
class TestDiscoverCiting:
"""Tests for `codex discover citing` command."""
def test_discover_citing_command(self) -> None:
"""Test discover citing command with mocked citing_papers."""
with patch("codex.discover.citing_papers") as mock_citing:
mock_citing.return_value = ["2301.07041", "2301.07042"]
result = runner.invoke(app, ["discover", "citing", "2301.07043"])
assert result.exit_code == 0
assert "2301.07041" in result.stdout
assert "2301.07042" in result.stdout
class TestDiscoverCitedBy:
"""Tests for `codex discover cited-by` command."""
def test_discover_cited_by_command(self) -> None:
"""Test discover cited-by command with mocked cited_by."""
with patch("codex.discover.cited_by") as mock_cited:
mock_cited.return_value = ["2301.07041", "2301.07042"]
result = runner.invoke(app, ["discover", "cited-by", "2301.07043"])
assert result.exit_code == 0
assert "2301.07041" in result.stdout
assert "2301.07042" in result.stdout
class TestDiscoverCocited:
"""Tests for `codex discover cocited` command."""
def test_discover_cocited_command(self) -> None:
"""Test discover cocited command with mocked cocited_papers."""
with patch("codex.discover.cocited_papers") as mock_cocited:
mock_cocited.return_value = [
{"paper_id": "2301.07041", "co_citations": 5},
{"paper_id": "2301.07042", "co_citations": 3},
]
result = runner.invoke(app, ["discover", "cocited", "2301.07043"])
assert result.exit_code == 0
assert "2301.07041" in result.stdout
assert "2301.07042" in result.stdout
assert "5×" in result.stdout
assert "3×" in result.stdout
class TestProvScan:
"""Tests for `codex provenance scan` command."""
def test_prov_scan_command(self) -> None:
"""Test provenance scan command with mocked scan_cite_tags."""
with patch("codex.provenance.scan_cite_tags") as mock_scan:
mock_scan.return_value = [
{
"file": "src/main.cpp",
"line": "42",
"symbol": "compute_embedding",
"bibkey": "2301.07041",
},
{
"file": "src/utils.h",
"line": "10",
"symbol": "helper_func",
"bibkey": "2301.07042",
},
]
result = runner.invoke(app, ["provenance", "scan", "/path/to/src"])
assert result.exit_code == 0
assert "src/main.cpp" in result.stdout
assert "2301.07041" in result.stdout
assert "compute_embedding" in result.stdout
class TestProvAddLink:
"""Tests for `codex provenance add-link` command."""
def test_prov_add_link_command(self) -> None:
"""Test provenance add-link command with mocked add_code_link."""
with patch("codex.provenance.add_code_link") as mock_add:
mock_link = CodeLink(
id=1,
symbol="my_func",
paper_id="2301.07041",
role="core_algorithm",
note="Primary reference",
added_at=datetime(2023, 1, 1),
)
mock_add.return_value = mock_link
result = runner.invoke(
app,
[
"provenance",
"add-link",
"my_func",
"--paper-id",
"2301.07041",
"--role",
"core_algorithm",
"--note",
"Primary reference",
],
)
assert result.exit_code == 0
assert "Created code_link" in result.stdout
assert "my_func" in result.stdout
assert "2301.07041" in result.stdout
class TestProvLinks:
"""Tests for `codex provenance links` command."""
def test_prov_links_command(self) -> None:
"""Test provenance links command with mocked list_code_links."""
with patch("codex.provenance.list_code_links") as mock_links:
mock_links.return_value = [
CodeLink(
id=1,
symbol="my_func",
paper_id="2301.07041",
role="core",
note=None,
added_at=datetime(2023, 1, 1),
),
CodeLink(
id=2,
symbol="another_func",
paper_id="2301.07042",
role="utility",
note=None,
added_at=datetime(2023, 1, 2),
),
]
result = runner.invoke(app, ["provenance", "links"])
assert result.exit_code == 0
assert "[1]" in result.stdout
assert "[2]" in result.stdout
assert "my_func" in result.stdout
assert "another_func" in result.stdout
class TestProvBib:
"""Tests for `codex provenance bib` command."""
def test_prov_bib_command(self) -> None:
"""Test provenance bib command with mocked export_bib."""
with patch("codex.provenance.export_bib") as mock_export:
mock_export.return_value = "@article{2301.07041,\n title = {Test},\n}"
result = runner.invoke(app, ["provenance", "bib"])
assert result.exit_code == 0
assert "@article" in result.stdout
assert "2301.07041" in result.stdout
def test_prov_bib_with_output_file(self, tmp_path: Any) -> None:
"""Test provenance bib command writing to file."""
with patch("codex.provenance.export_bib") as mock_export:
mock_export.return_value = "@article{2301.07041,\n title = {Test},\n}"
output_file = tmp_path / "refs.bib"
result = runner.invoke(app, ["provenance", "bib", "--output", str(output_file)])
assert result.exit_code == 0
assert "Wrote" in result.stdout
assert output_file.exists()
assert "@article" in output_file.read_text()
class TestAsk:
"""Tests for `codex ask` command."""
def test_ask_command_exits_1(self) -> None:
"""Test that ask command exits with code 1 (not yet implemented)."""
result = runner.invoke(app, ["ask", "what is machine learning?"])
assert result.exit_code == 1
assert "not yet implemented" in result.stdout or "not yet implemented" in result.stderr
# ---------------------------------------------------------------------------
# D-05a/b — Entry-point smoke tests (python -m codex, no uv run)
# ---------------------------------------------------------------------------
class TestEntryPoint:
"""Verify that `python -m codex` works without `uv run` (D-05a/b)."""
def test_python_m_codex_help(self) -> None:
"""`python -m codex --help` exits 0 and prints usage."""
import subprocess
import sys
proc = subprocess.run(
[sys.executable, "-m", "codex", "--help"],
capture_output=True,
text=True,
)
assert proc.returncode == 0, f"Non-zero exit: {proc.stderr}"
assert "Usage" in proc.stdout or "usage" in proc.stdout.lower(), (
f"No usage in output: {proc.stdout[:200]}"
)

View File

View File

@@ -0,0 +1,145 @@
"""Tests for codex.discover — graph-based discovery queries.
All DB access is mocked; no real PostgreSQL server required.
"""
from __future__ import annotations
from contextlib import contextmanager
from typing import Any
from unittest.mock import MagicMock, patch
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_conn_cm(mock_conn: MagicMock) -> Any:
"""Return a context-manager factory that yields *mock_conn*."""
@contextmanager # type: ignore[arg-type]
def _cm() -> Any:
yield mock_conn
return _cm
def _dict_row(**kwargs: Any) -> dict[str, Any]:
return dict(kwargs)
# ---------------------------------------------------------------------------
# 1. test_discovery_leads_returns_correct_format
# ---------------------------------------------------------------------------
def test_discovery_leads_returns_correct_format() -> None:
"""Mock conn returns rows; verify output has correct shape and ordering."""
from codex.discover import discovery_leads
mock_conn = MagicMock()
mock_conn.execute.return_value.fetchall.return_value = [
_dict_row(cited_id="W111", pull=5),
_dict_row(cited_id="W222", pull=3),
_dict_row(cited_id="W333", pull=1),
]
with patch("codex.discover.get_conn", side_effect=_make_conn_cm(mock_conn)):
result = discovery_leads(limit=10)
assert len(result) == 3
assert result[0] == {"cited_id": "W111", "pull": 5}
assert result[1] == {"cited_id": "W222", "pull": 3}
assert result[2] == {"cited_id": "W333", "pull": 1}
# Verify the SQL and limit param were passed
call_args = mock_conn.execute.call_args
sql: str = call_args[0][0]
params: dict[str, Any] = call_args[0][1]
assert "cited_id NOT IN" in sql
assert "pull DESC" in sql
assert params["limit"] == 10
# ---------------------------------------------------------------------------
# 2. test_citing_papers
# ---------------------------------------------------------------------------
def test_citing_papers() -> None:
"""Mock returns citing_id list; verify flat list of IDs is returned."""
from codex.discover import citing_papers
mock_conn = MagicMock()
mock_conn.execute.return_value.fetchall.return_value = [
_dict_row(citing_id="2301.00001"),
_dict_row(citing_id="2302.00002"),
]
with patch("codex.discover.get_conn", side_effect=_make_conn_cm(mock_conn)):
result = citing_papers("W999")
assert result == ["2301.00001", "2302.00002"]
call_args = mock_conn.execute.call_args
sql: str = call_args[0][0]
params: dict[str, Any] = call_args[0][1]
assert "cited_id" in sql
assert params["paper_id"] == "W999"
# ---------------------------------------------------------------------------
# 3. test_cited_by
# ---------------------------------------------------------------------------
def test_cited_by() -> None:
"""Mock returns cited_id list; verify flat list of IDs is returned."""
from codex.discover import cited_by
mock_conn = MagicMock()
mock_conn.execute.return_value.fetchall.return_value = [
_dict_row(cited_id="10.1000/ref_a"),
_dict_row(cited_id="10.1000/ref_b"),
]
with patch("codex.discover.get_conn", side_effect=_make_conn_cm(mock_conn)):
result = cited_by("2301.00001")
assert result == ["10.1000/ref_a", "10.1000/ref_b"]
call_args = mock_conn.execute.call_args
sql: str = call_args[0][0]
params: dict[str, Any] = call_args[0][1]
assert "citing_id" in sql
assert params["paper_id"] == "2301.00001"
# ---------------------------------------------------------------------------
# 4. test_cocited_papers
# ---------------------------------------------------------------------------
def test_cocited_papers() -> None:
"""Mock returns co-citation list; verify shape and ordering."""
from codex.discover import cocited_papers
mock_conn = MagicMock()
mock_conn.execute.return_value.fetchall.return_value = [
_dict_row(paper_id="W444", co_citations=7),
_dict_row(paper_id="W555", co_citations=2),
]
with patch("codex.discover.get_conn", side_effect=_make_conn_cm(mock_conn)):
result = cocited_papers("W111", limit=5)
assert len(result) == 2
assert result[0] == {"paper_id": "W444", "co_citations": 7}
assert result[1] == {"paper_id": "W555", "co_citations": 2}
call_args = mock_conn.execute.call_args
sql: str = call_args[0][0]
params: dict[str, Any] = call_args[0][1]
assert "co_citations DESC" in sql
assert params["paper_id"] == "W111"
assert params["limit"] == 5

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

View File

@@ -0,0 +1,186 @@
"""Tests for codex.embed.
The real :class:`FlagEmbedding.BGEM3FlagModel` would download 2 GB of
weights and load them onto the device. We mock it out so the suite
runs offline in milliseconds.
"""
from __future__ import annotations
from typing import Any
import numpy as np
import pytest
import codex.embed as embed_module
from codex.embed import Embedder, get_embedder
# ---------------------------------------------------------------------------
# Fakes / fixtures
# ---------------------------------------------------------------------------
class _FakeBGEModel:
"""Stand-in for :class:`BGEM3FlagModel` that records calls.
``encode()`` returns deterministic fake vectors so the L2-norm
assertions are exact. The ``call_count`` attribute lets tests
verify the single-forward-pass invariant of :meth:`Embedder.encode`.
"""
DIM = 1024
def __init__(self, *args: Any, **kwargs: Any) -> None:
self.init_args = args
self.init_kwargs = kwargs
self.call_count = 0
self.last_kwargs: dict[str, Any] | None = None
def encode(
self,
sentences: list[str],
**kwargs: Any,
) -> dict[str, Any]:
self.call_count += 1
self.last_kwargs = kwargs
n = len(sentences)
rng = np.random.default_rng(seed=42)
return {
"dense_vecs": rng.random((n, self.DIM)).astype(np.float32),
"lexical_weights": [{0: 0.5, 7: 0.25} for _ in range(n)],
}
@pytest.fixture
def fake_model(monkeypatch: pytest.MonkeyPatch) -> type[_FakeBGEModel]:
"""Replace ``BGEM3FlagModel`` in ``codex.embed`` with the fake class."""
monkeypatch.setattr(embed_module, "BGEM3FlagModel", _FakeBGEModel)
return _FakeBGEModel
@pytest.fixture(autouse=True)
def reset_singleton() -> None:
"""Reset the module-level singleton between tests."""
embed_module._embedder = None
# ---------------------------------------------------------------------------
# encode_dense
# ---------------------------------------------------------------------------
def test_encode_dense_shape_dtype_and_norm(fake_model: type[_FakeBGEModel]) -> None:
"""Dense output is (N, dim) float32 with unit L2 rows."""
e = Embedder()
out = e.encode_dense(["a", "b"])
assert out.shape == (2, 1024)
assert out.dtype == np.float32
norms = np.linalg.norm(out, axis=1)
np.testing.assert_allclose(norms, [1.0, 1.0], atol=1e-5)
def test_encode_dense_empty_input_returns_empty_matrix(
fake_model: type[_FakeBGEModel],
) -> None:
"""Empty input -> (0, dim) without invoking the model."""
e = Embedder()
# Reach into the wrapped model to verify it is not called.
fake = e._model
assert isinstance(fake, _FakeBGEModel)
out = e.encode_dense([])
assert out.shape == (0, 1024)
assert out.dtype == np.float32
assert fake.call_count == 0
# ---------------------------------------------------------------------------
# encode_sparse
# ---------------------------------------------------------------------------
def test_encode_sparse_returns_list_of_dicts(fake_model: type[_FakeBGEModel]) -> None:
"""Sparse output has one dict per input string."""
e = Embedder()
out = e.encode_sparse(["a", "b"])
assert len(out) == 2
for row in out:
assert isinstance(row, dict)
for key, value in row.items():
assert isinstance(key, int)
assert isinstance(value, float)
def test_encode_sparse_empty_input_returns_empty_list(
fake_model: type[_FakeBGEModel],
) -> None:
"""Empty input -> [] without invoking the model."""
e = Embedder()
fake = e._model
assert isinstance(fake, _FakeBGEModel)
out = e.encode_sparse([])
assert out == []
assert fake.call_count == 0
# ---------------------------------------------------------------------------
# encode (combined)
# ---------------------------------------------------------------------------
def test_encode_returns_tuple_and_uses_single_forward_pass(
fake_model: type[_FakeBGEModel],
) -> None:
"""encode() must issue exactly one model.encode() call for efficiency."""
e = Embedder()
fake = e._model
assert isinstance(fake, _FakeBGEModel)
dense, sparse = e.encode(["a"])
assert isinstance(dense, np.ndarray)
assert dense.shape == (1, 1024)
assert dense.dtype == np.float32
np.testing.assert_allclose(np.linalg.norm(dense, axis=1), [1.0], atol=1e-5)
assert isinstance(sparse, list)
assert len(sparse) == 1
assert isinstance(sparse[0], dict)
assert fake.call_count == 1
assert fake.last_kwargs is not None
assert fake.last_kwargs.get("return_dense") is True
assert fake.last_kwargs.get("return_sparse") is True
def test_encode_empty_input(fake_model: type[_FakeBGEModel]) -> None:
"""Empty input -> ((0, dim), [])."""
e = Embedder()
fake = e._model
assert isinstance(fake, _FakeBGEModel)
dense, sparse = e.encode([])
assert dense.shape == (0, 1024)
assert dense.dtype == np.float32
assert sparse == []
assert fake.call_count == 0
# ---------------------------------------------------------------------------
# Singleton
# ---------------------------------------------------------------------------
def test_get_embedder_returns_singleton(fake_model: type[_FakeBGEModel]) -> None:
"""Two calls return the same object."""
first = get_embedder()
second = get_embedder()
assert first is second
assert isinstance(first, Embedder)

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

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

@@ -0,0 +1,224 @@
"""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 FROM papers."""
conn = MagicMock()
conn.execute.return_value.fetchall.return_value = [{"id": 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
# ---------------------------------------------------------------------------
# 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)

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

@@ -0,0 +1,271 @@
"""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,
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()
# ---------------------------------------------------------------------------
# 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"}

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

502
tests/ingest/test_ingest.py Normal file
View File

@@ -0,0 +1,502 @@
"""Tests for codex.ingest — end-to-end idempotent ingest pipeline.
All external calls (DB, OpenAlex, S2, embed, parsing) are mocked so
the suite runs offline in milliseconds.
"""
from __future__ import annotations
from contextlib import contextmanager
from typing import Any
from unittest.mock import MagicMock, patch
import numpy as np
import pytest
from codex.ingest import IngestResult, _make_bibkey, ingest_paper
from codex.models import Citation, FigureChunk, FormulaChunk, Paper
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_conn_cm(mock_conn: MagicMock) -> Any:
"""Return a context-manager factory that yields *mock_conn*."""
@contextmanager # type: ignore[arg-type]
def _cm() -> Any:
yield mock_conn
return _cm
def _make_paper(
paper_id: str = "2301.07041",
openalex_id: str | None = "W123",
abstract: str | None = "Test abstract.",
) -> Paper:
return Paper(
id=paper_id,
title="A Test Paper",
openalex_id=openalex_id,
authors=["Alice", "Bob"],
year=2023,
abstract=abstract,
)
def _fake_embedder(dim: int = 1024) -> MagicMock:
"""Return a mock Embedder whose encode_dense returns deterministic output."""
embedder = MagicMock()
embedder.dim = dim
def encode_dense(texts: list[str]) -> np.ndarray:
return np.ones((len(texts), dim), dtype=np.float32)
embedder.encode_dense.side_effect = encode_dense
return embedder
# ---------------------------------------------------------------------------
# 1. test_ingest_paper_basic
# ---------------------------------------------------------------------------
def test_ingest_paper_basic() -> None:
"""fetch_paper returns a Paper; DB upsert is called; IngestResult has correct counts."""
paper = _make_paper()
mock_conn = MagicMock()
mock_conn.execute = MagicMock()
mock_conn.executemany = MagicMock()
mock_conn.commit = MagicMock()
# OpenAlex returns citing_id=openalex_id; ingest.py must rewrite to paper.id (D-05c).
raw_api_citations = [
Citation(citing_id=paper.openalex_id or "", cited_id="W999"),
]
with (
patch("codex.ingest.openalex.fetch_paper", return_value=paper) as mock_fetch,
patch("codex.ingest.openalex.fetch_citations", return_value=raw_api_citations),
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)
mock_fetch.assert_called_once_with(paper.id)
# Paper upsert must have been issued
assert mock_conn.execute.call_count >= 1
first_sql: str = mock_conn.execute.call_args_list[0][0][0]
assert "INSERT INTO papers" in first_sql
assert "ON CONFLICT" in first_sql
assert isinstance(result, IngestResult)
assert result.paper_id == paper.id
assert result.chunks_upserted == 0
assert result.citations_upserted == len(raw_api_citations)
# D-05c regression: citing_id must be rewritten to paper.id (DOI), not openalex_id.
mock_cursor = mock_conn.cursor.return_value.__enter__.return_value
cit_call = mock_cursor.executemany.call_args_list[0]
inserted_rows: list[tuple[str, str, Any]] = cit_call[0][1]
assert inserted_rows[0][0] == paper.id, (
f"citing_id must be paper.id='{paper.id}', not openalex_id='{paper.openalex_id}'"
)
# ---------------------------------------------------------------------------
# 2. test_ingest_paper_source_tex
# ---------------------------------------------------------------------------
def test_ingest_paper_source_tex(tmp_path: Any) -> None:
"""``.tex`` source → latex_to_text, chunk_text, embed, chunks inserted in DB."""
tex_file = tmp_path / "paper.tex"
tex_file.write_text(r"\section{Introduction} Hello world. This is a test paper.")
paper = _make_paper()
mock_conn = MagicMock()
mock_conn.execute = MagicMock()
mock_conn.executemany = MagicMock()
mock_conn.commit = MagicMock()
fake_chunks = ["chunk one text here.", "chunk two text here."]
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)),
patch("codex.parsing.tex.latex_to_text", return_value="Hello world. Intro text."),
patch("codex.parsing.tex.chunk_text", return_value=fake_chunks),
patch("codex.ingest.filter_chunks", side_effect=lambda chunks, settings: chunks),
):
result = ingest_paper(paper.id, source_path=str(tex_file))
# DELETE + INSERT for chunks
sql_calls = [str(c[0][0]) for c in mock_conn.execute.call_args_list]
assert any("DELETE FROM chunks" in sql for sql in sql_calls)
# executemany is called on the cursor, not on the connection directly
mock_cursor = mock_conn.cursor.return_value.__enter__.return_value
assert mock_cursor.executemany.call_count >= 1
chunk_insert_call = mock_cursor.executemany.call_args_list[0]
chunk_sql: str = chunk_insert_call[0][0]
assert "INSERT INTO chunks" in chunk_sql
assert result.chunks_upserted == len(fake_chunks)
# ---------------------------------------------------------------------------
# 3. test_ingest_paper_source_pdf
# ---------------------------------------------------------------------------
def test_ingest_paper_source_pdf(tmp_path: Any) -> None:
"""``.pdf`` source → pdf_to_markdown, chunk_text, embed, grobid refs, chunks + citations."""
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()
fake_chunks = ["pdf chunk one.", "pdf chunk two."]
grobid_refs = [
{"title": "Ref A", "authors": "X", "year": "2020", "doi": "10.1/ref_a", "arxiv_id": ""},
{"title": "Ref B", "authors": "Y", "year": "2021", "doi": "", "arxiv_id": "2101.00001"},
]
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)),
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.grobid.extract_references", return_value=grobid_refs),
patch("codex.ingest.filter_chunks", side_effect=lambda chunks, settings: chunks),
):
result = ingest_paper(paper.id, source_path=str(pdf_file))
# Chunks were inserted
assert result.chunks_upserted == len(fake_chunks)
# GROBID refs were merged into citations
# Two grobid refs → both have either doi or arxiv_id → 2 citations
assert result.citations_upserted == 2
# executemany is called on the cursor for chunks + citations (at least 2 calls total)
mock_cursor = mock_conn.cursor.return_value.__enter__.return_value
assert mock_cursor.executemany.call_count >= 2
# ---------------------------------------------------------------------------
# 4. test_ingest_paper_not_found
# ---------------------------------------------------------------------------
def test_ingest_paper_not_found() -> None:
"""OpenAlex returns None for unknown ID → ValueError raised."""
with (
patch("codex.ingest.openalex.fetch_paper", return_value=None),
patch("codex.ingest.get_embedder", return_value=_fake_embedder()),
pytest.raises(ValueError, match="Paper not found"),
):
ingest_paper("10.9999/unknown-doi")
# ---------------------------------------------------------------------------
# 5. test_ingest_paper_idempotent
# ---------------------------------------------------------------------------
def test_ingest_paper_idempotent() -> None:
"""Second call with the same paper_id overwrites without error (no unique violations)."""
paper = _make_paper()
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.get_embedder", return_value=_fake_embedder()),
patch("codex.ingest.get_conn", side_effect=_make_conn_cm(mock_conn)),
):
result1 = ingest_paper(paper.id)
result2 = ingest_paper(paper.id)
assert result1.paper_id == result2.paper_id == paper.id
# ON CONFLICT … DO UPDATE means no error on second call
all_execute_calls = mock_conn.execute.call_args_list
paper_upserts = [c for c in all_execute_calls if "INSERT INTO papers" in str(c[0][0])]
# Two calls → two paper upserts
assert len(paper_upserts) == 2
# ---------------------------------------------------------------------------
# 6. test_ingest_paper_arxiv_s2_fallback
# ---------------------------------------------------------------------------
def test_ingest_paper_arxiv_s2_fallback() -> None:
"""If OpenAlex returns None for an arXiv ID, S2 fallback produces a stub Paper."""
paper_id = "2301.07041"
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.semanticscholar.fetch_references", return_value=[]) 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 was consulted as fallback
mock_s2.assert_called()
assert result.paper_id == paper_id
# ---------------------------------------------------------------------------
# 7. test_ingest_paper_no_abstract_uses_zero_vector
# ---------------------------------------------------------------------------
def test_ingest_paper_no_abstract_uses_zero_vector() -> None:
"""A paper without an abstract gets a zero embedding vector (no model call)."""
paper = _make_paper(abstract=None)
mock_conn = MagicMock()
mock_conn.execute = MagicMock()
mock_conn.executemany = MagicMock()
mock_conn.commit = MagicMock()
fake_emb = _fake_embedder(dim=1024)
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_emb),
patch("codex.ingest.get_conn", side_effect=_make_conn_cm(mock_conn)),
):
ingest_paper(paper.id)
# encode_dense must NOT have been called (no abstract → zero vector shortcut)
fake_emb.encode_dense.assert_not_called()
# Verify zero vector was passed in the paper upsert
upsert_call = mock_conn.execute.call_args_list[0]
params: dict[str, Any] = upsert_call[0][1]
emb: list[float] = params["abstract_emb"]
assert len(emb) == 1024
assert all(v == 0.0 for v in emb)
# ---------------------------------------------------------------------------
# 8. F-09 Rich Parsing tests
# ---------------------------------------------------------------------------
def test_ingest_paper_rich_calls_formula_and_figure_parsers(tmp_path: Any) -> None:
"""When rich=True and source is PDF, formula + figure parsers are invoked."""
import codex.parsing.figures as _figures_mod
import codex.parsing.mathpix as _mathpix_mod
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()
fake_formula = FormulaChunk(paper_id=paper.id, page=1, raw_latex=r"\alpha", context="test")
fake_figure = FigureChunk(
paper_id=paper.id, page=1, image_path="/tmp/fig.png", caption="Figure 1."
)
mock_settings = MagicMock()
mock_settings.figures_dir = str(tmp_path / "figures")
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)),
patch("codex.parsing.nougat.pdf_to_markdown", return_value=""),
patch("codex.parsing.tex.chunk_text", return_value=[]),
patch("codex.parsing.grobid.extract_references", return_value=[]),
patch.object(
_mathpix_mod, "extract_formulas", return_value=[fake_formula]
) as mock_formulas,
patch.object(_figures_mod, "extract_figures", return_value=[fake_figure]) as mock_figures,
patch("codex.ingest.get_settings", return_value=mock_settings),
):
result = ingest_paper(paper.id, source_path=str(pdf_file), rich=True)
mock_formulas.assert_called_once()
mock_figures.assert_called_once()
assert result.formulas_upserted == 1
assert result.figures_upserted == 1
def test_ingest_paper_no_rich_skips_parsers(tmp_path: Any) -> None:
"""When rich=False (default), formula and figure parsers are NOT called."""
import codex.parsing.figures as _figures_mod
import codex.parsing.mathpix as _mathpix_mod
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()
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)),
patch("codex.parsing.nougat.pdf_to_markdown", return_value=""),
patch("codex.parsing.tex.chunk_text", return_value=[]),
patch("codex.parsing.grobid.extract_references", return_value=[]),
patch.object(_mathpix_mod, "extract_formulas", return_value=[]) as mock_formulas,
patch.object(_figures_mod, "extract_figures", return_value=[]) as mock_figures,
):
result = ingest_paper(paper.id, source_path=str(pdf_file), rich=False)
mock_formulas.assert_not_called()
mock_figures.assert_not_called()
assert result.formulas_upserted == 0
assert result.figures_upserted == 0
def test_ingest_paper_rich_requires_pdf_source(tmp_path: Any) -> None:
"""When rich=True but source is a .tex file, formula/figure parsers are NOT called."""
import codex.parsing.figures as _figures_mod
import codex.parsing.mathpix as _mathpix_mod
tex_file = tmp_path / "paper.tex"
tex_file.write_text(r"\section{Intro}")
paper = _make_paper()
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.get_embedder", return_value=_fake_embedder()),
patch("codex.ingest.get_conn", side_effect=_make_conn_cm(mock_conn)),
patch("codex.parsing.tex.latex_to_text", return_value=""),
patch("codex.parsing.tex.chunk_text", return_value=[]),
patch.object(_mathpix_mod, "extract_formulas", return_value=[]) as mock_formulas,
patch.object(_figures_mod, "extract_figures", return_value=[]) as mock_figures,
):
result = ingest_paper(paper.id, source_path=str(tex_file), rich=True)
mock_formulas.assert_not_called()
mock_figures.assert_not_called()
assert result.formulas_upserted == 0
assert result.figures_upserted == 0
# ---------------------------------------------------------------------------
# D-04 — bibkey auto-generation heuristic
# ---------------------------------------------------------------------------
def test_make_bibkey_single_author() -> None:
paper = Paper(id="doi:10.1/x", title="T", authors=["John Smith"], year=2023)
assert _make_bibkey(paper) == "Smith2023"
def test_make_bibkey_two_authors() -> None:
paper = Paper(id="doi:10.1/x", title="T", authors=["Alice Foo", "Bob Bar"], year=2015)
assert _make_bibkey(paper) == "FooBar2015"
def test_make_bibkey_three_authors() -> None:
paper = Paper(
id="doi:10.1/x",
title="T",
authors=["Alexander Bobenko", "Ulrich Pinkall", "Boris Springborn"],
year=2015,
)
assert _make_bibkey(paper) == "BobenkoPinkallSpringborn2015"
def test_make_bibkey_many_authors_uses_etal() -> None:
paper = Paper(
id="doi:10.1/x",
title="T",
authors=["A One", "B Two", "C Three", "D Four"],
year=2020,
)
assert _make_bibkey(paper) == "OneEtAl2020"
def test_make_bibkey_no_authors_returns_none() -> None:
paper = Paper(id="doi:10.1/x", title="T", authors=[], year=2023)
assert _make_bibkey(paper) is None
def test_make_bibkey_no_year_returns_none() -> None:
paper = Paper(id="doi:10.1/x", title="T", authors=["John Smith"], year=None)
assert _make_bibkey(paper) is None
def test_ingest_sets_bibkey_when_none(tmp_path: Any) -> None:
"""ingest_paper auto-generates bibkey when paper has none (D-04)."""
paper = _make_paper() # bibkey=None (default from _make_paper)
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.get_embedder", return_value=_fake_embedder()),
patch("codex.ingest.get_conn", side_effect=_make_conn_cm(mock_conn)),
):
ingest_paper(paper.id)
upsert_params: dict[str, Any] = mock_conn.execute.call_args_list[0][0][1]
assert upsert_params["bibkey"] is not None, "bibkey must be auto-generated when paper has none"
# authors=["Alice", "Bob"], year=2023 → 2 authors ≤ 3 → concat last tokens
assert upsert_params["bibkey"] == "AliceBob2023"
# ---------------------------------------------------------------------------
def test_ingest_result_has_formula_figure_counts() -> None:
"""IngestResult has formulas_upserted and figures_upserted fields with default 0."""
result = IngestResult(paper_id="test", chunks_upserted=0, citations_upserted=0)
assert result.formulas_upserted == 0
assert result.figures_upserted == 0
result2 = IngestResult(
paper_id="test",
chunks_upserted=5,
citations_upserted=3,
formulas_upserted=10,
figures_upserted=2,
)
assert result2.formulas_upserted == 10
assert result2.figures_upserted == 2

View File

View File

@@ -0,0 +1,100 @@
"""End-to-end smoke test: ingest → discover → provenance flow (all mocked)."""
from __future__ import annotations
from contextlib import contextmanager
from typing import Any
from unittest.mock import MagicMock, patch
import numpy as np
from codex.discover import discovery_leads
from codex.ingest import ingest_paper
from codex.models import Citation, Paper
from codex.provenance import export_bib
def _make_conn_cm(mock_conn: MagicMock) -> Any:
"""Return a context-manager factory that yields *mock_conn*."""
@contextmanager
def _cm() -> Any:
yield mock_conn
return _cm
def test_full_pipeline_smoke() -> None:
"""ingest_paper → discovery_leads → export_bib all execute without errors (all mocked)."""
paper = Paper(id="2301.07041", title="T", openalex_id="W123")
citations = [Citation(citing_id="2301.07041", cited_id="2301.99999")]
# --- mock connection setup ---
mock_conn = MagicMock()
mock_conn.execute = MagicMock()
mock_conn.commit = MagicMock()
# discovery_leads: conn.execute(...).fetchall() → empty list (no leads)
discover_execute_result = MagicMock()
discover_execute_result.fetchall.return_value = []
# export_bib: conn.execute(...).fetchall() → one paper row with bibkey
bib_row: dict[str, Any] = {
"id": "2301.07041",
"bibkey": "paper2023",
"title": "T",
"authors": ["Author One"],
"year": 2023,
}
bib_execute_result = MagicMock()
bib_execute_result.fetchall.return_value = [bib_row]
# The ingest path calls conn.execute multiple times (paper upsert, etc.).
# discovery_leads and export_bib each call conn.execute once and use fetchall.
# We use side_effect to route calls: first N calls from ingest return a generic
# mock; then we supply discover and bib results in order.
_call_count: list[int] = [0]
def _execute_side_effect(*args: Any, **kwargs: Any) -> MagicMock:
_call_count[0] += 1
sql: str = str(args[0]) if args else ""
if "cited_id NOT IN" in sql:
return discover_execute_result
if "FROM papers" in sql and "bibkey IS NOT NULL" in sql:
return bib_execute_result
return MagicMock()
mock_conn.execute.side_effect = _execute_side_effect
# cursor() context manager for executemany calls inside ingest
mock_cursor = MagicMock()
mock_conn.cursor.return_value.__enter__ = MagicMock(return_value=mock_cursor)
mock_conn.cursor.return_value.__exit__ = MagicMock(return_value=False)
embedder = MagicMock()
embedder.dim = 1024
embedder.encode_dense.return_value = np.zeros((1, 1024), dtype=np.float32)
conn_cm = _make_conn_cm(mock_conn)
with (
patch("codex.ingest.openalex.fetch_paper", return_value=paper),
patch("codex.ingest.openalex.fetch_citations", return_value=citations),
patch("codex.ingest.get_embedder", return_value=embedder),
patch("codex.ingest.get_conn", side_effect=conn_cm),
patch("codex.discover.get_conn", side_effect=conn_cm),
patch("codex.provenance.get_conn", side_effect=conn_cm),
):
# Step 1: ingest
result = ingest_paper("2301.07041")
# Step 2: discover
leads = discovery_leads()
# Step 3: export bib
bib = export_bib(["2301.07041"])
# --- assertions ---
assert result.paper_id == "2301.07041"
assert isinstance(leads, list)
assert "@article" in bib

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

211
tests/mcp/test_graceful.py Normal file
View File

@@ -0,0 +1,211 @@
"""test_graceful.py — missing/unavailable features must degrade, never crash.
All tools that wrap optional features (wiki, synthesis leads, provenance)
must return a structured "feature not available" response instead of raising.
"""
from __future__ import annotations
import json
import tempfile
from pathlib import Path
from unittest.mock import patch
# ---------------------------------------------------------------------------
# ask — always returns "not available" (RAG not implemented)
# ---------------------------------------------------------------------------
def test_ask_returns_not_available() -> None:
"""ask() must return {'error': 'not available'} — RAG not yet implemented."""
from codex.mcp_server import ask
result = ask("What is conformal geometry?")
assert result == {"error": "not available"}
# ---------------------------------------------------------------------------
# wiki_read — page missing → "feature not available"
# ---------------------------------------------------------------------------
def test_wiki_read_missing_page_returns_not_available() -> None:
"""wiki_read() with a nonexistent page must return {"error": "feature not available"}."""
from codex.mcp_server import wiki_read
with (
tempfile.TemporaryDirectory() as tmpdir,
patch("codex.config.get_settings") as mock_cfg,
):
mock_cfg.return_value.wiki_dir = tmpdir
result = wiki_read("nonexistent_concept")
assert result == {"error": "feature not available"}
def test_wiki_read_existing_page_returns_markdown() -> None:
"""wiki_read() with an existing page must return {markdown, sources}."""
from codex.mcp_server import wiki_read
with (
tempfile.TemporaryDirectory() as tmpdir,
patch("codex.config.get_settings") as mock_cfg,
):
mock_cfg.return_value.wiki_dir = tmpdir
# Write a fake wiki page
page = Path(tmpdir) / "conformal_maps.md"
page.write_text("# Conformal Maps\n\nSome content.", encoding="utf-8")
result = wiki_read("conformal_maps")
assert "markdown" in result
assert "sources" in result
assert "# Conformal Maps" in str(result["markdown"])
def test_wiki_read_exception_returns_not_available() -> None:
"""wiki_read() must degrade gracefully when get_settings raises."""
from codex.mcp_server import wiki_read
with patch("codex.config.get_settings", side_effect=RuntimeError("config error")):
result = wiki_read("any_slug")
assert result == {"error": "feature not available"}
# ---------------------------------------------------------------------------
# wiki_list — missing wiki dir → empty list
# ---------------------------------------------------------------------------
def test_wiki_list_missing_dir_returns_empty() -> None:
"""wiki_list() must return [] when the wiki directory does not exist."""
from codex.mcp_server import wiki_list
with (
tempfile.TemporaryDirectory() as tmpdir,
patch("codex.config.get_settings") as mock_cfg,
):
mock_cfg.return_value.wiki_dir = str(Path(tmpdir) / "no_wiki_here")
result = wiki_list()
assert result == []
def test_wiki_list_existing_dir_returns_pages() -> None:
"""wiki_list() must return page entries for .md files in the wiki dir."""
from codex.mcp_server import wiki_list
with (
tempfile.TemporaryDirectory() as tmpdir,
patch("codex.config.get_settings") as mock_cfg,
):
mock_cfg.return_value.wiki_dir = tmpdir
(Path(tmpdir) / "concept_a.md").write_text("# A", encoding="utf-8")
(Path(tmpdir) / "concept_b.md").write_text("# B", encoding="utf-8")
(Path(tmpdir) / "index.md").write_text("# Index", encoding="utf-8") # must be skipped
result = wiki_list()
slugs = {r["slug"] for r in result}
assert "concept_a" in slugs
assert "concept_b" in slugs
assert "index" not in slugs # index.md is excluded
# ---------------------------------------------------------------------------
# discover_leads — domain function raises → error dict, no crash
# ---------------------------------------------------------------------------
def test_discover_leads_db_error_returns_error() -> None:
"""discover_leads() must return [{"error": ...}] when the discovery call fails."""
from codex.mcp_server import discover_leads
# Patch the domain function directly so the exception always surfaces
with patch("codex.discover.discovery_leads", side_effect=RuntimeError("no DB")):
result = discover_leads(limit=5)
assert len(result) == 1
assert "error" in result[0]
# ---------------------------------------------------------------------------
# synthesis_browse — missing leads dir → "feature not available"
# ---------------------------------------------------------------------------
def test_synthesis_browse_missing_dir_returns_not_available() -> None:
"""synthesis_browse() must return [{'error': 'feature not available'}] when leads/ absent."""
from codex.mcp_server import synthesis_browse
with (
tempfile.TemporaryDirectory() as tmpdir,
patch("codex.config.get_settings") as mock_cfg,
):
mock_cfg.return_value.leads_dir = str(Path(tmpdir) / "no_leads_here")
result = synthesis_browse()
assert result == [{"error": "feature not available"}]
def test_synthesis_browse_existing_dir_returns_items() -> None:
"""synthesis_browse() must parse and return JSON files from the leads dir."""
from codex.mcp_server import synthesis_browse
with (
tempfile.TemporaryDirectory() as tmpdir,
patch("codex.config.get_settings") as mock_cfg,
):
leads_dir = Path(tmpdir) / "leads"
leads_dir.mkdir()
(leads_dir / "lead1.json").write_text(
json.dumps({"kind": "connection", "title": "C1"}), encoding="utf-8"
)
(leads_dir / "lead2.json").write_text(
json.dumps({"kind": "conjecture", "title": "C2"}), encoding="utf-8"
)
mock_cfg.return_value.leads_dir = str(leads_dir)
result = synthesis_browse(kind="all")
assert len(result) == 2
kinds = {str(r.get("kind")) for r in result}
assert "connection" in kinds
assert "conjecture" in kinds
def test_synthesis_browse_kind_filter() -> None:
"""synthesis_browse(kind='conjecture') must return only conjectures."""
from codex.mcp_server import synthesis_browse
with (
tempfile.TemporaryDirectory() as tmpdir,
patch("codex.config.get_settings") as mock_cfg,
):
leads_dir = Path(tmpdir) / "leads"
leads_dir.mkdir()
(leads_dir / "lead1.json").write_text(
json.dumps({"kind": "connection", "title": "C1"}), encoding="utf-8"
)
(leads_dir / "lead2.json").write_text(
json.dumps({"kind": "conjecture", "title": "C2"}), encoding="utf-8"
)
mock_cfg.return_value.leads_dir = str(leads_dir)
result = synthesis_browse(kind="conjecture")
assert all(str(r.get("kind")) == "conjecture" for r in result)
assert len(result) == 1
# ---------------------------------------------------------------------------
# provenance_verify — exception → "feature not available"
# ---------------------------------------------------------------------------
def test_provenance_verify_exception_returns_not_available() -> None:
"""provenance_verify() must return [{"error": ...}] on scan failure."""
from codex.mcp_server import provenance_verify
with patch("codex.provenance.scan_cite_tags", side_effect=RuntimeError("scan failed")):
result = provenance_verify("/some/nonexistent/path")
assert len(result) == 1
assert "error" in result[0]

121
tests/mcp/test_http_auth.py Normal file
View File

@@ -0,0 +1,121 @@
"""test_http_auth.py — HTTP transport token-auth enforcement.
Tests:
1. _require_http_token() raises RuntimeError when MCP_AUTH_TOKEN is absent.
2. _require_http_token() returns the token when it is set.
3. main() with transport='http' raises without token (refuses to start).
4. main() with transport='stdio' runs mcp.run (happy path, no token needed).
"""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
# ---------------------------------------------------------------------------
# _require_http_token
# ---------------------------------------------------------------------------
def test_require_http_token_raises_without_token() -> None:
"""_require_http_token() must raise RuntimeError when mcp_auth_token is None."""
from codex.mcp_server import _require_http_token
mock_settings = MagicMock()
mock_settings.mcp_auth_token = None
with (
patch("codex.config.get_settings", return_value=mock_settings),
pytest.raises(RuntimeError, match="MCP_AUTH_TOKEN"),
):
_require_http_token()
def test_require_http_token_returns_token_when_set() -> None:
"""_require_http_token() must return the token string when it is configured."""
from codex.mcp_server import _require_http_token
mock_settings = MagicMock()
mock_settings.mcp_auth_token = "super-secret-token"
with patch("codex.config.get_settings", return_value=mock_settings):
result = _require_http_token()
assert result == "super-secret-token"
# ---------------------------------------------------------------------------
# main() — HTTP transport without token → RuntimeError
# ---------------------------------------------------------------------------
def test_main_http_without_token_raises() -> None:
"""main() with MCP_TRANSPORT=http and no token must raise RuntimeError."""
from codex.mcp_server import main
mock_settings = MagicMock()
mock_settings.mcp_transport = "http"
mock_settings.mcp_auth_token = None
with (
patch("codex.config.get_settings", return_value=mock_settings),
pytest.raises(RuntimeError, match="MCP_AUTH_TOKEN"),
):
main()
# ---------------------------------------------------------------------------
# main() — stdio transport runs mcp.run
# ---------------------------------------------------------------------------
def test_main_stdio_calls_mcp_run() -> None:
"""main() with stdio transport must call mcp.run(transport='stdio')."""
from codex import mcp_server
mock_settings = MagicMock()
mock_settings.mcp_transport = "stdio"
with (
patch("codex.config.get_settings", return_value=mock_settings),
patch.object(mcp_server.mcp, "run") as mock_run,
):
mcp_server.main()
mock_run.assert_called_once_with(transport="stdio")
# ---------------------------------------------------------------------------
# main() — HTTP transport with valid token attempts uvicorn.run
# ---------------------------------------------------------------------------
def test_main_http_with_token_calls_uvicorn() -> None:
"""main() with http transport + valid token must invoke uvicorn.run."""
from codex import mcp_server
mock_settings = MagicMock()
mock_settings.mcp_transport = "http"
mock_settings.mcp_auth_token = "test-token"
mock_settings.mcp_host = "127.0.0.1"
mock_settings.mcp_port = 8765
# Stub the ASGI app returned by streamable_http_app
mock_app = MagicMock()
mock_app.add_middleware = MagicMock()
with (
patch("codex.config.get_settings", return_value=mock_settings),
patch.object(mcp_server.mcp, "streamable_http_app", return_value=mock_app),
patch("uvicorn.run") as mock_uvicorn,
):
mcp_server.main()
mock_uvicorn.assert_called_once()
call_kwargs = mock_uvicorn.call_args
assert call_kwargs is not None
# host and port must match settings
_, kwargs = call_kwargs
assert kwargs.get("host") == "127.0.0.1"
assert kwargs.get("port") == 8765

View File

@@ -0,0 +1,83 @@
"""test_readonly.py — CRITICAL: ensure no write/ingest tools are exposed via MCP.
This test MUST fail if any write or ingest tool is accidentally registered.
It is the primary enforcement point for the read-only invariant of F-14.
Read-only tools (allowed):
search, ask, wiki_read, wiki_list, discover_leads,
provenance_verify, synthesis_browse
Write/ingest tools that must NEVER appear:
Any tool whose name contains an ingest/write/delete/add/update/remove
verb, or any of the known write-mode CLI operations.
"""
from __future__ import annotations
import asyncio
import re
# ---------------------------------------------------------------------------
# Write-verb pattern — any tool matching this is a forbidden write tool.
# ---------------------------------------------------------------------------
_WRITE_VERB_RE = re.compile(
r"(?:^|_)(ingest|write|delete|remove|add|update|upsert|insert|create|sync|compile|export)",
re.IGNORECASE,
)
# Explicit allowlist — only these names may appear.
ALLOWED_TOOLS = frozenset(
{
"search",
"ask",
"wiki_read",
"wiki_list",
"discover_leads",
"provenance_verify",
"synthesis_browse",
}
)
def test_no_write_tools_registered() -> None:
"""CRITICAL: fail if any write-verb tool is registered on the MCP server."""
from codex.mcp_server import mcp
tools = asyncio.run(mcp.list_tools())
registered = {t.name for t in tools}
violations: list[str] = []
for name in registered:
if _WRITE_VERB_RE.search(name):
violations.append(name)
assert not violations, (
f"Write/ingest tools found in MCP server (FORBIDDEN): {violations}. "
"MCP server must expose read-only tools only."
)
def test_only_allowed_tools_registered() -> None:
"""CRITICAL: registered tools must be a subset of the explicit allowlist."""
from codex.mcp_server import mcp
tools = asyncio.run(mcp.list_tools())
registered = {t.name for t in tools}
disallowed = registered - ALLOWED_TOOLS
assert not disallowed, (
f"Tools not on the read-only allowlist: {disallowed}. "
"Add them to ALLOWED_TOOLS only after verifying they are read-only."
)
def test_all_allowed_tools_present() -> None:
"""Every tool in the allowlist must be registered (completeness check)."""
from codex.mcp_server import mcp
tools = asyncio.run(mcp.list_tools())
registered = {t.name for t in tools}
missing = ALLOWED_TOOLS - registered
assert not missing, f"Expected read-only tools missing from server: {missing}"

176
tests/mcp/test_search.py Normal file
View File

@@ -0,0 +1,176 @@
"""test_search.py — verify the search tool: retrieval, return format, error handling.
Since search() imports get_embedder and get_conn lazily (inside the function body),
we patch them at their source modules: codex.embed and codex.db.
"""
from __future__ import annotations
from contextlib import contextmanager
from typing import Any
from unittest.mock import MagicMock, patch
import numpy as np
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_conn_cm(mock_conn: MagicMock) -> Any:
"""Return a context-manager factory that yields *mock_conn*."""
@contextmanager # type: ignore[arg-type]
def _cm() -> Any:
yield mock_conn
return _cm
def _fake_row(
bibkey: str,
paper_id: str,
ord_val: int,
content: str,
dist: float,
) -> dict[str, Any]:
return {
"bibkey": bibkey,
"paper_id": paper_id,
"ord": ord_val,
"content": content,
"dist": dist,
}
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
def test_search_returns_correct_keys() -> None:
"""search() must return dicts with the required five keys."""
from codex.mcp_server import search
mock_conn = MagicMock()
mock_conn.execute.return_value.fetchall.return_value = [
_fake_row("Springborn2008", "springborn-2008", 16, "The volume formula is V = ...", 0.2),
]
fake_dense = np.zeros((1, 1024), dtype=np.float32)
mock_embedder = MagicMock()
mock_embedder.encode_dense.return_value = fake_dense
with (
patch("codex.embed.get_embedder", return_value=mock_embedder),
patch("codex.db.get_conn", side_effect=_make_conn_cm(mock_conn)),
):
results = search("volume formula", limit=5)
assert len(results) == 1
hit = results[0]
assert set(hit.keys()) == {"bibkey", "paper_id", "locator", "score", "snippet"}
def test_search_score_is_similarity_not_distance() -> None:
"""score = 1 - distance, so a dist of 0.0 should yield score 1.0."""
from codex.mcp_server import search
mock_conn = MagicMock()
mock_conn.execute.return_value.fetchall.return_value = [
_fake_row("Author2020", "paper-x", 3, "Some chunk content here.", 0.0),
]
fake_dense = np.zeros((1, 1024), dtype=np.float32)
mock_embedder = MagicMock()
mock_embedder.encode_dense.return_value = fake_dense
with (
patch("codex.embed.get_embedder", return_value=mock_embedder),
patch("codex.db.get_conn", side_effect=_make_conn_cm(mock_conn)),
):
results = search("some query")
assert results[0]["score"] == 1.0
def test_search_locator_format() -> None:
"""locator must be 'chunk <ord>'."""
from codex.mcp_server import search
mock_conn = MagicMock()
mock_conn.execute.return_value.fetchall.return_value = [
_fake_row("Auth2021", "paper-y", 42, "Content.", 0.3),
]
fake_dense = np.zeros((1, 1024), dtype=np.float32)
mock_embedder = MagicMock()
mock_embedder.encode_dense.return_value = fake_dense
with (
patch("codex.embed.get_embedder", return_value=mock_embedder),
patch("codex.db.get_conn", side_effect=_make_conn_cm(mock_conn)),
):
results = search("query")
assert results[0]["locator"] == "chunk 42"
def test_search_snippet_truncated_to_300() -> None:
"""snippet must be truncated to 300 characters."""
from codex.mcp_server import search
long_content = "x" * 500
mock_conn = MagicMock()
mock_conn.execute.return_value.fetchall.return_value = [
_fake_row("Auth2022", "paper-z", 1, long_content, 0.1),
]
fake_dense = np.zeros((1, 1024), dtype=np.float32)
mock_embedder = MagicMock()
mock_embedder.encode_dense.return_value = fake_dense
with (
patch("codex.embed.get_embedder", return_value=mock_embedder),
patch("codex.db.get_conn", side_effect=_make_conn_cm(mock_conn)),
):
results = search("query")
assert len(results[0]["snippet"]) == 300 # type: ignore[arg-type]
def test_search_returns_error_dict_on_exception() -> None:
"""When the embedder raises, search must return [{"error": ...}], not crash."""
from codex.mcp_server import search
mock_embedder = MagicMock()
mock_embedder.encode_dense.side_effect = RuntimeError("DB down")
with patch("codex.embed.get_embedder", return_value=mock_embedder):
results = search("query")
assert len(results) == 1
assert "error" in results[0]
def test_search_provenance_keys() -> None:
"""Each hit must carry bibkey and paper_id for provenance traceability."""
from codex.mcp_server import search
mock_conn = MagicMock()
mock_conn.execute.return_value.fetchall.return_value = [
_fake_row("Crane1999", "crane-1999", 7, "Discrete exterior calculus ...", 0.15),
]
fake_dense = np.zeros((1, 1024), dtype=np.float32)
mock_embedder = MagicMock()
mock_embedder.encode_dense.return_value = fake_dense
with (
patch("codex.embed.get_embedder", return_value=mock_embedder),
patch("codex.db.get_conn", side_effect=_make_conn_cm(mock_conn)),
):
results = search("exterior calculus")
assert results[0]["bibkey"] == "Crane1999"
assert results[0]["paper_id"] == "crane-1999"

49
tests/mcp/test_server.py Normal file
View File

@@ -0,0 +1,49 @@
"""test_server.py — verify the MCP server instantiates and all tools are registered."""
from __future__ import annotations
import asyncio
# ---------------------------------------------------------------------------
# Expected tools (must match EXACTLY what mcp_server.py registers)
# ---------------------------------------------------------------------------
EXPECTED_TOOLS = {
"search",
"ask",
"wiki_read",
"wiki_list",
"discover_leads",
"provenance_verify",
"synthesis_browse",
}
def test_server_instance_exists() -> None:
"""The module-level ``mcp`` FastMCP instance must exist."""
from codex.mcp_server import mcp
assert mcp is not None
assert mcp.name == "codex"
def test_all_tools_registered() -> None:
"""Every expected tool name must be registered on the server."""
from codex.mcp_server import mcp
tools = asyncio.run(mcp.list_tools())
registered = {t.name for t in tools}
missing = EXPECTED_TOOLS - registered
assert not missing, f"Tools not registered: {missing}"
def test_no_extra_unexpected_write_tools() -> None:
"""No tool outside the expected set should be registered (belt-and-suspenders)."""
from codex.mcp_server import mcp
tools = asyncio.run(mcp.list_tools())
registered = {t.name for t in tools}
unexpected = registered - EXPECTED_TOOLS
assert not unexpected, f"Unexpected tools registered: {unexpected}"

View File

View File

@@ -0,0 +1,245 @@
"""Tests for codex.parsing.figures — figure extraction module.
All external dependencies (fitz, PIL) are mocked so the suite runs offline.
"""
from __future__ import annotations
from typing import Any
from unittest.mock import MagicMock, patch
def _make_mock_rect(
x0: float = 10.0, y0: float = 10.0, x1: float = 200.0, y1: float = 150.0
) -> MagicMock:
"""Build a mock fitz.Rect-like object."""
rect = MagicMock()
rect.x0 = x0
rect.y0 = y0
rect.x1 = x1
rect.y1 = y1
return rect
def _make_mock_doc(
images: list[tuple[int, ...]], # list of (xref, ...)
text_blocks: list[Any],
img_rects: list[Any],
page_count: int = 1,
) -> tuple[MagicMock, MagicMock]:
"""Build a mock fitz document with one page."""
mock_pix = MagicMock()
mock_pix.n = 3 # RGB — no conversion needed
mock_pix.save = MagicMock()
mock_page = MagicMock()
mock_page.get_images.return_value = images
mock_page.get_text.return_value = text_blocks
mock_page.get_image_rects.return_value = img_rects
mock_doc = MagicMock()
mock_doc.__len__.return_value = page_count
mock_doc.__getitem__ = MagicMock(return_value=mock_page)
return mock_doc, mock_pix
class TestExtractFigures:
"""Tests for extract_figures()."""
def test_no_caption_returns_empty_string(self, tmp_path: Any) -> None:
"""Figure with no nearby caption gets empty caption string."""
img_rect = _make_mock_rect(10, 10, 200, 150)
text_blocks = [
(300.0, 300.0, 400.0, 320.0, "Unrelated text here.", 0, 0),
]
mock_doc, mock_pix = _make_mock_doc(
images=[(5, 0, 0, 0, 0, 0, 0, 0, 8, "png")],
text_blocks=text_blocks,
img_rects=[img_rect],
)
with (
patch("fitz.open", return_value=mock_doc),
patch("fitz.Pixmap", return_value=mock_pix),
):
from codex.parsing.figures import extract_figures
results = extract_figures("paper.pdf", output_dir=str(tmp_path))
assert len(results) == 1
assert results[0].caption == ""
def test_figure_png_is_saved(self, tmp_path: Any) -> None:
"""PNG save is called with the correct output path."""
img_rect = _make_mock_rect()
mock_doc, mock_pix = _make_mock_doc(
images=[(5, 0, 0, 0, 0, 0, 0, 0, 8, "png")],
text_blocks=[],
img_rects=[img_rect],
)
with (
patch("fitz.open", return_value=mock_doc),
patch("fitz.Pixmap", return_value=mock_pix),
):
from codex.parsing.figures import extract_figures
results = extract_figures("paper.pdf", output_dir=str(tmp_path))
assert len(results) == 1
mock_pix.save.assert_called_once()
saved_path: str = mock_pix.save.call_args[0][0]
assert saved_path.endswith(".png")
def test_caption_detected_below_image(self, tmp_path: Any) -> None:
"""Caption block immediately below the image is detected."""
img_rect = _make_mock_rect(10, 10, 200, 150)
caption_block = (10.0, 155.0, 200.0, 170.0, "Figure 1: Architecture overview.", 0, 0)
mock_doc, mock_pix = _make_mock_doc(
images=[(5, 0, 0, 0, 0, 0, 0, 0, 8, "png")],
text_blocks=[caption_block],
img_rects=[img_rect],
)
with (
patch("fitz.open", return_value=mock_doc),
patch("fitz.Pixmap", return_value=mock_pix),
):
from codex.parsing.figures import extract_figures
results = extract_figures("paper.pdf", output_dir=str(tmp_path))
assert results[0].caption == "Figure 1: Architecture overview."
def test_caption_detected_above_image(self, tmp_path: Any) -> None:
"""Caption block immediately above the image is also detected."""
img_rect = _make_mock_rect(10, 80, 200, 200)
caption_block = (10.0, 50.0, 200.0, 75.0, "Fig. 2: Loss curve.", 0, 0)
mock_doc, mock_pix = _make_mock_doc(
images=[(5, 0, 0, 0, 0, 0, 0, 0, 8, "png")],
text_blocks=[caption_block],
img_rects=[img_rect],
)
with (
patch("fitz.open", return_value=mock_doc),
patch("fitz.Pixmap", return_value=mock_pix),
):
from codex.parsing.figures import extract_figures
results = extract_figures("paper.pdf", output_dir=str(tmp_path))
assert results[0].caption == "Fig. 2: Loss curve."
def test_german_caption_prefix(self, tmp_path: Any) -> None:
"""'Abbildung' prefix is recognized as a valid German caption."""
img_rect = _make_mock_rect(10, 10, 200, 150)
caption_block = (10.0, 155.0, 200.0, 170.0, "Abbildung 3: Ergebnisse.", 0, 0)
mock_doc, mock_pix = _make_mock_doc(
images=[(5, 0, 0, 0, 0, 0, 0, 0, 8, "png")],
text_blocks=[caption_block],
img_rects=[img_rect],
)
with (
patch("fitz.open", return_value=mock_doc),
patch("fitz.Pixmap", return_value=mock_pix),
):
from codex.parsing.figures import extract_figures
results = extract_figures("paper.pdf", output_dir=str(tmp_path))
assert "Abbildung" in results[0].caption
def test_unrelated_text_not_used_as_caption(self, tmp_path: Any) -> None:
"""Text blocks not starting with a caption prefix are ignored."""
img_rect = _make_mock_rect(10, 10, 200, 150)
non_caption = (10.0, 155.0, 200.0, 170.0, "This is a random sentence.", 0, 0)
mock_doc, mock_pix = _make_mock_doc(
images=[(5, 0, 0, 0, 0, 0, 0, 0, 8, "png")],
text_blocks=[non_caption],
img_rects=[img_rect],
)
with (
patch("fitz.open", return_value=mock_doc),
patch("fitz.Pixmap", return_value=mock_pix),
):
from codex.parsing.figures import extract_figures
results = extract_figures("paper.pdf", output_dir=str(tmp_path))
assert results[0].caption == ""
def test_no_images_returns_empty_list(self, tmp_path: Any) -> None:
"""PDF with no embedded images returns empty list."""
mock_doc, mock_pix = _make_mock_doc(
images=[],
text_blocks=[],
img_rects=[],
)
with (
patch("fitz.open", return_value=mock_doc),
patch("fitz.Pixmap", return_value=mock_pix),
):
from codex.parsing.figures import extract_figures
results = extract_figures("paper.pdf", output_dir=str(tmp_path))
assert results == []
def test_image_without_rect_is_skipped(self, tmp_path: Any) -> None:
"""Images for which get_image_rects returns empty list are skipped."""
mock_doc, mock_pix = _make_mock_doc(
images=[(5, 0, 0, 0, 0, 0, 0, 0, 8, "png")],
text_blocks=[],
img_rects=[], # no rect
)
with (
patch("fitz.open", return_value=mock_doc),
patch("fitz.Pixmap", return_value=mock_pix),
):
from codex.parsing.figures import extract_figures
results = extract_figures("paper.pdf", output_dir=str(tmp_path))
assert results == []
def test_output_dir_created_if_absent(self, tmp_path: Any) -> None:
"""output_dir is created if it does not exist."""
new_dir = tmp_path / "new_subdir" / "figures"
assert not new_dir.exists()
mock_doc, mock_pix = _make_mock_doc(images=[], text_blocks=[], img_rects=[])
with (
patch("fitz.open", return_value=mock_doc),
patch("fitz.Pixmap", return_value=mock_pix),
):
from codex.parsing.figures import extract_figures
extract_figures("paper.pdf", output_dir=str(new_dir))
assert new_dir.exists()
def test_paper_id_from_pdf_stem(self, tmp_path: Any) -> None:
"""paper_id in FigureChunk is the stem of the PDF filename."""
img_rect = _make_mock_rect()
mock_doc, mock_pix = _make_mock_doc(
images=[(5, 0, 0, 0, 0, 0, 0, 0, 8, "png")],
text_blocks=[],
img_rects=[img_rect],
)
with (
patch("fitz.open", return_value=mock_doc),
patch("fitz.Pixmap", return_value=mock_pix),
):
from codex.parsing.figures import extract_figures
results = extract_figures("/tmp/2301.07041.pdf", output_dir=str(tmp_path))
assert results[0].paper_id == "2301.07041"

View File

@@ -0,0 +1,161 @@
"""Tests for codex.parsing.grobid."""
from __future__ import annotations
from pathlib import Path
from unittest.mock import MagicMock, patch
from codex.parsing.grobid import extract_references, extract_structure
# Real GROBID TEI output uses type="arXiv" (camel-case), not "arxiv".
_TEI_XML = """\
<?xml version="1.0" encoding="UTF-8"?>
<TEI xmlns="http://www.tei-c.org/ns/1.0">
<text>
<back>
<div type="references">
<listBibl>
<biblStruct>
<analytic>
<title level="a">Deep Learning for NLP</title>
<author>
<persName>
<forename>Jane</forename>
<surname>Doe</surname>
</persName>
</author>
</analytic>
<monogr>
<imprint>
<date type="published" when="2021-06"/>
</imprint>
</monogr>
<idno type="DOI">10.1234/nlp.2021</idno>
<idno type="arXiv">2101.00001</idno>
</biblStruct>
<biblStruct>
<analytic>
<title level="a">Attention Is All You Need</title>
<author>
<persName>
<forename>Ashish</forename>
<surname>Vaswani</surname>
</persName>
</author>
<author>
<persName>
<forename>Noam</forename>
<surname>Shazeer</surname>
</persName>
</author>
</analytic>
<monogr>
<imprint>
<date type="published" when="2017"/>
</imprint>
</monogr>
<idno type="DOI">10.5678/attention</idno>
</biblStruct>
</listBibl>
</div>
</back>
</text>
</TEI>
"""
class _FakeResponse:
text = _TEI_XML
status_code = 200
def raise_for_status(self) -> None:
pass
def _mock_post(xml: str = _TEI_XML) -> MagicMock:
fake = MagicMock()
fake.text = xml
fake.status_code = 200
fake.raise_for_status = MagicMock()
return fake
class TestExtractReferences:
def test_returns_two_dicts(self, tmp_path: Path) -> None:
pdf = tmp_path / "paper.pdf"
pdf.write_bytes(b"%PDF fake")
with patch("httpx.Client") as mock_client_cls:
mock_client = MagicMock()
mock_client_cls.return_value.__enter__.return_value = mock_client
mock_client.post.return_value = _mock_post()
refs = extract_references(str(pdf), grobid_url="http://localhost:8070")
assert len(refs) == 2
def test_all_keys_present(self, tmp_path: Path) -> None:
pdf = tmp_path / "paper.pdf"
pdf.write_bytes(b"%PDF fake")
with patch("httpx.Client") as mock_client_cls:
mock_client = MagicMock()
mock_client_cls.return_value.__enter__.return_value = mock_client
mock_client.post.return_value = _mock_post()
refs = extract_references(str(pdf), grobid_url="http://localhost:8070")
required_keys = {"title", "authors", "year", "doi", "arxiv_id"}
for ref in refs:
assert required_keys == set(ref.keys()), f"Missing keys in {ref}"
def test_first_ref_values(self, tmp_path: Path) -> None:
pdf = tmp_path / "paper.pdf"
pdf.write_bytes(b"%PDF fake")
with patch("httpx.Client") as mock_client_cls:
mock_client = MagicMock()
mock_client_cls.return_value.__enter__.return_value = mock_client
mock_client.post.return_value = _mock_post()
refs = extract_references(str(pdf), grobid_url="http://localhost:8070")
first = refs[0]
assert first["title"] == "Deep Learning for NLP"
assert "Jane Doe" in first["authors"]
assert first["year"] == "2021"
assert first["doi"] == "10.1234/nlp.2021"
assert first["arxiv_id"] == "2101.00001"
def test_second_ref_missing_arxiv_is_empty_string(self, tmp_path: Path) -> None:
pdf = tmp_path / "paper.pdf"
pdf.write_bytes(b"%PDF fake")
with patch("httpx.Client") as mock_client_cls:
mock_client = MagicMock()
mock_client_cls.return_value.__enter__.return_value = mock_client
mock_client.post.return_value = _mock_post()
refs = extract_references(str(pdf), grobid_url="http://localhost:8070")
second = refs[1]
assert second["arxiv_id"] == ""
assert second["title"] == "Attention Is All You Need"
assert second["year"] == "2017"
class TestExtractStructure:
def test_returns_xml_string(self, tmp_path: Path) -> None:
pdf = tmp_path / "paper.pdf"
pdf.write_bytes(b"%PDF fake")
with patch("httpx.Client") as mock_client_cls:
mock_client = MagicMock()
mock_client_cls.return_value.__enter__.return_value = mock_client
mock_client.post.return_value = _mock_post("<TEI>raw xml</TEI>")
result = extract_structure(str(pdf), grobid_url="http://localhost:8070")
assert result == "<TEI>raw xml</TEI>"
mock_client.post.assert_called_once()
assert "processFulltextDocument" in mock_client.post.call_args[0][0]

View File

@@ -0,0 +1,386 @@
"""Tests for codex.parsing.mathpix — formula extraction module.
All external dependencies (fitz, pix2tex, httpx, get_settings) are mocked
so the suite runs offline without real API calls or model downloads.
"""
from __future__ import annotations
from typing import Any
from unittest.mock import MagicMock, patch
import pytest
from codex.models import FormulaChunk
def _mock_open_pdf() -> MagicMock:
"""Return a mock that satisfies ``open(path, 'rb')`` as a context manager."""
mock_file = MagicMock()
mock_file.__enter__ = MagicMock(return_value=mock_file)
mock_file.__exit__ = MagicMock(return_value=False)
mock_open = MagicMock(return_value=mock_file)
return mock_open
class TestMathPixAPIPath:
"""Tests for the MathPix cloud API backend."""
def test_mathpix_200_returns_formulas(self) -> None:
"""extract_formulas routes to MathPix when creds are provided and parses result."""
mmd_content = "\\[\n\\alpha + \\beta\n\\]\n\\[\nE = mc^2\n\\]"
mock_settings = MagicMock()
mock_settings.mathpix_app_id = None
mock_settings.mathpix_app_key = None
mock_settings.pix2tex_fallback = True
with (
patch("codex.parsing.mathpix.get_settings", return_value=mock_settings),
patch("builtins.open", _mock_open_pdf()),
patch("httpx.post") as mock_post,
patch("httpx.get") as mock_get,
patch("time.sleep"),
):
mock_post.return_value = MagicMock(
status_code=200,
json=lambda: {"pdf_id": "abc123"},
)
mock_post.return_value.raise_for_status = MagicMock()
mock_get.return_value = MagicMock(
status_code=200,
text=mmd_content,
)
from codex.parsing.mathpix import _extract_formulas_mathpix
results = _extract_formulas_mathpix("paper.pdf", "app_id", "app_key")
assert len(results) == 2
assert all(isinstance(r, FormulaChunk) for r in results)
assert results[0].raw_latex == "\\[\n\\alpha + \\beta\n\\]"
def test_mathpix_empty_pdf_id_returns_empty(self) -> None:
"""When MathPix returns no pdf_id, result is empty list."""
with (
patch("builtins.open", _mock_open_pdf()),
patch("httpx.post") as mock_post,
patch("time.sleep"),
):
mock_post.return_value = MagicMock(
status_code=200,
json=lambda: {}, # No pdf_id
)
mock_post.return_value.raise_for_status = MagicMock()
from codex.parsing.mathpix import _extract_formulas_mathpix
results = _extract_formulas_mathpix("paper.pdf", "app_id", "app_key")
assert results == []
def test_mathpix_paper_id_from_stem(self) -> None:
"""paper_id in FormulaChunk is derived from the PDF filename stem."""
mmd_content = "\\[\nx^2\n\\]"
with (
patch("builtins.open", _mock_open_pdf()),
patch("httpx.post") as mock_post,
patch("httpx.get") as mock_get,
patch("time.sleep"),
):
mock_post.return_value = MagicMock(
status_code=200,
json=lambda: {"pdf_id": "xyz"},
)
mock_post.return_value.raise_for_status = MagicMock()
mock_get.return_value = MagicMock(status_code=200, text=mmd_content)
from codex.parsing.mathpix import _extract_formulas_mathpix
results = _extract_formulas_mathpix("/tmp/2301.07041.pdf", "id", "key")
assert results[0].paper_id == "2301.07041"
def test_mathpix_page_counter_increments(self) -> None:
"""Page counter increments on MathPix page markers in MMD."""
mmd_content = "\\[\nA\n\\]\n<!-- Page 2 -->\n\\[\nB\n\\]"
with (
patch("builtins.open", _mock_open_pdf()),
patch("httpx.post") as mock_post,
patch("httpx.get") as mock_get,
patch("time.sleep"),
):
mock_post.return_value = MagicMock(
status_code=200,
json=lambda: {"pdf_id": "p1"},
)
mock_post.return_value.raise_for_status = MagicMock()
mock_get.return_value = MagicMock(status_code=200, text=mmd_content)
from codex.parsing.mathpix import _extract_formulas_mathpix
results = _extract_formulas_mathpix("p.pdf", "id", "key")
assert results[0].page == 1
assert results[1].page == 2
def test_mathpix_http_error_propagates(self) -> None:
"""HTTP errors from MathPix are raised (tenacity reraises after retries)."""
import httpx
with (
patch("builtins.open", _mock_open_pdf()),
patch("httpx.post") as mock_post,
):
mock_post.return_value = MagicMock(status_code=401)
mock_post.return_value.raise_for_status.side_effect = httpx.HTTPStatusError(
"401", request=MagicMock(), response=MagicMock()
)
from codex.parsing.mathpix import _extract_formulas_mathpix
with pytest.raises(httpx.HTTPStatusError):
_extract_formulas_mathpix("p.pdf", "id", "key")
class TestPix2TexFallbackPath:
"""Tests for the local pix2tex backend."""
def _make_fitz_page(
self,
blocks: list[Any],
pixmap_samples: bytes = b"\xff" * (30 * 100 * 3),
pixmap_w: int = 100,
pixmap_h: int = 30,
) -> MagicMock:
"""Build a mock fitz page with given text blocks and a fake pixmap."""
mock_pix = MagicMock()
mock_pix.width = pixmap_w
mock_pix.height = pixmap_h
mock_pix.samples = pixmap_samples
mock_page = MagicMock()
mock_page.get_text.return_value = blocks
mock_page.get_pixmap.return_value = mock_pix
return mock_page
def test_pix2tex_fallback_invoked_without_creds(self) -> None:
"""When no MathPix creds, pix2tex fallback is called."""
math_block = (0.0, 0.0, 200.0, 50.0, "∑ α∫β∇γ∂δ∞ε", 0, 0)
mock_settings = MagicMock()
mock_settings.mathpix_app_id = None
mock_settings.mathpix_app_key = None
mock_settings.pix2tex_fallback = True
mock_settings.figures_dir = "/tmp/figs"
mock_model = MagicMock(return_value=r"\sum \alpha")
mock_page = self._make_fitz_page([math_block])
mock_doc = MagicMock()
mock_doc.__len__.return_value = 1
mock_doc.__getitem__ = MagicMock(return_value=mock_page)
with (
patch("codex.parsing.mathpix.get_settings", return_value=mock_settings),
patch("codex.parsing.mathpix._get_pix2tex_model", return_value=mock_model),
patch("fitz.open", return_value=mock_doc),
patch("fitz.Matrix"),
patch("fitz.Rect"),
):
from PIL import Image
with patch.object(Image, "frombytes", return_value=MagicMock()):
from codex.parsing.mathpix import extract_formulas
results = extract_formulas("paper.pdf")
assert len(results) == 1
assert results[0].raw_latex == r"\sum \alpha"
def test_pix2tex_bbox_size_filter(self) -> None:
"""Blocks smaller than MIN_HEIGHT_PT or MIN_WIDTH_PT are skipped."""
tiny_block = (0.0, 0.0, 10.0, 5.0, "∑∫∂∞∇ε", 0, 0) # 10pt wide, 5pt tall
mock_settings = MagicMock()
mock_settings.mathpix_app_id = None
mock_settings.mathpix_app_key = None
mock_settings.pix2tex_fallback = True
mock_model = MagicMock(return_value=r"\sum")
mock_page = self._make_fitz_page([tiny_block])
mock_doc = MagicMock()
mock_doc.__len__.return_value = 1
mock_doc.__getitem__ = MagicMock(return_value=mock_page)
with (
patch("codex.parsing.mathpix.get_settings", return_value=mock_settings),
patch("codex.parsing.mathpix._get_pix2tex_model", return_value=mock_model),
patch("fitz.open", return_value=mock_doc),
patch("fitz.Matrix"),
patch("fitz.Rect"),
):
from codex.parsing.mathpix import extract_formulas
results = extract_formulas("paper.pdf")
assert results == []
def test_pix2tex_math_ratio_filter(self) -> None:
"""Blocks with low math-character ratio are skipped."""
plain_text_block = (0.0, 0.0, 200.0, 50.0, "This is just regular text with no math.", 0, 0)
mock_settings = MagicMock()
mock_settings.mathpix_app_id = None
mock_settings.mathpix_app_key = None
mock_settings.pix2tex_fallback = True
mock_model = MagicMock(return_value=r"\alpha")
mock_page = self._make_fitz_page([plain_text_block])
mock_doc = MagicMock()
mock_doc.__len__.return_value = 1
mock_doc.__getitem__ = MagicMock(return_value=mock_page)
with (
patch("codex.parsing.mathpix.get_settings", return_value=mock_settings),
patch("codex.parsing.mathpix._get_pix2tex_model", return_value=mock_model),
patch("fitz.open", return_value=mock_doc),
patch("fitz.Matrix"),
patch("fitz.Rect"),
):
from codex.parsing.mathpix import extract_formulas
results = extract_formulas("paper.pdf")
assert results == []
def test_pix2tex_exception_swallowed(self) -> None:
"""If pix2tex raises, the block is skipped (no exception propagates)."""
math_block = (0.0, 0.0, 200.0, 50.0, "∑ α∫β∇γ∂δ∞ε", 0, 0)
mock_settings = MagicMock()
mock_settings.mathpix_app_id = None
mock_settings.mathpix_app_key = None
mock_settings.pix2tex_fallback = True
mock_model = MagicMock(side_effect=RuntimeError("GPU OOM"))
mock_page = self._make_fitz_page([math_block])
mock_doc = MagicMock()
mock_doc.__len__.return_value = 1
mock_doc.__getitem__ = MagicMock(return_value=mock_page)
with (
patch("codex.parsing.mathpix.get_settings", return_value=mock_settings),
patch("codex.parsing.mathpix._get_pix2tex_model", return_value=mock_model),
patch("fitz.open", return_value=mock_doc),
patch("fitz.Matrix"),
patch("fitz.Rect"),
):
from PIL import Image
with patch.object(Image, "frombytes", return_value=MagicMock()):
from codex.parsing.mathpix import extract_formulas
results = extract_formulas("paper.pdf") # Must not raise
assert results == []
class TestRouteSelection:
"""Tests for extract_formulas route selection logic."""
def test_mathpix_selected_when_creds_present(self) -> None:
"""extract_formulas calls MathPix backend when both creds are set."""
mock_settings = MagicMock()
mock_settings.mathpix_app_id = "my_id"
mock_settings.mathpix_app_key = "my_key"
mock_settings.pix2tex_fallback = True
with (
patch("codex.parsing.mathpix.get_settings", return_value=mock_settings),
patch(
"codex.parsing.mathpix._extract_formulas_mathpix", return_value=[]
) as mock_mathpix,
patch(
"codex.parsing.mathpix._extract_formulas_pix2tex", return_value=[]
) as mock_pix2tex,
):
from codex.parsing.mathpix import extract_formulas
extract_formulas("paper.pdf")
mock_mathpix.assert_called_once_with("paper.pdf", "my_id", "my_key")
mock_pix2tex.assert_not_called()
def test_pix2tex_selected_without_creds(self) -> None:
"""extract_formulas calls pix2tex when no MathPix creds are set."""
mock_settings = MagicMock()
mock_settings.mathpix_app_id = None
mock_settings.mathpix_app_key = None
mock_settings.pix2tex_fallback = True
with (
patch("codex.parsing.mathpix.get_settings", return_value=mock_settings),
patch(
"codex.parsing.mathpix._extract_formulas_mathpix", return_value=[]
) as mock_mathpix,
patch(
"codex.parsing.mathpix._extract_formulas_pix2tex", return_value=[]
) as mock_pix2tex,
):
from codex.parsing.mathpix import extract_formulas
extract_formulas("paper.pdf")
mock_pix2tex.assert_called_once_with("paper.pdf")
mock_mathpix.assert_not_called()
def test_empty_string_creds_treated_as_absent(self) -> None:
"""Empty string credentials fall through to pix2tex (not MathPix)."""
mock_settings = MagicMock()
mock_settings.mathpix_app_id = ""
mock_settings.mathpix_app_key = ""
mock_settings.pix2tex_fallback = True
with (
patch("codex.parsing.mathpix.get_settings", return_value=mock_settings),
patch(
"codex.parsing.mathpix._extract_formulas_mathpix", return_value=[]
) as mock_mathpix,
patch(
"codex.parsing.mathpix._extract_formulas_pix2tex", return_value=[]
) as mock_pix2tex,
):
from codex.parsing.mathpix import extract_formulas
extract_formulas("paper.pdf")
mock_pix2tex.assert_called_once()
mock_mathpix.assert_not_called()
def test_formula_extraction_disabled_when_fallback_false(self) -> None:
"""No extraction when pix2tex_fallback=False and no creds."""
mock_settings = MagicMock()
mock_settings.mathpix_app_id = None
mock_settings.mathpix_app_key = None
mock_settings.pix2tex_fallback = False
with (
patch("codex.parsing.mathpix.get_settings", return_value=mock_settings),
patch(
"codex.parsing.mathpix._extract_formulas_mathpix", return_value=[]
) as mock_mathpix,
patch(
"codex.parsing.mathpix._extract_formulas_pix2tex", return_value=[]
) as mock_pix2tex,
):
from codex.parsing.mathpix import extract_formulas
results = extract_formulas("paper.pdf")
assert results == []
mock_mathpix.assert_not_called()
mock_pix2tex.assert_not_called()

View File

@@ -0,0 +1,70 @@
"""Tests for codex.parsing.nougat."""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import httpx
import pytest
from codex.parsing.nougat import pdf_to_markdown
class _FakeResponse:
"""Minimal stand-in for httpx.Response."""
def __init__(self, text: str, status_code: int = 200) -> None:
self.text = text
self.status_code = status_code
def raise_for_status(self) -> None:
if self.status_code >= 400:
raise httpx.HTTPStatusError(
"error",
request=MagicMock(),
response=MagicMock(status_code=self.status_code),
)
class TestPdfToMarkdown:
def test_success_returns_response_text(self, tmp_path: pytest.TempdirFactory) -> None:
pdf = tmp_path / "paper.pdf" # type: ignore[operator]
pdf.write_bytes(b"%PDF fake")
expected = "# Title\nContent"
fake_response = _FakeResponse(expected)
with patch("httpx.Client") as mock_client_cls:
mock_client = MagicMock()
mock_client_cls.return_value.__enter__.return_value = mock_client
mock_client.post.return_value = fake_response
result = pdf_to_markdown(str(pdf), nougat_url="http://localhost:8080")
assert result == expected
mock_client.post.assert_called_once()
call_kwargs = mock_client.post.call_args
assert "/predict" in call_kwargs[0][0]
def test_connect_error_retried_exactly_twice(self, tmp_path: pytest.TempdirFactory) -> None:
"""ConnectError must trigger 2 retries (3 total attempts)."""
pdf = tmp_path / "paper.pdf" # type: ignore[operator]
pdf.write_bytes(b"%PDF fake")
call_count = 0
def _raising_post(*args: object, **kwargs: object) -> _FakeResponse:
nonlocal call_count
call_count += 1
raise httpx.ConnectError("refused")
with patch("httpx.Client") as mock_client_cls:
mock_client = MagicMock()
mock_client_cls.return_value.__enter__.return_value = mock_client
mock_client.post.side_effect = _raising_post
with pytest.raises(httpx.ConnectError):
pdf_to_markdown(str(pdf), nougat_url="http://localhost:8080")
# tenacity: stop_after_attempt(3) → 1 original + 2 retries = 3 calls
assert call_count == 3

112
tests/parsing/test_tex.py Normal file
View File

@@ -0,0 +1,112 @@
"""Tests for codex.parsing.tex."""
from __future__ import annotations
import pytest
from codex.parsing.tex import chunk_text, extract_sections, latex_to_text
class TestExtractSections:
def test_two_sections_returned(self) -> None:
latex = r"\section{Intro}" + "\nHello \\cite{foo} world\n"
latex += r"\section{Method}" + "\nResult"
sections = extract_sections(latex)
assert len(sections) == 2
def test_section_titles(self) -> None:
latex = r"\section{Intro}" + "\nHello\n" + r"\section{Method}" + "\nResult"
sections = extract_sections(latex)
assert sections[0][0] == "Intro"
assert sections[1][0] == "Method"
def test_cite_removed_from_text(self) -> None:
latex = r"\section{Intro}" + "\nHello \\cite{foo} world\n"
latex += r"\section{Method}" + "\nResult"
sections = extract_sections(latex)
body = sections[0][1]
assert r"\cite" not in body
assert "foo" not in body
def test_no_sections_returns_empty(self) -> None:
assert extract_sections("No sections here at all.") == []
def test_subsection_recognised(self) -> None:
latex = r"\subsection{Background}" + "\nSome text."
sections = extract_sections(latex)
assert len(sections) == 1
assert sections[0][0] == "Background"
def test_comments_stripped(self) -> None:
latex = r"\section{A}" + "\nHello % this is a comment\nWorld"
sections = extract_sections(latex)
assert "%" not in sections[0][1]
assert "comment" not in sections[0][1]
def test_math_stripped(self) -> None:
latex = r"\section{A}" + "\nLet $x = 1$ and $$y = 2$$ be numbers."
sections = extract_sections(latex)
body = sections[0][1]
assert "$" not in body
class TestChunkText:
@pytest.fixture()
def long_text(self) -> str:
# Build a 600-word string with real sentences
sentence = "The quick brown fox jumps over the lazy dog near the river. "
words_per_sentence = len(sentence.split())
repeats = (600 // words_per_sentence) + 1
return (sentence * repeats).strip()
def test_at_least_two_chunks(self, long_text: str) -> None:
chunks = chunk_text(long_text, size=512, overlap=64)
assert len(chunks) >= 2
def test_each_chunk_within_size_bound(self, long_text: str) -> None:
size, overlap = 512, 64
chunks = chunk_text(long_text, size=size, overlap=overlap)
for chunk in chunks:
word_count = len(chunk.split())
assert word_count <= size + overlap, (
f"Chunk has {word_count} words, expected <= {size + overlap}"
)
def test_empty_text_returns_empty_list(self) -> None:
assert chunk_text("") == []
def test_short_text_returns_single_chunk(self) -> None:
chunks = chunk_text("Hello world.", size=512, overlap=64)
assert len(chunks) == 1
def test_chunks_cover_all_content(self) -> None:
# First chunk must start with first word, last chunk must end with last word
text = " ".join(f"word{i}" for i in range(600))
chunks = chunk_text(text, size=200, overlap=50)
assert chunks[0].startswith("word0")
assert chunks[-1].endswith("word599")
class TestLatexToText:
def test_no_percent_in_output(self) -> None:
latex = r"\section{A}" + "\nSome % comment\ntext."
result = latex_to_text(latex)
assert "%" not in result
def test_no_cite_in_output(self) -> None:
latex = r"\section{A}" + "\nHello \\cite{bar} world."
result = latex_to_text(latex)
assert r"\cite" not in result
assert "bar" not in result
def test_returns_string(self) -> None:
latex = r"\section{A}" + "\nSimple text."
result = latex_to_text(latex)
assert isinstance(result, str)
assert len(result) > 0
def test_no_section_fallback(self) -> None:
latex = r"Some \cite{foo} text with % comment"
result = latex_to_text(latex)
assert r"\cite" not in result
assert "%" not in result

View File

View File

@@ -0,0 +1,209 @@
"""Tests for codex.provenance — @cite scan, code_links CRUD, bib export.
All DB access is mocked; no real PostgreSQL server required.
"""
from __future__ import annotations
from contextlib import contextmanager
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from unittest.mock import MagicMock, patch
from codex.models import CodeLink
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_conn_cm(mock_conn: MagicMock) -> Any:
"""Return a context-manager factory that yields *mock_conn*."""
@contextmanager # type: ignore[arg-type]
def _cm() -> Any:
yield mock_conn
return _cm
def _dict_row(**kwargs: Any) -> dict[str, Any]:
return dict(kwargs)
# ---------------------------------------------------------------------------
# 1. test_scan_cite_tags
# ---------------------------------------------------------------------------
def test_scan_cite_tags(tmp_path: Path) -> None:
"""A .cpp file with @cite produces correct dict entries."""
from codex.provenance import scan_cite_tags
src_file = tmp_path / "algo.cpp"
src_file.write_text(
"// Helper function\n"
"void compute_hodge() {\n"
" // @cite somepaper2023\n"
" do_stuff();\n"
"}\n",
encoding="utf-8",
)
results = scan_cite_tags(str(tmp_path))
assert len(results) == 1
hit = results[0]
assert hit["bibkey"] == "somepaper2023"
assert hit["line"] == "3"
assert hit["file"] == "algo.cpp"
# symbol extracted from "compute_hodge()" on the line above
assert hit["symbol"] == "compute_hodge"
# ---------------------------------------------------------------------------
# 2. test_scan_cite_tags_no_files
# ---------------------------------------------------------------------------
def test_scan_cite_tags_no_files(tmp_path: Path) -> None:
"""Empty directory returns an empty list."""
from codex.provenance import scan_cite_tags
result = scan_cite_tags(str(tmp_path))
assert result == []
# ---------------------------------------------------------------------------
# 3. test_add_code_link
# ---------------------------------------------------------------------------
def test_add_code_link() -> None:
"""Mock conn, verify INSERT is issued and returned CodeLink has id from RETURNING."""
from codex.provenance import add_code_link
now = datetime(2026, 6, 5, tzinfo=UTC)
mock_conn = MagicMock()
mock_conn.execute.return_value.fetchone.return_value = _dict_row(id=42, added_at=now)
with patch("codex.provenance.get_conn", side_effect=_make_conn_cm(mock_conn)):
link = add_code_link(
symbol="dec::hodge_star",
paper_id="2301.07041",
role="implements Thm 3.2",
note="core algorithm",
)
assert isinstance(link, CodeLink)
assert link.id == 42
assert link.added_at == now
assert link.symbol == "dec::hodge_star"
assert link.paper_id == "2301.07041"
assert link.role == "implements Thm 3.2"
assert link.note == "core algorithm"
call_args = mock_conn.execute.call_args
sql: str = call_args[0][0]
assert "INSERT INTO code_links" in sql
assert "RETURNING" in sql
mock_conn.commit.assert_called_once()
# ---------------------------------------------------------------------------
# 4. test_list_code_links_all
# ---------------------------------------------------------------------------
def test_list_code_links_all() -> None:
"""No filter → all rows returned as CodeLink list."""
from codex.provenance import list_code_links
now = datetime(2026, 6, 5, tzinfo=UTC)
mock_conn = MagicMock()
mock_conn.execute.return_value.fetchall.return_value = [
_dict_row(id=1, symbol="foo::bar", paper_id="W111", role="uses", note=None, added_at=now),
_dict_row(id=2, symbol="baz::qux", paper_id=None, role=None, note="ref", added_at=now),
]
with patch("codex.provenance.get_conn", side_effect=_make_conn_cm(mock_conn)):
links = list_code_links()
assert len(links) == 2
assert all(isinstance(lnk, CodeLink) for lnk in links)
assert links[0].symbol == "foo::bar"
assert links[0].id == 1
assert links[1].symbol == "baz::qux"
assert links[1].paper_id is None
# No WHERE clause in the SQL
call_args = mock_conn.execute.call_args
sql: str = call_args[0][0]
assert "WHERE" not in sql
# ---------------------------------------------------------------------------
# 5. test_list_code_links_filtered
# ---------------------------------------------------------------------------
def test_list_code_links_filtered() -> None:
"""Filter by paper_id → WHERE clause applied and only matching rows returned."""
from codex.provenance import list_code_links
now = datetime(2026, 6, 5, tzinfo=UTC)
mock_conn = MagicMock()
mock_conn.execute.return_value.fetchall.return_value = [
_dict_row(
id=3,
symbol="some::func",
paper_id="2301.07041",
role=None,
note=None,
added_at=now,
),
]
with patch("codex.provenance.get_conn", side_effect=_make_conn_cm(mock_conn)):
links = list_code_links(paper_id="2301.07041")
assert len(links) == 1
assert links[0].paper_id == "2301.07041"
call_args = mock_conn.execute.call_args
sql: str = call_args[0][0]
params: dict[str, Any] = call_args[0][1]
assert "WHERE paper_id" in sql
assert params["paper_id"] == "2301.07041"
# ---------------------------------------------------------------------------
# 6. test_export_bib_format
# ---------------------------------------------------------------------------
def test_export_bib_format() -> None:
"""Mock returns one paper row; verify valid BibTeX string output."""
from codex.provenance import export_bib
mock_conn = MagicMock()
mock_conn.execute.return_value.fetchall.return_value = [
_dict_row(
id="2301.07041",
bibkey="smith2023conformal",
title="Conformal Approaches to Laplacian Eigenmaps",
authors=["Alice Smith", "Bob Jones"],
year=2023,
),
]
with patch("codex.provenance.get_conn", side_effect=_make_conn_cm(mock_conn)):
bib = export_bib()
assert "@article{smith2023conformal," in bib
assert "title = {Conformal Approaches to Laplacian Eigenmaps}" in bib
assert "author = {Alice Smith and Bob Jones}" in bib
assert "year = {2023}" in bib
assert "note = {2301.07041}" in bib

View File

View File

@@ -0,0 +1,245 @@
"""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,
classify_section,
filter_chunks,
is_quality_chunk,
run_quality_pass,
)
# ---------------------------------------------------------------------------
# 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

View File

View File

@@ -0,0 +1,35 @@
"""Smoke tests for codex.config.Settings."""
from __future__ import annotations
from codex.config import Settings, get_settings
def test_settings_defaults() -> None:
"""Settings() is constructable without env vars; expected defaults are set."""
s = Settings()
assert s.database_url == "postgresql://researcher:change_me@localhost:5432/papers"
assert s.embedding_model == "BAAI/bge-m3"
assert s.embedding_dim == 1024
def test_settings_env_override(monkeypatch: object) -> None:
"""DATABASE_URL env var overrides the default database_url."""
import pytest
mp: pytest.MonkeyPatch = monkeypatch # type: ignore[assignment]
mp.setenv("DATABASE_URL", "postgresql://x:y@h:5432/db")
s = Settings()
assert s.database_url == "postgresql://x:y@h:5432/db"
def test_get_settings_is_singleton() -> None:
"""get_settings() returns the same object on repeated calls; new object after cache_clear."""
get_settings.cache_clear()
first = get_settings()
second = get_settings()
assert first is second
get_settings.cache_clear()
third = get_settings()
assert third is not first

35
tests/scaffold/test_db.py Normal file
View File

@@ -0,0 +1,35 @@
"""Smoke tests for codex.db — get_conn and apply_schema."""
from __future__ import annotations
from unittest.mock import MagicMock, patch
def test_get_conn_context_manager() -> None:
"""get_conn() yields a context manager that calls psycopg.connect and cleans up."""
from codex.db import get_conn
mock_conn = MagicMock()
mock_connect = MagicMock()
# psycopg.connect is itself used as a context manager inside get_conn
mock_connect.return_value.__enter__ = MagicMock(return_value=mock_conn)
mock_connect.return_value.__exit__ = MagicMock(return_value=False)
with patch("codex.db.psycopg.connect", mock_connect), get_conn() as conn:
assert conn is mock_conn
mock_connect.assert_called_once()
def test_apply_schema_executes_sql() -> None:
"""apply_schema(conn) calls conn.execute and conn.commit."""
from codex.db import apply_schema
mock_conn = MagicMock()
# Provide a fake schema.sql so Path.read_text succeeds
with patch("codex.db.Path.read_text", return_value="CREATE TABLE IF NOT EXISTS test ();"):
apply_schema(mock_conn)
mock_conn.execute.assert_called_once()
mock_conn.commit.assert_called_once()

View File

@@ -0,0 +1,34 @@
"""Smoke tests for codex.models dataclasses."""
from __future__ import annotations
from codex.models import Chunk, Citation, CodeLink, Paper
def test_paper_defaults() -> None:
"""Paper(id=..., title=...) has correct optional-field defaults."""
p = Paper(id="2301.00001", title="T")
assert p.openalex_id is None
assert p.authors == []
assert p.year is None
def test_chunk_required_fields() -> None:
"""Chunk(paper_id=..., ord=..., content=...) has id=None and embedding=None."""
c = Chunk(paper_id="x", ord=0, content="c")
assert c.id is None
assert c.embedding is None
def test_citation_fields() -> None:
"""Citation(citing_id=..., cited_id=...) has context=None."""
cit = Citation(citing_id="a", cited_id="b")
assert cit.context is None
def test_codelink_fields() -> None:
"""CodeLink(symbol=...) has paper_id=None, role=None, and id=None."""
cl = CodeLink(symbol="foo")
assert cl.paper_id is None
assert cl.role is None
assert cl.id is None

View File

140
tests/sources/test_arxiv.py Normal file
View File

@@ -0,0 +1,140 @@
"""Tests for codex.sources.arxiv."""
from __future__ import annotations
import io
import tarfile
import httpx
import pytest
from codex.sources import arxiv
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_tar_gz(files: dict[str, bytes]) -> bytes:
"""Build an in-memory .tar.gz archive from a dict of {name: content}."""
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w:gz") as tf:
for name, content in files.items():
info = tarfile.TarInfo(name=name)
info.size = len(content)
tf.addfile(info, io.BytesIO(content))
return buf.getvalue()
# ---------------------------------------------------------------------------
# fetch_source — success: in-memory tar.gz with one .tex
# ---------------------------------------------------------------------------
def test_fetch_source_returns_latex(monkeypatch: pytest.MonkeyPatch) -> None:
"""fetch_source extracts and returns the LaTeX content from a .tar.gz."""
latex_content = b"\\documentclass{article}\n\\begin{document}\nHello world\n\\end{document}"
tar_bytes = _make_tar_gz({"main.tex": latex_content})
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 "\\documentclass" in result
assert "Hello world" in result
# ---------------------------------------------------------------------------
# fetch_source — 404 → None
# ---------------------------------------------------------------------------
def test_fetch_source_404_returns_none(monkeypatch: pytest.MonkeyPatch) -> None:
"""A 404 response should return None."""
def mock_get(
url: str,
*,
timeout: int = 60,
follow_redirects: bool = True,
) -> httpx.Response:
return httpx.Response(404)
monkeypatch.setattr(httpx, "get", mock_get)
result = arxiv.fetch_source("nonexistent-id")
assert result is None
# ---------------------------------------------------------------------------
# fetch_source — no .tex in archive → None
# ---------------------------------------------------------------------------
def test_fetch_source_no_tex_returns_none(monkeypatch: pytest.MonkeyPatch) -> None:
"""If the archive contains no .tex files, return None."""
tar_bytes = _make_tar_gz({"README.md": b"# Paper\nNo LaTeX here."})
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 None
# ---------------------------------------------------------------------------
# fetch_source — fallback to largest .tex when no \documentclass
# ---------------------------------------------------------------------------
def test_fetch_source_fallback_to_largest_tex(monkeypatch: pytest.MonkeyPatch) -> None:
"""When no file has \\documentclass, the largest .tex is returned."""
small_tex = b"% small file\n\\section{Intro}"
large_tex = b"% large file\n" + b"x" * 500
tar_bytes = _make_tar_gz({"small.tex": small_tex, "large.tex": large_tex})
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 "large file" in result
# ---------------------------------------------------------------------------
# fetch_pdf_url — pure computation, no mock needed
# ---------------------------------------------------------------------------
def test_fetch_pdf_url_pure_computation() -> None:
"""fetch_pdf_url returns the correct URL without making any HTTP request."""
url = arxiv.fetch_pdf_url("2301.07041")
assert url == "https://arxiv.org/pdf/2301.07041.pdf"
url2 = arxiv.fetch_pdf_url("1234.56789")
assert url2 == "https://arxiv.org/pdf/1234.56789.pdf"

View File

@@ -0,0 +1,200 @@
"""Tests for codex.sources.openalex."""
from __future__ import annotations
import httpx
import pytest
from codex.models import Citation, Paper
from codex.sources import openalex
from codex.sources.openalex import _resolve_id
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
_SAMPLE_WORK = {
"id": "https://openalex.org/W2741809807",
"doi": "10.1145/3592430",
"title": "Conformal Prediction: A Review",
"publication_year": 2023,
"authorships": [
{"author": {"display_name": "Alice Smith"}},
{"author": {"display_name": "Bob Jones"}},
],
"abstract_inverted_index": {
"Conformal": [0],
"prediction": [1],
"is": [2],
"great": [3],
},
"referenced_works": [
"https://openalex.org/W1",
"https://openalex.org/W2",
],
}
# ---------------------------------------------------------------------------
# _resolve_id
# ---------------------------------------------------------------------------
def test_resolve_doi() -> None:
assert _resolve_id("10.1145/3592430") == "doi:10.1145/3592430"
def test_resolve_arxiv() -> None:
# arXiv IDs resolve to their arXiv DOI (the arxiv: path 404s on OpenAlex).
assert _resolve_id("2301.07041") == "doi:10.48550/arXiv.2301.07041"
def test_resolve_arxiv_legacy() -> None:
# Legacy category/number IDs (pre-2007) resolve the same way.
assert _resolve_id("math/0603097") == "doi:10.48550/arXiv.math/0603097"
def test_resolve_arxiv_prefix_stripped() -> None:
# An explicit arxiv: prefix is stripped before building the arXiv DOI.
assert _resolve_id("arxiv:2301.07041") == "doi:10.48550/arXiv.2301.07041"
def test_resolve_w_id_unchanged() -> None:
assert _resolve_id("W2741809807") == "W2741809807"
def test_resolve_prefixed_unchanged() -> None:
assert _resolve_id("doi:10.1145/x") == "doi:10.1145/x"
# ---------------------------------------------------------------------------
# fetch_paper — success
# ---------------------------------------------------------------------------
def test_fetch_paper_success(monkeypatch: pytest.MonkeyPatch) -> None:
"""Successful fetch maps all fields to Paper correctly."""
def mock_get(url: str, params: dict[str, str] | None = None) -> httpx.Response:
assert "doi:10.1145/3592430" in url
return httpx.Response(200, json=_SAMPLE_WORK)
monkeypatch.setattr(openalex, "_get", mock_get)
paper = openalex.fetch_paper("10.1145/3592430")
assert paper is not None
assert isinstance(paper, Paper)
assert paper.title == "Conformal Prediction: A Review"
assert paper.year == 2023
assert paper.authors == ["Alice Smith", "Bob Jones"]
assert paper.abstract == "Conformal prediction is great"
assert paper.openalex_id == "https://openalex.org/W2741809807"
def test_fetch_paper_arxiv_id_prefixed(monkeypatch: pytest.MonkeyPatch) -> None:
"""Bare arXiv IDs are resolved to their arXiv DOI in the URL."""
called_url: list[str] = []
def mock_get(url: str, params: dict[str, str] | None = None) -> httpx.Response:
called_url.append(url)
return httpx.Response(200, json=_SAMPLE_WORK)
monkeypatch.setattr(openalex, "_get", mock_get)
openalex.fetch_paper("2301.07041")
assert called_url and "doi:10.48550/arXiv.2301.07041" in called_url[0]
# ---------------------------------------------------------------------------
# fetch_paper — 404 → None
# ---------------------------------------------------------------------------
def test_fetch_paper_404_returns_none(monkeypatch: pytest.MonkeyPatch) -> None:
"""A 404 response should return None without raising."""
def mock_get(url: str, params: dict[str, str] | None = None) -> httpx.Response:
raise httpx.HTTPStatusError(
"Not Found",
request=httpx.Request("GET", url),
response=httpx.Response(404, request=httpx.Request("GET", url)),
)
monkeypatch.setattr(openalex, "_get", mock_get)
result = openalex.fetch_paper("nonexistent-id")
assert result is None
# ---------------------------------------------------------------------------
# fetch_paper — 429 retry then success
# ---------------------------------------------------------------------------
def test_fetch_paper_retry_on_429(monkeypatch: pytest.MonkeyPatch) -> None:
"""On 429×3 then 200, returns 1 Paper after 3 failed attempts."""
attempt = [0]
def controlled_get(url: str, params: dict[str, str] | None = None) -> httpx.Response:
attempt[0] += 1
if attempt[0] <= 3:
resp = httpx.Response(429, request=httpx.Request("GET", url))
raise httpx.HTTPStatusError(
"Too Many Requests",
request=httpx.Request("GET", url),
response=resp,
)
return httpx.Response(200, json=_SAMPLE_WORK)
monkeypatch.setattr(openalex, "_get", controlled_get)
result: Paper | None = None
errors = 0
for _ in range(5):
try:
result = openalex.fetch_paper("10.1145/3592430")
break
except httpx.HTTPStatusError:
errors += 1
assert result is not None
assert isinstance(result, Paper)
assert errors == 3
# ---------------------------------------------------------------------------
# fetch_citations — uses referenced_works field
# ---------------------------------------------------------------------------
def test_fetch_citations(monkeypatch: pytest.MonkeyPatch) -> None:
"""fetch_citations reads referenced_works from the work object."""
def mock_get(url: str, params: dict[str, str] | None = None) -> httpx.Response:
return httpx.Response(200, json=_SAMPLE_WORK)
monkeypatch.setattr(openalex, "_get", mock_get)
oa_id = "https://openalex.org/W2741809807"
citations = openalex.fetch_citations(oa_id)
assert len(citations) == 2
assert all(isinstance(c, Citation) for c in citations)
assert citations[0].citing_id == oa_id
assert citations[0].cited_id == "https://openalex.org/W1"
assert citations[1].cited_id == "https://openalex.org/W2"
def test_fetch_citations_404_returns_empty(monkeypatch: pytest.MonkeyPatch) -> None:
"""404 on the work fetch returns an empty list."""
def mock_get(url: str, params: dict[str, str] | None = None) -> httpx.Response:
raise httpx.HTTPStatusError(
"Not Found",
request=httpx.Request("GET", url),
response=httpx.Response(404, request=httpx.Request("GET", url)),
)
monkeypatch.setattr(openalex, "_get", mock_get)
assert openalex.fetch_citations("W999") == []

View File

@@ -0,0 +1,126 @@
"""Tests for codex.sources.semanticscholar."""
from __future__ import annotations
import httpx
import pytest
from codex.models import Citation
from codex.sources import semanticscholar
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
_SAMPLE_REFS_RESPONSE = {
"data": [
{
"citedPaper": {
"paperId": "abc123",
"externalIds": {"DOI": "10.1000/xyz1"},
},
"contexts": ["This approach was first described in [1]."],
},
{
"citedPaper": {
"paperId": "def456",
"externalIds": {"ArXiv": "2301.07041"},
},
"contexts": [],
},
]
}
_SAMPLE_RECS_RESPONSE = {
"recommendedPapers": [
{"paperId": "rec_id_1"},
{"paperId": "rec_id_2"},
{"paperId": "rec_id_3"},
]
}
# ---------------------------------------------------------------------------
# fetch_references
# ---------------------------------------------------------------------------
def test_fetch_references_returns_citations(monkeypatch: pytest.MonkeyPatch) -> None:
"""fetch_references maps API response to Citation objects correctly."""
def mock_get(url: str, params: dict[str, object] | None = None) -> httpx.Response:
return httpx.Response(200, json=_SAMPLE_REFS_RESPONSE)
monkeypatch.setattr(semanticscholar, "_get", mock_get)
citations = semanticscholar.fetch_references("abc123")
assert len(citations) == 2
assert all(isinstance(c, Citation) for c in citations)
# First citation uses DOI and has context
assert citations[0].citing_id == "abc123"
assert citations[0].cited_id == "10.1000/xyz1"
assert citations[0].context == "This approach was first described in [1]."
# Second citation falls back to ArXiv ID, no context
assert citations[1].citing_id == "abc123"
assert citations[1].cited_id == "2301.07041"
assert citations[1].context is None
def test_fetch_references_404_returns_empty(monkeypatch: pytest.MonkeyPatch) -> None:
"""A 404 response should return an empty list."""
def mock_get(url: str, params: dict[str, object] | None = None) -> httpx.Response:
raise httpx.HTTPStatusError(
"Not Found",
request=httpx.Request("GET", url),
response=httpx.Response(404, request=httpx.Request("GET", url)),
)
monkeypatch.setattr(semanticscholar, "_get", mock_get)
result = semanticscholar.fetch_references("nonexistent")
assert result == []
# ---------------------------------------------------------------------------
# fetch_recommendations
# ---------------------------------------------------------------------------
def test_fetch_recommendations_returns_list_of_str(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""fetch_recommendations returns a flat list of paperId strings."""
def mock_get(url: str, params: dict[str, object] | None = None) -> httpx.Response:
return httpx.Response(200, json=_SAMPLE_RECS_RESPONSE)
monkeypatch.setattr(semanticscholar, "_get", mock_get)
result = semanticscholar.fetch_recommendations("abc123", limit=3)
assert isinstance(result, list)
assert len(result) == 3
assert all(isinstance(r, str) for r in result)
assert result == ["rec_id_1", "rec_id_2", "rec_id_3"]
def test_fetch_recommendations_404_returns_empty(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A 404 response should return an empty list."""
def mock_get(url: str, params: dict[str, object] | None = None) -> httpx.Response:
raise httpx.HTTPStatusError(
"Not Found",
request=httpx.Request("GET", url),
response=httpx.Response(404, request=httpx.Request("GET", url)),
)
monkeypatch.setattr(semanticscholar, "_get", mock_get)
result = semanticscholar.fetch_recommendations("nonexistent")
assert result == []

View File

124
tests/synthesis/conftest.py Normal file
View File

@@ -0,0 +1,124 @@
"""Shared synthesis fixtures — keep DB/LLM/embed fully mocked."""
from __future__ import annotations
from pathlib import Path
from typing import Any
from unittest.mock import MagicMock
import pytest
# Shared corpus fixture used across the test suite.
FIXTURE_CHUNKS: list[dict[str, Any]] = [
{
"id": 1,
"paper_id": "springborn-2008",
"ord": 16,
"content": (
"For an ideal tetrahedron with dihedral angles the hyperbolic volume is "
"V = L(gamma1) + L(gamma2) + L(gamma3). "
"The volume function V0 is strictly concave on the angle domain."
),
"bibkey": "Springborn2008",
},
{
"id": 2,
"paper_id": "bps-2015",
"ord": 3,
"content": (
"A discrete conformal map is defined by logarithmic scale factors u_i "
"at each vertex such that the edge lengths satisfy a compatibility condition."
),
"bibkey": "BobenkoPinkallSpringborn2015",
},
{
"id": 3,
"paper_id": "luo-2004",
"ord": 0,
"content": (
"The combinatorial Yamabe flow drives the edge lengths towards a target "
"curvature on a triangulated surface."
),
"bibkey": "Luo2004",
},
]
class StubLLM:
"""Deterministic mock LLM that returns a preset response."""
def __init__(self, response: str | list[str]) -> None:
if isinstance(response, str):
self._responses = [response]
else:
self._responses = list(response)
self._calls: list[tuple[str, str]] = []
def generate(self, prompt: str, model: str) -> str:
self._calls.append((prompt, model))
if not self._responses:
return ""
if len(self._responses) == 1:
return self._responses[0]
return self._responses.pop(0)
@property
def calls(self) -> list[tuple[str, str]]:
return list(self._calls)
@pytest.fixture
def stub_llm() -> StubLLM:
"""Default stub returning empty string (override per test as needed)."""
return StubLLM("")
@pytest.fixture
def fixture_chunks() -> list[dict[str, Any]]:
return [dict(c) for c in FIXTURE_CHUNKS]
@pytest.fixture
def mock_retrieve(monkeypatch: pytest.MonkeyPatch) -> None:
"""Patch _retrieve_chunks to return deterministic fixture chunks (no DB)."""
monkeypatch.setattr(
"codex.synthesis._retrieve_chunks",
lambda queries, top_k: [dict(c) for c in FIXTURE_CHUNKS],
)
@pytest.fixture
def mock_paper_titles(monkeypatch: pytest.MonkeyPatch) -> None:
"""Patch _list_paper_titles to return a deterministic three-paper corpus."""
monkeypatch.setattr(
"codex.synthesis._list_paper_titles",
lambda db_conn: [
("Springborn2008", "A variational principle for weighted Delaunay triangulations"),
(
"BobenkoPinkallSpringborn2015",
"Discrete conformal maps and ideal hyperbolic polyhedra",
),
("Luo2004", "Combinatorial Yamabe Flow on Surfaces"),
],
)
@pytest.fixture
def mock_settings(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path:
"""Settings stub pointing leads_dir at tmp_path/leads."""
from codex.config import Settings
leads_path = tmp_path / "leads"
mock = MagicMock(spec=Settings)
mock.leads_dir = str(leads_path)
mock.synthesis_llm_model = "test-model"
mock.synthesis_llm_url = None
mock.ollama_base_url = "http://localhost:11434"
mock.synthesis_top_k = 6
mock.synthesis_min_grounded_ratio = 0.5
mock.synthesis_gap_min_coverage = 2
mock.wiki_dir = str(tmp_path / "wiki")
monkeypatch.setattr("codex.synthesis.get_settings", lambda: mock)
return leads_path

179
tests/synthesis/test_cli.py Normal file
View File

@@ -0,0 +1,179 @@
"""Tests for the ``codex synthesis`` CLI command group."""
from __future__ import annotations
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from typer.testing import CliRunner
from codex.cli import app
from codex.synthesis import Lead, Provenance
runner = CliRunner()
def _grounded_lead(lead_id: str = "L-0001") -> Lead:
return Lead(
id=lead_id,
kind="connection",
title="Connection — X ⇄ Y",
body="Some grounded body. [X2020 #chunk 1]",
provenance=[Provenance(bibkey="X2020", locator="chunk 1")],
confidence=0.8,
)
def _conjecture(lead_id: str = "L-0002") -> Lead:
return Lead(
id=lead_id,
kind="conjecture",
title="Bold guess",
body="Hypothetical.",
provenance=[Provenance(bibkey="X2020", locator="chunk 1")],
confidence=0.0,
suggested_validation="Run a falsification spike.",
)
@pytest.fixture
def isolated_leads_dir(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path:
"""Point Settings.leads_dir at tmp_path/leads for CLI tests."""
from codex.config import Settings
leads_path = tmp_path / "leads"
settings = MagicMock(spec=Settings)
settings.leads_dir = str(leads_path)
settings.synthesis_llm_model = "test-model"
settings.synthesis_llm_url = None
settings.ollama_base_url = "http://localhost:11434"
settings.synthesis_top_k = 6
settings.synthesis_min_grounded_ratio = 0.5
settings.synthesis_gap_min_coverage = 2
monkeypatch.setattr("codex.cli.get_settings", lambda: settings, raising=False)
monkeypatch.setattr("codex.synthesis.get_settings", lambda: settings)
monkeypatch.setattr(
"codex.config.get_settings", lambda: settings, raising=False
)
return leads_path
def test_synthesis_leads_invokes_three_finders(
monkeypatch: pytest.MonkeyPatch,
isolated_leads_dir: Path,
) -> None:
"""`codex synthesis leads --lib-path` calls connection + gap + improvement."""
calls: list[str] = []
def fake_connections(*, top_k: int, llm: object) -> list[Lead]:
calls.append("connection")
return [_grounded_lead("L-0001")]
def fake_gaps(
*,
lib_path: str | None = None,
llm: object,
topics: list[str] | None = None,
) -> list[Lead]:
calls.append("gap")
return []
def fake_improvements(lib_path: str, *, top_k: int, llm: object) -> list[Lead]:
calls.append("improvement")
return []
monkeypatch.setattr("codex.synthesis.find_connections", fake_connections)
monkeypatch.setattr("codex.synthesis.find_gaps", fake_gaps)
monkeypatch.setattr("codex.synthesis.find_improvements", fake_improvements)
monkeypatch.setattr("codex.synthesis.default_llm_client", lambda: object())
result = runner.invoke(app, ["synthesis", "leads", "--lib-path", "/tmp/fake"])
assert result.exit_code == 0, result.stdout
assert calls == ["connection", "gap", "improvement"]
assert (isolated_leads_dir / "grounded" / "L-0001.md").exists()
def test_synthesis_leads_without_lib_path_skips_improvement(
monkeypatch: pytest.MonkeyPatch,
isolated_leads_dir: Path,
) -> None:
"""Without --lib-path, the improvement finder must be skipped (graceful)."""
calls: list[str] = []
monkeypatch.setattr(
"codex.synthesis.find_connections",
lambda *, top_k, llm: (calls.append("connection"), [])[1],
)
monkeypatch.setattr(
"codex.synthesis.find_gaps",
lambda *, lib_path=None, llm, topics=None: (calls.append("gap"), [])[1],
)
monkeypatch.setattr(
"codex.synthesis.find_improvements",
lambda *args, **kwargs: (calls.append("improvement"), [])[1],
)
monkeypatch.setattr("codex.synthesis.default_llm_client", lambda: object())
result = runner.invoke(app, ["synthesis", "leads"])
assert result.exit_code == 0, result.stdout
assert "improvement" not in calls
assert "connection" in calls
assert "gap" in calls
def test_synthesis_conjectures_writes_to_conjectures_dir(
monkeypatch: pytest.MonkeyPatch,
isolated_leads_dir: Path,
) -> None:
"""`codex synthesis conjectures` lands files under leads/conjectures/."""
monkeypatch.setattr(
"codex.synthesis.propose_conjectures",
lambda *, top_k, llm: [_conjecture("L-0042")],
)
monkeypatch.setattr("codex.synthesis.default_llm_client", lambda: object())
result = runner.invoke(app, ["synthesis", "conjectures"])
assert result.exit_code == 0, result.stdout
assert (isolated_leads_dir / "conjectures" / "L-0042.md").exists()
assert not (isolated_leads_dir / "grounded" / "L-0042.md").exists()
def test_synthesis_report_separates_sections(
monkeypatch: pytest.MonkeyPatch,
isolated_leads_dir: Path,
) -> None:
"""`codex synthesis report` shows grounded and conjectures in distinct headers."""
from codex.synthesis import write_leads
write_leads(
[_grounded_lead("L-0001"), _conjecture("L-0002")],
str(isolated_leads_dir),
)
result = runner.invoke(app, ["synthesis", "report"])
assert result.exit_code == 0, result.stdout
assert "GROUNDED LEADS" in result.stdout
assert "CONJECTURES" in result.stdout
# Visible separation: GROUNDED header strictly precedes CONJECTURES header
assert result.stdout.index("GROUNDED LEADS") < result.stdout.index("CONJECTURES")
# UNVERIFIED tag in the conjectures header
assert "UNVERIFIED" in result.stdout
def test_synthesis_report_empty(
isolated_leads_dir: Path,
) -> None:
"""`codex synthesis report` with no leads still prints both sections."""
result = runner.invoke(app, ["synthesis", "report"])
assert result.exit_code == 0
assert "GROUNDED LEADS" in result.stdout
assert "CONJECTURES" in result.stdout
assert "(none)" in result.stdout

View File

@@ -0,0 +1,113 @@
"""Tests for stage-5 conjecture generation.
HARD invariant: every conjecture MUST carry status='unverified', a
non-empty suggested_validation, and non-empty provenance.
"""
from __future__ import annotations
from pathlib import Path
from codex.synthesis import _parse_conjecture_output, propose_conjectures
from tests.synthesis.conftest import StubLLM
_VALID_CONJECTURE = (
"Title: A discrete cosmetic-surgery analogue exists for Yamabe flows.\n"
"Body: The combinatorial Yamabe flow [Luo2004 #chunk 0] is a discrete "
"analogue of conformal change. One could hope the cosmetic surgery "
"conjecture has a discrete formulation in this setting.\n"
"Validation: Search for a counter-example among simplicial 3-manifolds "
"with at most 12 tetrahedra using a CSP solver."
)
_MISSING_VALIDATION = (
"Title: A naive idea.\n"
"Body: Some thoughts that follow [Luo2004 #chunk 0]. No falsification path "
"is described."
)
def test_parse_valid_conjecture_output() -> None:
"""_parse_conjecture_output extracts title / body / validation."""
parsed = _parse_conjecture_output(_VALID_CONJECTURE)
assert parsed is not None
title, body, validation = parsed
assert title.startswith("A discrete cosmetic-surgery")
assert "Luo2004" in body
assert "counter-example" in validation
def test_parse_conjecture_missing_validation_returns_none() -> None:
"""Output without a Validation: line is rejected."""
assert _parse_conjecture_output(_MISSING_VALIDATION) is None
def test_parse_conjecture_empty_input() -> None:
"""Empty LLM output → None."""
assert _parse_conjecture_output("") is None
def test_propose_conjectures_marks_status_unverified(
mock_retrieve: None,
mock_paper_titles: None,
mock_settings: Path,
) -> None:
"""Every emitted conjecture has status='unverified'."""
llm = StubLLM(_VALID_CONJECTURE)
leads = propose_conjectures(top_k=6, llm=llm)
assert len(leads) >= 1
for lead in leads:
assert lead.kind == "conjecture"
assert lead.status == "unverified"
def test_propose_conjectures_requires_validation(
mock_retrieve: None,
mock_paper_titles: None,
mock_settings: Path,
) -> None:
"""Every emitted conjecture has a non-empty suggested_validation."""
llm = StubLLM(_VALID_CONJECTURE)
leads = propose_conjectures(top_k=6, llm=llm)
assert len(leads) >= 1
for lead in leads:
assert lead.suggested_validation.strip() != ""
def test_propose_conjectures_requires_provenance(
mock_retrieve: None,
mock_paper_titles: None,
mock_settings: Path,
) -> None:
"""Every conjecture has at least one Provenance entry (seed chunks)."""
llm = StubLLM(_VALID_CONJECTURE)
leads = propose_conjectures(top_k=6, llm=llm)
assert len(leads) >= 1
for lead in leads:
assert len(lead.provenance) >= 1
def test_propose_conjectures_drops_when_validation_missing(
mock_retrieve: None,
mock_paper_titles: None,
mock_settings: Path,
) -> None:
"""LLM output without a Validation: line is rejected (zero leads)."""
llm = StubLLM(_MISSING_VALIDATION)
leads = propose_conjectures(top_k=6, llm=llm)
assert leads == []
def test_propose_conjectures_handles_llm_failure(
mock_retrieve: None,
mock_paper_titles: None,
mock_settings: Path,
) -> None:
"""LLM raising → returns [] (mirrors F-12 graceful degradation)."""
class FailingLLM:
def generate(self, prompt: str, model: str) -> str:
raise ConnectionError("ollama unreachable")
leads = propose_conjectures(top_k=6, llm=FailingLLM())
assert leads == []

View File

@@ -0,0 +1,136 @@
"""Tests for find_gaps — coverage map + code-vs-corpus signals."""
from __future__ import annotations
from pathlib import Path
from typing import Any
import pytest
from codex.synthesis import find_gaps
from tests.synthesis.conftest import StubLLM
def test_find_gaps_code_cites_missing_bibkey(
monkeypatch: pytest.MonkeyPatch,
mock_paper_titles: None,
mock_settings: Path,
fixture_chunks: list[dict[str, object]],
tmp_path: Path,
) -> None:
"""A bibkey referenced in code but absent from corpus → direct gap lead."""
# Build a small C++ source tree with an @cite pointing at a missing bibkey
src_dir = tmp_path / "src"
src_dir.mkdir()
(src_dir / "main.cpp").write_text(
"// @cite UnknownPaper2099\nvoid foo() {}\n",
encoding="utf-8",
)
# All probes return the fixture chunks (so coverage map yields nothing)
monkeypatch.setattr(
"codex.synthesis._retrieve_chunks",
lambda queries, top_k: [dict(c) for c in fixture_chunks],
)
leads = find_gaps(lib_path=str(src_dir), llm=StubLLM(""))
bibkeys_in_gaps = {p.bibkey for lead in leads for p in lead.provenance}
# The missing bibkey shows up as a gap
assert any("UnknownPaper2099" in lead.title for lead in leads)
assert "UnknownPaper2099" in bibkeys_in_gaps
def test_find_gaps_code_known_bibkey_not_reported(
monkeypatch: pytest.MonkeyPatch,
mock_paper_titles: None,
mock_settings: Path,
fixture_chunks: list[dict[str, object]],
tmp_path: Path,
) -> None:
"""A bibkey already in the corpus is NOT flagged by the code-vs-corpus path."""
src_dir = tmp_path / "src"
src_dir.mkdir()
(src_dir / "main.cpp").write_text(
"// @cite Springborn2008\nvoid foo() {}\n",
encoding="utf-8",
)
monkeypatch.setattr(
"codex.synthesis._retrieve_chunks",
lambda queries, top_k: [dict(c) for c in fixture_chunks],
)
leads = find_gaps(lib_path=str(src_dir), llm=StubLLM(""))
# Springborn2008 IS in the corpus → not a gap
assert not any("Springborn2008" in lead.title for lead in leads)
def test_find_gaps_missing_lib_path_ok(
monkeypatch: pytest.MonkeyPatch,
mock_paper_titles: None,
mock_settings: Path,
fixture_chunks: list[dict[str, object]],
) -> None:
"""Without lib_path the code-vs-corpus path is skipped (no crash)."""
monkeypatch.setattr(
"codex.synthesis._retrieve_chunks",
lambda queries, top_k: [dict(c) for c in fixture_chunks],
)
leads = find_gaps(lib_path=None, llm=StubLLM(""))
# No crash; result list may be empty depending on coverage map.
assert isinstance(leads, list)
def test_find_gaps_coverage_map_low_coverage_triggers_llm(
monkeypatch: pytest.MonkeyPatch,
mock_paper_titles: None,
mock_settings: Path,
) -> None:
"""A topic covered by only 1 bibkey (< 2) triggers the LLM gap prompt.
The grounded body must reference Springborn2008 [...] in a content-5-gram
that exists in the chunk, otherwise the lead is dropped.
"""
sparse_chunks: list[dict[str, Any]] = [
{
"id": 10,
"paper_id": "springborn-2008",
"ord": 16,
"content": (
"For an ideal tetrahedron with dihedral angles the hyperbolic volume is "
"V = L(gamma1) + L(gamma2) + L(gamma3). "
"The volume function V0 is strictly concave on the angle domain."
),
"bibkey": "Springborn2008",
},
]
monkeypatch.setattr(
"codex.synthesis._retrieve_chunks",
lambda queries, top_k: [dict(c) for c in sparse_chunks],
)
grounded_body = (
"Only Springborn2008 covers the volume formula at depth, while comparison "
"papers are missing.\n"
"For an ideal tetrahedron with dihedral angles the hyperbolic volume is "
"V = L(gamma1) + L(gamma2) + L(gamma3). [Springborn2008 #chunk 16]\n"
)
leads = find_gaps(llm=StubLLM(grounded_body))
assert any(lead.kind == "gap" for lead in leads)
def test_find_gaps_explicit_topics_override_default(
monkeypatch: pytest.MonkeyPatch,
mock_paper_titles: None,
mock_settings: Path,
) -> None:
"""Passing topics= explicitly overrides the default (paper titles)."""
captured_queries: list[list[str]] = []
def fake_retrieve(queries: list[str], top_k: int) -> list[dict[str, Any]]:
captured_queries.append(queries)
return [] # forces direct-gap-no-chunks path
monkeypatch.setattr("codex.synthesis._retrieve_chunks", fake_retrieve)
find_gaps(llm=StubLLM(""), topics=["custom topic A", "custom topic B"])
# Both custom topics should have been probed
flat = [q for qs in captured_queries for q in qs]
assert "custom topic A" in flat
assert "custom topic B" in flat

View File

@@ -0,0 +1,175 @@
"""Tests for grounded leads (connection / gap / improvement).
The Grounding-Guard is re-used from :mod:`codex.wiki` (F-12). These tests
verify that ungrounded claims are either dropped or annotated with ⚠,
and that grounded statements survive intact.
"""
from __future__ import annotations
from pathlib import Path
from codex.synthesis import (
_finalise_grounded_lead,
_mark_ungrounded,
find_connections,
find_gaps,
)
from tests.synthesis.conftest import StubLLM
_GROUNDED_CONNECTION = (
"Both papers build hyperbolic volumes from the Lobachevsky function.\n"
"For an ideal tetrahedron with dihedral angles the hyperbolic volume is "
"V = L(gamma1) + L(gamma2) + L(gamma3). [Springborn2008 #chunk 16]\n"
"A discrete conformal map is defined by logarithmic scale factors u_i at "
"each vertex such that the edge lengths satisfy a compatibility condition. "
"[BobenkoPinkallSpringborn2015 #chunk 3]\n"
)
_UNGROUNDED_CONNECTION = (
"Both papers prove the Riemann hypothesis via the Lobachevsky function. "
"[Springborn2008 #chunk 99]\n"
)
def test_finalise_grounded_lead_returns_none_without_claims(
fixture_chunks: list[dict[str, object]],
) -> None:
"""Body with no inline citations yields no grounded lead."""
lead = _finalise_grounded_lead(
seq=1,
kind="connection",
title="t",
body="A plain statement with no citation marker.",
chunks=fixture_chunks,
min_grounded_ratio=0.5,
)
assert lead is None
def test_finalise_grounded_lead_drops_when_ratio_below_floor(
fixture_chunks: list[dict[str, object]],
) -> None:
"""All-ungrounded body → None (not silently promoted)."""
lead = _finalise_grounded_lead(
seq=1,
kind="connection",
title="t",
body=_UNGROUNDED_CONNECTION,
chunks=fixture_chunks,
min_grounded_ratio=0.5,
)
assert lead is None
def test_finalise_grounded_lead_succeeds_when_grounded(
fixture_chunks: list[dict[str, object]],
) -> None:
"""Grounded body passes the floor and returns a populated Lead."""
lead = _finalise_grounded_lead(
seq=1,
kind="connection",
title="Both papers share a building block",
body=_GROUNDED_CONNECTION,
chunks=fixture_chunks,
min_grounded_ratio=0.5,
)
assert lead is not None
assert lead.kind == "connection"
assert lead.id == "L-0001"
assert lead.confidence >= 0.5
assert lead.status == "unverified"
assert lead.suggested_validation == ""
# All claims should be grounded
assert all(c.grounded for c in lead.claims)
def test_mark_ungrounded_prefixes_warning() -> None:
"""_mark_ungrounded prefixes ⚠ to the matching claim text once."""
from codex.wiki import Claim
body = "Some fact. [X2020 #c 1]\nOther fact. [Y2021 #c 2]\n"
claims = [
Claim(text="Some fact", bibkey="X2020", locator="c 1", grounded=False),
Claim(text="Other fact", bibkey="Y2021", locator="c 2", grounded=True),
]
out = _mark_ungrounded(body, claims)
assert "⚠ Some fact" in out
# Grounded claim is not annotated
assert "⚠ Other fact" not in out
def test_find_connections_emits_grounded_lead(
mock_retrieve: None,
mock_paper_titles: None,
mock_settings: Path,
) -> None:
"""End-to-end: with a grounded LLM response, a connection lead is produced."""
llm = StubLLM(_GROUNDED_CONNECTION)
leads = find_connections(top_k=6, llm=llm)
assert len(leads) >= 1
lead = leads[0]
assert lead.kind == "connection"
assert lead.provenance, "grounded lead must carry provenance"
# No ⚠ should appear because all claims grounded
assert "" not in lead.body
def test_find_connections_drops_ungrounded_response(
mock_retrieve: None,
mock_paper_titles: None,
mock_settings: Path,
) -> None:
"""Ungrounded LLM responses are dropped, not emitted with ⚠."""
llm = StubLLM(_UNGROUNDED_CONNECTION)
leads = find_connections(top_k=6, llm=llm)
assert leads == []
def test_find_connections_handles_llm_failure(
mock_retrieve: None,
mock_paper_titles: None,
mock_settings: Path,
) -> None:
"""LLM raising → returns [] (mirrors F-12 graceful degradation)."""
class FailingLLM:
def generate(self, prompt: str, model: str) -> str:
raise ConnectionError("ollama unreachable")
leads = find_connections(top_k=6, llm=FailingLLM())
assert leads == []
def test_find_gaps_topic_with_no_chunks(
monkeypatch,
mock_paper_titles: None,
mock_settings: Path,
) -> None:
"""When retrieval returns no chunks, a gap lead is emitted directly."""
monkeypatch.setattr("codex.synthesis._retrieve_chunks", lambda queries, top_k: [])
leads = find_gaps(llm=StubLLM(""))
assert len(leads) >= 1
assert all(lead.kind == "gap" for lead in leads)
# Direct gap leads carry a validation hint (re-ingest)
assert all(lead.suggested_validation for lead in leads)
def test_find_gaps_well_covered_topic_skipped(
monkeypatch,
mock_paper_titles: None,
mock_settings: Path,
fixture_chunks: list[dict[str, object]],
) -> None:
"""Topics covered by enough bibkeys are NOT emitted as gaps.
Three distinct bibkeys in the fixture > synthesis_gap_min_coverage=2,
so every topic is well-covered and gets skipped.
"""
monkeypatch.setattr(
"codex.synthesis._retrieve_chunks",
lambda queries, top_k: [dict(c) for c in fixture_chunks],
)
leads = find_gaps(llm=StubLLM(""))
# All probes well-covered → no gap leads
assert leads == []

View File

@@ -0,0 +1,100 @@
"""Tests for the Lead / Provenance datamodel (F-13)."""
from __future__ import annotations
from typing import get_args
import pytest
from codex.synthesis import Lead, LeadKind, LeadStatus, Provenance
def test_provenance_requires_bibkey_and_locator() -> None:
"""Provenance has two required positional fields."""
p = Provenance(bibkey="Smith2020", locator="chunk 7")
assert p.bibkey == "Smith2020"
assert p.locator == "chunk 7"
def test_provenance_missing_field_raises() -> None:
"""Provenance without locator must fail to construct (TypeError)."""
with pytest.raises(TypeError):
Provenance(bibkey="Smith2020") # type: ignore[call-arg]
def test_lead_required_fields() -> None:
"""Lead requires id, kind, title, body, provenance, confidence."""
lead = Lead(
id="L-0001",
kind="connection",
title="A connection",
body="Some body text.",
provenance=[Provenance(bibkey="X2020", locator="chunk 1")],
confidence=0.8,
)
assert lead.id == "L-0001"
assert lead.kind == "connection"
assert lead.status == "unverified" # default
assert lead.suggested_validation == "" # default
assert lead.claims == [] # default factory
def test_lead_kind_literals_exact() -> None:
"""Lead kinds are exactly: connection, gap, improvement, conjecture."""
assert set(get_args(LeadKind)) == {"connection", "gap", "improvement", "conjecture"}
def test_lead_status_literals_exact() -> None:
"""Lead statuses are exactly: unverified, spiking, go, no-go."""
assert set(get_args(LeadStatus)) == {"unverified", "spiking", "go", "no-go"}
def test_lead_provenance_can_be_multiple() -> None:
"""A Lead may carry multiple provenance pointers."""
provs = [
Provenance(bibkey="A2020", locator="chunk 1"),
Provenance(bibkey="B2021", locator="chunk 7"),
]
lead = Lead(
id="L-0002",
kind="gap",
title="t",
body="b",
provenance=provs,
confidence=0.5,
)
assert len(lead.provenance) == 2
def test_lead_missing_provenance_field_raises() -> None:
"""Constructing Lead without provenance is a TypeError."""
with pytest.raises(TypeError):
Lead( # type: ignore[call-arg]
id="L-0003",
kind="connection",
title="x",
body="y",
confidence=0.5,
)
def test_lead_empty_provenance_raises() -> None:
"""Constructing Lead with empty provenance list raises ValueError."""
with pytest.raises(ValueError, match="at least one Provenance"):
Lead(
id="L-0004",
kind="connection",
title="x",
body="y",
provenance=[],
confidence=0.5,
)
def test_lead_id_format_helper() -> None:
"""The internal _make_lead_id helper produces zero-padded L-XXXX strings."""
from codex.synthesis import _make_lead_id
assert _make_lead_id(1) == "L-0001"
assert _make_lead_id(42) == "L-0042"
assert _make_lead_id(9999) == "L-9999"

View File

@@ -0,0 +1,132 @@
"""INVARIANT tests — conjectures live in leads/conjectures/, never in wiki/.
This is the hard F-13 invariant. Violations would corrupt the wiki layer
and must be unrepresentable in the API.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from codex.synthesis import Lead, Provenance, write_leads
def _grounded_lead(lead_id: str = "L-0001") -> Lead:
return Lead(
id=lead_id,
kind="connection",
title="Connection title",
body="Some grounded body. [X2020 #chunk 1]",
provenance=[Provenance(bibkey="X2020", locator="chunk 1")],
confidence=0.8,
status="unverified",
)
def _conjecture(lead_id: str = "L-0002") -> Lead:
return Lead(
id=lead_id,
kind="conjecture",
title="A bold conjecture",
body="Hypothetical statement seeded by [X2020 #chunk 1].",
provenance=[Provenance(bibkey="X2020", locator="chunk 1")],
confidence=0.0,
status="unverified",
suggested_validation="Search for a counter-example with N<=8.",
)
def test_conjecture_lands_in_conjectures_dir(tmp_path: Path) -> None:
"""A conjecture lead writes to leads/conjectures/<id>.md."""
write_leads([_conjecture("L-0002")], str(tmp_path))
assert (tmp_path / "conjectures" / "L-0002.md").exists()
# And NOT in grounded/
assert not (tmp_path / "grounded" / "L-0002.md").exists()
def test_conjecture_never_in_grounded_dir(tmp_path: Path) -> None:
"""No conjecture file may appear under leads/grounded/."""
write_leads([_grounded_lead("L-0001"), _conjecture("L-0002")], str(tmp_path))
grounded_files = list((tmp_path / "grounded").glob("*.md"))
conj_files = list((tmp_path / "conjectures").glob("*.md"))
assert len(grounded_files) == 1
assert grounded_files[0].name == "L-0001.md"
assert len(conj_files) == 1
assert conj_files[0].name == "L-0002.md"
def test_conjecture_never_in_wiki_dir(tmp_path: Path) -> None:
"""The wiki/ directory is never touched by write_leads."""
wiki_dir = tmp_path / "wiki"
wiki_dir.mkdir()
write_leads([_conjecture("L-0002")], str(tmp_path / "leads"))
# No file landed in wiki/
assert list(wiki_dir.glob("*.md")) == []
def test_conjecture_markdown_contains_unverified_marker(tmp_path: Path) -> None:
"""The on-disk conjecture file announces UNVERIFIED status."""
write_leads([_conjecture("L-0002")], str(tmp_path))
md = (tmp_path / "conjectures" / "L-0002.md").read_text(encoding="utf-8")
assert "UNVERIFIED" in md
assert "Suggested validation" in md
assert "Search for a counter-example" in md
def test_invariant_violation_raises(tmp_path: Path) -> None:
"""Constructing a Lead with kind='conjecture' but routed by code into
grounded/ would be a programmer error — guarded by the kind→dir check.
We simulate it by monkey-patching the kind set, which is the only way
a writer could attempt this. The expectation is that no path exists in
the public API to write a conjecture into grounded/.
"""
# Hard guarantee: the only sanctioned writer routes by lead.kind.
# Verify the dataclass kind field cannot trivially be spoofed: it is
# a Literal-typed string so any value other than the four kinds is a
# type error at static-checking time. At runtime, write_leads still
# rejects unknown kinds.
bogus = Lead(
id="L-0003",
kind="conjecture", # legitimate kind …
title="x",
body="y",
provenance=[Provenance(bibkey="X2020", locator="chunk 1")],
confidence=0.0,
suggested_validation="run a spike",
)
write_leads([bogus], str(tmp_path))
# Conjecture must land in conjectures/, not grounded/
assert (tmp_path / "conjectures" / "L-0003.md").exists()
assert not (tmp_path / "grounded" / "L-0003.md").exists()
def test_unknown_kind_rejected(tmp_path: Path) -> None:
"""A Lead with an unrecognised kind raises at write time."""
lead = Lead(
id="L-0009",
kind="bogus-kind", # type: ignore[arg-type]
title="t",
body="b",
provenance=[Provenance(bibkey="X2020", locator="chunk 1")],
confidence=0.5,
)
with pytest.raises(RuntimeError):
write_leads([lead], str(tmp_path))
def test_index_separates_grounded_and_conjectures(tmp_path: Path) -> None:
"""INDEX.md visibly separates grounded leads from conjectures."""
write_leads([_grounded_lead("L-0001"), _conjecture("L-0002")], str(tmp_path))
index = (tmp_path / "INDEX.md").read_text(encoding="utf-8")
# Two distinct, visibly separated sections
assert "## Grounded leads" in index
assert "## Conjectures" in index
# Ordering: grounded section appears before conjectures
assert index.index("Grounded leads") < index.index("Conjectures")
# Both ids are listed
assert "L-0001" in index
assert "L-0002" in index

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

227
tests/wiki/test_check.py Normal file
View File

@@ -0,0 +1,227 @@
"""Tests for ``codex wiki check`` CLI command.
Verifies:
- Exit 0 when all pages are grounded (no ⚠ in any page).
- Exit 1 when at least one page contains an ungrounded claim (⚠ marker).
"""
from __future__ import annotations
import textwrap
from pathlib import Path
import pytest
from typer.testing import CliRunner
from codex.cli import app
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def wiki_dir_all_grounded(tmp_path: Path) -> Path:
"""Create a wiki directory with only grounded pages (no ⚠)."""
wiki = tmp_path / "wiki"
wiki.mkdir()
(wiki / "discrete-conformal-map.md").write_text(
textwrap.dedent(
"""\
# Discrete Conformal Map
A discrete conformal map is defined by logarithmic scale factors. [BPS2015 #chunk 0]
The variational principle gives the optimum. [BPS2015 #chunk 1]
"""
),
encoding="utf-8",
)
(wiki / "circle-packing.md").write_text(
textwrap.dedent(
"""\
# Circle Packing
Circle packing is studied via the Koebe-Andreev-Thurston theorem. [Thurston1985 #page 3]
"""
),
encoding="utf-8",
)
return wiki
@pytest.fixture
def wiki_dir_with_ungrounded(tmp_path: Path) -> Path:
"""Create a wiki directory where one page has an ungrounded claim (⚠)."""
wiki = tmp_path / "wiki"
wiki.mkdir()
(wiki / "lobachevsky-function.md").write_text(
textwrap.dedent(
"""\
# Lobachevsky Function
The volume formula is V = L(γ₁)+L(γ₂)+L(γ₃). [Springborn2008 #chunk 16]
⚠ The closed form L(x) = -∫₀ˣ log|2 sin t| dt. [Springborn2008 #chunk 99]
"""
),
encoding="utf-8",
)
return wiki
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
def test_wiki_check_exit_0_when_all_grounded(
wiki_dir_all_grounded: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""codex wiki check exits 0 when no ⚠ in any page."""
from unittest.mock import MagicMock
from codex.config import Settings
mock_settings = MagicMock(spec=Settings)
mock_settings.wiki_dir = str(wiki_dir_all_grounded)
monkeypatch.setattr("codex.cli.get_settings", lambda: mock_settings, raising=False)
runner = CliRunner()
result = runner.invoke(app, ["wiki", "check", "--output-dir", str(wiki_dir_all_grounded)])
assert result.exit_code == 0, (
f"Expected exit 0, got {result.exit_code}. Output: {result.output}"
)
def test_wiki_check_exit_1_when_ungrounded(
wiki_dir_with_ungrounded: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""codex wiki check exits 1 when at least one ⚠ is present."""
from unittest.mock import MagicMock
from codex.config import Settings
mock_settings = MagicMock(spec=Settings)
mock_settings.wiki_dir = str(wiki_dir_with_ungrounded)
monkeypatch.setattr("codex.cli.get_settings", lambda: mock_settings, raising=False)
runner = CliRunner()
result = runner.invoke(app, ["wiki", "check", "--output-dir", str(wiki_dir_with_ungrounded)])
assert result.exit_code == 1, (
f"Expected exit 1, got {result.exit_code}. Output: {result.output}"
)
def test_wiki_check_empty_dir_exits_0(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""codex wiki check exits 0 when wiki dir has no .md pages (nothing to check)."""
wiki = tmp_path / "wiki"
wiki.mkdir()
from unittest.mock import MagicMock
from codex.config import Settings
mock_settings = MagicMock(spec=Settings)
mock_settings.wiki_dir = str(wiki)
monkeypatch.setattr("codex.cli.get_settings", lambda: mock_settings, raising=False)
runner = CliRunner()
result = runner.invoke(app, ["wiki", "check", "--output-dir", str(wiki)])
assert result.exit_code == 0
def test_wiki_check_index_and_log_ignored(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""codex wiki check ignores index.md and log.md even if they contain ⚠."""
wiki = tmp_path / "wiki"
wiki.mkdir()
# index.md and log.md with ⚠ — must NOT trigger exit 1
(wiki / "index.md").write_text("# Wiki Index\n\n⚠ some note\n", encoding="utf-8")
(wiki / "log.md").write_text("# Log\n\n⚠ ungrounded\n", encoding="utf-8")
from unittest.mock import MagicMock
from codex.config import Settings
mock_settings = MagicMock(spec=Settings)
mock_settings.wiki_dir = str(wiki)
monkeypatch.setattr("codex.cli.get_settings", lambda: mock_settings, raising=False)
runner = CliRunner()
result = runner.invoke(app, ["wiki", "check", "--output-dir", str(wiki)])
assert result.exit_code == 0, (
f"index.md / log.md should be excluded. exit={result.exit_code}, out={result.output}"
)
def test_wiki_check_exit_2_when_draft_not_empty(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""codex wiki check exits 2 when wiki/draft/ is non-empty (quarantined pages)."""
wiki = tmp_path / "wiki"
wiki.mkdir()
draft = wiki / "draft"
draft.mkdir()
(draft / "bad-concept.md").write_text(
"# Bad Concept\n\n⚠ Ungrounded hallucination. [FakeBib2025 #chunk 0]\n",
encoding="utf-8",
)
from unittest.mock import MagicMock
from codex.config import Settings
mock_settings = MagicMock(spec=Settings)
mock_settings.wiki_dir = str(wiki)
monkeypatch.setattr("codex.cli.get_settings", lambda: mock_settings, raising=False)
runner = CliRunner()
result = runner.invoke(app, ["wiki", "check", "--output-dir", str(wiki)])
assert result.exit_code == 2, (
f"Expected exit 2 (quarantined pages), got {result.exit_code}. Output: {result.output}"
)
def test_wiki_check_exit_2_takes_priority_over_exit_1(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Exit 2 (quarantined) takes priority over exit 1 (ungrounded in wiki/)."""
wiki = tmp_path / "wiki"
wiki.mkdir()
draft = wiki / "draft"
draft.mkdir()
# Main wiki with ungrounded claim
(wiki / "concept-a.md").write_text(
"# Concept A\n\n⚠ Ungrounded claim. [SomeBib #chunk 0]\n",
encoding="utf-8",
)
# Draft with quarantined page
(draft / "concept-b.md").write_text(
"# Concept B\n\nQuarantined content.\n",
encoding="utf-8",
)
from unittest.mock import MagicMock
from codex.config import Settings
mock_settings = MagicMock(spec=Settings)
mock_settings.wiki_dir = str(wiki)
monkeypatch.setattr("codex.cli.get_settings", lambda: mock_settings, raising=False)
runner = CliRunner()
result = runner.invoke(app, ["wiki", "check", "--output-dir", str(wiki)])
assert result.exit_code == 2

761
tests/wiki/test_compile.py Normal file
View File

@@ -0,0 +1,761 @@
"""Tests for compile_concept, compile_all, write_index, append_log.
DB and LLM are fully mocked — no network or database access required.
"""
from __future__ import annotations
import textwrap
from pathlib import Path
from typing import Any
from unittest.mock import MagicMock
import pytest
from codex.wiki import (
Claim,
CompileReport,
Concept,
ConceptPage,
_chunk_hash,
_detect_conflicts,
_inject_cross_refs,
_parse_claims,
_run_grounding_guard,
append_log,
compile_concept,
write_index,
)
# ---------------------------------------------------------------------------
# Shared fixtures
# ---------------------------------------------------------------------------
FIXTURE_CHUNKS: list[dict[str, Any]] = [
{
"id": 1,
"paper_id": "springborn-2008",
"ord": 16,
"content": (
"For an ideal tetrahedron with dihedral angles the hyperbolic volume is "
"V = L(gamma1) + L(gamma2) + L(gamma3). "
"The volume function V0 is strictly concave on the angle domain."
),
"bibkey": "Springborn2008",
"dist": 0.31,
},
{
"id": 2,
"paper_id": "springborn-2008",
"ord": 0,
"content": (
"A discrete conformal map is defined by logarithmic scale factors u_i "
"at each vertex such that the edge lengths satisfy a compatibility condition."
),
"bibkey": "Springborn2008",
"dist": 0.35,
},
]
FIXTURE_CONCEPT = Concept(
slug="lobachevsky-function",
title="Lobachevsky Function",
aliases=["Milnor Lobachevsky", "Clausen function"],
emphasis="Verwendung in hyperbolischen Volumenformeln (Springborn 2008).",
)
# Grounded LLM output: claim text is a substring of the fixture chunk
_GROUNDED_CLAIM = (
"For an ideal tetrahedron with dihedral angles the hyperbolic volume is"
" V = L(gamma1) + L(gamma2) + L(gamma3). [Springborn2008 #chunk 16]"
)
GROUNDED_LLM_OUTPUT = (
"The Lobachevsky function L appears as the building block of hyperbolic volume.\n"
+ _GROUNDED_CLAIM
+ "\nThe volume function V0 is strictly concave on the angle domain."
" [Springborn2008 #chunk 16]\n"
)
# Ungrounded LLM output: claim text not present in any chunk
UNGROUNDED_LLM_OUTPUT = textwrap.dedent(
"""\
The closed form is L(x) = -integral from 0 to x of log|2 sin t| dt. [Springborn2008 #chunk 99]
"""
)
class MockLLM:
"""Deterministic mock LLM that returns a preset response."""
def __init__(self, response: str) -> None:
self._response = response
def generate(self, prompt: str, model: str) -> str:
return self._response
# ---------------------------------------------------------------------------
# _parse_claims
# ---------------------------------------------------------------------------
def test_parse_claims_finds_citation() -> None:
"""Claims with [BibKey #locator] format are parsed correctly."""
md = "Some result. [Springborn2008 #chunk 16]"
claims = _parse_claims(md)
assert len(claims) == 1
assert claims[0].bibkey == "Springborn2008"
assert claims[0].locator == "chunk 16"
def test_parse_claims_no_citations() -> None:
"""Text without citation markers returns empty list."""
md = "A standalone sentence with no citation."
claims = _parse_claims(md)
assert claims == []
def test_parse_claims_multiple() -> None:
"""Multiple citations in same text are all parsed."""
md = "First claim. [Smith2020 #page 5]\nSecond claim. [Jones2019 #eq 3]\n"
claims = _parse_claims(md)
assert len(claims) == 2
def test_parse_claims_ignores_markdown_links() -> None:
"""Markdown hyperlinks like [text](https://example.com) are NOT parsed as claims."""
md = "See the [related work](https://example.com) for details."
claims = _parse_claims(md)
assert claims == []
def test_parse_claims_ignores_multi_word_bibkey() -> None:
"""Multi-word 'bibkeys' (spaces inside) are not treated as real citations."""
md = "Some text [not a bibkey here] and more."
claims = _parse_claims(md)
assert claims == []
# ---------------------------------------------------------------------------
# _run_grounding_guard
# ---------------------------------------------------------------------------
def test_grounding_guard_marks_grounded_claim() -> None:
"""A claim whose text appears in the cited chunk is marked grounded."""
claim = Claim(
text="the hyperbolic volume is V = L(gamma1) + L(gamma2) + L(gamma3)",
bibkey="Springborn2008",
locator="chunk 16",
)
result = _run_grounding_guard([claim], FIXTURE_CHUNKS)
assert result[0].grounded is True
def test_grounding_guard_marks_ungrounded_claim() -> None:
"""A claim not present in the cited chunk is marked ungrounded."""
claim = Claim(
text="integral from 0 to x of log 2 sin t dt closed form definition",
bibkey="Springborn2008",
locator="chunk 99",
)
result = _run_grounding_guard([claim], FIXTURE_CHUNKS)
assert result[0].grounded is False
def test_grounding_guard_ungrounded_when_bibkey_missing() -> None:
"""A claim citing a bibkey not in any chunk is ungrounded."""
claim = Claim(text="some statement", bibkey="UnknownBib2000", locator="page 1")
result = _run_grounding_guard([claim], FIXTURE_CHUNKS)
assert result[0].grounded is False
def test_grounding_guard_adversarial_stopword_bypass() -> None:
"""Adversarial: stopword-3-gram matches are NOT sufficient for grounding.
'the discrete conformal map' appears in the chunk, but this is a stopword
phrase. The preceding hallucinated sentences contain NO content-5-gram
present in the chunk → grounded=False.
"""
# The adversarial text from the session-prompt review finding:
adversarial_text = (
"The Riemann hypothesis is proven by combining Hodge theory with quantum entanglement. "
"Furthermore P=NP holds for hyperbolic tetrahedra. The discrete conformal map"
)
claim = Claim(
text=adversarial_text,
bibkey="Springborn2008",
locator="chunk 0",
)
result = _run_grounding_guard([claim], FIXTURE_CHUNKS)
assert result[0].grounded is False, (
"Stopword 3-gram 'the discrete conformal map' must NOT ground a hallucinated claim"
)
def test_grounding_guard_last_sentence_grounded_earlier_hallucination_ignored() -> None:
"""Only the last sentence is checked for grounding.
If the last sentence IS grounded, the earlier hallucinated sentence is
irrelevant (it has no citation → it won't be parsed as a Claim at all).
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.
grounded_sentence = (
"the hyperbolic volume is V = L(gamma1) + L(gamma2) + L(gamma3)"
)
claim = Claim(
text=grounded_sentence,
bibkey="Springborn2008",
locator="chunk 16",
)
result = _run_grounding_guard([claim], FIXTURE_CHUNKS)
assert result[0].grounded is True, (
"Last sentence with content-5-gram present in chunk must be grounded"
)
# ---------------------------------------------------------------------------
# _inject_cross_refs
# ---------------------------------------------------------------------------
def test_inject_cross_refs_replaces_title() -> None:
"""Occurrences of another concept's title are replaced with [[slug]]."""
concepts = [
Concept(
slug="discrete-conformal-map",
title="Discrete Conformal Map",
aliases=[],
),
FIXTURE_CONCEPT,
]
text = "This is related to Discrete Conformal Map theory."
result = _inject_cross_refs(text, concepts, current_slug="lobachevsky-function")
assert "[[discrete-conformal-map]]" in result
def test_inject_cross_refs_skips_current_slug() -> None:
"""The current concept's own title is not replaced."""
concepts = [FIXTURE_CONCEPT]
text = "The Lobachevsky Function is important."
result = _inject_cross_refs(text, concepts, current_slug="lobachevsky-function")
assert "[[lobachevsky-function]]" not in result
def test_inject_cross_refs_replaces_alias() -> None:
"""Aliases are also replaced with [[slug]]."""
concepts = [
Concept(
slug="circle-packing",
title="Circle Packing",
aliases=["Koebe-Andreev-Thurston", "circle pattern"],
),
FIXTURE_CONCEPT,
]
text = "The Koebe-Andreev-Thurston theorem gives a packing."
result = _inject_cross_refs(text, concepts, current_slug="lobachevsky-function")
assert "[[circle-packing]]" in result
def test_inject_cross_refs_skips_inline_code() -> None:
"""Concept titles inside backtick inline-code spans are NOT replaced."""
concepts = [
Concept(
slug="circle-packing",
title="circle-packing",
aliases=[],
),
FIXTURE_CONCEPT,
]
# The term "circle-packing" appears both bare (should be replaced)
# and inside backticks (should NOT be replaced).
text = "Use `circle-packing` for this. See also circle-packing theory."
result = _inject_cross_refs(text, concepts, current_slug="lobachevsky-function")
assert "`circle-packing`" in result # inline-code preserved
assert "[[circle-packing]]" in result # bare occurrence replaced
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."""
concepts = [
Concept(slug="circle-packing", title="Circle Packing", aliases=[]),
Concept(slug="discrete-conformal-map", title="Discrete Conformal Map", aliases=[]),
FIXTURE_CONCEPT,
]
# Compiling lobachevsky-function only; text mentions the other two concepts
text = "Discrete Conformal Map is used with Circle Packing."
result = _inject_cross_refs(text, concepts, current_slug="lobachevsky-function")
assert "[[circle-packing]]" in result
assert "[[discrete-conformal-map]]" in result
# ---------------------------------------------------------------------------
# compile_concept (full mock)
# ---------------------------------------------------------------------------
@pytest.fixture
def mock_retrieve(monkeypatch: pytest.MonkeyPatch) -> None:
"""Patch _retrieve_chunks to return deterministic fixture chunks."""
monkeypatch.setattr(
"codex.wiki._retrieve_chunks",
lambda queries, top_k: FIXTURE_CHUNKS,
)
@pytest.fixture
def mock_formulas(monkeypatch: pytest.MonkeyPatch) -> None:
"""Patch _try_embed_formulas to be a no-op (F-09 not present)."""
monkeypatch.setattr("codex.wiki._try_embed_formulas", lambda concept, chunks: None)
@pytest.fixture
def mock_settings(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path:
"""Patch get_settings to return a settings pointing at tmp_path."""
from codex.config import Settings
wiki_path = tmp_path / "wiki"
wiki_path.mkdir()
mock = MagicMock(spec=Settings)
mock.wiki_dir = str(wiki_path)
mock.wiki_llm_model = "test-model"
mock.wiki_llm_url = None
mock.ollama_base_url = "http://localhost:11434"
mock.wiki_top_k = 6
mock.wiki_min_grounding_rate = 0.5
monkeypatch.setattr("codex.wiki.get_settings", lambda: mock)
monkeypatch.setattr("codex.cli.get_settings", lambda: mock, raising=False)
return wiki_path
def test_compile_concept_returns_concept_page(
mock_retrieve: None,
mock_formulas: None,
mock_settings: Path,
) -> None:
"""compile_concept returns a ConceptPage with non-empty markdown."""
llm = MockLLM(GROUNDED_LLM_OUTPUT)
page = compile_concept(
FIXTURE_CONCEPT, FIXTURE_CHUNKS, top_k=6, llm=llm, wiki_dir=mock_settings
)
assert isinstance(page, ConceptPage)
assert page.concept.slug == "lobachevsky-function"
assert len(page.markdown) > 0
def test_compile_concept_has_chunk_hash(
mock_retrieve: None,
mock_formulas: None,
mock_settings: Path,
) -> None:
"""Compiled page has a non-empty chunk_hash."""
llm = MockLLM(GROUNDED_LLM_OUTPUT)
page = compile_concept(
FIXTURE_CONCEPT, FIXTURE_CHUNKS, top_k=6, llm=llm, wiki_dir=mock_settings
)
assert len(page.chunk_hash) == 64 # SHA-256 hex
def test_compile_concept_grounded_claim_not_flagged(
mock_retrieve: None,
mock_formulas: None,
mock_settings: Path,
) -> None:
"""A grounded claim does NOT receive the ⚠ marker in the output markdown."""
llm = MockLLM(GROUNDED_LLM_OUTPUT)
page = compile_concept(
FIXTURE_CONCEPT, FIXTURE_CHUNKS, top_k=6, llm=llm, wiki_dir=mock_settings
)
# When all claims are grounded, no ⚠ in markdown
# (may still have 0 ⚠ if claims found; just check no false positive for grounded)
grounded = [c for c in page.claims if c.grounded]
for claim in grounded:
assert claim.grounded is True
def test_compile_concept_ungrounded_claim_marked(
mock_retrieve: None,
mock_formulas: None,
mock_settings: Path,
) -> None:
"""An ungrounded claim is marked ⚠ in the page markdown."""
llm = MockLLM(UNGROUNDED_LLM_OUTPUT)
page = compile_concept(
FIXTURE_CONCEPT, FIXTURE_CHUNKS, top_k=6, llm=llm, wiki_dir=mock_settings
)
ungrounded = [c for c in page.claims if not c.grounded]
if ungrounded:
assert "" in page.markdown
def test_compile_concept_header_present(
mock_retrieve: None,
mock_formulas: None,
mock_settings: Path,
) -> None:
"""Page markdown starts with an H1 header for the concept title."""
llm = MockLLM(GROUNDED_LLM_OUTPUT)
page = compile_concept(
FIXTURE_CONCEPT, FIXTURE_CHUNKS, top_k=6, llm=llm, wiki_dir=mock_settings
)
assert page.markdown.startswith("# Lobachevsky Function")
# ---------------------------------------------------------------------------
# write_index
# ---------------------------------------------------------------------------
def test_index_creates_index_md(
mock_settings: Path,
) -> None:
"""write_index creates wiki/index.md."""
pages = [
("discrete-conformal-map", "Discrete Conformal Map"),
("circle-packing", "Circle Packing"),
]
write_index(pages, wiki_dir=mock_settings)
index_path = mock_settings / "index.md"
assert index_path.exists()
def test_index_contains_links(
mock_settings: Path,
) -> None:
"""wiki/index.md contains [[slug]] links for each page."""
pages = [
("discrete-conformal-map", "Discrete Conformal Map"),
("circle-packing", "Circle Packing"),
]
write_index(pages, wiki_dir=mock_settings)
content = (mock_settings / "index.md").read_text(encoding="utf-8")
assert "[[discrete-conformal-map]]" in content
assert "[[circle-packing]]" in content
def test_index_contains_header(
mock_settings: Path,
) -> None:
"""wiki/index.md starts with a # Wiki Index header."""
write_index([], wiki_dir=mock_settings)
content = (mock_settings / "index.md").read_text(encoding="utf-8")
assert content.startswith("# Wiki Index")
# ---------------------------------------------------------------------------
# append_log
# ---------------------------------------------------------------------------
def test_log_append_creates_log_md(
mock_settings: Path,
) -> None:
"""append_log creates wiki/log.md if it does not exist."""
report = CompileReport(
compiled=["lobachevsky-function"],
skipped=[],
ungrounded=[],
)
append_log(report, wiki_dir=mock_settings)
log_path = mock_settings / "log.md"
assert log_path.exists()
def test_log_append_does_not_overwrite(
mock_settings: Path,
) -> None:
"""Two calls to append_log accumulate entries — older entry is NOT overwritten."""
report1 = CompileReport(compiled=["first-concept"])
report2 = CompileReport(compiled=["second-concept"])
append_log(report1, wiki_dir=mock_settings)
append_log(report2, wiki_dir=mock_settings)
content = (mock_settings / "log.md").read_text(encoding="utf-8")
assert "first-concept" in content
assert "second-concept" in content
def test_log_append_records_ungrounded(
mock_settings: Path,
) -> None:
"""Ungrounded claims appear in the log entry."""
report = CompileReport(
compiled=["lobachevsky-function"],
ungrounded=[("lobachevsky-function", "some ungrounded claim text")],
)
append_log(report, wiki_dir=mock_settings)
content = (mock_settings / "log.md").read_text(encoding="utf-8")
assert "some ungrounded claim text" in content
assert "" in content
def test_log_append_records_skipped(
mock_settings: Path,
) -> None:
"""Skipped slugs appear in the log entry."""
report = CompileReport(
compiled=[],
skipped=["circle-packing"],
)
append_log(report, wiki_dir=mock_settings)
content = (mock_settings / "log.md").read_text(encoding="utf-8")
assert "circle-packing" in content
# ---------------------------------------------------------------------------
# Idempotency: second compile without chunk change → no rewrite
# ---------------------------------------------------------------------------
def test_idempotent_no_rewrite_when_unchanged(
mock_retrieve: None,
mock_formulas: None,
mock_settings: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Second compile_all with unchanged chunks skips the concept (changed_only=True)."""
from codex.wiki import compile_all
call_count = 0
original_compile = compile_concept
def counting_compile(
concept: Concept, chunks: list[Any], **kwargs: Any
) -> ConceptPage:
nonlocal call_count
call_count += 1
return original_compile(concept, chunks, **kwargs)
monkeypatch.setattr("codex.wiki.compile_concept", counting_compile)
# Write concepts.yaml into wiki_dir
yaml_content = textwrap.dedent(
"""\
concepts:
- slug: lobachevsky-function
title: Lobachevsky Function
aliases: [Milnor Lobachevsky]
"""
)
(mock_settings / "concepts.yaml").write_text(yaml_content, encoding="utf-8")
llm = MockLLM(GROUNDED_LLM_OUTPUT)
# First compile: should call compile_concept
compile_all(changed_only=True, llm=llm, output_dir=str(mock_settings))
first_count = call_count
# Second compile with same chunks: should skip
compile_all(changed_only=True, llm=llm, output_dir=str(mock_settings))
second_count = call_count
assert first_count >= 1, "First run should compile at least once"
assert second_count == first_count, "Second run should not recompile (unchanged chunks)"
def test_force_all_recompiles_even_when_unchanged(
mock_retrieve: None,
mock_formulas: None,
mock_settings: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""compile_all with changed_only=False recompiles regardless of hash."""
from codex.wiki import compile_all
call_count = 0
original_compile = compile_concept
def counting_compile(
concept: Concept, chunks: list[Any], **kwargs: Any
) -> ConceptPage:
nonlocal call_count
call_count += 1
return original_compile(concept, chunks, **kwargs)
monkeypatch.setattr("codex.wiki.compile_concept", counting_compile)
yaml_content = textwrap.dedent(
"""\
concepts:
- slug: lobachevsky-function
title: Lobachevsky Function
aliases: [Milnor Lobachevsky]
"""
)
(mock_settings / "concepts.yaml").write_text(yaml_content, encoding="utf-8")
llm = MockLLM(GROUNDED_LLM_OUTPUT)
compile_all(changed_only=True, llm=llm, output_dir=str(mock_settings))
compile_all(changed_only=False, llm=llm, output_dir=str(mock_settings))
assert call_count >= 2, "Force --all should recompile even when unchanged"
# ---------------------------------------------------------------------------
# _chunk_hash
# ---------------------------------------------------------------------------
def test_chunk_hash_is_stable() -> None:
"""Same chunks produce the same hash each time."""
h1 = _chunk_hash(FIXTURE_CHUNKS)
h2 = _chunk_hash(FIXTURE_CHUNKS)
assert h1 == h2
assert len(h1) == 64
def test_chunk_hash_differs_on_content_change() -> None:
"""Different content produces different hash."""
modified = [dict(FIXTURE_CHUNKS[0], content="completely different content"), FIXTURE_CHUNKS[1]]
h1 = _chunk_hash(FIXTURE_CHUNKS)
h2 = _chunk_hash(modified)
assert h1 != h2
# ---------------------------------------------------------------------------
# LLM error handling (Fix 4)
# ---------------------------------------------------------------------------
def test_compile_concept_llm_unavailable_returns_empty_page(
mock_retrieve: None,
mock_formulas: None,
mock_settings: Path,
) -> None:
"""When LLM raises an exception, compile_concept returns an empty ConceptPage (no crash)."""
class FailingLLM:
def generate(self, prompt: str, model: str) -> str:
raise ConnectionError("Ollama unavailable")
page = compile_concept(
FIXTURE_CONCEPT, FIXTURE_CHUNKS, top_k=6, llm=FailingLLM(), wiki_dir=mock_settings
)
assert isinstance(page, ConceptPage)
assert page.markdown == ""
assert page.claims == []
assert page.chunk_hash != "" # hash still computed from chunks
# ---------------------------------------------------------------------------
# Quarantine (Fix 2)
# ---------------------------------------------------------------------------
def test_compile_all_quarantines_low_grounding_page(
mock_retrieve: None,
mock_formulas: None,
mock_settings: Path,
) -> None:
"""A page with grounding rate < 0.5 goes to wiki/draft/ not 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",
)
# Ungrounded LLM output → all claims ungrounded → grounding rate 0.0 < 0.5
llm = MockLLM(UNGROUNDED_LLM_OUTPUT)
report = compile_all(changed_only=False, llm=llm, output_dir=str(mock_settings))
draft_dir = mock_settings / "draft"
assert draft_dir.exists(), "draft/ directory should have been created"
draft_pages = list(draft_dir.glob("*.md"))
assert len(draft_pages) >= 1, "Quarantined page should exist in wiki/draft/"
assert "lobachevsky-function" in report.quarantined
# Normal wiki/ path must NOT exist for this page
assert not (mock_settings / "lobachevsky-function.md").exists()
def test_compile_all_does_not_quarantine_grounded_page(
mock_retrieve: None,
mock_formulas: None,
mock_settings: Path,
) -> None:
"""A page with grounding rate >= 0.5 is written to wiki/, not quarantined."""
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",
)
llm = MockLLM(GROUNDED_LLM_OUTPUT)
report = compile_all(changed_only=False, llm=llm, output_dir=str(mock_settings))
assert "lobachevsky-function" not in report.quarantined
assert (mock_settings / "lobachevsky-function.md").exists()
# ---------------------------------------------------------------------------
# Conflict detection (Fix 9)
# ---------------------------------------------------------------------------
def test_detect_conflicts_finds_adversative_keywords() -> None:
"""Chunks from different bibkeys both containing adversative keywords → conflict."""
chunks = [
{
"id": 10,
"paper_id": "p1",
"ord": 0,
"content": "The discrete map works well, but diverges in degenerate cases.",
"bibkey": "Smith2020",
},
{
"id": 11,
"paper_id": "p2",
"ord": 0,
"content": "However, the approach from Jones is more stable.",
"bibkey": "Jones2019",
},
]
conflicts = _detect_conflicts("test-concept", chunks)
assert len(conflicts) >= 1
slugs, bk1s, bk2s = zip(*conflicts, strict=True)
assert "test-concept" in slugs
def test_detect_conflicts_no_conflict_when_single_bibkey() -> None:
"""Only one bibkey → no conflict possible."""
conflicts = _detect_conflicts("test-concept", FIXTURE_CHUNKS)
assert conflicts == []
# ---------------------------------------------------------------------------
# append_log quarantine rendering
# ---------------------------------------------------------------------------
def test_log_append_records_quarantined(
mock_settings: Path,
) -> None:
"""Quarantined slugs appear in the log entry."""
report = CompileReport(
compiled=[],
quarantined=["some-concept"],
)
append_log(report, wiki_dir=mock_settings)
content = (mock_settings / "log.md").read_text(encoding="utf-8")
assert "some-concept" in content
assert "QUARANTINED" in content

122
tests/wiki/test_concepts.py Normal file
View File

@@ -0,0 +1,122 @@
"""Tests for load_concepts() — YAML parsing of wiki/concepts.yaml."""
from __future__ import annotations
import textwrap
from pathlib import Path
import pytest
from codex.wiki import Concept, load_concepts
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
FIXTURE_YAML = textwrap.dedent(
"""\
concepts:
- slug: discrete-conformal-map
title: Discrete Conformal Map
aliases: [discrete conformal equivalence, discrete uniformization]
emphasis: Fokus auf die variationelle Charakterisierung (BPS 2015).
- slug: circle-packing
title: Circle Packing
aliases: [Koebe-Andreev-Thurston, circle pattern]
- slug: lobachevsky-function
title: Lobachevsky Function
aliases: [Milnor Lobachevsky, Clausen function]
emphasis: Verwendung in hyperbolischen Volumenformeln (Springborn 2008).
- slug: discrete-yamabe-flow
title: Discrete Yamabe Flow
aliases: [discrete Ricci flow, Chow-Luo]
- slug: hyperideal-tetrahedron
title: Hyperideal Tetrahedron
aliases: [hyperbolic volume, Schläfli, Ushijima]
"""
)
@pytest.fixture
def concepts_yaml(tmp_path: Path) -> Path:
"""Write fixture YAML to a temp file and return the path."""
p = tmp_path / "concepts.yaml"
p.write_text(FIXTURE_YAML, encoding="utf-8")
return p
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
def test_load_concepts_returns_all_concepts(concepts_yaml: Path) -> None:
"""All five concept entries are returned."""
concepts = load_concepts(str(concepts_yaml))
assert len(concepts) == 5
def test_load_concepts_types(concepts_yaml: Path) -> None:
"""Every returned item is a Concept instance."""
concepts = load_concepts(str(concepts_yaml))
for c in concepts:
assert isinstance(c, Concept)
def test_load_concepts_first_slug_and_title(concepts_yaml: Path) -> None:
"""First concept has correct slug and title."""
concepts = load_concepts(str(concepts_yaml))
assert concepts[0].slug == "discrete-conformal-map"
assert concepts[0].title == "Discrete Conformal Map"
def test_load_concepts_aliases_are_lists(concepts_yaml: Path) -> None:
"""Aliases are parsed as a list of strings."""
concepts = load_concepts(str(concepts_yaml))
for c in concepts:
assert isinstance(c.aliases, list)
for alias in c.aliases:
assert isinstance(alias, str)
def test_load_concepts_first_has_two_aliases(concepts_yaml: Path) -> None:
"""First concept has exactly two aliases."""
concepts = load_concepts(str(concepts_yaml))
assert concepts[0].aliases == ["discrete conformal equivalence", "discrete uniformization"]
def test_load_concepts_emphasis_present(concepts_yaml: Path) -> None:
"""Concepts with emphasis field have it parsed correctly."""
concepts = load_concepts(str(concepts_yaml))
dcm = next(c for c in concepts if c.slug == "discrete-conformal-map")
assert dcm.emphasis is not None
assert "variationelle" in dcm.emphasis
def test_load_concepts_emphasis_absent_is_none(concepts_yaml: Path) -> None:
"""Concepts without emphasis field have emphasis=None."""
concepts = load_concepts(str(concepts_yaml))
cp = next(c for c in concepts if c.slug == "circle-packing")
assert cp.emphasis is None
def test_load_concepts_slugs_unique(concepts_yaml: Path) -> None:
"""All slugs are distinct."""
concepts = load_concepts(str(concepts_yaml))
slugs = [c.slug for c in concepts]
assert len(slugs) == len(set(slugs))
def test_load_concepts_unicode_in_aliases(concepts_yaml: Path) -> None:
"""Aliases with non-ASCII characters (Schläfli) are parsed without error."""
concepts = load_concepts(str(concepts_yaml))
ht = next(c for c in concepts if c.slug == "hyperideal-tetrahedron")
assert any("Schläfli" in a for a in ht.aliases)
def test_load_concepts_empty_yaml(tmp_path: Path) -> None:
"""An empty concepts list returns an empty list (no crash)."""
p = tmp_path / "concepts.yaml"
p.write_text("concepts: []\n", encoding="utf-8")
concepts = load_concepts(str(p))
assert concepts == []

2167
uv.lock generated

File diff suppressed because it is too large Load Diff

20
wiki/concepts.yaml Normal file
View File

@@ -0,0 +1,20 @@
# Schema-Datei: was die KB abdeckt + Emphasis-Hebel (analog CLAUDE.md im Second-Brain).
# Der Mensch kuratiert diese Liste — sie steuert, worüber die Wiki Seiten baut.
concepts:
- slug: discrete-conformal-map
title: Discrete Conformal Map
aliases: [discrete conformal equivalence, discrete uniformization]
emphasis: Fokus auf die variationelle Charakterisierung (BPS 2015).
- slug: circle-packing
title: Circle Packing
aliases: [Koebe-Andreev-Thurston, circle pattern]
- slug: lobachevsky-function
title: Lobachevsky Function
aliases: [Milnor Lobachevsky, Clausen function]
emphasis: Verwendung in hyperbolischen Volumenformeln (Springborn 2008).
- slug: discrete-yamabe-flow
title: Discrete Yamabe Flow
aliases: [discrete Ricci flow, Chow-Luo]
- slug: hyperideal-tetrahedron
title: Hyperideal Tetrahedron
aliases: [hyperbolic volume, Schläfli, Ushijima]