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.
- reference_audit/__init__.py +8 -0
- reference_audit/alignmentcheck.py +136 -0
- reference_audit/bookcheck.py +98 -0
- reference_audit/cache/__init__.py +1 -0
- reference_audit/cache/db.py +108 -0
- reference_audit/cache/store.py +128 -0
- reference_audit/cli.py +190 -0
- reference_audit/config.py +179 -0
- reference_audit/fieldcheck.py +599 -0
- reference_audit/inputs.py +96 -0
- reference_audit/llm/__init__.py +6 -0
- reference_audit/llm/client.py +85 -0
- reference_audit/llm/prompts.py +206 -0
- reference_audit/llm/schemas.py +38 -0
- reference_audit/matching/__init__.py +6 -0
- reference_audit/matching/adjudicate.py +63 -0
- reference_audit/matching/features.py +185 -0
- reference_audit/matching/names.py +238 -0
- reference_audit/matching/pool.py +344 -0
- reference_audit/matching/sameobject.py +150 -0
- reference_audit/matching/scoring.py +75 -0
- reference_audit/matching/verdict.py +56 -0
- reference_audit/matching/webcheck.py +189 -0
- reference_audit/models.py +412 -0
- reference_audit/parsing/__init__.py +1 -0
- reference_audit/parsing/bib.py +392 -0
- reference_audit/parsing/context.py +55 -0
- reference_audit/parsing/entities.py +22 -0
- reference_audit/parsing/identifiers.py +165 -0
- reference_audit/parsing/latex.py +83 -0
- reference_audit/parsing/tex.py +159 -0
- reference_audit/pdf/__init__.py +16 -0
- reference_audit/pdf/embedded.py +117 -0
- reference_audit/pdf/grobid.py +234 -0
- reference_audit/pdf/tei.py +470 -0
- reference_audit/pipeline.py +1692 -0
- reference_audit/report.py +330 -0
- reference_audit/sources/__init__.py +6 -0
- reference_audit/sources/arxiv.py +81 -0
- reference_audit/sources/base.py +54 -0
- reference_audit/sources/clickhouse.py +575 -0
- reference_audit/sources/crossref.py +90 -0
- reference_audit/sources/dblp.py +140 -0
- reference_audit/sources/google_books.py +101 -0
- reference_audit/sources/http.py +260 -0
- reference_audit/sources/normalize.py +464 -0
- reference_audit/sources/openalex.py +93 -0
- reference_audit/sources/openlibrary.py +158 -0
- reference_audit/sources/publisher.py +186 -0
- reference_audit/sources/registry.py +117 -0
- reference_audit/sources/render.py +115 -0
- reference_audit/sources/semantic_scholar.py +68 -0
- reference_audit/sources/titlewords.py +31 -0
- reference_audit/sources/web.py +256 -0
- reference_audit/versioning.py +125 -0
- reference_audit-0.1.0.dist-info/METADATA +715 -0
- reference_audit-0.1.0.dist-info/RECORD +60 -0
- reference_audit-0.1.0.dist-info/WHEEL +4 -0
- reference_audit-0.1.0.dist-info/entry_points.txt +2 -0
- 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'Amore`, `&`) 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.
|