Changelog
0.4.2 — 2026-08-29
Section titled “0.4.2 — 2026-08-29”citations
Section titled “citations”- The plugin’s hook never ran.
hooks.jsonlisted its event at the top level, where the loader reads onlyhooksandmodules, so the plugin reportedfailed to loadand no hook was ever registered — from the file’s first commit through 0.4.1. The events now sit underhooks. Anyone who installed this plugin expecting drift to be reported on every edit was not getting it.
prereg
Section titled “prereg”- The plugin’s hook never ran.
hooks.jsonlisted its event at the top level, where the loader reads onlyhooksandmodules, so the plugin reportedfailed to loadand no hook was ever registered — from the file’s first commit through 0.4.1. The events now sit underhooks. Anyone who installed this plugin expecting drift to be reported on every edit was not getting it.
results-cli
Section titled “results-cli”- The plugin’s hook never ran.
hooks.jsonlisted its event at the top level, where the loader reads onlyhooksandmodules, so the plugin reportedfailed to loadand no hook was ever registered — from the file’s first commit through 0.4.1. The events now sit underhooks. Anyone who installed this plugin expecting drift to be reported on every edit was not getting it.
reproducible-science
Section titled “reproducible-science”-
The plugin’s two hooks never ran.
hooks.jsonlistedPostToolUseandPreToolUseat the top level, where the loader reads onlyhooksandmodules, so the plugin reportedfailed to loadand neither hook was ever registered — from the file’s first commit through 0.4.1. The events now sit underhooks, and all four plugins in this workspace had the same defect.The suite passed throughout, because
test_plugin_hooks.pyparsedhooks.jsonitself and asked the runtime nothing. A test now asserts the top-level shape for all four plugins and fails on the shape that shipped.
provenance-core
Section titled “provenance-core”No significant changes.
0.4.1 — 2026-08-29
Section titled “0.4.1 — 2026-08-29”citations
Section titled “citations”- A quotation pinned to a
.pysource was gradeduncheckedrather than resolved..pywas absent fromTEXT_SUFFIXES, so the file went topdftotext, which answeredSyntax Error: Couldn't read xref tableand left every quotation in it unchecked — reported as a source the reader could not open, when nothing there needed a PDF reader at all. Python sources are now read directly off disk, and any claim quoting one should be re-verified. (#54)
prereg
Section titled “prereg”No significant changes.
results-cli
Section titled “results-cli”No significant changes.
reproducible-science
Section titled “reproducible-science”- A
PreToolUsehook that says a registered plan was never frozen, before the analysis runs instead of after it.frozen_plan_changed.pynotices a plan edited after its freeze; nothing noticed a plan carrying no freeze at all, so the mechanism engaged only for authors who had already opted in. The new hook reads the command about to run, and where that command invokes an analysis under a directory holding aPREREG.mdwith no**Plan sha256:**line, it says so. It never denies the call and stays silent when there is no plan, when the plan is frozen, and when the command is not a run. (#53)
provenance-core
Section titled “provenance-core”No significant changes.
0.4.0 — 2026-08-29
Section titled “0.4.0 — 2026-08-29”citations
Section titled “citations”-
[citations] A
.texsource is read withdetexrather thanpdftotext. A.texwith no declaredextract_cmdgot the PDF reader, which answeredSyntax Error: Couldn't read xref table, and every quotation in it gradeduncheckeduntil an author declareddetexby hand — for the commonest manuscript format in this domain, withdetexalready among the default extractors. (#36) -
[citations] An
ambiguousoutcome is printed. The checker could return it and the report had no column for it, so it was counted and never shown: the table stopped summing to the number of quotations above it, and a run whose only problem was ambiguity read as clean. (#39) -
[citations]
citations buildkeeps the citations of a paper whose bibliography it could not read. Records are rewritten whole from the bibliographies, so a paper contributing none — an import with no repository on this machine, a path that moved — lost itscited_byentry from every record another paper also cites. Measured on this repository’s own library, one such paper bled seven citations per rebuild, silently. (#40) -
[citations] A rebuild keeps a pinned artifact it cannot repin.
localandsha256name the copy that was read and hashed, and both were filled only from a paper’s own directories — so a pin the library established itself, for a work no paper’sclaims/covers, was dropped on every rebuild without being reported. (#43) -
[citations] The command list matches the commands. Four CLIs documented their subcommands in a module docstring that nothing compared to the parser:
citationsadvertised abibcommand that has never existed and omittedpinandprojects,preregomittedsetup,resultsomittedreanchor, andreproomitted four. -
A page assertion the declared extractor cannot be asked about is reported rather than dropped.
A page is verifiable only where this module can request one page on its own terms, which means no declared
extract_cmd: an arbitrary program has no page flag, and askingpdftotextfor page 4 of a source whose author declareddetexwould run a PDF reader over something they just said is not a PDF. That reasoning still holds. What did not hold was doing it in silence.A record asserting
page: 3under a declaredpdftotext -layouthad the assertion dropped and nothing said so: 154 page assertions in one audited claim, where page 9999 on a three-page document graded identically to the correct page. The newpage uncheckedwarning does not change a verdict; it makes the omission visible, which is the difference between a check that passed and a check that never ran.A plain-text source is exempt. It has no pages, so a page recorded against one is an inapplicable field rather than an unverified assertion.
-
An extraction that fails is cached, where only a successful one was.
functools.lru_cachestores a value on a normal return and stores nothing when the call raises, so the cache aroundextractmemoized every reading it managed and none of the ones it could not, and a source no extractor could read was re-attempted once per quotation rather than once per document.Measured on a sixteen-claim corpus whose sources all declared an extractor that printed nothing: 2,210 poppler invocations for 14 unique artifacts, 158 times the work the corpus requires, and 21 minutes of wall clock of which 95% was the repeated subprocess. The same run now takes 14.6 seconds.
reading_withhad the same shape and matters more, because the second-opinion path reaches it on everynot found: a document no reader could open was re-attempted by every reader, once per quotation.extract.cache_clearandreading_with.cache_clearkeep working – the memoization moved underneath them and the handles are delegated, since both were public and a caller managing the cache should not have to know which function holds it. -
An extractor that writes a file beside the source is reported. The existing check hashes the artifact before and after, which catches a renderer that overwrites the file it was given and cannot see one that writes a sibling – the commoner shape, since
pdftotext -layout X.pdfwith no trailing-writesX.txtand prints nothing.Thirty-two such files accumulated in one audited repository over three weeks, unnoticed because the directory is gitignored. Nothing was corrupted there, but a
.txtpinned as a source beside a same-stem PDF would have been silently replaced by this tool’s own output, and every quotation would then have resolved against text the checker wrote.Narrowed to files sharing the source’s stem rather than watching the whole directory, so an unrelated process writing there cannot trip it. Nothing is deleted: the file is named in the report and left where it is.
-
The corpus size behind
readers.PREFERREDis the one the measurement recorded. The README andreaders.pyjustified preferring pypdf over pdfplumber with “1,792 passage checks”, a number that appears in no artifact:research/pdf-readers/results.jsonrecords 1,593 checks over 80 documents for the quotations corpus and 458 over 132 for the sampled one, andverify.pycited 1,593 for the sibling claim in the same commit. Both now read 1,593, and both carry the agreement rates the artifact holds – 92.7% against 90.2% – so the sentence can be checked rather than believed.The preference itself was correct and is unchanged. What was wrong was the evidence cited for it, which is the failure these tools exist to catch.
-
folddrops combining marks instead of composing them. A renderer typesetsnaïveas a dotless i carrying a combining diaeresis, which is how LaTeX writes it, while the quotation is typed with the precomposed letter; composing leaves those two different strings and the passage reads as absent. Seven quotations from one paper failed on that alone. The dotlessıand the dotted capitalİare mapped explicitly, since no normalization form reaches either.skeletonabsorbs a hyphen joining two word characters, at least one a letter, together with any whitespace after it –prefix-matchingagainstprefixmatching,non- sparseagainstnonsparse,pythia-1.4bagainstpythia1.4b– and an underscore, which is a subscript the extractor has already flattened. The bounds are the point: a minus sign is preceded by a space or by nothing, never by a word character, so-0.42and0.42stay distinct, and5-3is left alone where a range and a subtraction are indistinguishable.
Changed
Section titled “Changed”-
[citations] A claims file states whose reading a characterization is, and the report says how many were not measured.
verifyresolves the strings inquotesagainst the pinned bytes; nothing readsstatement. A file whose quotation is exact and whose statement overreaches passed every check, and the report said nothing about the second object.Interpretationcarries a requiredwhose, and the report counts readings, those attributed to a party other than the source, and those marked contested — all as unchecked, because this package cannot measure them. (#30) -
A
not foundon a paginated source consults the remaining readers before it stands.not foundis an accusation against the manuscript – the source was read and the passage is not in it – and one reader was making it.pdftotext -layoutpreserves a page’s visual geometry, so on a two-column paper it interleaves the columns and shreds every sentence spanning the gutter. Measured on one such paper: 110 of its 160 quotations read as absent under-layoutand 157 resolve under pypdf, from a correctly pinned source with nothing wrong with the quotations.Whichever reader answers is recorded as a fallback naming the one that missed it, so a rescued passage never reads like one the declared reader found. Where no reader finds the passage, the detail names every reader that looked. The escalation is on the failure path alone: a passage the first reader resolves still costs one extraction, and
ambiguousis never escalated, since a reader that merges columns could hide an occurrence rather than settle anything. -
A quotation is checked by counting its occurrences, where it was checked by testing whether the document contained it at all. A quotation points at one passage; a pointer resolving to three of them has identified none, and the page and section attached to it are then asserted about a passage nobody picked out. A passage occurring more than once is now
ambiguous, a fifth state distinct fromindeterminate– the extractors disagreeing about what a document holds asks for a better reader, while a repeated passage asks for an anchor the author writes.Quotegainsprefixandsuffix, the W3C Web AnnotationTextQuoteSelectorneighbours. They are joined to the passage and counted in its place where it repeats, and are consulted nowhere else. Both default to empty, so a quotation that resolves uniquely is checked exactly as before.Counting respects token boundaries:
the cataloginsidethe catalogueis a shared prefix and not the document saying it twice.
-
- [citations] A PDF is readable without poppler, and every result names what read it.
verifyshelled out topdftotext -layoutand returneduncheckedwhere the binary was absent, sopip install citationscould not check a PDF at all. Where poppler is missing or fails on a document, the chain now falls through to pypdf or pdfplumber and records the substitution as a fallback with its reason, so a result never rests silently on an extractor other than the one it names. A source declaringextract_cmddoes not enter the chain: its author named the program that produces the text, so it runs or the check isunchecked.citations verify --triangulateasks every installed reader instead of one and reports disagreement asindeterminate, a fourth outcome that leaves the question open rather than asserting the passage is absent — extractors disagreeing is a fact about the readers, andnot foundis an accusation against the manuscript. (#14)
- [citations] A PDF is readable without poppler, and every result names what read it.
-
- [citations] A claims file’s
extract_cmdis read, and the report says what ran. The field was declared on the model and documented in the claim-file example whileverify.extracthardcodedpdftotext -layout, so a.texmanuscript reached a PDF reader and gradeduncheckedfor every sentence in it. A source declaringextract_cmdis now read by the command it names: the source path replaces{}or is appended, and what the command prints to stdout is what the quotations resolve against. Every result carries the extractor and a sha256 of the text it produced, since a pin establishes that the bytes did not change and nothing about how they were read. The command is never handed to a shell — the declared string is split into a program and arguments — and onlypdftotextanddetexrun unasked; anything else needscitations verify --allow-extractor NAME, written by whoever runs the check rather than by whoever wrote the file. A refused command and an uninstalled one are bothuncheckedand say which they are; neither makes the passage absent. (#16)
- [citations] A claims file’s
-
A fetched document now records whether its length was checked against the extent Paperclip declared for it, on
Document.extent_verifiedand in thepaperclip:block a claims file carries. Wheretail -n 1declares no last line the completeness check cannot run, andlinesis then counted from what arrived rather than confirmed against what the source said it should be; the two cases were previously indistinguishable in the record.The field defaults to false, so anything that does not set it reads as unverified rather than claiming a check nothing performed. (#18)
-
citations coverageasks the questionverifycannot: is every quotation in the manuscript pinned at all?verifyreads the claims files and 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.citations coverage paper/draft.tex --claims claims [--strict]citations coverage paper/draft.tex --claims claims --attributeOutcomes are
covered,uncoveredandunresolvable, reported separately rather than as a pass or a fail: a quotation too short to distinguish from noise, or one whose neighbouring source would not open, is undecided and not a defect in the manuscript.--strictfails on the undecided ones too, matchingverify.--attributeadditionally checks each quotation against the artifact of a source cited near it, which catches a passage credited to the wrong paper. It never reports a misattribution when any neighbouring source could not be read: the passage may belong to the one that would not open. (#22) -
[citations]
citations pinwrites a quotation into a claims file and refuses one the source does not contain. A claims file is written by hand and read byverifyafterwards; between those two moments sat a passage transcribed from a viewer whose ligature or line-wrapped hyphen differs from what the extractor produces. That was reported later, over a corpus, against a file nobody had open. It is now refused at the moment of writing. (#31) -
[citations]
citations projectsreports which project names nothing answers to, andcitations tagsgroups records against a declared vocabulary. Ninety records namedevaluation-scopehours after that repository was renamed and nothing reported it, becausecited_byis a free-text key no check compared against the projects that exist. Tags are declared on a paper inpapers.yamland reach records throughcited_by, so a work a paper cites tomorrow carries them without anything being re-run. (#38) -
[citations] A record can name the citation key a new bibliography should use.
cited_byrecords what each paper actually writes and those diverge honestly, since a key is part of a paper’s own source. Naming the divergence without naming a winner left the reader to pick one, which is how the divergence started. (#41) -
- [citations]
citations addwrites an entry into a.biband refuses a key the file already has. Appending by hand put a duplicate key in a paper’s bibliography twice in one session. BibTeX’s answer to a repeated key is non-fatal – it reportsRepeated entry, keeps the copy defined first, skips the second and writes a.bblwithout it – so the entry just added never reaches the reference list and the failure surfaces asCitation undefinedwarnings that name nothing.addtakes the entry from a file, from standard input, or fetched by--doior--arxiv; a fetched entry is shown before it is written and carries every author the registry lists, neverand others. A repeated key exits non-zero, prints both entries side by side and writes nothing, with case folded because BibTeX folds it. The write is read back before it is reported, and the original bytes go back if the file does not parse as what it parsed as before plus one entry.citations lint --bib <file>asks the same question of a file already written and needs neither papis nor a library.
- [citations]
-
A
not foundsays where the passage stopped matching, and what the source reads there.not foundis an accusation against the manuscript: the source was read and the passage is not in it. It is usually wrong, and the cause is usually one character the document’s text layer dropped – a minus sign, an en dash, a hyphen falling on a line break wherefold’s de-hyphenation removes a real one. Finding that meant a binary search for the longest prefix of the quotation the document still contains, done by hand, three times in one corpus.divergence(quote, text)does that search, and the report shows both sides at the point they part:the first 155 characters are in the source and the rest is notquoted: ...tionality, e.g. vec('king') - vec('man') + vec('woman') = vec(source: ...tionality, e.g. vec('king') vec('man') + vec('woman') = vec('qwith the repair named: split the quotation into adjacent fragments either side of the missing character, never truncate it to the part that matches, because a truncated quotation resolves and says something its source does not.
Reported only where the prefix is substantial – forty characters, or half the quotation. A shorter one matched by coincidence, and pointing at where it ran out sends a reader to a passage the quotation was never taken from.
packages/citations/README.mdcollects the four causes.
prereg
Section titled “prereg”No significant changes.
results-cli
Section titled “results-cli”- [results-cli]
results sealaccepts a directory and records it as a tree. A dataset is a directory, so sealing one meant naming every file it contained, and a file added afterwards was simply absent from the record with nothing to notice. The seal looked complete and was not. A tree digest is order-independent and covers every path, andverifyre-derives it the way it was recorded. (#37) - [results-cli]
results verifysays when the ledger is not in history.citationsreports a record that is not committed, because a pin nobody else can read is not evidence anyone can appeal to;resultsmakes the same promise about its ledger and said nothing. Reported after the chain verifies, never blocking. (#45)
reproducible-science
Section titled “reproducible-science”- [repro]
repro verifyno longer prints a blank line under a regeneration section that said nothing. The separator was printed whenever a manifest declared a regeneration, and the ordinary state — not requested, since regeneration runs only under--regenerate— is the one state the section says nothing about. (#26) - [reproducible-science]
repro checkfinds a paper’s claims where papers keep them, and tellscitationswhere they are. A project holding sixty-one pinned quotations atpaper/prior_art/claimswas told no tool applied to it, because detection looked only at the repository root. A project withclaims/at its root fared worse: the check rancitations verifywith no claims directory, which reports nothing to check and exits 2, so it was reported as FAILED. (#44) - [reproducible-science] The cross-tool report names the claim it is about.
resultsrecords a claim’s text underclaim; this readclaim_idand thenid, neither of which it has ever written, so every claim printed as?. The tests wroteclaim_idthemselves, so the reader agreed with the fixtures and neither had to agree with the writer.
Changed
Section titled “Changed”- [reproducible-science] A tool is detected by what it can act on, not by a directory name.
claimsis an ordinary word: a Python package and a knowledge-graph registry were both read as citations projects and then failed for holding no quotations.prereg checklooks for a file namedPREREG.md, while detection looked for a directory namedpreregs, so a directory of hand-named plans was reported as a project whose registrations were broken. An empty directory no longer counts as use. (#45)
-
[repro]
repro demowrites a worked example and runs the real workflow over it. The first five minutes previously required reading the specification to find out what a manifest is for.repro demoscaffolds a self-contained project — twelve paired measurements, a stdlib analysis seeded so the same numbers come out on every machine, a manuscript stating one of them, and apin.pythat writes the manifest — then drivesresults init/seal/run/claimandrepro verifyover it, printing each command and what it actually returned.It then breaks the project twice, because a walkthrough where everything passes shows nothing about what the report keeps apart. A word no assertion reads is edited: every assertion still holds, the run fails on the pin alone, and the two decisions over that file come back marked non-authoritative. The manifest is re-pinned, the number the manuscript reports is changed, and the manifest re-pinned again — so the pin is clean and the comparison is the only thing left to fail. Both edits are restored, and the directory is left verifying, with a README naming three more failures worth producing by hand.
Offline throughout, and it refuses a directory that already holds files unless
--forceis given, which removes only the files the demo itself writes. (#26) -
[reproducible-science]
reproruns the four tools, andrepro checkruns the ones a project uses in one pass. Each tool keeps its own command; the delegation is a spelling. What only exists here is one pass over a project, one report and one exit code across every tool it actually uses, in place of four commands each with its own idea of a clean run. A tool the project does not use is reported as unused rather than as passing. (#35)
provenance-core
Section titled “provenance-core”-
gitrefnow runs git against the directory the caller names. Git resolves its repository from the environment before the working directory, socwdalone never named one: under any git hook, which exportsGIT_DIR,commit(path)andis_dirty(path)answered for whatever repository invoked the process.repro.provenance.of_treeis built on those calls, so a provenance record written from a hook named the wrong repository’s commit and dirty state.Only the variables that relocate git are dropped –
GIT_DIR,GIT_WORK_TREE,GIT_INDEX_FILEand the rest ofgitref.REDIRECTS.GIT_SSH_COMMAND,GIT_COMMITTER_DATEand the other variables that configure git once it knows where it is are untouched, and a caller passing nocwdstill inherits everything.gitref.clean_env()exposes the same environment to callers that run git themselves.results.timeline.freeze_timestampusedgit -C, which is a directory change and is outranked the same way; it now goes throughgitref. (#20)
0.3.1 — 2026-08-26
Section titled “0.3.1 — 2026-08-26”Four corrections to citations, all of them defects introduced by the extract_cmd support
that shipped hours earlier in 0.3.0. Each was found by running the new code over real claim
sets, and each made a source unverifiable rather than merely awkward.
- [citations] A source declaring that it needs no extractor was refused as a program named
none.paperclip.source_blockwritesextract_cmd: nonefor a pinned text artifact, because naming an extractor would claim a step that never ran;verifythen read that as a command, refused it, and advised--allow-extractor none— running a program that does not exist. Every source written bycitations resolve --via paperclipandcitations import-paperclipcame backuncheckedin 0.3.0. The declaration is now recognized, and matched on its first word so the reason an author writes beside it (none -- Markdown is read directly) is read as the declaration rather than as a command. (#24) - [citations] A passage separated by U+2010 HYPHEN read as absent from a document containing
it.
foldnormalized the em dash, the en dash and the minus sign but not U+2010, which is visually identical to the ASCII hyphen and is what publishers emit: a source readingpatients‐in‐waitingdid not match a quotation typed with the ASCII hyphen, and the result wasnot found— an accusation against the manuscript — for a quotation that is verbatim correct. (#24) - [citations] An extractor that wrote to the file it was given damaged the artifact silently.
A declared
extract_cmdis an arbitrary program handed a path, and nothing stopped it writing where it read: a renderer whose output filename matched its input overwrote the bytes the pin names, and the pin then failed against a file the checker itself had damaged.verifyreads and never writes, so the artifact’s digest is now taken before and after a declared command runs and a change is reported as a defect in the command. (#24) - [citations] A declared command that printed nothing reported
no text extracted, which is what a document holding no text also reports — the two facts the three-outcome model exists to keep apart, given one message. An empty stdout from a command that exited 0 now says so, and where the command ispdftotextwithout a trailing-it names that:pdftotext FILEwritesFILE.txtand prints nothing, which is how ten sources in one claim set read as textless. # Changelog Every package in this workspace carries the same version and is released on the same day, so one file covers all four. Entries are scoped with a[package]prefix; unprefixed entries apply to the workspace as a whole. (#24)
0.3.0 — 2026-08-26
Section titled “0.3.0 — 2026-08-26”Corrections come first. Two of them changed what the tools reported: a confirmatory claim was
ordered against an exposure by comparing timestamp strings, so the answer depended on the
committer’s time zone, and a provenance record written from inside a git hook named the wrong
repository entirely. A third, under Changed, is not a defect but will move results: a claims
file’s extract_cmd was accepted and ignored, and is now run.
-
A release could ship packages pinned to different sibling series, and neither
bumpnorcheckcould see it.scripts/versions.pylocated the dependency array with a pattern ending at a line-initial]. That matched neither of the two forms the manifests actually use reliably: inprereg, a single-line array with no later array to close on, it did not match at all, sobump 0.3.0moved every package’s version and leftpreregrequiringprovenance-core>=0.2,<0.3; incitations, it ran past the array’s own bracket to the one closingclassifiers.checkread the same way and reported lockstep. The array’s end is now found by balancing brackets, and the wheel install that caught it is covered by a test. (#23) -
[results] A freeze and an exposure were compared as strings, so the ordering depended on the committer’s time zone.
--frozen-atresolved a commit through git’s%cI, which carries a local UTC offset, and compared it with the ledger’s UTC timestamp using<on the two strings — comparing the offsets rather than the instants. It passed on a machine four hours behind UTC, where the local hour is numerically smaller, and failed on a runner in UTC, where the ordering fell to whether+orZsorts before.. Both are now parsed to instants before comparison. A claim recorded as confirmatory on a machine east of UTC should be re-checked. (#5) -
[citations] Cleaning a BibTeX field deleted the argument of every markup command except
\emph. Braces were stripped before control sequences, so\texttt{inspect\_ai}became\textttinspect\_aiand the command pattern then matched through the start of the argument. A record naming the exact package versions an evaluation ran on was written out as Python packages _ai 0.3.260 and _cyber 0.1.0. Control sequences are now resolved first, and the whitespace after a command is kept where the result is displayed — removing it turnedSmith\etal JonesintoSmithJones. (#8) -
[provenance-core]
gitrefran git against the repository the environment named, not the one the caller did. Git resolves its repository from the environment before the working directory, socwdalone never named one: under any git hook, which exportsGIT_DIR,commit(path)andis_dirty(path)answered for whatever repository invoked the process.repro.provenance.of_treeis built on those calls, so a provenance record written from a hook named the wrong repository’s commit and dirty state.Only the variables that relocate git are dropped –
GIT_DIR,GIT_WORK_TREE,GIT_INDEX_FILEand the rest ofgitref.REDIRECTS.GIT_SSH_COMMAND,GIT_COMMITTER_DATEand the other variables that configure git once it knows where it is are untouched, and a caller passing nocwdstill inherits everything.gitref.clean_env()exposes the same environment to callers that run git themselves.results.timeline.freeze_timestampusedgit -C, which is a directory change and is outranked the same way; it now goes throughgitref. (#20) -
Test helpers across
preregandresultsran git with an inherited environment, which wrecked the checkout they ran in. Underpre-commit, which exportsGIT_INDEX_FILEwhile it stashes, theirgit add -Astaged into the outer worktree’s index and left every tracked file there staged as deleted – a wrecked checkout produced by a passing test, withpytest -n autoworkers racing on the one index. They now build their environment withgitref.clean_env().
Changed
Section titled “Changed”- [citations] A claims file that declares
extract_cmdnow behaves differently, and existing ones may need editing. The field was accepted and ignored: every source was read bypdftotext -layoutor, for aTEXT_SUFFIXESfile, straight off disk. It is now run. A repository that declared a command which is not a per-source extractor will see its quotations move — a batch regeneration script that prints a summary and ignores the path yieldsnot foundfor every passage, and a command outside the default allowlist yieldsuncheckeduntil--allow-extractornames it. Both were found in real claim sets on the upgrade. Checkcitations verifybefore and after: a source whose bytes on disk are already the text being quoted should declare noextract_cmdat all, ornone. (#16)
-
[results]
results coveragereads a manuscript and says which of its numbers are bound. Given a paper and a sealed run, it enumerates every numeric token, sorts each into traceable (table cells, measurements, parameters, equation content), untraceable (bibliographic and structural furniture), or reported-separately (figure axes, dense lines, orphans, bounds, extraction failures), and reports how many of the traceable ones a claim already binds. The third group is never folded into the other two: a limit of the scanner is not a property of the article. (#3) -
[results] A plan frozen before the results were seen protects a confirmatory claim.
results claim --confirmatoryrefused whenever its run postdated anoutcomes seenevent, which treats exposure as decisive. Exposure is evidence that contamination was possible; propagation is what threatens a confirmatory reading, and a plan already committed cannot be reached by an exposure that follows it.--frozen-at <commit>names a commit containing the frozen plan; where its commit date precedes the first exposure the claim records as confirmatory withafter_outcomes_seenfalse, andverifylists it separately rather than silently among the rest. (#4) -
[citations] Full text through Paperclip, pinned by digest.
citations resolve --via paperclip <doi>fetches a source’s full text from Paperclip, writes it tosources/paperclip/and pins those bytes by sha256;citations import-paperclip <repo>does the same for every paper in a Paperclip paper repo and carries its committed claims across as statements. Verification stays offline against the pinned copy, so Paperclip never decides whether a quotation matches. An identifier with no open-access full text, a document that arrives incomplete, and a missingcitations[paperclip]extra all leave the source unpinned, so their quotations readuncheckedrather thannot found. (#10) -
[citations] A PDF is readable without poppler, and every result names what read it.
verifyshelled out topdftotext -layoutand returneduncheckedwhere the binary was absent, sopip install citationscould not check a PDF at all. Where poppler is missing or fails on a document, the chain now falls through to pypdf or pdfplumber and records the substitution as a fallback with its reason, so a result never rests silently on an extractor other than the one it names. A source declaringextract_cmddoes not enter the chain: its author named the program that produces the text, so it runs or the check isunchecked.citations verify --triangulateasks every installed reader instead of one and reports disagreement asindeterminate, a fourth outcome that leaves the question open rather than asserting the passage is absent — extractors disagreeing is a fact about the readers, andnot foundis an accusation against the manuscript. (#14) -
[citations] A claims file’s
extract_cmdis read, and the report says what ran. The field was declared on the model and documented in the claim-file example whileverify.extracthardcodedpdftotext -layout, so a.texmanuscript reached a PDF reader and gradeduncheckedfor every sentence in it. A source declaringextract_cmdis now read by the command it names: the source path replaces{}or is appended, and what the command prints to stdout is what the quotations resolve against. Every result carries the extractor and a sha256 of the text it produced, since a pin establishes that the bytes did not change and nothing about how they were read. The command is never handed to a shell — the declared string is split into a program and arguments — and onlypdftotextanddetexrun unasked; anything else needscitations verify --allow-extractor NAME, written by whoever runs the check rather than by whoever wrote the file. A refused command and an uninstalled one are bothuncheckedand say which they are; neither makes the passage absent. (#16) -
[repro] A
correspondenceassertion compares two artifacts, and aproselocator addresses a value in a document. Every other kind compares an artifact against a literal written in the manifest, so a claim a document makes about the code beside it required transcribing one side intoreported, where nothing checks it: rewriting each such field to the measured value passes a manifest whose documents are still wrong. A correspondence reads both sides and compares them, privileging neither — when they disagree the decision reports both values and names neither as wrong. A side that does not extract makes the comparison impossible rather than false, so a gap on one side reports asnot_foundand never asmismatch.The
proselocator addresses the value between two literal anchors in a document’s extracted text. No pattern searches for a number: the author declares where the value sits, which is why it is an address rather than a recovery.form: cardinal_wordreads an English cardinal written out; without it, a spelled-out number is refused asnumber_as_wordrather than reported as missing. (#17) -
[citations] A fetched document records whether its length was checked against the extent Paperclip declared for it, on
Document.extent_verifiedand in thepaperclip:block a claims file carries. Wheretail -n 1declares no last line the completeness check cannot run, andlinesis then counted from what arrived rather than confirmed against what the source said it should be; the two cases were previously indistinguishable in the record.The field defaults to false, so anything that does not set it reads as unverified rather than claiming a check nothing performed. (#18)
-
[citations]
citations coverageasks the questionverifycannot: is every quotation in the manuscript pinned at all?verifyreads the claims files and 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.citations coverage paper/draft.tex --claims claims [--strict]citations coverage paper/draft.tex --claims claims --attributeOutcomes are
covered,uncoveredandunresolvable, reported separately rather than as a pass or a fail: a quotation too short to distinguish from noise, or one whose neighbouring source would not open, is undecided and not a defect in the manuscript.--strictfails on the undecided ones too, matchingverify.--attributeadditionally checks each quotation against the artifact of a source cited near it, which catches a passage credited to the wrong paper. It never reports a misattribution when any neighbouring source could not be read: the passage may belong to the one that would not open. (#22) -
[repro] Every decision records the extraction toolchain and the digest of what it produced.
backend_versionis a protocol string naming a backend’s interface, so apdftotextupgrade that resolved a ligature differently changed an extracted passage while the artifact’s digest held and the report readbackend_version: "1"on both sides of it. A decision now carriestoolandtool_version— the binary’s version as it prints it, or the installed distribution’s for the format adapters — andextraction_digest, the sha256 of the whole extracted text for a quotation and of the extracted value for a number. The version says why a reading changed; the digest catches a change from any cause, including a rebuilt binary reporting the same version. A version or digest that was sought and not obtained is recorded asunknown, so it stays distinguishable from one never sought. The fields are provenance: no outcome moves, and whether a changed toolchain should break a pin is left to policy.
0.2.0 — 2026-08-24
Section titled “0.2.0 — 2026-08-24”First release from the monorepo. The four packages previously released separately from their own repositories; they now share one version, one lockfile and one release.
Several entries below are corrections to results the tools reported. They are listed first because a verifier that reports a clean run it did not earn is worse than one that fails.
- [citations] A quotation matched a source that contradicted it. The fallback used when a
verbatim match fails stripped every non-alphanumeric character, so
p < 0.05resolved against a source readingp = 0.05, and-0.42against0.42. It now removes whitespace and nothing else, which is what a PDF extractor actually mangles. - [citations] A source edited after being pinned still passed. Every quotation was checked against the file on disk and nothing compared it to the recorded digest, so a changed source produced a clean run. A broken pin now fails the run and is reported before the quotation results, because it changes how they should be read.
- [citations]
--strictdid not fail on unresolved quotations. A deleted source, an unpinned one, an unparseable claims file and a missingpdftotextall left a build green while nothing had been verified. - [citations] BibTeX entries were split with a regex that dropped or merged entries containing nested braces. Entries are now separated by counting braces.
- [results] The ledger could be extended after being tampered with.
append_eventbuilt on a chain already reported as edited and re-anchored over the evidence, so a damaged ledger verified clean from the next ordinary command onward. It now refuses. - [results] Truncating the ledger and re-anchoring was a two-command clean bill of health.
reanchornow refuses a chain reported as truncated, which is the cheapest tampering there is: no line has to be forged, so the hash chain stays intact and only the count disagrees. - [results]
verify --filesreported a deleted sealed file asok, and reported a path sealed under several different hashes asokwhen the file matched any one of them. - [prereg]
freezeproceeded when there was no commit to name.git()returns empty on any non-zero exit, so a missing binary, a locked index and a directory outside a repository all read as success, and a freeze recorded a commit-shaped string in place of a commit. - [prereg] The log was editable after freezing. The plan hash deliberately stops at the
log, which left the only record of what changed after registration freely deletable while
checkstill reported the plan unchanged. Entries are now chained, with an anchor recording the length so a removal from the end is visible. - [prereg]
checkpassed at a repository root when a plan below it was never frozen, so whether an unfrozen registration passed CI depended on which directory it ran from. - [repro] A structured array element resolved as a single value. A two-field record
stringified as
(0.91, 0.02)and was reported as one extracted value, so a manuscript reporting0.91could verify against a record rather than a number. - [repro] A passage found on the wrong page reported as verified. The page was recorded as a warning, and no policy reads decision warnings, so the assertion was unenforceable.
- [repro] Duplicate artifact and claim ids silently kept the last declaration, which could drop a broken pin from the report entirely and leave the strict policy passing with no violations at all.
- [repro] Duplicate keys in JSON resolved to whichever came last, so one artifact could hold two values for one quantity and address one of them with nothing said about the other.
- [repro] Conformance fixtures now pin the reason and the artifact validity, not only the
outcome. Four cases share the outcome
uncheckedand three sharenot_found; without the reason, a defect in the tool and a fact about the manuscript were indistinguishable. - [repro] A
broken_pinconformance case, which the fixture set named in its skip list but never contained. - Coverage is measured and gated at 70%. Most suites drive a CLI in a real subprocess, which coverage does not follow by default, so the CLI modules reported 0–18% while their tests passed.
- Dependency floors are resolved in CI (
--resolution lowest-direct), which found two declared minimums that could not be installed at all. - Lockstep versioning is enforced by
make versions: every package carries one version and pins its siblings to that series.
Changed
Section titled “Changed”- Restructured as a uv workspace. One lockfile, four packages, one release.
- Python 3.11 is now required.
- [citations] [repro] Raised
pyyamlto>=6.0.2andpydanticto>=2.9. The previous floors resolved to versions that fail to build on current Python.