fix(citations): never report a check the engine did not make - #17
Conversation
A 2026-07-27 audit found the verifier returning `verified` when it meant `unchecked`. Each defect below was reproduced against HEAD before fixing, and each now has a regression test that fails against the old code. The unifying property: an absent input, an unreachable host, or a provider that does not publish a field are all `unknown` — never `pass`. Metadata axis - A silent pass band between TITLE_DISTINCT (0.60) and TITLE_MATCH (0.85) let a subtly-wrong title score `verified` 0.90 with the detail "Title, authors, and year agree with the record". Now warns and names both titles. This is the signature of an LLM-hallucinated reference — DOI right, title subtly wrong — so it is the highest-value fix for the tool's stated purpose. - A bare DOI with no title/authors/year also scored `verified` 0.90 with that same "agree with the record" string, asserting agreement with nothing. The axis now returns `unknown`, the verdict caps at `unverifiable`, and the score is a weighted mean over the axes that actually ran, reported in a new `axes_evaluated` field. Retraction axis - "No retraction recorded in Crossref." was emitted whenever no signal was found, including for DataCite-resolved DOIs whose records carry no retraction-capable fields at all. It now reports `unknown` and names the source actually consulted. - OpenAlex `is_retracted` was never consulted once Crossref resolved the DOI, though README:33 promises both, so an OpenAlex-only retraction returned `verified`. Both are now cross-checked. False-negative retractions are the most consequential error class this tool has. URL axis - Transport failures (timeout, DNS, TLS, reset) were reported as "URL does not resolve (HTTP None) and is not archived" — our own network trouble stated as a fact about the citation. `default_head` now distinguishes transport failure from an HTTP response, and the axis reports `unknown`. "HTTP None" can no longer appear in output. - A dead unarchived URL landed on exactly 0.8 and so read as `verified`; it now forces `warnings`. - An attempted-but-indeterminate axis no longer scores a flawless 1.0: dropping `unknown` axes from the mean was right, but on its own it made "we never reached the host" indistinguishable from "we fetched it and it was live". Names (a linguistics-facing tool cannot fail on diacritics) - `_surname` split on non-ASCII runs and kept the fragment after the diacritic: Müller -> 'ller', Zhāng -> 'ng', Ó Séaghdha -> 'aghdha'. Correct citations by authors with non-English names drew false "author overlap low (0%)" warnings. Now ASCII-folds before splitting. - BibTeX LaTeX accents were never decoded, so \"uller / \o rensen scored 0.0 overlap against the true Unicode record. Now round-trips to 1.0. Robustness - One transient error mid-bibliography discarded every verdict already computed; failures are now per-reference with an `errors` count in the summary, plus Retry-After-aware backoff on 429/503. - Study steps persisted findings and flipped status in separate statements, so an interrupt duplicated findings on resume; now atomic. - A study could report `done` with steps still pending, and creating with an existing slug silently discarded the submitted plan while reporting "Created study N". Manifest and CI - plugin.yaml declared 7 tools while the plugin registers 11, hiding the entire study pipeline from any host that trusts the manifest. Added the four names, bumped 0.1.0 -> 0.2.0, and added a test asserting manifest == registry so it cannot drift again. - CI ran only tests/test_check_sanitization.py — 154 of 2,000+ test lines. Added an offline suite job on every PR and a weekly MATILDE_LIVE=1 job, which is the only guard against Crossref/OpenAlex/DataCite schema drift. Tests: 169 passed, 7 skipped (was 144/7). Not addressed here: tests/test_plugin_tools.py fails when run as a single file (KeyError: 'matilde_plugin') because it depends on import order. This pre-exists on main and is unrelated to these changes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The old test asserted `0.65 < TITLE_MATCH_THRESHOLD` — arithmetic on two constants, which passes against an empty module and proves nothing. Raised in review, and it is precisely the failure this file exists to catch: a check that reports success without exercising the behaviour it claims to test. Replaced with a five-point parametrized sweep across the band (~0.63 floor to ~0.83 ceiling) that asserts, for each, that the metadata axis warns, the verdict is not 'verified', the score is below threshold, and the detail does not claim the titles agree. Each fixture also asserts it is still inside the band, so a drifting fixture fails loudly instead of silently leaving the band untested — which it did catch: one candidate scored 0.857 and was replaced. 173 passed, 7 skipped (was 169/7). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Independent audit — corrections to this PR's own claimsAn adversarial audit was run on this branch. It came back MERGE WITH FOLLOW-UP: the engine work is sound, the score/verdict invariant holds under an exhaustive 256-combination fuzz, and 20 of the 24 new tests genuinely fail against Corrections to the body
Regressions found — follow-ups, not blockers
Separately noted, not caused by this PR: CI verified on real 3.12: offline suite pass, sanitize pass, live lane correctly skipping. The weekly |
Why
A 35-agent audit on 2026-07-27 (adversarially verified, zero findings refuted) found the citation verifier returning
verifiedwhen it meantunchecked. For a tool whose entire purpose is catching hallucinated references, that is the worst possible failure direction.Every defect below was reproduced against
mainbefore being fixed. Actual old outputs:verified0.90…for translation)verified0.90fail_surname("Müller")'ller'Zhāng→'ng',Ó Séaghdha→'aghdha')M\"ullervs recordMüller0.0All of these are pre-GLM defects — every affected file dates 2026-06-17..23 and the last commit before this branch was 2026-07-01. They are not regressions; they were never right.
The property this enforces
An absent input, an unreachable host, or a provider that does not publish a field are all
unknown— neverpass. The score is now a weighted mean over the axes that actually ran, and a newaxes_evaluatedfield states what that denominator was, so "of what could be checked, how much checked out" is falsifiable rather than implied.Fixes
Metadata — warn band added between
TITLE_DISTINCT(0.60) andTITLE_MATCH(0.85), naming both titles; a reference with neither title nor authors returnsunknownand caps atunverifiable.Retraction — no longer claims Crossref provenance it does not have (DataCite records carry no retraction-capable fields at all); OpenAlex
is_retractedis now cross-checked even when Crossref resolves the DOI, as README:33 already promised. False-negative retractions are the most consequential error class here.URL —
default_headdistinguishes transport failure from an HTTP response; transport failure reportsunknownnaming the exception."HTTP None"can no longer appear in output. A dead unarchived URL forceswarningsinstead of landing on exactly 0.8. An attempted-but-indeterminate axis no longer scores a flawless 1.0 — droppingunknownaxes from the mean was correct, but on its own it made "never reached the host" indistinguishable from "fetched it, it was live".Names —
_surnameASCII-folds before splitting, and BibTeX LaTeX accents are decoded. A linguistics-facing tool that silently fails onMüller,SørensenandZhāngis a credibility problem, not just a bug.Robustness — per-reference error isolation in bibliography runs (one 429 on item 40 of 50 used to discard all 39 prior verdicts) with Retry-After-aware backoff; atomic step persistence so an interrupt cannot duplicate findings on resume; a study can no longer report
donewith steps pending; creating with an existing slug no longer silently discards the submitted plan while reporting "Created study N".Manifest + CI —
plugin.yamldeclared 7 tools while the plugin registers 11, hiding the whole study pipeline from any host that trusts the manifest; added the four names, bumped to 0.2.0, and added a test asserting manifest == registry. CI ran onlytest_check_sanitization.py(154 of 2,000+ test lines) — added an offline job on every PR plus a weeklyMATILDE_LIVE=1job, which is the only guard against provider schema drift.Tests
169 passed, 7 skipped (was 144/7). 24 new tests in
tests/test_citation_honesty.py, one per defect, each verified to fail againstmain— the old code was restored and re-probed to confirm the assertions actually bite rather than passing vacuously. The manifest guard was verified by deleting a tool from the YAML and confirming a precise failure message.Reviewer notes
_INDETERMINATE_SCORE_CAP = 0.95choice is a judgement call; the requirement is only that an unverifiable URL scores strictly below a confirmed one.README.md:91's# -> "verified" 1.0is now correct under the renormalized scoring (the audit had flagged it as needing0.9, which was true only under the old weighting). Deliberately left alone.tests/test_plugin_tools.pyfails when run as a single file (KeyError: 'matilde_plugin') due to import-order coupling. Pre-exists onmain, unrelated to this branch, left for a separate PR.🤖 Generated with Claude Code