Skip to content

citations

Bibliographic records, read from disk by default.

claims.py can verify that a number in prose matches a run in the ledger. It could not verify that a citation in prose points at a real paper, and had no way to express "supported by someone else's published result" as distinct from "supported by my run". A reference had no representation here at all.

The offline guarantee and its exception. CLAUDE.md states "Local models via Ollama; nothing leaves the machine." The web reader breaks that, so:

  1. It is absent unless ATTEST_CITATION_WEB is set, checked when the resolver is BUILT rather than when it is called -- a disabled reader cannot be coaxed into one request by an unusual code path.
  2. Every record carries source and fetched_at, so any answer can be asked where it came from.
  3. cite.sources reports which readers can reach the network, from the same surface that would have done the reaching.

A guarantee with a documented exception is honest. One that quietly stopped holding is not.

No Zotero library existed on the machine where this was written. The reader is built from Zotero's documented schema and tested against a fixture built to the same document, so it is plausible rather than verified. If you have a real library, point this at it -- the shape-tolerance tests say what should happen when the layout differs, but only a real one proves what does.

BibtexReader

.bib files on disk.

A hand-rolled reader rather than a dependency: the grammar used here is @type{key, field = {value},}, which is what every tool emits, and this codebase has a standing preference against pulling a parser in for a machine-generated format (see ledger_adapters/generic._config_shape).

A truncated entry yields nothing rather than raising: a .bib killed mid-write is the commonest way one ends, and losing the whole file for one bad entry is silent data loss.

all()

Every entry each .bib file yields, skipping ones with no title.

A missing file is silently skipped rather than an error, matching ZoteroReader: a .bib named in config but not (yet) present is an absent source, not a broken one.

lookup(key)

The entry whose citation key or DOI matches key, case-insensitive.

Scans all() rather than an index: .bib files are small and this reader has no persistent state to keep one in sync with a file that may have changed on disk between calls.

Reference dataclass

One bibliographic record, and where it came from.

source and fetched_at are the provenance pair. A record from disk has fetched_at=None; one from the network carries a date. A cache keeps the original date rather than refreshing it -- the cache must not launder a network record into something that looks local.

matches(needle)

Whether a free-text query plausibly names this record.

to_row()

This reference's cite.* wire projection.

Truncates authors to 6 and adds n_authors (the true count) so a long author list does not blow out a response -- the same 3-of-n budget pattern RankedItem.to_row applies to tags.

arxiv_id is deliberately OMITTED: it is redundant with doi/url for most records, and dropping it here is a stated decision rather than a silent gap a future editor might "fix" back in inconsistently.

Resolver

The configured readers, asked in order, recording which one answered.

from_env(*, zotero_path=None, bib_paths=None, cache_dir=None, store=None) classmethod

Build from the environment.

ATTEST_CITATION_WEB is read HERE (via web_enabled) and by the library sync's reader list, both at construction. Reading it at call time would mean a resolver built while disabled could still make a request if the variable changed under it.

lookup(key)

The library store's answer, else the first reader's, tried in order.

Order is the constructor's reader list, which from_env fixes as zotero, then bibtex, then web -- so a network lookup is only ever tried after every local, offline reader has already said no.

search(needle)

Free-text search across the readers that can be enumerated.

Network readers are skipped rather than queried: all() raises on them, and a search that silently fanned out to CrossRef would be the offline guarantee failing quietly.

sources()

Which readers are configured, and which of them can reach the network -- so cite.sources reports the offline exception from the same surface that would have done the reaching, rather than a separate claim about it.

WebReader

Metadata by DOI or arXiv id, from CrossRef and arXiv.

This is the only thing in the project that leaves the machine, and it exists only when ATTEST_CITATION_WEB was set at construction. Records are cached content-addressed and never expire: a published paper's metadata does not change, and an expiring cache turns one network call into a recurring one, which is the opposite of the guarantee.

all()

Not supported: a network source has no fixed set to enumerate.

Resolver.search relies on this raising -- it skips every reader with network=True before calling all(), so a search that fanned out to CrossRef here would be the offline guarantee failing quietly. This exists so that bypassing that guard is loud rather than a silent empty result.

lookup(key)

DOI or arXiv id metadata from CrossRef, cached content-addressed.

A cache hit returns the ORIGINAL fetched_at, never today's date -- the cache must not launder a network record into one that looks local. Any failure (unreachable network, a 404, a changed payload shape) returns None rather than raising: an unreachable network is an absent source, exactly like a missing Zotero library, and should not break a lookup whose other readers can still answer.

ZoteroReader

A local Zotero library, opened read-only.

Zotero holds an exclusive lock while running, so this opens with mode=ro&immutable=1: it reads a live library without being able to write to it. If that fails -- no library, a corrupt file, a schema this does not recognise -- the reader returns no records rather than raising. A missing Zotero is an absent source, not an error, and a resolver whose other readers work must keep working.

all()

Every item in the library with a title, read-only and tolerant.

No library, a corrupt file, or a schema this does not recognise all yield nothing rather than raising -- see the class docstring. This reader ships tested only against a fixture built to Zotero's documented schema; there was no real library on the machine where it was written, so the fixture is plausible, not verified. If you have a real one, point this at it.

lookup(key)

The item whose Zotero key or DOI matches key, case-insensitive.

Scans all() -- the library is opened fresh each call (see _connect), so there is no cached index to keep in sync with a library that changed since the last lookup.

raw_items()

(zotero key, {field: value}, [authors]) for every titled, undeleted item.

The library sync reads this rather than all() because it wants fields Reference does not carry (abstractNote, publicationTitle, extra). Same tolerance as all(): nothing on any sqlite error.

bib_paths_from_env()

ATTEST_BIB_PATHS (os.pathsep-separated), else every *.bib in the cwd.

s2_enabled()

ATTEST_CITATION_SCHOLAR: Semantic Scholar reference lists.

A second flag because reference lists are a larger, rate-limited surface than a metadata lookup, and a reader who accepts one need not accept both.

web_enabled()

ATTEST_CITATION_WEB: CrossRef and the arXiv API.

Read by the caller that BUILDS readers, never by a reader at call time -- a disabled reader cannot be coaxed into one request by an unusual path.

zotero_path_from_env()

ATTEST_ZOTERO_PATH, else Zotero's default location.