Keep a line break in metadata, and one cache file per DOI - #87
Conversation
|
Claude finished @cmungall's task in 3m 21s —— View job Review: Keep a line break in metadata, and one cache file per DOI
Both defects are real and the diagnosis in the description is accurate. The Six things below, roughly in order of how much they matter. The first two both bite hardest on exactly the downstream project this is for. 1. Resolution is non-deterministic when both spellings already exist
That mixed state is not hypothetical: it is precisely the state the PR describes downstream ("two tracked entries for one paper"), and it is what every case-sensitive Linux checkout of that repo looks like today. The caller's own exact spelling is not even preferred, so asking for Preferring an exact match among the names folded = path.name.casefold()
matches = [c for c in path.parent.iterdir() if c.name.casefold() == folded]
if not matches:
return path
return next((c for c in matches if c.name == path.name), min(matches))The 2. A full directory scan per DOI path resolution is O(N²) over the cache
For the 6,721-entry cache named in the description, a run touching every reference is ~6,700 directory scans of ~6,700 entries each: tens of millions of dirent reads and string folds where there were none before. Building the cache from scratch is quadratic too. A 3. The filename stops churning, but
|
Two ways a cache entry's identity or content is lost on the way to disk. Both were found downstream, where each had been worked around with a runtime monkeypatch over this module's internals; they are ordinary defects here. A literal line break ends a YAML scalar, and `_quote_yaml_value` quotes on a character list that does not include one. A Crossref title containing a newline is therefore interpolated straight into the frontmatter and the entry stops parsing -- and re-fetching writes the same broken file, so nothing recovers it. Such a value is now emitted JSON-style, which is valid YAML and reloads with the break intact. The guard is `splitlines() != [value]`, which covers every break the scanner honours -- U+0085, U+2028 and U+2029 as well as \n and \r -- plus the vertical tab and form feed its reader rejects as non-printable; all five were verified to produce the same unparseable file. `len(...) > 1` would not do, since a trailing break splits to one element. json.dumps leaves NEL, LS and PS literal even with ensure_ascii=False, so those are escaped explicitly; emitted raw inside a quoted scalar they fold to a space on reload, which is the silent metadata edit the JSON form exists to avoid. Folding it to a space would also parse, but it silently edits metadata that is compared against the fetched record elsewhere. DOI names are case-insensitive by specification, so DOI:10.1016/S0002-9440(10) 63332-9 and its lowercase spelling are one reference. The prefix is normalized and the suffix is not, so each spelling got its own cache file: a duplicate download, and two committed entries for one paper in a project that versions its cache. A DOI path now resolves to whatever spelling is already on disk. Resolution is memoized per directory. Scanning on every call is quadratic over a cache, and `_cache_path` is on the read path and the write path both: measured against a 6,721-entry directory, 6.7 ms a call, or 45 seconds of pure path resolution for a run touching every reference. One scan brings that to 0.57 s. Where both spellings are already present -- what a case-sensitive checkout of an already-duplicated cache looks like -- the caller's exact spelling wins and the lexicographically first is the tiebreak, so the answer does not depend on directory order. An existing entry also keeps the reference_id recorded inside it. The path resolving to one file is not enough on its own: rewriting that line under the caller's capitalization leaves a one-line diff in a committed cache on every cross-spelling re-fetch, which is the churn this removes, one layer in. Resolution looks for the existing file rather than imposing a canonical spelling, so no cache is renamed underneath anyone -- one downstream project holds 6,721 DOI entries and sees no diff. The directory scan comes first and `exists()` is deliberately not consulted: on a case-insensitive filesystem it is true for a spelling that is not the one on disk, so trusting it would return the caller's spelling and write the colliding second file this is meant to prevent. Scoped to DOI. A PMID or an NCT id is not case-insensitive, and folding those would merge genuinely distinct references. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ef92485 to
c61044d
Compare
|
Claude finished @cmungall's task in 4m 22s —— View job Re-review: Keep a line break in metadata, and one cache file per DOI
The force-push addresses all four substantive points from the last round, and addresses them in the right way rather than the cheap way:
Six things below. The first is the one I would want fixed before this lands. 1. Two live fetchers share a cache directory but not the index, so the duplicate file can still appear
# validation/repairer.py:113-114
self.validator = SupportingTextValidator(validation_config) # builds its own fetcher (supporting_text_validator.py:90)
self.fetcher = ReferenceFetcher(validation_config)
The pre-memo version did not have this — a per-call A test would be two fetchers over one 2.
|
|
All six taken. Findings 1 and 2 were right to be called out as worst where this is headed, and I measured both rather than reasoning about them. 2. The quadratic scanYou were right, and it was worse than an inefficiency — it was a regression I introduced, against a 6,721-entry cache: Memoized per directory, invalidated by Worth recording: the downstream workaround this PR replaces did keep a per-directory index. I dropped it when porting the idea upstream and reintroduced the scan. The patch was better engineered than my first version of the fix. 1. Non-determinismFixed as you suggested — exact match among the names The test for it skips on a filesystem that cannot hold both spellings, which is the one running here, so it is real coverage only on Linux/CI. 4. The line-break setYou flagged this as suspected rather than verified. It is verified: all five characters produce the same unparseable file. Guard is now Fixing it exposed a second layer underneath. 3.
|
The DOI case index is per-instance and per-process, so a file written by anything else -- another process, or a subprocess this one shelled out to -- is invisible to it until the process ends. That window is not hypothetical. A downstream backfill script shells out to fetch a missing reference; the subprocess writes into a directory the parent has already indexed, and a later lookup of the same DOI in another capitalization does not find the new file (monarch-initiative/dismech#12083). Re-scanning on every miss would close the window and undo the memoization the previous commit added, so the remedy is explicit: `forget_cache_listing()` drops the listings, and the next resolution that needs one rebuilds it. The docstring now states the window rather than leaving it to be rediscovered. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude finished @cmungall's task in 3m 10s —— View job Re-review: Keep a line break in metadata, and one cache file per DOI
First, a bookkeeping note so the thread reads correctly: the reply above ("All six taken") went out three seconds after my second review, so it is answering the first one — and the code confirms it, On that commit: the diagnosis is right and the reasoning for an explicit reset over re-scanning is right. Re-scanning on every miss really would undo the memoization — a cold build misses once per reference, so it is the same 45 seconds back. Documenting the window in But the commit treats the window as cross-process, and it isn't only that. The same staleness exists between two fetchers in one process, and there the new method does not reach it. 1.
|
… rejects Five fixes from review, kept as their own commit so the delta is readable. The case index was per-instance, and Repairer holds two fetchers over one cache directory -- its own and the one inside SupportingTextValidator -- interleaved across many references. Each one's writes were invisible to the other, so a DOI arriving uppercase through one and lowercase through the other still wrote two files: the defect this branch removes, surviving in the path most likely to hit it. The index is now module-level and keyed by directory, so every write already funnelling through _remember_cache_file keeps it accurate process-wide, and the per-directory scan is still paid once rather than once per instance. forget_cache_listing() is left covering exactly the window it is named for, a writer outside this process. json.dumps escapes only below 0x20, so DEL and the C1 block reached the file raw, and a raw C1 byte makes ruamel's reader refuse the entry outright -- verified for \x7f, \x80 and \x92. The realistic source is mojibake: Windows-1252 read as Latin-1 turns smart quotes and en-dashes into \x91-\x97, a common shape for bibliographic metadata. The escape set is now a regex over the characters the reader rejects or the scanner folds, and it also decides the routing, so such a value reaches the JSON form in the first place. _stored_reference_id read the existing entry as strict UTF-8, so an entry too mangled to decode became one that could not be overwritten -- the file that most needs rewriting. It reads with errors="replace"; only the reference_id line is used. It is also now applied to DOI references alone, which is what the argument covers: sanitization collapses : / ? and = to _, so two distinct url: references can share a filename, and cementing the first writer's id there would change how a pre-existing collision is handled for an identifier type the DOI case-insensitivity argument says nothing about. Also drops a duplicated _remember_cache_file call, and stops a test asserting that a stale read stays stale -- pinning the absence of a fix makes a later self-invalidating index fail the suite as though it were a regression. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude finished @cmungall's task in 4m 15s —— View job Re-review: Keep a line break in metadata, and one cache file per DOI
All six items from the previous round are addressed, and #1 is addressed in a way I could actually verify rather than take on assertion. The claim the module-level index rests on is "every write funnels through
The other four: DEL and the C1 block now both route ( Three things, all smaller than last round's. 1. The DOI gate in
|
|
You are right about the thread, and thank you for reconstructing it rather than assuming. My "All six taken" went out three seconds after your second review and was answering the first — so the second round's list had never been responded to, and I would not have noticed. It is addressed below along with this round's finding, in 1. Two fetchers, one directoryConfirmed: Hoisted to a module-level dict keyed by directory, as you suggested, and the shape works out the way you predicted: every write already funnels through One caveat on the test you asked for. I wrote it, and it passes on this machine with or without the fix — macOS folds the two filenames together, so the second write lands in the first file whatever the index says. The trace shows the difference plainly: so the fix is verified, but the test is only real coverage on a case-sensitive filesystem. That is the second time this filesystem has hidden a case bug from me in this PR. 4. The second review's list
2 and 3The stale-read assertion is gone — you are right that pinning the absence of a fix means a later self-invalidating index fails the suite as though it were a regression. The duplicated 5. mtime validationGenuinely attractive, and I have not taken it, so the reasoning rather than a silent no. With the index now shared process-wide, the remaining window is a writer outside the process, and 1126 passing, 1 skipped (the both-spellings case, on this filesystem), mypy, Ruff, doctests and |
dismech rewrote nine private methods of `linkml-reference-validator` at runtime, every time the reference gate ran, and reimplemented two more of its internals in a parallel module. Every defect those worked around is now fixed upstream, so this deletes them and pins the behaviours with tests instead. Keeping them had stopped being neutral. `src/dismech/patch_reference_validator.py` no longer imported against current upstream -- `just validate-kb-references` died at startup with `AttributeError: 'function' object has no attribute '__func__'`. That is the gate, not a test. And `_wrap_xml_extractor` appended JATS tables *after* calling upstream, which now extracts tables itself, so running the patch against current upstream wrote every table into the cache twice. Deleted, with the upstream issue that replaces each: network retry on PMIDSource fetches linkml/linkml-reference-validator#66 "restricted" discarding a real body linkml/linkml-reference-validator#67 JATS tables dropped linkml/linkml-reference-validator#68 bare NCT cache-path mismatch linkml/linkml-reference-validator#69 unquoted author values linkml/linkml-reference-validator#70 frontmatter split on a `---` substring linkml/linkml-reference-validator#71 "Total checks: 0" on a clean run linkml/linkml-reference-validator#72 title newlines breaking the emitter linkml/linkml-reference-validator#87 DOI cache filename case collisions linkml/linkml-reference-validator#87 `src/dismech/doi_cache_case.py` goes with them: #87 landed the case-insensitive cache lookup upstream, including the memoised directory index, so the local resolver is redundant. Its #9112 regression test is kept, retargeted at the public `ReferenceFetcher.forget_cache_listing()`. Two pieces of the parallel mirror layer go the same way: `split_snippet` now calls the public `split_supporting_text` (#74) instead of reimplementing the private `_split_query`, verified identical across all 186,812 snippets in `kb/`; and the local ligature table is gone, since `normalize_text` folds them itself (#73). Two deliberate asymmetries, both measured rather than assumed: - `normalize_relaxed` keeps NFKC, renamed `fold_compatibility`. Upstream declines NFKC on purpose -- it would rewrite `10^6` as `106` in the gate -- but this advisory pass asks a different question, and NFKC is what bridges `u` (U+00B5) to `u` (U+03BC) in PDF-extracted text. 158 of 186,812 snippets normalize differently with it. - AE/OE folding moves into `scripts/check_reference_titles.py`, the only place that needs it. Upstream preserves those as the distinct letters they are; title comparison is a *similarity* check where an archaic `anaemia` spelled with the ligature should still match. No snippet in `kb/` contains one. The frontmatter contract had to grow to accept the upgrade. The validator now stamps each cache file with the extractor version that wrote it and records why a full-text fetch was declined; `ReferenceCacheFrontmatter` forbids unknown fields, so before this every file the upgraded validator touched failed `just check-reference-cache-frontmatter` -- 146 of 146 after validating a single KB entry. The five fields are optional because the repository holds both generations of cache file at once. `tests/test_reference_validator_patch.py` and its frontmatter sibling are replaced by `tests/test_upstream_validator_behaviours.py`, which asserts the behaviour rather than the patch and names the upstream issue each test pins -- including a tripwire for the table doubling, one pinning a known residual (upstream's fix is length-gated, so a sub-1000-character body saying "restricted" is still discarded), and one asserting the patch module stays deleted. The monkeypatch policy this should have followed from the start goes in the `dismech-references` skill rather than CLAUDE.md: open the upstream issue before writing the patch, name it at the patch site, and test the behaviour you need rather than the patch, so the test keeps passing when the fix lands and the patch comes out. NOT READY TO MERGE: `pyproject.toml` points at the upstream git branch, marked PIN-ON-RELEASE. Swap it for a version floor once the release is cut. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
dismech rewrote eleven private methods of `linkml-reference-validator` at runtime, every time the reference gate ran, and reimplemented two more of its internals in a parallel module. Ten of those defects are now fixed upstream, so this deletes them and pins the behaviours with tests instead. The module shrinks from 832 lines to 97. Keeping them had stopped being neutral. `src/dismech/patch_reference_validator.py` no longer imported against current upstream -- `just validate-kb-references` died at startup with `AttributeError: 'function' object has no attribute '__func__'`. That is the gate, not a test. And `_wrap_xml_extractor` appended JATS tables *after* calling upstream, which now extracts tables itself, so running the patch against current upstream wrote every table into the cache twice. Deleted, with the upstream issue that replaces each: network retry on PMIDSource fetches linkml/linkml-reference-validator#66 "restricted" discarding a real body linkml/linkml-reference-validator#67 JATS tables dropped linkml/linkml-reference-validator#68 bare NCT cache-path mismatch linkml/linkml-reference-validator#69 unquoted author values linkml/linkml-reference-validator#70 frontmatter split on a `---` substring linkml/linkml-reference-validator#71 "Total checks: 0" on a clean run linkml/linkml-reference-validator#72 title newlines breaking the emitter linkml/linkml-reference-validator#87 DOI cache filename case collisions linkml/linkml-reference-validator#87 `src/dismech/doi_cache_case.py` goes with them: #87 landed the case-insensitive cache lookup upstream, including the memoised directory index, so the local resolver is redundant. Its #9112 regression test is kept, retargeted at the public `ReferenceFetcher.forget_cache_listing()`. Two pieces of the parallel mirror layer go the same way: `split_snippet` now calls the public `split_supporting_text` (#74) instead of reimplementing the private `_split_query`, verified identical across all 186,812 snippets in `kb/`; and the local ligature table is gone, since `normalize_text` folds them itself (#73). Two deliberate asymmetries, both measured rather than assumed: - `normalize_relaxed` keeps NFKC, renamed `fold_compatibility`. Upstream declines NFKC on purpose -- it would rewrite `10^6` as `106` in the gate -- but this advisory pass asks a different question, and NFKC is what bridges `u` (U+00B5) to `u` (U+03BC) in PDF-extracted text. 158 of 186,812 snippets normalize differently with it. - AE/OE folding moves into `scripts/check_reference_titles.py`, the only place that needs it. Upstream preserves those as the distinct letters they are; title comparison is a *similarity* check where an archaic `anaemia` spelled with the ligature should still match. No snippet in `kb/` contains one. The frontmatter contract had to grow to accept the upgrade. The validator now stamps each cache file with the extractor version that wrote it and records why a full-text fetch was declined; `ReferenceCacheFrontmatter` forbids unknown fields, so before this every file the upgraded validator touched failed `just check-reference-cache-frontmatter` -- 146 of 146 after validating a single KB entry. The five fields are optional because the repository holds both generations of cache file at once. `tests/test_reference_validator_patch.py` and its frontmatter sibling are replaced by `tests/test_upstream_validator_behaviours.py`, which asserts the behaviour rather than the patch and names the upstream issue each test pins -- including a tripwire for the table doubling, one pinning a known residual (upstream's fix is length-gated, so a sub-1000-character body saying "restricted" is still discarded), and one asserting the patch module stays deleted. The monkeypatch policy this should have followed from the start goes in the `dismech-references` skill rather than CLAUDE.md: open the upstream issue before writing the patch, name it at the patch site, and test the behaviour you need rather than the patch, so the test keeps passing when the fix lands and the patch comes out. One patch survives, and it is not a workaround. `_wrap_url_fetch` strips `<script>` blocks, comments and tag attributes out of the raw HTML `URLSource` caches, because this repository commits its reference cache to a public git repository and upstream does no extraction on that path at all -- 150 of the 199 `content_type: url` entries currently carry a script block. Nothing committed turns out to be a real credential (the signed URLs are public CDN figure links, the one `api_key` is an empty template parameter), so it is hygiene rather than an incident, but deleting the patch would put that material back. Filed as linkml/linkml-reference-validator#92 and deleted when that lands. `test_only_one_monkeypatch_remains_and_it_cites_an_upstream_issue` enforces the budget mechanically: the module may patch exactly one class attribute, and must name the upstream issue that removes it. Adding a twelfth patch fails the suite. That guard is the point -- two of the eleven were added by curation PRs while this branch was open, one of them scraping non-open-access articles past a browser check, and neither was noticed until a rebase. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
dismech rewrote eleven private methods of `linkml-reference-validator` at runtime, every time the reference gate ran, and reimplemented two more of its internals in a parallel module. Ten of those defects are now fixed upstream, so this deletes them and pins the behaviours with tests instead. The module shrinks from 832 lines to 97. Keeping them had stopped being neutral. `src/dismech/patch_reference_validator.py` no longer imported against current upstream -- `just validate-kb-references` died at startup with `AttributeError: 'function' object has no attribute '__func__'`. That is the gate, not a test. And `_wrap_xml_extractor` appended JATS tables *after* calling upstream, which now extracts tables itself, so running the patch against current upstream wrote every table into the cache twice. Deleted, with the upstream issue that replaces each: network retry on PMIDSource fetches linkml/linkml-reference-validator#66 "restricted" discarding a real body linkml/linkml-reference-validator#67 JATS tables dropped linkml/linkml-reference-validator#68 bare NCT cache-path mismatch linkml/linkml-reference-validator#69 unquoted author values linkml/linkml-reference-validator#70 frontmatter split on a `---` substring linkml/linkml-reference-validator#71 "Total checks: 0" on a clean run linkml/linkml-reference-validator#72 title newlines breaking the emitter linkml/linkml-reference-validator#87 DOI cache filename case collisions linkml/linkml-reference-validator#87 `src/dismech/doi_cache_case.py` goes with them: #87 landed the case-insensitive cache lookup upstream, including the memoised directory index, so the local resolver is redundant. Its #9112 regression test is kept, retargeted at the public `ReferenceFetcher.forget_cache_listing()`. Two pieces of the parallel mirror layer go the same way: `split_snippet` now calls the public `split_supporting_text` (#74) instead of reimplementing the private `_split_query`, verified identical across all 186,812 snippets in `kb/`; and the local ligature table is gone, since `normalize_text` folds them itself (#73). Two deliberate asymmetries, both measured rather than assumed: - `normalize_relaxed` keeps NFKC, renamed `fold_compatibility`. Upstream declines NFKC on purpose -- it would rewrite `10^6` as `106` in the gate -- but this advisory pass asks a different question, and NFKC is what bridges `u` (U+00B5) to `u` (U+03BC) in PDF-extracted text. 158 of 186,812 snippets normalize differently with it. - AE/OE folding moves into `scripts/check_reference_titles.py`, the only place that needs it. Upstream preserves those as the distinct letters they are; title comparison is a *similarity* check where an archaic `anaemia` spelled with the ligature should still match. No snippet in `kb/` contains one. The frontmatter contract had to grow to accept the upgrade. The validator now stamps each cache file with the extractor version that wrote it and records why a full-text fetch was declined; `ReferenceCacheFrontmatter` forbids unknown fields, so before this every file the upgraded validator touched failed `just check-reference-cache-frontmatter` -- 146 of 146 after validating a single KB entry. The five fields are optional because the repository holds both generations of cache file at once. `tests/test_reference_validator_patch.py` and its frontmatter sibling are replaced by `tests/test_upstream_validator_behaviours.py`, which asserts the behaviour rather than the patch and names the upstream issue each test pins -- including a tripwire for the table doubling, one pinning a known residual (upstream's fix is length-gated, so a sub-1000-character body saying "restricted" is still discarded), and one asserting the patch module stays deleted. The monkeypatch policy this should have followed from the start goes in the `dismech-references` skill rather than CLAUDE.md: open the upstream issue before writing the patch, name it at the patch site, and test the behaviour you need rather than the patch, so the test keeps passing when the fix lands and the patch comes out. One patch survives, and it is not a workaround. `_wrap_url_fetch` strips `<script>` blocks, comments and tag attributes out of the raw HTML `URLSource` caches, because this repository commits its reference cache to a public git repository and upstream does no extraction on that path at all -- 150 of the 199 `content_type: url` entries currently carry a script block. Nothing committed turns out to be a real credential (the signed URLs are public CDN figure links, the one `api_key` is an empty template parameter), so it is hygiene rather than an incident, but deleting the patch would put that material back. Filed as linkml/linkml-reference-validator#92 and deleted when that lands. `test_only_one_monkeypatch_remains_and_it_cites_an_upstream_issue` enforces the budget mechanically: the module may patch exactly one class attribute, and must name the upstream issue that removes it. Adding a twelfth patch fails the suite. That guard is the point -- two of the eleven were added by curation PRs while this branch was open, one of them scraping non-open-access articles past a browser check, and neither was noticed until a rebase. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
dismech rewrote eleven private methods of `linkml-reference-validator` at runtime, every time the reference gate ran, and reimplemented two more of its internals in a parallel module. Ten of those defects are now fixed upstream, so this deletes them and pins the behaviours with tests instead. The module shrinks from 832 lines to 97. Keeping them had stopped being neutral. `src/dismech/patch_reference_validator.py` no longer imported against current upstream -- `just validate-kb-references` died at startup with `AttributeError: 'function' object has no attribute '__func__'`. That is the gate, not a test. And `_wrap_xml_extractor` appended JATS tables *after* calling upstream, which now extracts tables itself, so running the patch against current upstream wrote every table into the cache twice. Deleted, with the upstream issue that replaces each: network retry on PMIDSource fetches linkml/linkml-reference-validator#66 "restricted" discarding a real body linkml/linkml-reference-validator#67 JATS tables dropped linkml/linkml-reference-validator#68 bare NCT cache-path mismatch linkml/linkml-reference-validator#69 unquoted author values linkml/linkml-reference-validator#70 frontmatter split on a `---` substring linkml/linkml-reference-validator#71 "Total checks: 0" on a clean run linkml/linkml-reference-validator#72 title newlines breaking the emitter linkml/linkml-reference-validator#87 DOI cache filename case collisions linkml/linkml-reference-validator#87 `src/dismech/doi_cache_case.py` goes with them: #87 landed the case-insensitive cache lookup upstream, including the memoised directory index, so the local resolver is redundant. Its #9112 regression test is kept, retargeted at the public `ReferenceFetcher.forget_cache_listing()`. Two pieces of the parallel mirror layer go the same way: `split_snippet` now calls the public `split_supporting_text` (#74) instead of reimplementing the private `_split_query`, verified identical across all 186,812 snippets in `kb/`; and the local ligature table is gone, since `normalize_text` folds them itself (#73). Two deliberate asymmetries, both measured rather than assumed: - `normalize_relaxed` keeps NFKC, renamed `fold_compatibility`. Upstream declines NFKC on purpose -- it would rewrite `10^6` as `106` in the gate -- but this advisory pass asks a different question, and NFKC is what bridges `u` (U+00B5) to `u` (U+03BC) in PDF-extracted text. 158 of 186,812 snippets normalize differently with it. - AE/OE folding moves into `scripts/check_reference_titles.py`, the only place that needs it. Upstream preserves those as the distinct letters they are; title comparison is a *similarity* check where an archaic `anaemia` spelled with the ligature should still match. No snippet in `kb/` contains one. The frontmatter contract had to grow to accept the upgrade. The validator now stamps each cache file with the extractor version that wrote it and records why a full-text fetch was declined; `ReferenceCacheFrontmatter` forbids unknown fields, so before this every file the upgraded validator touched failed `just check-reference-cache-frontmatter` -- 146 of 146 after validating a single KB entry. The five fields are optional because the repository holds both generations of cache file at once. `tests/test_reference_validator_patch.py` and its frontmatter sibling are replaced by `tests/test_upstream_validator_behaviours.py`, which asserts the behaviour rather than the patch and names the upstream issue each test pins -- including a tripwire for the table doubling, one pinning a known residual (upstream's fix is length-gated, so a sub-1000-character body saying "restricted" is still discarded), and one asserting the patch module stays deleted. The monkeypatch policy this should have followed from the start goes in the `dismech-references` skill rather than CLAUDE.md: open the upstream issue before writing the patch, name it at the patch site, and test the behaviour you need rather than the patch, so the test keeps passing when the fix lands and the patch comes out. One patch survives, and it is not a workaround. `_wrap_url_fetch` strips `<script>` blocks, comments and tag attributes out of the raw HTML `URLSource` caches, because this repository commits its reference cache to a public git repository and upstream does no extraction on that path at all -- 150 of the 199 `content_type: url` entries currently carry a script block. Nothing committed turns out to be a real credential (the signed URLs are public CDN figure links, the one `api_key` is an empty template parameter), so it is hygiene rather than an incident, but deleting the patch would put that material back. Filed as linkml/linkml-reference-validator#92 and deleted when that lands. `test_only_one_monkeypatch_remains_and_it_cites_an_upstream_issue` enforces the budget mechanically: the module may patch exactly one class attribute, and must name the upstream issue that removes it. Adding a twelfth patch fails the suite. That guard is the point -- two of the eleven were added by curation PRs while this branch was open, one of them scraping non-open-access articles past a browser check, and neither was noticed until a rebase. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two small defects in how a cache entry reaches disk. Both were found downstream in dismech, where each had been worked around with a runtime monkeypatch over this module's internals — and one of those patches has just started crashing on
main, which is the better argument for fixing them here.A line break in a title breaks the entry
_quote_yaml_valuequotes on a character list that does not include\n. Crossref titles do contain literal line breaks, so the value is interpolated straight into the frontmatter and the file no longer parses:Re-fetching rewrites the same broken file, so nothing downstream recovers it.
Such values are now emitted JSON-style, which is valid YAML and reloads with the break intact. Folding the break into a space would also parse, but it silently edits metadata — and the title is compared against the fetched record elsewhere, so an edited one reads as a mismatch.
A DOI in two capitalizations gets two cache files
DOI names are case-insensitive by specification. The prefix is normalized; the suffix is not:
One reference, two files: a duplicate download, and for a project that commits its cache, two tracked entries for one paper. On a case-insensitive filesystem those two paths then collide in git, which is how this surfaced — a repo in that state cannot
rebase,checkoutorstashon a clean tree.A DOI path now resolves to whatever spelling already exists in the cache directory.
Resolution finds the existing file rather than imposing a canonical spelling, so no existing cache is renamed: the downstream project holding 6,721 DOI entries sees no diff, and whichever spelling was written first keeps winning.
The directory scan comes first, and
exists()is deliberately not consulted. On a case-insensitive filesystemexists()is true for a spelling that is not the one on disk, so trusting it returns the caller's spelling and writes exactly the colliding second file this is meant to prevent. My first attempt had that bug and the tests caught it.Scoped to DOI on purpose — a PMID or an NCT id is not case-insensitive, and folding those would merge genuinely distinct references.
Why here rather than downstream
The DOI workaround wrapped
_cache_path, which was a@staticmethodin v0.2.1 and is an instance method onmain. Against currentmainthe patch now raises at import:which takes down every validation run rather than quietly losing the fix. That is the normal fate of a patch over a private API, and it is why both of these belong upstream.
Since the first review
_cache_pathis on the read and write paths — measured at 6.7 ms a call against a 6,721-entry cache, or 45 s of pure path resolution for a run touching every reference. Now 0.57 s.iterdir()order is arbitrary, so the previous version could hand back the other spelling and differ between checkouts.\nand\r. Fixing it surfaced thatjson.dumps(ensure_ascii=False)leaves U+0085, U+2028 and U+2029 literal, so they were still folded to a space on reload; those are now escaped explicitly.reference_id. The path resolving to one file was not enough: rewriting that line under the caller's capitalization left a one-line diff in a committed cache on every cross-spelling re-fetch.forget_cache_listing()(fdf0cee) lets a caller drop the memo after an outside writer touches the directory — a window dismech#12083 already hit with a subprocess-based backfill.Tests
18, written first, in
tests/test_frontmatter_and_doi_case.py: eight line-break shapes round-tripping (including U+0085, U+2028, U+2029, vertical tab and form feed, all verified to produce the same unparseable file), single-line quoting unchanged, two capitalizations sharing one path, a save under each spelling producing one file, an existing entry keeping its own capitalization and its storedreference_id, both spellings on disk resolving deterministically, at most one directory scan across many resolutions, a non-DOI reference not being folded, and the stale-index window closing after a reset.One skips on a case-insensitive filesystem — the both-spellings case cannot be set up there — so it is real coverage only on Linux/CI.
Full suite 1119 passing (live-network tests deselected), mypy, Ruff, doctests and
mkdocs build --strictclean.🤖 Generated with Claude Code