reference-audit 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. reference_audit/__init__.py +8 -0
  2. reference_audit/alignmentcheck.py +136 -0
  3. reference_audit/bookcheck.py +98 -0
  4. reference_audit/cache/__init__.py +1 -0
  5. reference_audit/cache/db.py +108 -0
  6. reference_audit/cache/store.py +128 -0
  7. reference_audit/cli.py +190 -0
  8. reference_audit/config.py +179 -0
  9. reference_audit/fieldcheck.py +599 -0
  10. reference_audit/inputs.py +96 -0
  11. reference_audit/llm/__init__.py +6 -0
  12. reference_audit/llm/client.py +85 -0
  13. reference_audit/llm/prompts.py +206 -0
  14. reference_audit/llm/schemas.py +38 -0
  15. reference_audit/matching/__init__.py +6 -0
  16. reference_audit/matching/adjudicate.py +63 -0
  17. reference_audit/matching/features.py +185 -0
  18. reference_audit/matching/names.py +238 -0
  19. reference_audit/matching/pool.py +344 -0
  20. reference_audit/matching/sameobject.py +150 -0
  21. reference_audit/matching/scoring.py +75 -0
  22. reference_audit/matching/verdict.py +56 -0
  23. reference_audit/matching/webcheck.py +189 -0
  24. reference_audit/models.py +412 -0
  25. reference_audit/parsing/__init__.py +1 -0
  26. reference_audit/parsing/bib.py +392 -0
  27. reference_audit/parsing/context.py +55 -0
  28. reference_audit/parsing/entities.py +22 -0
  29. reference_audit/parsing/identifiers.py +165 -0
  30. reference_audit/parsing/latex.py +83 -0
  31. reference_audit/parsing/tex.py +159 -0
  32. reference_audit/pdf/__init__.py +16 -0
  33. reference_audit/pdf/embedded.py +117 -0
  34. reference_audit/pdf/grobid.py +234 -0
  35. reference_audit/pdf/tei.py +470 -0
  36. reference_audit/pipeline.py +1692 -0
  37. reference_audit/report.py +330 -0
  38. reference_audit/sources/__init__.py +6 -0
  39. reference_audit/sources/arxiv.py +81 -0
  40. reference_audit/sources/base.py +54 -0
  41. reference_audit/sources/clickhouse.py +575 -0
  42. reference_audit/sources/crossref.py +90 -0
  43. reference_audit/sources/dblp.py +140 -0
  44. reference_audit/sources/google_books.py +101 -0
  45. reference_audit/sources/http.py +260 -0
  46. reference_audit/sources/normalize.py +464 -0
  47. reference_audit/sources/openalex.py +93 -0
  48. reference_audit/sources/openlibrary.py +158 -0
  49. reference_audit/sources/publisher.py +186 -0
  50. reference_audit/sources/registry.py +117 -0
  51. reference_audit/sources/render.py +115 -0
  52. reference_audit/sources/semantic_scholar.py +68 -0
  53. reference_audit/sources/titlewords.py +31 -0
  54. reference_audit/sources/web.py +256 -0
  55. reference_audit/versioning.py +125 -0
  56. reference_audit-0.1.0.dist-info/METADATA +715 -0
  57. reference_audit-0.1.0.dist-info/RECORD +60 -0
  58. reference_audit-0.1.0.dist-info/WHEEL +4 -0
  59. reference_audit-0.1.0.dist-info/entry_points.txt +2 -0
  60. reference_audit-0.1.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,715 @@
1
+ Metadata-Version: 2.5
2
+ Name: reference-audit
3
+ Version: 0.1.0
4
+ Summary: Audit .bib/.tex references: identify the exact artifact, screen for hallucinated references, and produce the best reference.
5
+ License-File: LICENSE
6
+ Requires-Python: >=3.14
7
+ Requires-Dist: anyascii>=0.3.3
8
+ Requires-Dist: beautifulsoup4>=4.15.0
9
+ Requires-Dist: bibtexparser>=2.1
10
+ Requires-Dist: clickhouse-connect>=0.8
11
+ Requires-Dist: curl-cffi>=0.7
12
+ Requires-Dist: httpx>=0.28.1
13
+ Requires-Dist: loguru>=0.7.3
14
+ Requires-Dist: openai>=2.44.0
15
+ Requires-Dist: pydantic-settings>=2.14.2
16
+ Requires-Dist: pydantic>=2.13.4
17
+ Requires-Dist: pylatexenc<3,>=2.10
18
+ Requires-Dist: pypdf>=6.20.0
19
+ Requires-Dist: rapidfuzz>=3.14.5
20
+ Requires-Dist: tenacity>=9.1.4
21
+ Requires-Dist: tqdm>=4.67
22
+ Requires-Dist: typer>=0.26.8
23
+ Description-Content-Type: text/markdown
24
+
25
+ # reference-audit
26
+
27
+ Audit the references in a paper. Given a `.bib` bibliography and the `.tex` that cites it — or just
28
+ the paper's **PDF** — `reference-audit` figures out **which real-world document each reference
29
+ actually points to** and flags ones that don't match anything real (hallucinated references).
30
+
31
+ For every entry it returns one of three verdicts:
32
+
33
+ - **exactly one match** — the reference resolves to a single real document (with its DOI/ISBN/URL,
34
+ and any preprint↔published versions merged).
35
+ - **no match** — no real document corresponds to the entry (a likely hallucination).
36
+ - **multiple matches** — the entry is ambiguous and matches more than one distinct document.
37
+
38
+ It also backfills missing DOIs/ISBNs, normalizes malformed identifiers, and reports dangling or
39
+ uncited citations.
40
+
41
+ > Designed for three audiences: authors polishing their bibliography, automated screening of
42
+ > submissions for hallucinated references, and AI agents writing papers. The full product
43
+ > specification lives in [`architecture/SPEC.md`](architecture/SPEC.md).
44
+
45
+ ## How it works
46
+
47
+ For each entry the tool runs a funnel that prefers cheap, deterministic evidence and only escalates
48
+ to an LLM when needed:
49
+
50
+ 1. **Parse** the input — either the `.bib` (resolving `\cite`/`\nocite` in the `.tex`), or a PDF, from
51
+ which a locally-run [GROBID](https://github.com/grobidOrg/grobid) extracts both the printed
52
+ reference list and the in-text citation markers. A PDF that embeds the BibTeX records of its cited
53
+ works (as one made with the [AI4X template](#pdfs-that-embed-their-references) does) is read from
54
+ those records instead, without GROBID. Either way, DOIs, ISBNs, arXiv ids, OpenAlex Work
55
+ ids (an `openalex.org/W…` URL becomes a first-class identifier) and Google Books volume ids (a
56
+ `books.google.…/books?id=…` URL) are normalized identically, and everything below is unchanged.
57
+ HTML character references left by web-scraped metadata (`d&apos;Amore`, `&amp;`) are decoded in
58
+ the `.bib`'s title, author, venue, publisher and pages, and in every source record's title,
59
+ authors, venue and publisher, so the two compare as plain text in identification and in the field
60
+ check of step 7, and findings report the decoded value.
61
+ 2. **Query** multiple scholarly databases — Crossref, OpenAlex, Semantic Scholar, arXiv, DBLP, Open
62
+ Library, Google Books — by identifier where the source supports it, and by title/author. The
63
+ title/author search is routed by entry type: a `@book`/`@incollection` goes to Open Library,
64
+ Google Books and Crossref; a `@misc` also to arXiv; anything else to Crossref, OpenAlex, Semantic
65
+ Scholar and DBLP. With the [ClickHouse backend](#local-clickhouse-backend), Semantic Scholar,
66
+ OpenAlex and DBLP are read from a local mirror instead. A cited Google Books volume id is
67
+ resolved directly to that volume, and a cited OpenAlex Work id to that Work when the entry
68
+ carries no DOI or arXiv id (OpenAlex is queried by those first) — the authoritative key for
69
+ entries, notably trade books, the other sources miss. DBLP is the authority for the premier
70
+ CS/ML venues (NeurIPS, ICLR, ICML/PMLR, TMLR), which mint no DOI and are thinly covered by the
71
+ article-centric sources. A paper cited only by its proceedings or OpenReview URL is confirmed by
72
+ a near-exact title and a strong author overlap with a record (a bare URL is not treated as a
73
+ matching anchor, so such an entry takes the same strict title+author path as one with no
74
+ identifier at all). DBLP is queried through its SPARQL endpoint (`sparql.dblp.org`), because its
75
+ search API now serves automated clients a bot-challenge page, never JSON. A truncated author
76
+ list (the BibTeX `and others` / "et al." convention) is recognized as such, so its named authors
77
+ all appearing in the full record's author list (in any order) confirms the entry rather than
78
+ reading the omitted names as a different work. The **publisher** of record (the DOI landing
79
+ page's BibTeX export, for MIT Press / Silverchair and SAGE / Atypon) is not an identity source: it
80
+ is queried only after the verdict, to enrich the matched work's venue, volume, issue, pages,
81
+ publisher and year.
82
+ 3. **Pool** the results, merging records that are the same work (shared identifier, or a
83
+ preprint↔published version link). Pooling keys on a record's canonical ISBN-13 only, so a book
84
+ chapter that inherits its volume's ISBNs is not fused with its sibling chapters. When the
85
+ accepted candidates are counted as works, ISBNs are treated as a *set*: one book registers several
86
+ ISBN-13s (print + electronic, per edition), so records that share any one of them are the same
87
+ work rather than different books. A version relation (a matching title, or a
88
+ database's version link) joins two groups only when every record of one has authors compatible
89
+ with every record of the other, compared person by person. So a conference paper is not fused with
90
+ a journal extension that has other authors (CrossFormer at ICLR, CrossFormer++ in TPAMI), not even
91
+ through a one-author supplementary-material DOI that fits both. A pooled record keeps its member
92
+ records, and its venue is never a preprint server (`arXiv`, DBLP's `CoRR`) while a member names
93
+ the journal or conference. Its author list, which scoring and the LLM see, comes from the most
94
+ reliable source that has one (the publisher, then DBLP, Crossref, arXiv, OpenAlex, Semantic
95
+ Scholar), not from the citation-richest record.
96
+ 4. **Score** each candidate with interpretable features (title/author/year/venue similarity,
97
+ identifier agreement, and distinct-work signals). Identifier agreement is likewise set-aware for
98
+ ISBNs — a cite that gives a book's electronic ISBN matches a source record carrying that book's
99
+ print ISBN, and it counts as a *conflict* only when the two ISBN sets are wholly disjoint (this is
100
+ what lets a book-chapter cited by its volume's ISBN resolve deterministically instead of falling
101
+ to the LLM).
102
+ 5. **Adjudicate** anything the formal rules leave undecided with an LLM, asking one record at a time
103
+ whether it can correspond to the entry; a second LLM check decides whether two strong candidates
104
+ are the *same* work. At most `llm_max_candidates` (8) candidates, the best-scoring, are
105
+ adjudicated per entry; an entry with more is left unresolved and says so.
106
+ 6. **Verdict** — count the distinct works and report none / exactly one / multiple. An entry left
107
+ without a verdict says why: the source that failed, the LLM call that failed, or the candidates
108
+ that stayed undecided.
109
+ 7. **Check the fields** of an exactly-one match (title, authors, venue, year, volume, pages, …).
110
+ - Each field is compared against the **version the entry cites**, read from the pooled record's
111
+ members: a citation of the arXiv preprint against the preprint, one of a journal or conference
112
+ against the published paper, and of a conference paper and its later journal version, the one
113
+ from the cited year. Within that version the value most sources agree on wins, so one source's
114
+ defect (OpenAlex's corrupted title for Constitutional AI) is outvoted.
115
+ - **Authors** are checked person by person against every source's author list, so name order
116
+ (`Tian Li` / `Li Tian`), compound surnames, diminutives (`Tim` / `Timothy`) and one source's
117
+ defect do not flag a real author, and a near-namesake does not pass (`Carreira` is not
118
+ `Barreira`). A cited author on no source is an
119
+ `error`. A citation that names only some of the work's authors without `and others` is reported
120
+ per `--partial-authors`: `ignore`, `warn` (an `uncertain` finding, the default) or `error`.
121
+ - A difference no rule settles goes to the LLM, which is shown the matched work as the database
122
+ records it, never the entry under review. A substituted title word, or a different journal or
123
+ conference series, is an error.
124
+ - The **cited DOI** is checked too (except for a book, which is legitimately cited by a chapter
125
+ DOI, and an arXiv DataCite DOI): when the matched work does not carry it, the DOI's own records
126
+ are looked up. A DOI that belongs to a different paper, or that the doi.org Handle API does not
127
+ know, is reported as a wrong `doi` field, naming that paper. A DOI found for backfill is checked
128
+ at doi.org too: one that does not resolve is reported as unusable, not as a find.
129
+ 8. **Suggest a better version** of a matched work: the published version of a cited preprint, a
130
+ newer arXiv version, or a later edition of a cited book.
131
+
132
+ A reference identified only by a URL (a `@misc` blog post, software or project page that no scholarly
133
+ database indexes) is verified against the page itself: the tool fetches the URL, checks the page's
134
+ own HTML metadata (Open Graph / Twitter card / `citation_*` / Dublin Core / `<title>` + authors)
135
+ against the citation, and falls back to the LLM when that metadata is missing or inconclusive. A dead
136
+ link or a page that cannot be confirmed is reported and left unresolved, never silently passed. Only
137
+ a high-confidence LLM judgment that the live page is a *different* resource gives *no match* (a
138
+ possibly wrong or fabricated URL).
139
+
140
+ Many such pages are **JavaScript single-page apps** (e.g. `data.europa.eu` dataset pages): a plain
141
+ HTTP fetch returns only an empty app shell, with the real title/text injected after the page's
142
+ JavaScript runs. The tool detects these shells and re-fetches them through a headless browser
143
+ (Chromium via `--dump-dom`) so the rendered page is judged like any other. This is the reliability
144
+ fix for a class of false hallucinations: an unreadable shell is *never* read as "a different page".
145
+ If no headless browser is available, or rendering fails, the entry is reported and left unresolved
146
+ (retried next run) rather than flagged. Rendering is on by default (`web_render_enabled`); point
147
+ `WEB_RENDER_BROWSER_PATH` at a Chrome/Chromium binary if one is not auto-detected on `PATH`.
148
+
149
+ A reference that cites an **OpenAlex Work id** (an `openalex.org/W…` URL, common for books and other
150
+ titles the article-centric search and Crossref/Open Library miss) is resolved directly to that Work,
151
+ also when the entry carries a DOI or arXiv id (OpenAlex is then queried by both).
152
+ When the Work matches the entry's title and authors it is pinned as the match — the author-supplied
153
+ Work id is treated as authoritative identity, so a similar-titled foreign-DOI record is never merged
154
+ in and its identifiers are never backfilled onto the entry. A cited DOI that belongs to another work
155
+ is then reported by the cited-DOI check (a `doi` field finding naming that work). A cited Work id
156
+ whose Work has a mismatched title/author does not confirm the entry; the entry gets whatever verdict
157
+ the other candidates support, and an issue names the work the id resolves to. A Work id that resolves
158
+ to nothing, or whose lookup failed, is reported too. These Work-id issues are reported for every
159
+ verdict, including an unresolved entry.
160
+
161
+ For **books**, Open Library is the authority of record for identity: it is edition-aware, so it can
162
+ confirm a real book the article-centric matcher rejected (e.g. a 1976 original whose only DOI-bearing
163
+ candidate is a 2018 reprint). Because of this, a book is only ever called a hallucination when that
164
+ authority was actually consulted — if Open Library is unreachable, an article-search "no match" is
165
+ **not** trusted: the entry is left unresolved (reported, retried next run) rather than flagged as a
166
+ likely hallucination we never really checked.
167
+
168
+ A book is frequently cited by a **chapter-level DOI** (Oxford Scholarship Online and similar mint one
169
+ DOI per chapter, e.g. `10.1093/{isbn}.003.0002`), which resolves to a *component* whose title differs
170
+ from the book — so the article-centric matcher rejects it as a non-match. When such a book carries no
171
+ ISBN of its own, the ISBNs on that cited DOI's own record are used to locate the book in Open Library
172
+ (every ISBN is tried, since one book registers several and Open Library indexes only some). The cited
173
+ *edition* is still pinned by the entry's own year/publisher, so a book cited by a chapter DOI grounds
174
+ on the edition the author actually cited (the 1989 original, not a 1992 reprint the DOI rides on), and
175
+ the report notes that a chapter/component DOI was cited.
176
+
177
+ **Google Books** supplements this where Open Library falls short. Open Library's title search is
178
+ strict — a title carrying its subtitle (*"Why Nations Fail: The Origins of Power, Prosperity, and
179
+ Poverty"*) or a single off-by-one ISBN can return nothing, so a real book is reported "not found
180
+ there". Google Books is more forgiving (`intitle:`/`inauthor:`/`isbn:`), and when the `.bib` carries
181
+ a Google Books **volume id** in its URL, that volume is resolved directly and pinned as authoritative
182
+ identity — exactly like a cited OpenAlex Work id. This matters because a same-titled journal-article
183
+ (e.g. a book *review* that reuses the book's title and authors and carries a DOI the book lacks) would
184
+ otherwise be matched and have its DOI wrongly backfilled onto the `@book`. For a `@book` or
185
+ `@incollection`, an edition Open Library confirms still takes precedence over either pin.
186
+
187
+ Results are cached in a local SQLite DB, so re-running on the same `.bib` makes **no** repeat
188
+ network or LLM calls for what succeeded. Errors are never cached: an entry left without a verdict,
189
+ a failed source query or an undetermined doi.org check is retried on the next run. A transient
190
+ outage never counts as "no match".
191
+
192
+ ## Requirements
193
+
194
+ - Python 3.14
195
+ - [`uv`](https://docs.astral.sh/uv/)
196
+ - An OpenAI API key (for the LLM adjudication step)
197
+ - **For PDF input only:** a running GROBID. It is not managed by this tool — start one with:
198
+ ```bash
199
+ podman run -d --replace --name grobid -p 8070:8070 docker.io/grobid/grobid:0.9.1-crf
200
+ ```
201
+ The first start takes ~30 s while the models load. `--replace` swaps out an existing `grobid`
202
+ container (e.g. one from an older image). Docker has no `--replace`: run `docker rm -f grobid`
203
+ first, then the same command with `docker` instead of `podman` and without `--replace`.
204
+
205
+ ## Setup
206
+
207
+ ```bash
208
+ uv sync
209
+ ```
210
+
211
+ Create a `.env` file with your keys in the directory you run the tool from (normally the project
212
+ root; it is read from the current directory, and is git-ignored):
213
+
214
+ ```dotenv
215
+ # Required for LLM adjudication
216
+ OPENAI_API_KEY=sk-...
217
+ # OPENAI_BASE_URL=... # optional — an OpenAI-compatible endpoint
218
+ # Optional — sampling temperature. Unset ⇒ the model default (gpt-6-luna accepts only its default);
219
+ # set LLM_TEMPERATURE=0 when overriding --model with one that supports it, for more stable verdicts
220
+ # LLM_TEMPERATURE=0
221
+
222
+ # Optional — improve coverage / rate limits for the data sources
223
+ S2_API_KEY=...
224
+ CROSSREF_MAILTO=you@example.org # Crossref/OpenAlex polite-pool contact
225
+ PAPER_SEARCH_MCP_UNPAYWALL_EMAIL=you@example.org # used as that contact when CROSSREF_MAILTO is unset
226
+ OPENLIBRARY_EMAIL=you@example.org # sent in the User-Agent on Open Library requests (polite identification)
227
+ GOOGLE_BOOKS_API_KEY=... # Google Books per-project quota (the keyless endpoint shares a global daily quota that is routinely exhausted)
228
+
229
+ # Optional — only used for PDF input; overridable per-run with --grobid
230
+ GROBID_URL=http://localhost:8070
231
+
232
+ # Optional — read Semantic Scholar, OpenAlex and DBLP from a local ClickHouse mirror instead of their
233
+ # APIs (see "Local ClickHouse backend"); overridable per-run with --backend
234
+ SOURCE_BACKEND=clickhouse # default: api
235
+ CLICKHOUSE_HOST=127.0.0.1
236
+ CLICKHOUSE_PORT=8123
237
+ CLICKHOUSE_USER=default
238
+ CLICKHOUSE_DEFAULT_USER_PASSWORD=... # or CLICKHOUSE_PASSWORD
239
+ # CLICKHOUSE_S2_DB=s2ag CLICKHOUSE_OPENALEX_DB=openalex CLICKHOUSE_DBLP_DB=kb (the defaults;
240
+ # CLICKHOUSE_DB is accepted for CLICKHOUSE_S2_DB)
241
+
242
+ # Optional — a Chrome/Chromium binary for rendering JavaScript pages, if none is on PATH
243
+ # WEB_RENDER_BROWSER_PATH=/usr/bin/chromium
244
+ ```
245
+
246
+ `NCBI_API_KEY`, `NASA_ADS_API_KEY` and `CORE_API_KEY` are accepted in `.env` but not used: there is
247
+ no PubMed, ADS or CORE source. Any other `AuditConfig` field (in `config.py`) can also be set by its
248
+ upper-cased name, e.g. `MODEL=` replaces the default model and `USE_LLM=false` turns the LLM off.
249
+
250
+ Only `OPENAI_API_KEY` is needed to run the full pipeline; the data sources used by default
251
+ (Crossref, OpenAlex, Semantic Scholar, arXiv, DBLP, Open Library, and the publisher and cited-page
252
+ fetches) require no key. Google Books works without a key but on a
253
+ shared global daily quota that is frequently exhausted — set `GOOGLE_BOOKS_API_KEY` for reliable
254
+ book coverage at scale. You can also run with no LLM at all (`--no-llm`, see below).
255
+
256
+ ## Usage
257
+
258
+ Two input forms — a manuscript with its bibliography, or a PDF on its own:
259
+
260
+ ```bash
261
+ uv run reference-audit audit <main.tex> <references.bib> [options]
262
+ uv run reference-audit audit <paper.pdf> [options]
263
+ ```
264
+
265
+ They are mutually exclusive: a PDF *is* the whole input, since GROBID extracts both its reference list
266
+ and its in-text citations (or the PDF embeds its references' BibTeX records), so passing a `.bib`
267
+ alongside one is rejected.
268
+
269
+ Example, against the bundled pilot:
270
+
271
+ ```bash
272
+ uv run reference-audit audit \
273
+ tests/documents/directing-open-ended-evolution/initial.tex \
274
+ tests/documents/directing-open-ended-evolution/initial.bib
275
+ ```
276
+
277
+ ```
278
+ Reference audit
279
+ 28 entries · 28 cited · 0 uncited · 7 with issues · 1 commented twins
280
+ verdicts: 28 matched · 0 no-match · 0 ambiguous · 0 unresolved
281
+ types: article=18, book=2, inproceedings=5, misc=3
282
+
283
+ CAPITAL OFFENCES — No hallucinated citations
284
+
285
+ UNABLE TO VERIFY — For all other references at least one matching artifact was positively identified
286
+
287
+ ISSUES (7) — other problems to review:
288
+
289
+ [article] wolpert2007 (cited)
290
+ Using self-dissimilarity to quantify complexity
291
+ ids: doi:10.1002/cplx.20165
292
+ ⚠ DOI normalized from URL form ('https://doi.org/10.1002/cplx.20165' → '10.1002/cplx.20165')
293
+ ✓ exactly one match (high) — Matched a single work via crossref.
294
+ matched: doi:10.1002/cplx.20165 (crossref)
295
+
296
+ [inproceedings] fu2023dreamsim (cited)
297
+ DreamSim: Learning New Dimensions of Human Visual Similarity using Synthetic Data
298
+ ids: (no identifier)
299
+ ⚠ no DOI/arXiv id (will attempt DOI backfill)
300
+ ✓ exactly one match (high) — Matched a single work via semantic_scholar (2 versions).
301
+ ...
302
+
303
+ FORMATTING NITS & ADVISORIES (3) — cosmetic field fixes or citation notes:
304
+
305
+ [article] goldenfeld1992lectures (cited)
306
+ Lectures on Phase Transitions and the Renormalization Group
307
+ ids: doi:10.1201/9780429493492
308
+ · formatting nit in 'pages'='185-–197' — non-standard page separator in '185-–197'; use '--' [crossref]
309
+ ✓ exactly one match (high) — Matched a single work via crossref.
310
+ matched: doi:10.1201/9780429493492 (crossref)
311
+ ...
312
+
313
+ NO ISSUES (18) — verified, nothing to fix:
314
+ ...
315
+ ```
316
+
317
+ ### Options
318
+
319
+ | Option | Description |
320
+ | --- | --- |
321
+ | `-f, --format text\|json\|both` | Output format. `json` emits the full structured report (verdicts, candidates, features) — ideal for tooling and AI agents. Default: `text`. |
322
+ | `--no-network` | Parse only: identifiers + cited/uncited bookkeeping, no database or LLM calls. |
323
+ | `--no-llm` | Formal-only: skip LLM adjudication for fully deterministic output (useful in CI). |
324
+ | `--check-citations` | Advisory: for each in-text citation of a matched reference, check the citing context against the cited work's **abstract** (needs the LLM). See [Citation alignment](#citation-alignment---check-citations). |
325
+ | `--fresh` | Ignore results cached before this run and re-query everything; new results overwrite the old ones. Nothing is deleted, so other bibliographies sharing the cache DB keep their cached results. |
326
+ | `--cache PATH` | Cache DB location. Default: `.reference_audit/cache.db` beside the `.bib` (or the `.pdf`). |
327
+ | `--model NAME` | Override the LLM model (default `gpt-6-luna`). |
328
+ | `--grobid URL` | GROBID base URL for PDF input. Default `http://localhost:8070` (or `GROBID_URL`). |
329
+ | `--ignore-embedded` | For a PDF that embeds the BibTeX records of its cited works, extract the references with GROBID anyway. See [PDFs that embed their references](#pdfs-that-embed-their-references). |
330
+ | `--backend api\|clickhouse` | Where Semantic Scholar, OpenAlex and DBLP are read from: their public APIs, or a local ClickHouse mirror. Default `SOURCE_BACKEND`, else `api`. See [Local ClickHouse backend](#local-clickhouse-backend). |
331
+ | `--fail-on hallucinated\|multiple` | Exit non-zero if any entry gets that verdict — for gating submissions in CI. Any other value is rejected before the audit starts, and so is combining it with `--no-network` (the parse-only report has no verdicts to check). |
332
+ | `--partial-authors ignore\|warn\|error` | How to report a cited author list that names only some of the work's authors without `and others`: not at all, as `uncertain` (default), or as an `error`. Default `PARTIAL_AUTHORS`, else `warn`. |
333
+
334
+ `--no-network` applies to `.tex` + `.bib` input, and to a PDF that embeds the BibTeX records of its
335
+ references. Any other PDF's reference list comes from an HTTP call to GROBID, so there is no offline
336
+ parse path for it, and combining the two is rejected rather than quietly redefining what the flag
337
+ promises.
338
+
339
+ Example — fail a CI check if any reference looks hallucinated, as JSON:
340
+
341
+ ```bash
342
+ uv run reference-audit audit paper.tex refs.bib --format json --fail-on hallucinated
343
+ ```
344
+
345
+ ### Local ClickHouse backend
346
+
347
+ Semantic Scholar, OpenAlex and DBLP can be read from a local [ClickHouse](https://clickhouse.com)
348
+ mirror instead of their public APIs: the S2 Academic Graph dump (database `s2ag`), an OpenAlex
349
+ snapshot (`openalex`) and the DBLP XML dump (`kb.dblp_publication`). Select it with
350
+ `SOURCE_BACKEND=clickhouse` in `.env` or `--backend clickhouse`. The other sources (Crossref, arXiv,
351
+ Open Library, Google Books, publisher and web fetches) always use their APIs. Those requests stay inside
352
+ each API's published limits: they are spaced per source, and Crossref (3 at a time, its polite-pool
353
+ limit) and arXiv (1) also cap requests in flight, retries included. arXiv's terms allow one request
354
+ every three seconds, so lookups by arXiv id are the slowest step of a large batch. Without these
355
+ limits, a batch of a few hundred entries turned about a fifth of them into 429s.
356
+
357
+ The local sources have no rate limits and no third-party outages. A large batch is then paced by the
358
+ API sources that remain: about 45 minutes per thousand references, mostly Crossref's three requests in
359
+ flight. On the Semantic Scholar API (1 request/s), the same batch takes about an hour.
360
+ The two backends are interchangeable to the rest of the pipeline. The local adapters keep the API
361
+ adapters' source names, and shape their rows like the API's JSON before the same normalizers. Their
362
+ cached responses are kept apart, and the verdict cache is keyed by backend, so neither backend ever
363
+ serves the other's result.
364
+
365
+ **Title search** needs a full-text index on each searched table. It matches titles that contain
366
+ every searchable word of the cited title, shortest title first, then (S2 and OpenAlex, which have
367
+ citation counts) most-cited first. When none of those is a near-exact title (and the title has at
368
+ least 4 distinct words), it searches again with each word left out in turn, ranked by edit distance
369
+ to the cited title. So one misspelt or extra word no longer hides the real paper; two still do.
370
+ Before any entry is audited, the run checks that the server answers, that each index exists and is
371
+ fully built, and that each snapshot reports a coverage date. If not, it exits rather than scanning
372
+ hundreds of millions of rows per reference: for a missing index, with the statements below; for an
373
+ index still building, asking you to wait for its `system.mutations` entry to finish.
374
+
375
+ ```sql
376
+ ALTER TABLE s2ag.papers ADD INDEX IF NOT EXISTS idx_title_text title TYPE text(tokenizer = splitByNonAlpha, preprocessor = lower(title));
377
+ ALTER TABLE openalex.works_slim ADD INDEX IF NOT EXISTS idx_title_text title TYPE text(tokenizer = splitByNonAlpha, preprocessor = lower(title));
378
+ ALTER TABLE kb.dblp_publication ADD INDEX IF NOT EXISTS idx_title_text title TYPE text(tokenizer = splitByNonAlpha, preprocessor = lower(title));
379
+ ALTER TABLE <each of the above> MATERIALIZE INDEX idx_title_text; -- runs in the background; see system.mutations
380
+ ```
381
+
382
+ On the reference machine these built in about 6 minutes and take 7.1 GiB (S2), 13.6 GiB (OpenAlex)
383
+ and 181 MiB (DBLP). A title lookup then takes 0.1–2 s and an identifier lookup 20–90 ms. A
384
+ 40-reference HALLMARK sample audits in about 30 s. Identifier
385
+ lookups use `s2ag.paper_external_ids` and `openalex.works_slim.doi`, which need no extra index.
386
+
387
+ What the local data cannot supply, compared with the APIs:
388
+
389
+ - **Coverage ends at the snapshot.** The run reads each snapshot's end at startup (DBLP's dump date,
390
+ OpenAlex's newest `updated_date`, the newest publication date in the S2 dump) and prints it in the
391
+ report header. On the reference machine: OpenAlex 2026-06-26, DBLP 2026-09-19, S2 current. A work
392
+ newer than its snapshot is not there, so a *no match* for an entry dated in or after a snapshot's
393
+ year carries a note naming the snapshot. Audit very recent papers with the API backend.
394
+ - **OpenAlex has no `locations` locally**, so a Work's version links are its primary and best
395
+ open-access locations only. The preprint↔published merge then also rests on the identifier links
396
+ the other sources supply. Pages and the abstract come from the full `openalex.works` table (on
397
+ disk, about 1 s per batch).
398
+ - **The DBLP dump table has no landing-page URL and no pages.**
399
+ - Title search tolerates one wrong word (see above); the API searches are more forgiving still.
400
+ - A cold title search on S2 takes up to about 2 s: the text index is evaluated per part, and the
401
+ largest part holds about 200 M rows. Accepted as is; a finer index granularity could help.
402
+
403
+ ### Reading the output
404
+
405
+ The text report leads with two headline categories:
406
+
407
+ - **`CAPITAL OFFENCES`** — hallucinated citations: entries that conclusively resolve to **no** real
408
+ document (verdict *no match*). When there are none, the report says so explicitly
409
+ (*No hallucinated citations*).
410
+ - **`UNABLE TO VERIFY`** — entries we could **not** conclusively clear of being a hallucination, for
411
+ any reason: a transient network/LLM error, an unfamiliar `.bib` type, a dead link, an
412
+ adjudication left unsettled. This is the catch-all that keeps an inconclusive check from masquerading
413
+ as a clean pass. When it is empty the report states that for every other reference *at least one
414
+ matching artifact was positively identified*.
415
+
416
+ The remaining entries (at least one match found) are then split into `ISSUES`,
417
+ `FORMATTING NITS & ADVISORIES`, and `NO ISSUES`. Per-entry verdict glyphs:
418
+
419
+ - **`✓ exactly one match`** — resolved to a single document; the matched identifier and version
420
+ count are shown.
421
+ - **`✗ NO MATCH (possible hallucination)`** — nothing real corresponds; treat as a likely
422
+ hallucination.
423
+ - **`? MULTIPLE matches`** — ambiguous; the entry matches more than one distinct work.
424
+ - **unresolved** (no verdict line) — the tool could not conclude (e.g. a transient API error, or a
425
+ cited web page that is a dead link, a JavaScript app shell no browser could render, or otherwise
426
+ could not be confirmed). Never reported as a hallucination. Each cause is a `⚠` line: usually
427
+ `⚠ unresolved: …`, naming the failed source or the undecided candidates; `⚠ audit failed …` when
428
+ the entry's audit raised; `⚠ no title and no identifier …` when there was nothing to search on.
429
+ The JSON carries the list as `unresolved_reasons`.
430
+ - **`⚠` lines** — per-entry issues: a normalized/backfilled identifier, a missing ISBN, a dangling
431
+ citation, etc.
432
+ - The header counts cited and uncited entries; the **cited-but-missing** citations (a `\cite` with
433
+ no `.bib` entry) and the **uncited** entries are listed at the end of the report.
434
+ - **`UNPARSEABLE .bib ENTRIES`** — entries that could not be read, with their line and the reason:
435
+ an unbalanced brace (most often), another syntax error bibtexparser reports, a reference to an
436
+ undefined `@string`, a duplicate field, no fields at all, or a value whose LaTeX cannot be decoded.
437
+ They are not checked at all, and a `\cite` of one is not counted as missing. The JSON lists them
438
+ under `unparsed`.
439
+
440
+ ### Citation alignment (`--check-citations`)
441
+
442
+ A reference can be perfectly real yet cited for a claim its source never makes. With
443
+ `--check-citations`, for every in-text `\cite` of a reference that resolves to **exactly one** work
444
+ (other references get no alignment finding),
445
+ the auditor extracts the **citing context** (the sentence attaching the claim to the citation) and
446
+ compares it against that work's **abstract**, classifying each citation as:
447
+
448
+ - **`supported`** — the abstract corroborates the citing claim.
449
+ - **`contradicted`** — the abstract asserts the opposite; surfaced as a loud per-citation issue
450
+ (*citation may misrepresent the source*), with the abstract quote.
451
+ - **`not_in_abstract`** — the abstract is silent on the claim. An abstract is only a summary, so this
452
+ is **not** flagged as misuse — it is an advisory note (the full text may well support the claim).
453
+ - **`unverifiable`** — no abstract was retrievable, or the LLM was unavailable or its call failed.
454
+ Reported, never guessed — a silent/absent abstract is never read as a contradiction.
455
+
456
+ This is **abstract-only** in v1 (never full text) and **advisory**: it never changes a reference's
457
+ identification verdict. The JSON report carries the full per-citation `alignment_findings`
458
+ (status, evidence quote, confidence) for tooling and AI agents.
459
+
460
+ ### Auditing a PDF
461
+
462
+ ```bash
463
+ podman run -d --replace --name grobid -p 8070:8070 docker.io/grobid/grobid:0.9.1-crf # wait ~30 s
464
+ uv run reference-audit audit paper.pdf
465
+ ```
466
+
467
+ GROBID supplies both the reference list and the in-text citations, so the audit proceeds exactly as it
468
+ would from authored sources. What differs is that the input is now an *extraction*, and the report says
469
+ so rather than leaving you to assume otherwise:
470
+
471
+ - The header is marked `references extracted from PDF`, and an `INPUT NOTES` block lists what the
472
+ extraction could not recover.
473
+ - Each entry shows the printed reference number (`[article] b12 (ref #13, cited)`), since the key is a
474
+ GROBID-assigned id and means nothing on its own. A reference GROBID could not title is shown with the
475
+ raw text it read, so you can find it in the PDF.
476
+ - Counts a PDF cannot support are not faked. Commented twins and unresolved `\input`s are omitted
477
+ entirely. If GROBID links no in-text citation markers at all, the report says `citedness unknown` and
478
+ lists *nothing* as uncited — rather than claiming every reference is uncited.
479
+ - A reference with neither a title nor an identifier is reported as uncheckable and no source is
480
+ queried for it, instead of a title-less search returning an arbitrary paper that could be scored as a
481
+ match.
482
+
483
+ Failures are named, never degraded into an empty bibliography: an unreachable or failing GROBID, a PDF
484
+ with no text layer, and a PDF with no detectable reference list each exit non-zero with the specific
485
+ reason (and, for an unreachable service, the command to start one).
486
+
487
+ ### PDFs that embed their references
488
+
489
+ A PDF made with the [AI4X conference template](https://github.com/kazeevn/AI4X-2027-Template)
490
+ carries the BibTeX records of its cited works: the template's bibliography style (`ai4x.bst`, a copy
491
+ of `unsrturl.bst` that prints identically) writes each cited entry's record into the `.bbl`, and its
492
+ class embeds them as the PDF attachment `cited-references.bib.hex`. Only the cited entries are
493
+ embedded, with only the fields printed in the reference list plus identifiers (DOI, ISBN, arXiv id,
494
+ PubMed id), `@string` macros and `crossref` already resolved by BibTeX. The records are hex-encoded,
495
+ because they pass through TeX on the way to the attachment, where a raw `%`, `#` or `\` would break.
496
+
497
+ `reference-audit audit paper.pdf` looks for the attachment first. When it is there, the records are
498
+ audited exactly like a `.bib` — no GROBID, the same verdict cache, and `--no-network` works:
499
+
500
+ - The header reads `references embedded in PDF` (under `--no-network`, the usual `parse summary (no
501
+ network)`), and every entry is cited (BibTeX exported only what it printed).
502
+ - A record is what the author compiled, not necessarily what is printed — a hand-edited `.bbl` can make
503
+ them differ. Each record's title is therefore looked up in the PDF's printed reference list (after
504
+ its last *References* or *Bibliography* heading; in the whole text if there is neither), and one
505
+ that is not found is an issue on that entry, summarized in
506
+ `INPUT NOTES` and in `summary.titles_not_in_printed_references`.
507
+ - The records carry no citing sentences, so `--check-citations` is declared unchecked in `INPUT NOTES`.
508
+ `--ignore-embedded` takes the references, and the citing sentences, from GROBID instead.
509
+
510
+ A PDF without the attachment goes to GROBID as before. A malformed attachment, or a PDF the attachment
511
+ cannot be read from, goes to GROBID too, with the reason in `INPUT NOTES`.
512
+
513
+ ## Development
514
+
515
+ ```bash
516
+ uv run pytest # unit + integration tests (databases & LLM are mocked)
517
+ REFERENCE_AUDIT_LIVE=1 uv run pytest -m clickhouse # the live check of the local ClickHouse mirror
518
+ cfs validate # validate the governance artifacts and code traceability
519
+ ```
520
+
521
+ The unit and integration tests mock all database and LLM calls, so they are fast and offline. The
522
+ live tests (markers `clickhouse`, `grobid`, `pdf_build`) are not deselected by default: they run when
523
+ their service or toolchain is reachable and skip otherwise, unless `REFERENCE_AUDIT_LIVE=1` makes them
524
+ required. Add `-m "not clickhouse and not grobid and not pdf_build"` for a strictly offline run. When the tool gets a case
525
+ wrong, the fix is captured as a new test with the recorded response (see
526
+ [`architecture/SPEC.md`](architecture/SPEC.md), "General Design Principles").
527
+
528
+ ### The PDF extraction oracle
529
+
530
+ Extraction fidelity is measured rather than assumed. Each test document is compiled to PDF from its own
531
+ `.tex` + `.bib`, so that `.bib` is ground truth for whatever GROBID reads back out; extracted references
532
+ are matched to printed ones by identifier and normalized title (there are no shared keys), and the
533
+ results are asserted against pinned floors.
534
+
535
+ These tests need a LaTeX toolchain and a running GROBID. When either is missing they **skip** with a
536
+ message naming what to start; with `REFERENCE_AUDIT_LIVE=1` they are **required**, and a missing
537
+ dependency fails instead of skipping — so a broken setup cannot sit green indefinitely.
538
+
539
+ ```bash
540
+ uv run python tests/pdf_fixtures.py --all --preflight-only # what would a compile need?
541
+ uv run python tests/pdf_fixtures.py --all --compile # build the PDFs
542
+ REFERENCE_AUDIT_LIVE=1 uv run pytest -k pdf # measure, then assert the floors
543
+ ```
544
+
545
+ Build artifacts land in a gitignored `tests/documents/<slug>/.build/<version>/`; nothing is written
546
+ next to the fixtures. The committed oracle is GROBID's TEI XML for the smallest document
547
+ (`tests/fixtures/tei/`, with a `.provenance.txt` recording the GROBID image, request parameters and
548
+ TeX Live version) — never a PDF. Regenerate it with the command recorded in that `.provenance.txt`
549
+ (`--slug … --version … --compile --grobid <url> --write-tei`). The one committed PDF,
550
+ `tests/fixtures/embedded/paper.pdf`, is not a GROBID oracle: it is compiled from the `.tex` + `.bib`
551
+ beside it with the AI4X template's `ai4x.cls` and `ai4x.bst`, because what it tests is the attachment
552
+ only a template-built PDF has.
553
+
554
+ Two deliberate behaviors of the harness:
555
+
556
+ - **Missing source files are never stubbed.** A compile that lacks an `\input` target, `.sty` or `.bst`
557
+ fails with *every* missing file listed at once, so they can be supplied in one pass. Each document's
558
+ `SOURCES.md` records where its auxiliary files came from.
559
+ - **Missing figures are the one substitution**, since a bibliography does not depend on them. They are
560
+ replaced by generated placeholder images, and that substitution is stated in the build report and in
561
+ the recorded fixture's provenance — page layout differs from the published article, which does not
562
+ affect reference extraction.
563
+
564
+ The pinned numbers are a **regression floor for the recorded GROBID image and bibliography styles, not
565
+ a fidelity claim for arbitrary PDFs**: these bibliographies were typeset by BibTeX from clean data,
566
+ which is materially easier than what real-world PDFs contain.
567
+
568
+ ### Benchmarking on HALLMARK
569
+
570
+ Verdict accuracy is measured on [HALLMARK](https://github.com/rpatrik96/hallmark), a
571
+ citation-hallucination benchmark, by `benchmarks/hallmark_bench.py`.
572
+ [`benchmarks/README.md`](benchmarks/README.md) covers the setup, the `audit` / `score` / `submit`
573
+ subcommands, the verdict-to-label mappings, the results so far, and how to run on the withheld
574
+ `test_hidden` split.
575
+
576
+ ## Constructor Fabric
577
+
578
+ This project is built and governed with [Constructor Fabric's `studio`](https://github.com/constructorfabric/studio)
579
+ (the `cfs` CLI, distributed as the `constructor-studio` package). Studio is a **governance and
580
+ traceability framework for AI-assisted delivery** — it is *not* a runtime library, and the audit
581
+ pipeline does not call it. The auditor itself is plain async Python; studio governs *how* that code
582
+ is specified, traced, and validated.
583
+
584
+ Concretely:
585
+
586
+ - **Governed artifacts** live in [`architecture/`](architecture/) — the specification and design
587
+ (SPEC, PRD, DESIGN, DECOMPOSITION) plus per-feature documents
588
+ ([`features/parsing.md`](architecture/features/parsing.md),
589
+ [`features/identification.md`](architecture/features/identification.md),
590
+ [`features/citation-alignment.md`](architecture/features/citation-alignment.md),
591
+ [`features/pdf-input.md`](architecture/features/pdf-input.md)). These are the source of
592
+ truth for what the system is meant to do.
593
+ - **Code traceability** links implementation back to those artifacts via `@cpt-*` markers in
594
+ `src/`, so each governed requirement maps to the code that fulfills it. Both the offline parse
595
+ slice and the networked identification/verdict pipeline are traced to code.
596
+ - **The validation gate** ties it together:
597
+
598
+ ```bash
599
+ cfs validate # validate the governance artifacts and code traceability
600
+ ```
601
+
602
+ - **Studio's configuration** lives in `.cf-studio/` (the installed SDLC kit defines the required
603
+ artifact structure). The root `AGENTS.md` is tracked: its leading `@cf:root-agents` block is
604
+ studio's, and the project rules after it are written by hand. The root `CLAUDE.md` is generated by
605
+ `cfs update` on every run and is gitignored.
606
+
607
+ `constructor-studio` is not a project dependency: install the `cfs` CLI once, globally, as a uv tool:
608
+
609
+ ```bash
610
+ uv tool install git+https://github.com/constructorfabric/studio.git
611
+ ```
612
+
613
+ ### Using Constructor Fabric
614
+
615
+ You drive studio two ways: directly through the `cfs` CLI, and through your AI assistant, which
616
+ follows studio's governed SDLC workflows.
617
+
618
+ **1. The `cfs` CLI (run these yourself).** With the uv tool installed, `cfs` is on your `PATH`:
619
+
620
+ ```bash
621
+ cfs validate # the gate: artifact structure + code traceability must pass
622
+ cfs doctor # environment health check
623
+ cfs info # show project configuration and resolved kit paths
624
+ cfs spec-coverage # report @cpt marker coverage across the codebase
625
+ cfs list-ids # list every cpt-* ID and where it is defined
626
+ cfs where-defined cpt-referenceaudit-fr-identify-artifact # jump to a definition
627
+ cfs where-used cpt-referenceaudit-fr-identify-artifact # find every reference
628
+ cfs map --out map.html # interactive markdown↔source traceability graph
629
+ cfs check-updates # check studio + installed kits for updates
630
+ ```
631
+
632
+ `cfs --help` lists most of the command set (`check-updates` is not in it, but works and has its own
633
+ `--help`). **`cfs validate` is the one to run before
634
+ every commit** — it is the contract that the `architecture/` artifacts and the `@cpt-*` markers in
635
+ `src/` agree.
636
+
637
+ **2. AI-assisted SDLC workflows.** Studio ships the `sdlc` kit (under `.cf-studio/`) with governed
638
+ workflows — `doc-prd`, `doc-design`, `decompose`, `doc-feature`, `implement`, `pr-review`,
639
+ `reverse-engineer`, and more. Generate the integration for your editor/agent once:
640
+
641
+ ```bash
642
+ cfs generate-agents # writes agent/command/rule files for Claude, Cursor, Copilot, …
643
+ ```
644
+
645
+ Then ask your assistant for the task in natural language and it follows the matching workflow — for
646
+ example *"spec a new FEATURE for the canonical .bib output"* runs the `doc-feature` authoring loop
647
+ (template → deterministic gate → semantic review), and *"implement that feature"* runs `implement`,
648
+ adding `@cpt` markers and syncing the DECOMPOSITION checkboxes. The root `AGENTS.md` (and the
649
+ generated `CLAUDE.md`) bind these rules into the assistant's context.
650
+
651
+ **The everyday loop** when you change behavior — manually or via the assistant — is always: update
652
+ the governing artifact (PRD/DESIGN/DECOMPOSITION/feature) → update the code and its `@cpt` markers →
653
+ run `cfs validate` until green → update `README.md`. A `flow`/`algo`/`dod`/`state` definition
654
+ checked `[x]` in a FEATURE **requires** a matching code marker; unchecked **forbids** one. If a
655
+ capability is implemented but not yet `@cpt`-traced, leave its box unchecked and say so, rather than
656
+ claiming coverage you don't have.
657
+
658
+ **Updating studio itself:**
659
+
660
+ ```bash
661
+ uv tool upgrade constructor-studio # upgrade the cfs CLI itself
662
+ cfs update # refresh the project's studio install (kits are left alone unless you pass --with-kits yes)
663
+ ```
664
+
665
+ ## Project layout
666
+
667
+ ```
668
+ src/reference_audit/
669
+ parsing/ # .bib / .tex / identifier parsing; latex.py = LaTeX → Unicode for .bib values;
670
+ # entities.py = HTML character references; context.py = the shared sentence definition
671
+ pdf/ # PDF input: grobid.py (the only network module — one client, one named error per
672
+ # failure mode) + tei.py (pure TEI -> BibEntry / CitationContext mapping) +
673
+ # embedded.py (the BibTeX records a PDF embeds, and the printed-title check)
674
+ sources/ # modular adapters: Crossref, OpenAlex, Semantic Scholar, arXiv, DBLP, Open Library,
675
+ # Google Books, publisher (DOI landing-page citation export), web (cited-page fetch),
676
+ # render (headless-browser rendering of JS single-page-app pages); registry.py =
677
+ # routing; base.py = the adapter contract; normalize.py = raw JSON → SourceRecord;
678
+ # http.py = per-source spacing, in-flight caps and retries;
679
+ # clickhouse.py = the local-mirror backend for S2/OpenAlex/DBLP; titlewords.py = the
680
+ # searchable words of a title (shared by every all-words title search)
681
+ matching/ # candidate pooling, feature scoring, author-name matching (names.py), LLM
682
+ # adjudication of undecided candidates, SAME-OBJECT clustering, verdicts, web check
683
+ llm/ # OpenAI structured output (pydantic schemas): adjudication, field and alignment prompts
684
+ cache/ # SQLite memoization of DB/LLM calls (errors never cached)
685
+ alignmentcheck.py # --check-citations: citing context vs. the cited work's abstract
686
+ bookcheck.py # Open Library edition resolution (cited vs. latest edition)
687
+ fieldcheck.py# per-field correctness / formatting findings for an exactly-one match
688
+ versioning.py# better-version detection (published > preprint)
689
+ pipeline.py # async orchestration
690
+ report.py # text / JSON rendering
691
+ config.py # AuditConfig (model, keys, thresholds)
692
+ models.py # pydantic domain models
693
+ inputs.py # which input shape the CLI arguments name (.tex + .bib, or a .pdf)
694
+ cli.py # command-line entry point (Typer)
695
+ architecture/ # governed specification & design (SPEC, PRD, DESIGN, DECOMPOSITION, features)
696
+ benchmarks/
697
+ README.md # the HALLMARK benchmark: setup, subcommands, mappings, results, test_hidden
698
+ hallmark_bench.py # HALLMARK harness: audit a blind split, map verdicts to labels, run HALLMARK's
699
+ # evaluator, write submissions (needs the pinned checkout in benchmarks/.hallmark)
700
+ submissions/hallmark/ # HALLMARK submission files (predictions JSONL + manifest), one per split
701
+ hallmark_label_errors.md # dev_public entries labelled VALID that are hallucinated, with evidence
702
+ tests/
703
+ documents/ # test papers: <paper-title-slug>/<version>.{tex,bib} (initial, polished, …),
704
+ # plus the auxiliary LaTeX sources needed to compile them (see each SOURCES.md)
705
+ fixtures/tei/# recorded GROBID output + provenance, so the mapper is verifiable offline
706
+ fixtures/embedded/ # an AI4X-template PDF that embeds its references' BibTeX records, + its sources
707
+ fixtures/dblp/ # a recorded DBLP SPARQL response + provenance
708
+ pdf_fixtures.py # not a test: preflight + compile + TEI capture, runnable by hand
709
+ pdf_oracle.py # matches extracted references to printed ones and scores the difference
710
+ *.py # mocked unit/integration tests, plus the live ClickHouse / PDF tests (skipped when
711
+ # their service is missing); the pilot paper is the development oracle
712
+ ```
713
+
714
+ See [`architecture/SPEC.md`](architecture/SPEC.md) for the full specification and the
715
+ `architecture/` artifacts for the detailed design.