Skip to content

Fetch only openly-licensed files, and never demote a cache entry - #85

Merged
cmungall merged 1 commit into
mainfrom
fix/no-scraping-no-bronze
Sep 18, 2026
Merged

cmungall merged 1 commit into
mainfrom
fix/no-scraping-no-bronze

Conversation

@cmungall

Copy link
Copy Markdown
Member

A cached reference could hold an article's full text one day and only its abstract the next, with nothing to say why. Three changes stop that, and stop the validator fetching text it has no licence to redistribute.

The trigger was a real dismech cache entry: PMID_9177246.md held 18,464 characters of a 1997 PNAS paper, and a routine re-fetch rewrote it to a 1,199-character abstract. The quoted evidence in the knowledge base stopped validating. No error, no warning.

What was happening

Three things compounding.

The fetch was scraping a web page. Both OpenAlexProvider and UnpaywallProvider used pdf_url or landing_url. When a record has no file, that falls back to the article page and downloads it. PMC refuses this — it answers a rate-limited request with a reCAPTCHA interstitial:

Checking your browser - reCAPTCHA. Checking your browser before accessing pmc.ncbi.nlm.nih.gov …

served with HTTP 200, so nothing downstream can distinguish it from article text. Ten consecutive downloads of one URL returned 152089 152089 152089 21293 21294 21293 21293 21293 21292 21294 bytes — the 21 KB responses are the bot page.

The failure was recorded as a fact about the article. The bot page is correctly rejected, the abstract fetch succeeds on its own, and the record is written content_type: abstract_only with full_text_attempted: true — byte-identical to "this article has no full text."

The refresh was automatic. The extractor stamp from #62 marks every pre-stamp entry stale, so the next run re-fetches it. Before #62 an entry already marked full_text_html was never re-fetched, so this flakiness only ever affected first fetches, where failing loses nothing. It now overwrites committed content.

What changed

A landing page is not full text. Providers return a location only for a file (pdf_url, url_for_pdf). With only a page, they return None and the record stays abstract_only.

Bronze open access is not redistributable. oa_status: bronze means free to read on the publisher's site under no open licence. New access_type_for_oa_status maps gold/green/diamond/hybrid to open and everything else to publisher_free. No new gate was needed — _enrich_with_full_text already skips non-open locations, which is the route #61 built for Zotero private-library material. access_type was previously set by the Zotero provider alone, so everything the other providers returned defaulted to "open" and was saved as public.

An unrecognised or missing status is deliberately not treated as open: an unknown licence is not a grant.

A refresh may improve an entry; it may never demote one. _preserve_cached_full_text refuses a write that loses full text. This is not optional alongside the first change — without it, removing scraping would itself destroy data, because every stale entry would re-fetch, come back abstract-only, and overwrite itself.

_stale_fallback does not cover this case: it fires only when the source yields nothing, and here the abstract is something. The guard is narrow — only a loss is refused, the preserved entry stays stale so a later run retries, and force_refresh overrides it.

Verified on the real entry

before: full_text_html, 18,464 chars, quoted snippet present
        ↓ refresh returns only an abstract
  WARNING  Refresh of PMID:9177246 returned no full text (abstract_only);
           keeping the cached full_text_html entry rather than overwriting it.
           It stays stale, so a later run will try again.
after : full_text_html, 18,464 chars, quoted snippet present

And a fresh fetch now declines rather than scrapes: content_type: abstract_only | provider: None. No reCAPTCHA request is made.

Behaviour changes to expect

  • Full text previously obtained by scraping a landing page is no longer fetched. Those references resolve as abstract_only, and excerpts quoted from that text stop verifying. This is the intended fix, but it will look like a regression if you are not expecting it.
  • Bronze full text is no longer fetched into the public cache.
  • Existing cache entries are not rewritten or deleted. The guard protects them. They do stay stale, so each run re-attempts a refresh that declines, emitting one warning per entry.

One thing worth a second opinion

green is currently treated as open, on the grounds that a repository deposit is author- or repository-licensed, and the no-scraping rule already catches the awkward case (green-via-landing-page is refused for being a page, before licence is considered). If only gold and diamond should count as redistributable, that is a one-line change to OPEN_LICENCE_OA_STATUSES.

Tests

17 new across two files, written before the implementation.

  • test_open_access_policy.py — no landing pages from either provider, PDFs still returned, each open status maps to open, bronze and unknown do not, and one test pinning the join: a bronze location never reaches _apply_full_text_location.
  • test_no_cache_downgrade.py — a failed refresh keeps both the content and the file on disk; the entry stays un-stamped so migration retries; a successful refresh still rewrites; abstract→full-text upgrades are allowed; abstract→abstract is not a downgrade; no cached entry means nothing to preserve.

test_locate_falls_back_to_oa_url asserted the scraping this removes. It is inverted rather than deleted, so the file records why a landing page is not a located full text.

Full suite, mypy, Ruff, doctests and mkdocs build --strict all pass.

🤖 Generated with Claude Code

@claude

claude Bot commented Sep 17, 2026 •

Copy link
Copy Markdown
Contributor

Claude finished @cmungall's task in 2m 56s —— View job


Review: Fetch only openly-licensed files, and never demote a cache entry

  • Read the diff against origin/main
  • Review provider changes (landing page / OA status)
  • Review _preserve_cached_full_text guard
  • Review test coverage
  • Post review

This is a well-argued change and the diagnosis is convincing — an HTTP 200 reCAPTCHA page is genuinely indistinguishable downstream, and recording that as abstract_only is a fact about the fetch masquerading as a fact about the article. Three real findings below, the first of which I think matters.


1. The guard doesn't just refuse the delete — it serves the text the stale-HTML rule exists to withhold

reference_fetcher.py:247-264 loads with allow_stale_html=True, and fetch() then returns that entry to the caller:

preserved = self._preserve_cached_full_text(...)
if preserved is not None:
    self._cache[normalized_reference_id] = preserved
    return preserved          # <- goes straight to validation

The docstring draws the distinction and then the code crosses it:

That refusal is about whether such text may be served as evidence; this is about whether it may be deleted, and the answer to the second is no regardless of the first.

But preserved is the fetch result. _load_from_disk refuses stale full_text_html with the message "it may be a repository landing page" (reference_fetcher.py:955-959) — and under this PR's own no-scraping rule, stale HTML entries are disproportionately the ones that came from landing-page scraping. So the code path now reached most often is: entry scraped from a page pre-fix → refresh correctly declines → guard serves the scraped page to validation, where _stale_fallback would have withheld it. test_a_failed_full_text_refresh_keeps_the_cached_full_text pins exactly this (it asserts the stale HTML body is in result.content), so it's deliberate, but it isn't what the docstring claims.

Both goals are reachable — decide the write with the HTML bypass and the return without it:

if force_refresh or not self.needs_full_text(fresh):
    return None
cached = self._load_from_disk(id, allow_stale=True, allow_stale_html=True)
if cached is None or self.needs_full_text(cached):
    return None
logger.warning(...)
# Serving is a separate question from deleting: withhold stale HTML here
# the same way _stale_fallback does, but still refuse the overwrite.
servable = self._load_from_disk(id, allow_stale=True)
return servable if servable is not None else fresh

That needs the caller to distinguish "don't save" from "return this", e.g. returning (content_to_return, should_save). Fix this →

2. green — you asked for a second opinion

I'd drop it, or better, refine it. The module's own principle is "an unknown licence is not a grant" — but green describes where a copy lives (a repository), not under what licence. A green PMC author manuscript is free to read under the NIH policy with redistribution terms that vary by publisher; that is the same shape of claim as bronze, just with a different host. Including green while excluding bronze on licence grounds isn't self-consistent.

The stronger version: you already have the per-location licence in hand and are discarding it for this decision — best.get("license") at openalex.py:83 and unpaywall.py:76. Gate on it:

def access_type_for_oa_status(oa_status, license=None):
    status = (oa_status or "").lower()
    if status in {"gold", "diamond", "hybrid"}:
        return "open"
    if status == "green" and license:   # a deposit with a stated licence
        return "open"
    return PUBLISHER_FREE_ACCESS

This keeps the green deposits that actually carry a licence and drops the bare ones, without touching the rest of the policy. Fix this →

Separately: OpenAlex's work-level oa_status is the best status, so a work marked bronze can still have a green repository PDF in locations[] that this code never looks at. Conservative, not wrong — but it means some genuinely redistributable full text is now skipped. Worth a comment noting it as a known miss.

3. cache enrich now routes bronze into the private cache, silently

apply_full_text_location (reference_fetcher.py:429) does private = private or location.access_type not in (None, "open"). Since bronze now carries publisher_free instead of None, cache enrich will download bronze publisher PDFs into the private cache rather than the public one. That's probably the behaviour you want, but it's a new write destination for existing users and it appears in neither the PR body's "Behaviour changes to expect" list nor docs/troubleshooting.md. One sentence either way would settle it.


Smaller things

  • _preserve_cached_full_text compares content types, not size. A refresh returning 1,500 characters of full_text_html over a cached 18,464 passes the guard untouched, because needs_full_text(fresh) is False. Your own evidence is that bot pages arrive as 21 KB of HTML on a 200 — if one ever extracts past MIN_FULL_TEXT_CHARS, the invariant "may never demote one" doesn't hold. The observed case is covered; the stated invariant is broader than the code. Consider also refusing (or at least warning on) a write that shrinks full text by a large factor.
  • _load_from_disk docstring wasn't updated (reference_fetcher.py:908-913): allow_stale_html is undocumented, and allow_stale still says "Used only by :meth:_stale_fallback", which this PR makes false. Given the repo's docstring rule, worth fixing.
  • models.py:740 — access_type: Optional[str] = None # "open" | "user_library" | "institutional". That comment is the only place the field's domain is written down, and publisher_free is now a fourth value missing from it.
  • _preserve_cached_full_text re-reads and re-parses the cache file that fetch() already read moments earlier (the first read returned None for being stale). Once per stale entry per run, alongside one warning each — fine at current scale, just noting it.
  • format_hint is now unconditionally "pdf". Correct given the guard, but it does mean _materialize no longer ever receives an html hint from these two providers, which retires the abstract-equality check at reference_fetcher.py:462-466 for this path. Not a bug — just dead-ish code worth knowing about.

Tests

Good coverage, and the inversion of test_locate_falls_back_to_oa_url into test_locate_does_not_fall_back_to_oa_url rather than deleting it is the right call — the file now records why. test_bronze_is_skipped_by_ordinary_validation pinning the join between the mark and the gate is the test I'd have asked for.

Two gaps: nothing covers the serve-vs-delete distinction in finding #1 (a test asserting a stale-HTML preserve is not returned as evidence would have caught it), and nothing covers a same-type shrinking refresh from finding #4.

I was unable to run just test — the sandbox declined uv run pytest, so I reviewed statically. The findings above are from reading the code, not from a failing run.
· branch fix/no-scraping-no-bronze

@cmungall
cmungall force-pushed the fix/no-scraping-no-bronze branch from 26a5b2c to b53f5a4 Compare September 17, 2026 21:46
@cmungall

Copy link
Copy Markdown
Member Author

Thanks — finding 1 was a real defect, and the fix changed the shape of the guard rather than patching it.

Also rebased onto #84, which landed while this was open and restructures the same method. _preserve_cached_full_text now returns a FetchOutcome, and both of its branches set served_stale=True. That is exactly right for cache reference's message — "the cache still holds no current entry for it" — because the write was skipped either way.

Finding 1 — the guard served what the stale-HTML rule withholds

Fixed, and you were right that the docstring described the intent while the code crossed it. The method now makes two decisions instead of one:

# May it be overwritten? Stale HTML is readable for this question only.
cached = self._load_from_disk(id, allow_stale=True, allow_stale_html=True)
if cached is None or self.needs_full_text(cached):
    return None
...
# May its text be served? Asked without the bypass, so stale HTML is withheld
# exactly as _stale_fallback withholds it.
servable = self._load_from_disk(id, allow_stale=True)
return FetchOutcome(content=servable or fresh, served_stale=True)

So a pre-fix entry scraped from a landing page is now kept on disk and withheld from validation, which falls back to the freshly fetched abstract. The curator's file is not deleted, and its suspect text is not quoted back as though it came from the article.

test_a_failed_full_text_refresh_keeps_the_cached_full_text asserted the old behaviour, so it was wrong rather than incomplete. It is now parametrized over all three full-text types and asserts only the deletion claim, with the serving question covered separately:

  • test_stale_html_is_kept_on_disk_but_not_served_as_evidence
  • test_stale_non_html_full_text_is_both_kept_and_served
  • test_a_preserved_entry_reports_served_stale (both branches)

Finding 2 — green

Took your refinement. green is now open only when the location states a licence, on your reasoning that the status names a repository rather than a permission. The split is explicit:

SELF_EVIDENTLY_OPEN_OA_STATUSES = frozenset({"gold", "diamond", "hybrid"})
LICENCE_DEPENDENT_OA_STATUSES = frozenset({"green"})

Both providers pass best.get("license") through. A licence does not rescue bronze — only licence-dependent statuses consult it — and that is pinned by a test, since it is the obvious way for someone to misread the rule later.

Your point about work-level oa_status being the best across locations is recorded as a known miss in the function docstring: some genuinely redistributable full text is skipped, and widening it means walking every location rather than the best one.

Finding 3 — cache enrich destination

Documented in docs/troubleshooting.md, called out as a new write destination rather than buried.

Smaller things

  • Type-only comparison. Fixed. _refresh_loses_full_text now also refuses a same-type refresh that collapses below half the cached length, with the ratio as a named constant and a comment on why the bar is low — publishers do re-issue shorter corrected texts, so this should catch a collapse, not ordinary variation. Two tests: a collapse is refused, a 90%-length corrected rewrite still migrates.
  • _load_from_disk docstring. Fixed; allow_stale_html documented, and the "used only by _stale_fallback" claim corrected.
  • models.py:740. Fixed; publisher_free added to the domain comment, with a line on what non-open values mean.
  • Re-read of the cache file. Now two reads on the preserve path rather than one. Left as is — it happens once per preserved entry per run, and threading the parsed entry through would couple the write decision to the serve decision, which is what finding 1 was about.
  • format_hint always "pdf". Noted; the abstract-equality check at the old line is now unreachable from these two providers. Left alone rather than removed in this PR, since the HTML path is still reachable from PMC.

One thing I did not do: you flagged that you reviewed statically because the sandbox declined uv run pytest. Full suite, mypy, Ruff and mkdocs build --strict all pass locally on the rebased branch.

@claude

claude Bot commented Sep 17, 2026 •

Copy link
Copy Markdown
Contributor

Claude finished @cmungall's task in 4m 0s —— View job


Review: Fetch only openly-licensed files, and never demote a cache entry

  • Read the diff against origin/main
  • Re-check the findings from the previous review against the current head
  • Review provider / OA-status changes
  • Review _preserve_cached_full_text
  • Review test coverage
  • Post review

The head has moved since my last pass and the three things I raised are all addressed: the serve-vs-delete split is now explicit (allow_stale_html decides the write, a plain allow_stale load decides the return, with a test pinning both), green is gated on a stated licence, the shrink case has a rule and a test, and the _load_from_disk docstring, the models.py comment and docs/troubleshooting.md all cover the bronze→private-cache destination. test_stale_html_is_kept_on_disk_but_not_served_as_evidence is exactly the test that was missing.

Four things on the new code.


1. The shrink rule can permanently block the migration it was added to protect

_refresh_loses_full_text (reference_fetcher.py:414-451) applies the 50% rule across content types, but its own docstring scopes it narrowly:

  • it has the same type but a fraction of the text.

The code doesn't check that. So consider the population this release actually acts on: a pre-#62 full_text_html entry scraped from a landing page, holding the whole page — body, reference list, related-article furniture. The refresh now goes to PMC XML and extracts the article body only. If the body is under half the page text, _refresh_loses_full_text returns True and the better text is refused. And refused permanently: the entry is never written, so it is never stamped, so every subsequent run re-fetches, re-refuses, and logs another warning. cache reference exits 1 each time (served_stale). The only exit is --force.

That's the same shape as the bug being fixed, with the sign flipped: a type change from a page-scrape to a clean extraction is the case where a large shrink is expected. Either scope the ratio to same-type refreshes as the docstring says, or keep it cross-type but exempt a type the project considers better (XML over HTML). Whichever you pick, a test with cached full_text_html at 20k and fresh full_text_xml at 6k would pin it — right now nothing covers a cross-type shrink in either direction. Fix this →

2. The licence rule lives in two providers; two others hand back green without it

access_type_for_oa_status is called from openalex.py:83 and unpaywall.py:76 only. But pmc.py:64 and pmc.py:70 both emit FullTextLocation(..., oa_status="green", ...) with no access_type, and epmc_preprint.py:86 does the same with license=result.get("license") — which may well be None. access_type then defaults to None, and _enrich_with_full_text's gate is location.access_type not in (None, "open"), so those pass straight into the public cache.

So a green deposit with no stated licence is declined from OpenAlex and admitted from PMC, for the same article. That isn't a mechanical oversight to patch — routing PMC through the same function would decline most PMID full text, which is presumably not wanted — but the asymmetry should be stated rather than left implicit. My read is that the rule you are actually applying is narrower than "green needs a licence": it's "a publisher-site or repository file discovered via an OA index needs a licence", with PMC's own API trusted as a distinct route. If that's the intent, SELF_EVIDENTLY_OPEN_OA_STATUSES' docstring is the place to say it, since it currently reads as a project-wide policy. Fix this →

3. One page fetch survives, at the host the PR is about

PMCProvider._fetch_pmc_html (pmc.py:116-132) still does requests.get("https://www.ncbi.nlm.nih.gov/pmc/articles/PMC{id}/") as a fallback. That is the same host family that answers with the reCAPTCHA interstitial described in the PR body.

I don't think it's a bug: it requires div.article-body or div.tsec and returns None when neither is present, so a bot page yields nothing rather than garbage — a structural defence the oa_url path never had. But docs/troubleshooting.md now states "A landing page is not full text" as a global rule, and a reader who takes that literally will be surprised to find this path. One sentence in the doc, or a comment at pmc.py:117 naming the div requirement as the reason this fetch is safe where the others weren't. Fix this →

4. cache reference now says something untrue in the new branch

cli/cache.py:162-166 prints "it could not be re-fetched, so an out-of-date cache entry was served". In the stale-HTML branch that is precisely what did not happen: the guard deliberately withholds the cached entry and serves the freshly fetched abstract. The exit code is right, the sentence isn't.

Also worth a line in the docs: a preserved entry makes cache reference exit 1 on every run until someone passes --force, so a script that caches a list of references and gates on exit status will now fail indefinitely on those. That's a defensible consequence of the guard, but it's the kind of thing that gets diagnosed twice if it isn't written down. Fix this →


Smaller things

  • docs/troubleshooting.md — "gold, green, diamond and hybrid are treated as open" is contradicted by the next paragraph and by the code. Listing green among the unconditional four and then walking it back one paragraph later is the version a skimmer gets wrong; fold the qualification into the sentence.
  • base.py — one blank line before class FullTextProvider where PEP 8 wants two. just format is ruff check . with default rules (E4/E7/E9/F), so E302 isn't selected and CI won't catch it, but ruff format would rewrite it.
  • license as a parameter name shadows the builtin. flake8-builtins isn't enabled so nothing will flag it; it does mean license inside access_type_for_oa_status reads as a shadow to anyone scanning quickly. license_ or licence avoids it.
  • The extra _load_from_disk in the guard only runs when the top-of-fetch load already missed, so it costs one re-parse per stale entry per run and nothing at all for absent entries. Withdrawing my earlier note on this — it's bounded.

Tests

test_no_cache_downgrade.py is doing real work: parametrising the deletion claim over all three full-text types, then splitting out what may be served per type, is the right structure for a guard whose whole subtlety is that those are different questions. test_an_ordinary_same_size_rewrite_still_migrates at 0.9 is a good boundary pin.

Gaps: nothing covers a cross-type shrink (finding 1 — the failing case), and nothing covers the CLI message in the new branch (finding 4).

I could not run just test: neither uv nor just is on this runner and the sandbox declined a bare python3 -c "import pytest". Everything above is from reading the code, not from a failing run — in particular finding 1 is a reasoned path, not an observed one, and is worth reproducing before acting on it.
· branch fix/no-scraping-no-bronze

@cmungall
cmungall force-pushed the fix/no-scraping-no-bronze branch from b53f5a4 to b190701 Compare September 17, 2026 22:03
@claude

claude Bot commented Sep 17, 2026 •

Copy link
Copy Markdown
Contributor

Claude finished @cmungall's task in 4m 14s —— View job


Review: Fetch only openly-licensed files, and never demote a cache entry

  • Read the diff against origin/main at the new head
  • Re-check findings 1–4 from the previous review
  • Review the shrink rule and the OA-status policy
  • Review test coverage
  • Post review

All four findings from the last pass are closed. The CLI message now covers both branches without guessing which one ran (cli/cache.py:153-170), the licence rule's scope is written down where someone will actually read it — SELF_EVIDENTLY_OPEN_OA_STATUSES says in as many words that pmc and epmc_preprint are a different warrant and that the same green deposit can be declined from OpenAlex and admitted from PMC — _fetch_pmc_html names the div.article-body requirement as what makes that one page fetch safe, and the cross-type shrink is handled by FULL_TEXT_QUALITY with tests on both directions. test_a_page_scrape_upgrading_to_a_clean_extraction_still_migrates is the case I was worried about, and it pins the right thing.

One new finding on the exemption, then smaller notes.


1. The quality exemption is wider than the argument that justifies it, and the PDF route is the one it lets through

FULL_TEXT_QUALITY = ("full_text_html", "full_text_pdf", "full_text_xml") (reference_fetcher.py:58) exempts any move up the order from the length test, but the reasoning attached to it is specifically about parsing the same document better:

replacing a scraped page with the XML body extraction drops the reference list and the site furniture along with it

That argument holds for html → xml. It does not hold for html → pdf, which is not a cleaner parse of the cached document — it is a different file fetched from a different host by a different route, and nothing about it guarantees the text is the article.

And html → pdf is now the common refresh shape. After this PR, OpenAlex and Unpaywall return pdf_url only, so a stale DOI-bearing entry that gets re-fetched arrives as full_text_pdf by construction. Concretely:

  • cached: PMID_x.md, full_text_html, 20,000 chars — a PMC div.article-body extraction, stale only because of the Reject empty excerpts and fix content extraction bugs #62 stamp;
  • refresh: _materialize downloads the PDF, sniff_format says pdf, self._pdf_extractor gets an image-only scan (or a "purchase this article" cover sheet) and returns ~600 chars of text-layer cover page;
  • _apply_full_text_location:661 passes — the floor is MIN_FULL_TEXT_CHARS = 500, and is_stub_notice is XML-only by design (extract/xml.py:47), so nothing else looks at it;
  • _refresh_loses_full_text:481 sees _is_quality_upgrade("full_text_html", "full_text_pdf") == True, skips the ratio, returns False;
  • 20,000 chars of article body are overwritten by a cover page.

That is precisely the failure mode SHRINK_PRESERVE_RATIO was added for — "a bot page can arrive as full_text_html and clear the length floor while holding a fraction of the article" — reappearing one type over. It is rarer than the reCAPTCHA case (a bad PDF extraction, not a rate limit), but it is not exotic, and unlike the HTML case it is silent.

Two ways out, either fine:

# (a) exempt only the upgrade that was actually argued for
FULL_TEXT_QUALITY = ("full_text_html", "full_text_xml")

# (b) keep the ranking, but let an exemption relax the bar rather than remove it
UPGRADE_SHRINK_RATIO = 0.2  # a cleaner extraction is shorter, not empty
ratio = UPGRADE_SHRINK_RATIO if cls._is_quality_upgrade(...) else SHRINK_PRESERVE_RATIO
return fresh_length < cached_length * ratio

(b) keeps test_a_page_scrape_upgrading_to_a_clean_extraction_still_migrates green — that test shrinks to ~30%, comfortably above 0.2 — while still catching a 3% collapse. Whichever you pick, a test with cached full_text_html at 20k and fresh full_text_pdf at 600 would pin it; nothing currently exercises the pdf rung at all. Fix this →

Related, and worth a second thought even if you keep the ranking: full_text_pdf sitting above full_text_html reads oddly now that the ordering is load-bearing. A PMC div.article-body extraction is arguably the cleanest text in the system, while a pypdf dump carries running heads, page numbers and hyphenation. The ranking was descriptive when nothing consumed it; it now decides whether a guard runs.


Smaller things

  • Two warnings on the stale-HTML branch, one of them now misleading. _preserve_cached_full_text:463 warns, then the servable load at line 478 triggers _load_from_disk:1163 — "Refusing stale HTML full text for %s: it may be a repository landing page. Retry when the source is reachable to repair it." In this branch the source was reachable; the refresh was reached and refused. The first sentence still applies, the advice doesn't. Passing a flag to suppress it here, or softening it to "retry when the source serves full text again", would stop the pair reading as two unrelated problems.
  • Every refreshed public entry now gains a full_text_access_type: open line. Both index providers set access_type unconditionally where they previously left it None, and _save_to_disk:1031 writes the field when truthy. Semantically identical to its absence, but it means a cache-wide refresh shows up as a one-line diff on every enriched entry — worth knowing before someone reviews that commit.
  • docs/troubleshooting.md — the new refresh paragraph uses literal -- where the surrounding prose uses —, so it renders as two hyphens, and the last sentence runs past the file's wrap width (This is narrower than the stale fallback… is joined onto the previous line). Cosmetic, but it's the paragraph a confused user lands on.
  • The licence rename, the two blank lines before class FullTextProvider, and the models.py domain comment are all as asked. Good.

Tests

test_no_cache_downgrade.py now covers the four quadrants that matter — same-type collapse refused, same-type 0.9 rewrite allowed, cross-type shrink upward allowed, cross-type shrink downward refused — and the module docstring explains why each exists rather than restating the assertion. test_a_preserved_entry_keeps_failing_until_forced pins the exit-1-every-run consequence rather than leaving it to the docs, which is the right instinct for behaviour that will generate a bug report.

The gap is the full_text_pdf rung: no test touches it in either direction, which is why finding 1 reads as an untested branch rather than a wrong one.

I could not run the suite — the sandbox declined uv run pytest and python3 -m pytest, as on the previous two passes. Everything above is from reading the code; finding 1 is a traced path, not an observed failure, so it is worth confirming against a real short-PDF extraction before changing the ratio.
· branch fix/no-scraping-no-bronze

@cmungall

Copy link
Copy Markdown
Member Author

Finding 1 is real. You asked for it to be reproduced before acting, which was the right call — it reproduces exactly, and the consequence is worse than a refused update.

cached full_text_html: 20,361 chars   (pre-fix landing-page scrape)
fresh  full_text_xml :  6,270 chars   (clean PMC body extraction)
refused: True

And refused permanently, as you said: never written, so never stamped, so re-fetched and re-refused every run. That is the bug this guard exists to prevent, with the sign flipped, and it would have hit precisely the population this release acts on.

1. The shrink rule

Took the second of your two options rather than the first. Scoping the ratio to same-type refreshes would fix the reported case but leave clean XML replaceable by a fraction of it as HTML, which is the bot-page shape.

FULL_TEXT_QUALITY = ("full_text_html", "full_text_pdf", "full_text_xml")

A move up that order skips the length test; same-rank or a move down still applies it. Unranked types compare equal, so an unfamiliar type is never treated as an upgrade and the length test still governs it.

Both directions are now covered, as you asked:

  • test_a_page_scrape_upgrading_to_a_clean_extraction_still_migrates — cached HTML 20k, fresh XML 6k, must be written
  • test_a_shrinking_downgrade_to_a_weaker_type_is_still_refused — the reverse

2. The licence rule's scope

You read the intent correctly, and the docstring did read as project-wide policy. Recorded rather than patched: routing pmc through access_type_for_oa_status would decline most PMID full text for want of a licence field its API does not return, which is not the intent.

The rule now says it governs a file found by asking an OA index, where the index's own status is all that is known about the terms; an archive asked through its own API for a document it serves for machine retrieval is a stronger warrant. The asymmetry — the same green deposit declined from OpenAlex and admitted from PMC — is stated in SELF_EVIDENTLY_OPEN_OA_STATUSES and in docs/troubleshooting.md.

3. The surviving page fetch

Agreed it isn't a bug, and agreed the doc made it look like one. _fetch_pmc_html now carries a docstring naming the div.article-body / div.tsec requirement as what makes it safe where the oa_url fallback was not — an interstitial carries neither, so it yields None rather than caching the page. The troubleshooting section notes the exception.

4. The CLI message

Fixed — and fixing it surfaced a second one I had introduced. My own warning said "Refresh of %s returned no full text (%s)", which in the shrink branch is false: it did return full text, just less. Both messages now describe what happened.

The CLI message covers both paths rather than naming one:

Failed to cache PMID:1: it could not be re-fetched, or the refresh was refused for returning less than the cache already held. No entry was written, so the cache still holds no current entry for it.

That broke test_unreachable_source_with_a_stale_entry_reports_failure, which asserted "out-of-date" in result.output. I rewrote the assertion to check the invariant both paths share — no entry was written — rather than one path's phrasing, since coupling to the wording is what made it wrong when the second path arrived.

Two tests added: the preserved-entry message, and the exit-1-every-run-until---force behaviour, which is now also in the docs.

Smaller things

  • Docs contradiction — fixed. green no longer appears in the unconditional list, so the qualification is in the sentence rather than a paragraph later.
  • PEP 8 blank line — fixed.
  • license shadowing the builtin — renamed to licence throughout the helper and both callers. The test fixtures keep license= since that is the field name in the OpenAlex and Unpaywall payloads.
  • The extra _load_from_disk — thanks for withdrawing it; left as is.

1087 passing, mypy, Ruff and mkdocs build --strict clean. I re-checked that the run covered the pushed tree rather than a stale one.

@cmungall
cmungall force-pushed the fix/no-scraping-no-bronze branch from b190701 to 0cac1a8 Compare September 17, 2026 22:20
@claude

claude Bot commented Sep 17, 2026 •

Copy link
Copy Markdown
Contributor

Claude finished @cmungall's task in 5m 11s —— View job


Review: Fetch only openly-licensed files, and never demote a cache entry

  • Read the diff against origin/main at the new head
  • Re-check the findings from the previous pass
  • Review the licence rule and the quality/shrink ranking
  • Review test coverage
  • Post review

The previous pass's finding is closed properly: UPGRADE_SHRINK_RATIO relaxes the bar rather than removing it, FULL_TEXT_QUALITY carries its own justification, and both PDF directions are now pinned — test_a_cover_page_pdf_does_not_overwrite_a_cached_article_body and test_a_genuine_pdf_extraction_still_migrates are exactly the pair that was missing. The cache reference message no longer names a path it cannot know, and test_unreachable_source_with_a_stale_entry_writes_nothing asserting the shared invariant instead of the wording is the right correction.

Three things on the new head, one of which I think undoes part of rule 2 in practice.


1. implied-oa and other-oa are licence values, so green-without-a-licence is admitted after all

access_type_for_oa_status (base.py:98-104) decides the green case on truthiness:

if status in LICENCE_DEPENDENT_OA_STATUSES and licence:
    return "open"

But neither API uses null as its only way of saying "no stated licence". Unpaywall's oa_location.license includes implied-oa — its documented value for a copy it believes is free with no licence statement found — and both Unpaywall and OpenAlex use other-oa for "open, licence unspecified". Those are strings, so they are truthy, so they read as "this deposit states its terms" when they say the opposite.

That matters most for exactly the case the rule was written about. The motivating example in the docstring is a PMC author manuscript deposited under a funder policy with no licence statement — and a repository copy like that is a common shape for oa_status: green, license: "implied-oa" from Unpaywall. So the route the rule closes at license: None reopens at license: "implied-oa", silently, into the public cache.

The module's own principle settles it — "an unknown licence is not a grant" — it just isn't applied to the licence field the way it is applied to the status field. Given the rest of this PR treats an unrecognised status as non-open, the consistent form is a positive allowlist rather than a denylist:

#: Licence values that grant redistribution. Everything else, including
#: Unpaywall's ``implied-oa`` and both APIs' ``other-oa`` -- which name the
#: absence of a licence statement rather than a licence -- is not a grant.
def _states_a_licence(licence: Optional[str]) -> bool:
    value = (licence or "").lower()
    return value.startswith("cc-") or value in {"public-domain", "cc0"}

Two tests would pin it: green + implied-oa is not open, green + cc-by-nc still is. Right now test_green_with_a_stated_licence_is_public only exercises cc-by and test_green_without_a_licence_is_not_public only None, so the whole middle of the vocabulary is untested. Worth confirming the value names against the Unpaywall and OpenAlex docs before wiring the set — the mechanism is the finding, the exact spelling of the sentinels is theirs. Fix this →

2. full_text is the fourth content type and it is unranked, so the permanent-block case survives for it

FULL_TEXT_QUALITY covers full_text_html, full_text_pdf, full_text_xml. But _FORMAT_TO_CONTENT_TYPE (reference_fetcher.py:120-125) has a fourth entry: "text": "full_text", reached whenever a provider returns format_hint="text" (or none at all — _materialize:737 defaults to "text"). Zotero takes that path but writes private; a configured json_api text provider (json_api.py:78) takes it and writes public.

So a public cache entry can hold content_type: full_text. When one of those is refreshed to full_text_xml, _is_quality_upgrade returns False — not because XML is not better, but because the cached type isn't in the tuple — and the strict SHRINK_PRESERVE_RATIO applies. A 20k text-API body replaced by a 6k XML body extraction is then refused, and refused permanently, by the same never-written→never-stamped→re-refused loop that test_a_page_scrape_upgrading_to_a_clean_extraction_still_migrates exists to prevent. The documented intent — "an unfamiliar type is never treated as an upgrade" — is right for a type nobody has heard of, but full_text is not unfamiliar; it is one of this project's own four.

Cheapest fix is to rank it (("full_text", "full_text_html", "full_text_pdf", "full_text_xml"), on the grounds that a plain-text body carries no structure to verify); if you'd rather not rank it, saying in the docstring that full_text is deliberately excluded and why would at least make it a decision. Either way a test with cached full_text at 20k and fresh full_text_xml at 6k pins it. Fix this →

3. The cache enrich paragraph in the docs describes a change that didn't happen, and misses the one that did

docs/troubleshooting.md now says bronze is routed to the private cache "instead of the public one. That is a new destination for a file that previously landed in the public cache." It isn't new: enrich_command calls fetcher.apply_full_text_location(reference, location, provider, private=True) (cli/cache.py:315-317) with private=True hard-coded, so every file that command has ever written went to the private cache, bronze or gold. The access_type change cannot move it.

What does change on that path is provenance: _apply_full_text_location:707-711 nulls full_text_url for any non-open access_type, so a bronze PDF enriched from OpenAlex or Unpaywall now records no source URL where it previously recorded one. The comment justifying that — "non-public endpoints are not durable provenance and may contain session-specific access information" — was written for Zotero attachment URLs and doesn't describe a stable publisher PDF link. Dropping it is defensible, but it's the real user-visible change on this path, and it's the one not written down. Fix this →


Smaller things

  • Dead branch at reference_fetcher.py:474-477: if servable is None: return FetchOutcome(content=fresh, served_stale=True) followed by the identical construction with servable is just FetchOutcome(content=servable or fresh, served_stale=True). The two-branch form reads as though the cases differ.
  • The preserve warning builds its message eagerly (reference_fetcher.py:459-470) — the conditional f-string is evaluated before logger.warning decides whether anything is enabled. It's a warning, so it almost always is; noting it only because the rest of the file uses lazy %s args consistently.
  • test_a_preserved_entry_reports_served_stale parametrises full_text_html and full_text_xml but not full_text_pdf, where the deletion test covers all three. No behaviour hangs on it; the asymmetry just looks unintended.
  • The SELF_EVIDENTLY_OPEN_OA_STATUSES docstring carrying the index-vs-archive scope, and _fetch_pmc_html naming the div.article-body requirement as its structural defence, both land in the place a reader will actually hit. Same for the full_text_access_type: open diff-noise note — that's the kind of thing only found by having run it.

Tests

The four quadrants of the ratio rule plus both PDF directions is complete coverage of the guard as written, and the module docstrings explain why each case exists rather than restating the assertion. The gaps map onto the findings: nothing exercises a licence value between cc-by and None (finding 1), and nothing exercises the full_text content type in either direction (finding 2).

I could not run the suite — uv, just and a bare pytest invocation were all declined by the sandbox, as on the three previous passes. Everything above is from reading the code. Finding 1 depends on Unpaywall/OpenAlex vocabulary I'm recalling rather than reading, so check the sentinel names against their docs; finding 2 is a traced path through _FORMAT_TO_CONTENT_TYPE and _is_quality_upgrade, not an observed failure.
· branch fix/no-scraping-no-bronze

@cmungall

Copy link
Copy Markdown
Member Author

The PDF rung was a real gap, and the fix I made last round was wider than the argument I wrote for it. Reproduced before changing anything:

cached full_text_html: 20,801 chars   (clean article body)
fresh  full_text_pdf :    602 chars   (publisher cover sheet)
refused: False

602 characters clear MIN_FULL_TEXT_CHARS, is_stub_notice is XML-only, and _is_quality_upgrade waved the rest through. A whole article body overwritten, silently — the reCAPTCHA failure one rung over, exactly as you described.

The exemption

Took option (b): relax the bar rather than remove it.

UPGRADE_SHRINK_RATIO = 0.2   # a cleaner extraction is shorter, not empty

Both repro cases now behave: the 602-character cover page is refused, and the 20k-page → 6k-XML upgrade still migrates. Option (a) would have fixed the reported case too, but it leaves html → pdf with no check at all, and that is the shape this PR makes common.

Two tests on the rung that had none: test_a_cover_page_pdf_does_not_overwrite_a_cached_article_body and test_a_genuine_pdf_extraction_still_migrates.

The ordering

Your second point is the better half of the finding, and I have written it into FULL_TEXT_QUALITY rather than restructured around it. full_text_html is bimodal: it covers both a PMC div.article-body extraction — among the cleanest text in the system, as you say — and a pre-release landing-page scrape carrying a whole page. The ranking cannot tell them apart, which is now stated as precisely the reason an upgrade keeps a floor instead of being waved through. The constant's docstring says it decides which of two ratios applies, and nothing more.

Smaller things

  • The second warning. You are right that the advice was wrong in that branch — the source was reached. Now "retry when the source serves full text again to repair it". The first sentence stands, since the landing-page risk is what the refusal is about.
  • full_text_access_type: open on every refreshed entry. Confirmed by writing an entry and reading it back, and documented in the troubleshooting section, since a cache-wide refresh will show it as a one-line diff on every enriched entry.
  • Docs formatting. Fixed the literal --, the run-on line, and — more usefully — a sentence that still described the previous round's behaviour ("the length test is skipped"), which is no longer what the code does.

1089 passing, mypy, Ruff and mkdocs build --strict clean, checked against the pushed tree.

One note on your closing caveat: you have now flagged three findings as traced-not-observed, and all three reproduced exactly as traced. I have added the reproduction to the test suite in each case rather than only fixing the code, so the next reader gets the failing case rather than the argument for it.

@cmungall
cmungall force-pushed the fix/no-scraping-no-bronze branch from 0cac1a8 to 186f05e Compare September 17, 2026 22:35
@claude

claude Bot commented Sep 17, 2026 •

Copy link
Copy Markdown
Contributor

Claude finished @cmungall's task in 4m 54s —— View job


Review: Fetch only openly-licensed files, and never demote a cache entry

  • Read the diff against origin/main at the new head
  • Re-check the three findings from the previous pass
  • Review the licence rule and the quality/shrink ranking
  • Review test coverage
  • Post review

All three of the previous pass's findings are closed, and closed properly rather than papered over. states_a_licence is a positive allowlist with the sentinel names spelled out and the reasoning for why nc/nd still qualify (base.py:55-90); full_text is ranked at the bottom of FULL_TEXT_QUALITY with the "our type, not an unfamiliar one" distinction written into the constant; and the cache enrich docs paragraph now says the true thing — private=True was always unconditional, and what actually changed is the frontmatter. The full_text_url rework at reference_fetcher.py:745-754 is the better answer to that finding than the one I suggested: a publisher link is provenance, not a secret, and separating that from the private-endpoint rule is right.

Three things on the new head.


1. --force performs the exact demotion this PR exists to prevent, silently — and it is the remedy the tool itself recommends

_preserve_cached_full_text returns at reference_fetcher.py:456 before it loads anything:

if force_refresh:
    return None

So the force path never learns what it is about to overwrite, and emits nothing. Meanwhile both places a user meets this feature point them at it:

  • cli/cache.py:167 — "Re-run when the source is reachable, or pass --force to accept the refresh as it stands."
  • docs/troubleshooting.md — "until the source serves the full text again or --force accepts the shorter refresh."

Follow that advice on the motivating entry and PMID_9177246.md goes from 18,464 characters to a 1,199-character abstract with no output at all. That is the original bug, reached by doing what the error message says. And it is the likely response: a preserved entry fails every run (which test_a_preserved_entry_keeps_failing_until_forced pins), so the pressure to reach for --force is continuous, and it will typically be run across a batch rather than one reference at a time.

The opt-out is right — an explicit refresh that finds less is a result the caller asked for, as the docstring says. What is missing is that the caller is told. The guard has already computed everything needed:

if force_refresh:
    cached = self._load_from_disk(id, allow_stale=True, allow_stale_html=True)
    if cached is not None and not self.needs_full_text(cached) \
            and self._refresh_loses_full_text(cached, fresh):
        logger.warning(
            "--force is replacing the cached %s entry for %s (%d characters) "
            "with %s. The cached text is not recoverable from here.",
            cached.content_type, id, len(cached.content or ""),
            _RefreshLoss(fresh, cached),
        )
    return None

test_a_preserved_entry_keeps_failing_until_forced asserts --force exits 0 but not what happened to the file; a line asserting the cached text is gone would make the cost part of the contract rather than a side effect. Fix this →

2. SHRINK_PRESERVE_RATIO's rationale is attached to the wrong constant

reference_fetcher.py:36-51:

#: A refresh returning less than this share of the cached text is treated as a
#: loss rather than an update. ... this should catch a
#: collapse, not ordinary variation.
#:
#: ``access_type`` values whose source URL must not be recorded: ...
PRIVATE_ACCESS_TYPES = frozenset({"user_library", "institutional"})

#: Relaxed to :data:`UPGRADE_SHRINK_RATIO` when the refresh moves up
#: :data:`FULL_TEXT_QUALITY`.
SHRINK_PRESERVE_RATIO = 0.5

Two #: blocks were spliced when PRIVATE_ACCESS_TYPES was inserted: the first five lines describe the ratio and now document the frozenset, and SHRINK_PRESERVE_RATIO is left with a cross-reference and no statement of why the bar is where it is. These are Sphinx attribute docs, so this is what renders — PRIVATE_ACCESS_TYPES documented as a share of cached text, and the one number in the module a reader is most likely to question documented as "relaxed to a different number sometimes". Every other constant here carries its own argument, which is what makes the file readable; this is the one that lost it. Fix this →

3. The full_text_url rule flipped from fail-closed to fail-open

reference_fetcher.py:752-754 changed the test from "not in (None, "open")" to "in PRIVATE_ACCESS_TYPES". The bronze case is right and well argued. The side effect is that the default inverted: an access_type this module has not heard of used to have its URL suppressed, and now has it recorded. The routing decision three lines of code away still reads the other way — _save_by_access:613 and apply_full_text_location:693 both use not in (None, "open"), so an unrecognised value is private for storage and public for provenance.

Concretely bounded today: only zotero emits a private value and institutional has no producer, so nothing leaks now, and non-open content only ever reaches the private cache anyway. It matters as a default — this PR argues twice that an unknown status and an unknown licence are not grants, and this is the one place the unknown case now resolves permissively. The allowlist form costs nothing:

content.full_text_url = (
    location.url
    if location.access_type in (None, "open", PUBLISHER_FREE_ACCESS)
    else None
)

test_a_public_url_is_recorded_even_when_the_licence_is_not_open already parametrises all four known values; adding an unknown fifth is one line either way, and pins whichever answer you pick. Fix this →


Smaller things

  • The preserved-entry loop feeds the condition that creates it. A preserved entry is never stamped, so every run re-fetches it — and the PR's own evidence is that PMC serves the interstitial when rate-limited. A cache with many pre-fix HTML entries therefore issues the same fetch storm every run, which is what produces the bot pages, which is what causes the preserving. The memory cache bounds it within a process, not across runs. You have documented the warning-per-entry cost; the self-reinforcing part is the bit that will show up as "it got worse over time". A metadata marker that records "a refresh was refused at extractor version N" without counting as the stamp would break the loop while keeping the entry stale for a genuine retry — probably not this PR, but worth an issue.
  • allow_stale_html=True with allow_stale=False silently does nothing (_load_from_disk:1217 returns None first). Only one caller exists and it passes both, but the signature invites the combination and the docstring doesn't say it is meaningless.
  • _write_cache(..., stamped=False) in tests/test_no_cache_downgrade.py:42 is never called with stamped=True. Either a test that pins "a stamped entry is not re-fetched at all" is missing, or the parameter is vestigial.
  • base.py:90-92 — one blank line between states_a_licence and the PUBLISHER_FREE_ACCESS block where PEP 8 wants two. Same class as the one fixed last round; ruff format would rewrite it, ruff check with the configured rules won't catch it.
  • states_a_licence prefix matching — cc- covers the hyphenated vocabulary both APIs use. Worth knowing that anything spelled with a space ("cc by 4.0") or as a bare "pd" falls through to not-a-grant. That is the safe direction, so this is a note rather than a request; the allowlist's whole point is that the failure mode is skipping text rather than republishing it.

Tests

The four ratio quadrants, both PDF directions, the full_text rung, and the licence vocabulary between cc-by and None are all covered now, and test_green_with_a_non_licence_sentinel_is_not_public parametrising the three sentinels is the test that makes the allowlist a rule rather than a patch. Rewriting test_unreachable_source_with_a_stale_entry_reports_failure to assert "no current entry" — the invariant both paths share — instead of one path's wording is the right correction, and the comment explaining why the old assertion was wrong is worth more than the assertion.

The gap that maps to finding 1: test_a_preserved_entry_keeps_failing_until_forced checks the exit code after --force but not the file, so nothing in the suite currently records that --force destroys the preserved text.

I could not run the suite — uv run pytest was declined by the sandbox on this pass as on the previous three, so everything above is from reading the code. Findings 2 and 3 are read directly off the source; finding 1 is a traced path (the force_refresh early return plus the two places that recommend --force), not an observed run.
· branch fix/no-scraping-no-bronze

@cmungall

Copy link
Copy Markdown
Member Author

Finding 1 is right, and larger than the estimate. You asked for the value names to be confirmed against the APIs rather than taken from the review, so I pulled OpenAlex's whole licence vocabulary instead of spot-checking:

cc-by                     28,644,117
cc-by-nc-nd                9,464,049
public-domain              8,816,606
other-oa                   5,906,777
cc-by-nc                   5,410,797
cc-by-sa                   3,191,774
cc-by-nc-sa                2,727,878
cc-by-nd                     313,523
mit                              222
publisher-specific-oa              1

other-oa is 5.9M works, and live sampling confirms it pairs with green (3 of 100 random OA works). So the truthiness test admitted it, exactly as you traced.

1. The licence allowlist

Took your positive-allowlist form rather than naming the sentinels, since that way the exact spelling of implied-oa — which is Unpaywall's and does not appear in OpenAlex's vocabulary at all — stops being something this code has to get right. Anything outside the allowlist fails closed by construction:

def states_a_licence(licence):
    value = (licence or "").strip().lower()
    return value.startswith("cc-") or value in OPEN_LICENCES   # cc0, public-domain, mit

Every CC variant qualifies: nc restricts commercial use and nd restricts derivatives, neither restricts holding a verbatim copy, which is all a cache does. That reasoning is in the docstring so the next reader does not have to re-derive it.

Nine new parametrized cases across the middle of the vocabulary — other-oa, implied-oa, publisher-specific-oa not open; cc-by, cc-by-nc, cc-by-nc-nd, cc-by-sa, cc0, public-domain open.

2. full_text

Confirmed: _FORMAT_TO_CONTENT_TYPE maps "text" to it, json_api reaches it publicly, and it had the strict ratio and so the permanent-block loop. Ranked at the bottom — a plain-text body has no structure to verify against — with the reasoning written into FULL_TEXT_QUALITY alongside why a type from outside the tuple is still never an upgrade. Test added with cached full_text at 20k and fresh full_text_xml at 6k.

3. The cache enrich paragraph

You are right on both halves: enrich_command passes private=True unconditionally, so the destination was never new, and the provenance change was the one that went unwritten. Paragraph replaced.

I went a step past documenting it, because reading the justifying comment settled the question rather than leaving it open. "Non-public endpoints are not durable provenance and may contain session-specific access information" describes a localhost Zotero attachment; it does not describe a publisher's stable public link. And what licensing keeps out of the public cache is the text, not the address — so dropping the URL lost real provenance for no privacy gain. The rule now keys on a named PRIVATE_ACCESS_TYPES set (user_library, institutional) rather than on "not open", with the distinction spelled out at the assignment and a parametrized test over all four access types.

If you would rather keep the old behaviour and simply document it, say so and I will revert that half — it is the one change here that goes beyond the finding.

Smaller things

  • Dead branch — collapsed to FetchOutcome(content=servable or fresh, served_stale=True).
  • Eager message building — moved into a small _RefreshLoss whose __str__ does the branch, so the call site passes a lazy argument like the rest of the module.
  • The full_text_pdf parametrize gap — closed.

1104 passing, mypy, Ruff and mkdocs build --strict clean, checked against the pushed tree.

@cmungall
cmungall force-pushed the fix/no-scraping-no-bronze branch from 186f05e to d4c9e3a Compare September 17, 2026 22:53
@claude

claude Bot commented Sep 17, 2026 •

Copy link
Copy Markdown
Contributor

Claude finished @cmungall's task in 5m 43s —— View job


Review: Fetch only openly-licensed files, and never demote a cache entry

  • Read the diff against origin/main at the new head
  • Re-check the findings from the previous pass
  • Review the licence allowlist and the quality/shrink ranking
  • Review test coverage
  • Post review

All three of the previous pass's findings are closed. _warn_forced_discard (reference_fetcher.py:471-497) makes --force say what it costs, and test_force_refresh_warns_before_discarding_cached_full_text pins it; the spliced #: blocks are separated, and SHRINK_PRESERVE_RATIO has its own argument back; and PUBLIC_URL_ACCESS_TYPES is an allowlist with None and PUBLISHER_FREE_ACCESS in it and an unknown fifth value in the parametrize. _RefreshLoss is a neat way to keep the branch and the laziness. test_a_stamped_entry_is_served_from_cache_without_re_fetching closes the vestigial-stamped note by pinning the complement rather than deleting the parameter.

Two findings on the ratio arithmetic, both in the part that changed last round.


1. The ratio is applied to the whole record, and the record starts with the abstract — which is most of what a cover-page refresh returns

_refresh_loses_full_text (reference_fetcher.py:537-541) measures len(cached.content) against len(fresh.content). But _apply_full_text_location:771 stores

content.content = f"{abstract}\n\n{text}" if abstract else text

so both sides carry the abstract, and on the fresh side of a failed extraction the abstract is nearly all of it. The docstring says "less than this share of the cached text"; the code compares record lengths, and the difference is exactly the quantity that distinguishes a cover page from an article.

At SHRINK_PRESERVE_RATIO this is noise. At UPGRADE_SHRINK_RATIO it is the whole margin. The guard refuses when A + 600 < 0.2 × C, so it passes whenever A > 0.2C − 600:

cached record C abstract A needed to defeat the floor
10,000 1,400
15,000 2,400
20,000 3,400

A 1,400–2,000 character abstract is ordinary, and a 10k cached entry is an ordinary short paper. So for a medium-sized article the 0.2 floor is already spent on the abstract before the extraction is weighed at all, and html → pdf — which this PR makes the common refresh shape — silently overwrites the body with abstract-plus-cover-sheet. That is the failure test_a_cover_page_pdf_does_not_overwrite_a_cached_article_body was added to stop, surviving at a smaller article size.

The test does not see it because every test in the file builds fresh as a bare ReferenceContent (_fetch_returning:75-83), never through _apply_full_text_location, so the abstract prefix production always adds is absent from all of them. Comparing the extracted text — subtracting the abstract prefix the two records share — is what the docstring already claims. Fix this →

2. html → html is the page-scrape migration too, and it gets the strict ratio

FULL_TEXT_QUALITY fixes the cross-type case, and test_a_page_scrape_upgrading_to_a_clean_extraction_still_migrates pins it. But the same improvement within one rung is not covered, and it is reachable: PMCFullTextProvider.locate:65-68 returns format_hint="html" from _fetch_pmc_html whenever the XML path yields less than MIN_FULLTEXT_CHARS or fails. So:

  • cached: pre-fix full_text_html, a whole landing page scraped via the removed oa_url route, 20k;
  • refresh: PMC's div.article-body extraction, 6k — the clean text, by this PR's own account;
  • same content_type, so _is_quality_upgrade is False, SHRINK_PRESERVE_RATIO applies, 6000 < 10000 → refused.

Refused permanently, by the loop the constant's own docstring describes: never written, never stamped, re-fetched and re-refused every run, cache reference exiting 1 each time. The FULL_TEXT_QUALITY docstring names the bimodality — full_text_html covers both "among the cleanest text in the system" and "a landing-page scrape carrying a whole page" — and concludes that an upgrade keeps a floor. The other consequence of that bimodality is this one: within the rung, the refresh can be the page→body improvement, and there it gets the strict bar.

The two are distinguishable, which is what makes this cheap to fix. The pre-fix entry lacks html_full_text_version — that is precisely why _load_from_disk:1265-1277 refuses to serve it and why the method needs allow_stale_html to read it at all. A cached entry that the plain allow_stale load would refuse is one this guard has no reason to protect at full strength; giving that case UPGRADE_SHRINK_RATIO keeps the deletion refusal while letting the migration complete. Fix this →


Smaller things

  • _preserve_cached_full_text's docstring still says force_refresh "opts out entirely", which was true before _warn_forced_discard and is now a sentence short: it opts out of the refusal, not of the notice. Same in docs/troubleshooting.md — "force_refresh overrides the guard" is the place a user who has just been told to run --force will read, and it is worth saying there that they will be told what it replaced.
  • _warn_forced_discard reads and parses the cache file on every forced fetch, including the overwhelming majority where nothing is at risk. Bounded and cheap for cache reference, but --force across a corpus pays it per reference for a warning that almost never fires. Checking self._refresh_loses_full_text needs the cached entry, so there is no cheaper order — noting the cost, not asking for a change.
  • Three spellings of the same membership test. PUBLIC_URL_ACCESS_TYPES is a named allowlist; _save_by_access:649 and apply_full_text_location:729 still write not in (None, "open") inline, and _enrich_with_full_text:681 a third time. They mean different things deliberately — routing is stricter than provenance — but the inline tuples make that look like inconsistency rather than intent. Naming the routing set too would put the distinction in the names.
  • states_a_licence prefix-matches cc- and so treats a hypothetical future cc-by-nc-nd-4.0-with-conditions as a grant. Safe direction for a CC string, and the failure mode of the allowlist elsewhere is skipping text rather than republishing it, so this is a note only.
  • The SELF_EVIDENTLY_OPEN_OA_STATUSES scope paragraph and the OpenAlex vocabulary counts behind OPEN_LICENCES are the kind of thing that stops this being re-litigated in six months. states_a_licence's note on why nc and nd still qualify — neither restricts holding a verbatim copy, which is all a cache does — is the sentence that makes the rule reusable.

Tests

test_a_plain_text_body_upgrading_to_xml_still_migrates and the full_text rung, the two PDF directions, and the nine-case licence vocabulary all land where they were asked for, and the module docstrings still explain why each case exists rather than restating the assertion. test_green_with_a_non_licence_sentinel_is_not_public over all three sentinels is what makes the allowlist a rule.

The gap both findings share is the same one: _fetch_returning constructs fresh directly, so nothing in test_no_cache_downgrade.py exercises content shaped the way _apply_full_text_location actually shapes it, and nothing exercises a same-type refresh in the improving direction.

I could not run the suite — uv run pytest was declined by the sandbox on this pass as on the previous four, so everything above is from reading the code. Finding 1 is arithmetic read off _refresh_loses_full_text:537 and _apply_full_text_location:771; finding 2 is a traced path through PMCFullTextProvider.locate and _is_quality_upgrade, not an observed failure. Given the last three traced findings reproduced exactly, both are worth a repro before the fix rather than after.
· branch fix/no-scraping-no-bronze

@cmungall

Copy link
Copy Markdown
Member Author

Finding 1 is the best catch of the five rounds, because the loss is reached by following the tool's own advice. Reproduced:

>>> force_refresh=True  (what the failure message tells the user to run)
cache file: 18,897 chars -> 128 chars
snippet still present: False
>>> warnings emitted: none

Now:

--force is replacing the cached full_text_html entry for PMID:9177246
(18,809 characters) with no full text (abstract_only). The cached text is
not recoverable from here.

The opt-out stays — an explicit refresh that finds less is a result the caller asked for — but it is no longer silent. test_a_preserved_entry_keeps_failing_until_forced now asserts the cached text is gone from the file after --force, so the cost is part of the contract rather than a side effect, and test_force_refresh_warns_before_discarding_cached_full_text pins the warning.

2. The spliced doc comments

Mine, from inserting PRIVATE_ACCESS_TYPES in the middle of an existing block. Un-spliced: each #: block sits above the constant it describes, and SHRINK_PRESERVE_RATIO carries its own argument again with the cross-reference folded into it.

3. Fail-closed

Also mine, from last round, and the sharper half of the finding is that the PR argues twice that an unknown status and an unknown licence are not grants and then resolves the unknown case permissively three lines from a routing decision that goes the other way. Switched to the allowlist form:

PUBLIC_URL_ACCESS_TYPES = frozenset({None, "open", PUBLISHER_FREE_ACCESS})

with a "some-future-scheme" case added to the parametrized test, so the default is pinned rather than incidental.

The vestigial parameter was hiding a bug

_write_cache(..., stamped=True) had no caller, so I wrote the missing test — "a stamped entry is served from cache without re-fetching" — and it failed. The helper wrote only extractor_version, but _is_stale_cache_entry also requires xml_extraction_version on a full_text_xml entry and html_full_text_version on a full_text_html one, so stamped=True could not produce a current entry of either type. The parameter was not merely unused; it did not work. Fixed to write the format-specific stamp, and the test now passes and pins the complement of every other test in the file.

Smaller things

  • allow_stale_html without allow_stale — documented as meaningless, since allow_stale rejects a stale entry of any type before it is consulted.
  • PEP 8 blank line in base.py — fixed.
  • cc- prefix matching — agreed, and agreed it is the safe direction. Noted rather than changed: the allowlist's failure mode is skipping text we could have kept, not republishing text we should not have.

On the self-reinforcing loop

Your first smaller note is the one I would most like to keep on the record, and I agree it is not this PR. The loop is real — a preserved entry is never stamped, so it is re-fetched every run, and the fetch storm is what produces the rate-limiting that produces the bot pages that cause the preserving. Your suggested shape (a metadata marker recording "a refresh was refused at extractor version N", distinct from the stamp) sounds right, and I would add that it also wants a way to distinguish "refused because the source was rate-limited" from "refused because this article genuinely has no better text", since those want different retry behaviour. Happy to open an issue for it if you would like, or leave it for whoever picks it up.

1107 passing, mypy, Ruff, 126 doctests and mkdocs build --strict clean, checked against the pushed tree.

@cmungall
cmungall force-pushed the fix/no-scraping-no-bronze branch from d4c9e3a to 5778d11 Compare September 17, 2026 23:10
@claude

claude Bot commented Sep 17, 2026 •

Copy link
Copy Markdown
Contributor

Claude finished @cmungall's task in 5m 30s —— View job


Review: Fetch only openly-licensed files, and never demote a cache entry

  • Read the diff against origin/main at the new head
  • Re-check the findings from the previous pass
  • Review the ratio arithmetic and the new relaxations
  • Review test coverage
  • Post review

Both findings from the last pass are closed, and the second one is closed better than I asked for: _is_unverified_html distinguishes the page-scrape population by the stamp it lacks, which is the same signal _load_from_disk already uses, so the rule and the serving rule now rest on one fact rather than two. _extracted_lengths is the right shape for the abstract-prefix problem, and test_the_abstract_prefix_is_not_counted_as_recovered_full_text drives fresh through _apply_full_text_location instead of constructing it, which is what made the whole class of finding invisible before.

Three things on the new head — one in the arithmetic that replaced it, one in the relaxation that replaced the other, and one test that does not test what it is named for.


1. Subtracting the whole common prefix makes the verdict depend on where the text differs, not how much of it is lost

_extracted_lengths (reference_fetcher.py:594-619) documents itself as removing the abstract — "The common prefix is the abstract when there is one" — but nothing bounds it to the abstract. It subtracts every leading character the two records agree on, however far that runs into the body.

The degenerate case is reached whenever fresh is a prefix of cached: zip exhausts without a break, the else sets shared = min(len(...)), and fresh_length is 0. Zero is below cached_length × ratio for every positive ratio, so the refusal fires for any trailing-only loss, down to a single character — while the same number of characters lost from the middle of the document diverges the prefix early, leaves both residuals near full size, and sails through.

Concretely, a re-extraction that is byte-identical except that the extractor now trims a trailing section — an acknowledgements block, a final page of a PDF, a footer — is refused, and refused permanently by the never-written → never-stamped loop, with cache reference exiting 1 every run. That is the shape of change EXTRACTOR_CACHE_VERSION exists to roll out, and SHRINK_PRESERVE_RATIO's own docstring says the bar "should catch a collapse, not ordinary variation." Here it catches a one-character variation with certainty, provided it falls at the end.

_apply_full_text_location:826 writes exactly abstract + "\n\n" + text, so the abstract is bounded and identifiable: clamping the subtraction to the shared prefix up to that join gives you the finding you fixed without the position sensitivity. (An abstract containing a blank line would then be under-subtracted, which errs toward the strict bar — the safe direction.) Whichever bound you pick, cached 20k and fresh identical-minus-200-trailing-characters is the case to pin; nothing in test_no_cache_downgrade.py currently constructs a fresh that shares more than the abstract with cached. Fix this →

2. The unverified-HTML relaxation keys on the cached entry alone, so it also relaxes the downgrades FULL_TEXT_QUALITY says get no relaxation

reference_fetcher.py:588-591:

relaxed = cls._is_quality_upgrade(...) or cls._is_unverified_html(cached)

The argument for the second disjunct is specific and good: a page scrape replaced by a div.article-body extraction is the same content type, so the upgrade rung cannot see it. But _is_unverified_html asks nothing about fresh, so the relaxed bar also applies when the refresh moves down the order. FULL_TEXT_QUALITY's docstring states the opposite as a rule:

A change down the order gets no relaxation: clean XML replaced by a fraction of it as HTML is the shape of a bot page, not of a better parse.

For an unverified full_text_html entry that is now false. A cached 20k page-scrape refreshed to 4,100 characters of full_text — the bottom rung, reachable publicly through a configured json_api provider, or through any location with no format_hint (_materialize:874 defaults to "text") — clears 0.2 × 20000 and overwrites. The strict bar would have refused it.

The same asymmetry costs you something on the motivating entry itself. PMID_9177246 is pre-#62 full_text_html, so it is unverified by construction, and its guard is now 0.2 rather than 0.5: a refresh recovering 3,700 characters of the 18,464 replaces it. That may be the trade you want — the docstring's "the same doubt that stops it being served is why it does not get the strict bar" is a coherent position — but it is worth being explicit that it applies to the good pre-stamp PMC extractions too, which are unverified for a reason that has nothing to do with landing pages. Requiring the fresh type to rank at least as high keeps the page→body migration you added it for and leaves the downgrade rule true as written. Fix this →

Related: docs/troubleshooting.md describes the relaxation as applying "when the refresh is a cleaner extraction — a full_text_html page scrape replaced by full_text_xml body text". The unverified-HTML branch is a second, broader trigger and is not mentioned, so a reader working out why their entry was replaced has the wrong rule in front of them.

3. test_a_verified_html_entry_keeps_the_strict_bar passes with or without the distinction it is named for

tests/test_no_cache_downgrade.py:547-559: cached "certified clean body " * 800 is 16,800 characters, fresh "fragment " * 30 is 270. The strict bar is 8,400 and the relaxed bar is 3,360 — 270 is below both, so the assertion holds whether stamped=True changes anything or not. Delete _is_unverified_html entirely and this test still passes.

It is the only test covering the verified/unverified split, and that split is the whole of finding 2's mechanism. A fresh length inside the gap — "fragment " * 500 at 4,500 characters, refused when certified, written when not — would make it discriminate, and gives you the pair that pins both halves. Fix this →


Smaller things

  • access_type_for_oa_status lowercases the status but does not strip it (base.py:143), where states_a_licence does both (base.py:89). " gold" from a payload with stray whitespace falls through to publisher_free — the safe direction, so this is consistency rather than a bug, but the two functions sitting forty lines apart disagreeing on normalisation is the kind of thing that gets "fixed" in the wrong direction later.
  • _extracted_lengths' loop reuses the enumerate target as the result (reference_fetcher.py:613-618), so the pre-loop shared = 0, the break value and the for…else value are three different mechanisms feeding one name. It is correct on all three paths — including the empty-zip case — but os.path.commonprefix or itertools.takewhile would say it in one line without the reader having to check each path.
  • OPEN_LICENCES includes mit, which OpenAlex reports for 222 works against 28M cc-by. No objection — it is a real grant — just noting that a licence vocabulary this small is easier to keep as data than the cc- prefix rule implies, if a fourth ever shows up.
  • The SELF_EVIDENTLY_OPEN_OA_STATUSES scope paragraph, states_a_licence's account of why nc and nd still qualify, and the _fetch_pmc_html docstring all sit where the reader who needs them will hit them. The full_text_url rework (reference_fetcher.py:837-848) separates secrecy from licensing in the comment as well as the code, which is what makes it reviewable.

Tests

The file now covers the ratio's four quadrants, both PDF directions, the full_text rung, the abstract prefix through the production path, and the same-rung page→body migration, with docstrings that explain why each case exists. _apply_location existing at all — a helper that refreshes through _apply_full_text_location rather than constructing ReferenceContent — is the structural fix for the class of gap that produced the last two findings, and it is worth using it for more of the file over time, since every test above line 445 still builds fresh directly.

Remaining gaps map onto the findings: no test constructs a fresh sharing more than the abstract with cached (finding 1), no test exercises an unverified-HTML entry refreshed downward (finding 2), and the one test naming the verified/unverified split does not discriminate between the two bars (finding 3).

I could not run the suite — uv run pytest and a bare python3 heredoc were both declined by the sandbox, as on the previous five passes. Everything above is read off the source: finding 1 is the arithmetic of the for…else in _extracted_lengths, finding 3 is arithmetic on the literals in the test, and finding 2 is a traced path through _FORMAT_TO_CONTENT_TYPE and _is_quality_upgrade rather than an observed run.
· branch fix/no-scraping-no-bronze

@cmungall

Copy link
Copy Markdown
Member Author

Both findings are right, and finding 1 is worse than the estimate in the review. I checked the margin against this repo's own reference cache rather than reasoning about it:

abstract-only entries sampled: 2,924
  median 2,024 chars   p75 2,789   p90 3,761

abstract needed to defeat the 0.2 floor:
  10k cached -> 1,400      15k -> 2,400      20k -> 3,400

So the median abstract already defeats the floor on a 10k entry, and p75 defeats it at 15k. For an ordinary short paper the relaxed bar was entirely spent before the extraction was weighed at all.

1. The ratio now measures recovered text

_extracted_lengths subtracts the prefix the two records share before applying the ratio, which is what the docstring already claimed. A property worth noting falls out of it: when the fresh content is a strict prefix of the cached one — a truncated fetch — it scores zero recovered text and is refused outright, which is the right answer and one the previous arithmetic could not reach.

Your diagnosis of why no test caught it was exact, and it caught two of mine in turn. My first version of the test passed the raw cache file text as cached.content, so the two sides shared no prefix and it asserted nothing; it now builds both sides the way _apply_full_text_location does. The doctest then failed for a sibling reason — its "x" * n fixture made every body a prefix of every other — so it is rewritten with distinct bodies and a realistic abstract, and now demonstrates the rule instead of passing by construction.

2. html → html

Taken as you suggested, keyed on the signal that already exists: a pre-fix entry lacks html_full_text_version, which is precisely why _load_from_disk will not serve it without allow_stale_html. _is_unverified_html gives that case the relaxed bar; an entry this extractor certified keeps the strict one, pinned by test_a_verified_html_entry_keeps_the_strict_bar.

The deletion refusal is unchanged either way — this only decides which bar the improvement has to clear.

Smaller things

  • "opts out entirely" — corrected in both places. It opts out of the refusal, not the notice, and the docs now say so where a user who has just been told to run --force will read it.
  • _warn_forced_discard's read cost — agreed there is no cheaper order, since the decision needs the cached entry. Left as is.
  • Three spellings of the membership test — agreed the inline tuples make deliberate difference look like drift. Left for now rather than renaming two more sets in a change this size; happy to do it if you would rather it land here.
  • cc- prefix matching — noted, agreed on the direction.

1111 passing, mypy, Ruff, 126 doctests and mkdocs build --strict clean, checked against the pushed tree.

One observation after six rounds. Every round has found something real, and rounds 4, 5 and 6 each found a defect introduced by the previous round's fix — all in the same place, the shrink arithmetic, which is the one piece of this change that is a heuristic rather than a rule. The two rules either side of it (do not scrape a page; bronze is not redistributable) have been stable since round 1. If a seventh round finds more in the same spot, that is probably evidence the ratio wants to be something simpler rather than something better tuned.

@cmungall
cmungall force-pushed the fix/no-scraping-no-bronze branch from 5778d11 to bc36540 Compare September 17, 2026 23:30
@claude

claude Bot commented Sep 17, 2026 •

Copy link
Copy Markdown
Contributor

Claude finished @cmungall's task in 4m 52s —— View job


Review: Fetch only openly-licensed files, and never demote a cache entry

  • Read the diff against origin/main at the new head
  • Re-check the findings from the previous pass
  • Review the simplified guard
  • Review test coverage
  • Post review

You took the closing observation from the last round seriously, and I think you were right to: SHRINK_PRESERVE_RATIO, UPGRADE_SHRINK_RATIO, FULL_TEXT_QUALITY, _is_quality_upgrade, _is_unverified_html and _extracted_lengths are all gone, and _refresh_loses_full_text is one line. Four rounds of findings lived in that arithmetic and the fifth would have too. A guard whose rule is "full text may not become no full text" is checkable by reading it, which the ratio never was. test_a_cover_page_pdf_is_not_refused_by_this_guard pinning the accepted gap — with the instruction to invert it when PDF stub detection lands — is the right way to leave a hole.

The two rules either side of it are unchanged and still look right.

Three things, all downstream of the deletion rather than in it.


1. The case the ratio existed for is now not merely unguarded but silent

Accepting the cover-page overwrite is defensible, and the argument in the docstring is the one I'd make: a refusal is permanent (never written → never stamped → re-refused every run), an acceptance costs one entry, and a shorter extraction is usually a better one. The asymmetry is real.

But the conclusion drawn from it is wider than the argument. "Don't refuse on size" does not imply "don't mention size". Today _apply_full_text_location overwrites 20,801 characters of article body with a 602-character cover sheet — your own reproduction from round 5 — and emits nothing but the ordinary Cached PMID:x to … info line. A curator whose quoted evidence stops validating has no thread to pull.

A warning has none of the properties that made the refusal harmful: it does not block the write, so the entry is still stamped, the migration still completes, cache reference still exits 0, and a wrong guess costs one log line rather than a cache that can never migrate. The existing _RefreshLoss already renders the comparison.

if not self._refresh_loses_full_text(cached, fresh) and _is_much_shorter(cached, fresh):
    logger.warning(
        "Refresh of %s replaced the cached %s entry (%d characters) with %s. "
        "Written as usual -- a shorter extraction is often a cleaner one -- "
        "but check it if quoted excerpts stop verifying.",
        ...
    )

That keeps the decision where you've put it and restores the observability the ratio incidentally provided. Fix this →

Related, and cheap: the docstring defers the real fix with "see the issue linked from the PR" (reference_fetcher.py:486). Once this merges, that sentence is unresolvable from the source — a reader has to find the PR to find the issue. A bare #NN in the docstring would survive the move. (I couldn't read the current PR body from this runner to check the link is there at all.)

2. _preserve_cached_full_text's docstring still promises the rule you deleted

reference_fetcher.py:393-395, in the "Narrow on purpose" list:

  • only a loss of full text is refused — no full text at all, or a collapse to a fraction of the cached length (see _refresh_loses_full_text) — so an entry that gains full text…

The cross-reference points at the method that now opens "Deliberately a rule about kind, not a comparison of size". So the guard's own docstring and the predicate's docstring contradict each other, eighty lines apart, and the wrong one is the one a reader hits first — _preserve_cached_full_text is where you'd start.

Same sentence survives in the docs: docs/troubleshooting.md:883 says the failure persists "until the source serves the full text again or --force accepts the shorter refresh". There is no shorter refresh any more; the only thing --force accepts here is an abstract. Two paragraphs later the same file explains at length that sizes are not compared, so a skimmer gets whichever they read first. Fix this →

3. The deleted rule left its scaffolding in the test file, including one test that no longer tests anything

tests/test_no_cache_downgrade.py still carries the shape of the ratio after the ratio has gone:

  • test_a_verified_html_entry_keeps_the_strict_bar (line 456) now passes vacuously. It writes stamped=True, so the entry is current, so fetch returns it from disk and the source is never consulted — as test_a_stamped_entry_is_served_from_cache_without_re_fetching (line 354) exists to pin. The assertion holds no matter what the guard does; delete _preserve_cached_full_text entirely and it still passes. Its name asserts a policy this round removed, which is the worst combination: a future reader takes it as evidence a strict bar exists.
  • Three dead helpers: REAL_ABSTRACT (384), _apply_location (388) and _cached_like_production (411) have no callers. _apply_location was the fixture that made the last round's finding visible — worth keeping only if a test uses it, and none does. Ruff's configured rules won't flag module-level dead code.
  • Two empty section banners (283, 323) — "Losing full text without changing content type" and "An upgrade is not a blank cheque" — with nothing beneath them, and a third (381, "The ratio measures extracted text, not the record") heading only the dead helpers.
  • Stale docstrings in surviving tests: line 337 explains a failure in terms of "the quality ranking gave it the strict ratio", line 439 says "with the strict ratio that improvement is refused" and appeals to _is_unverified_html. Both tests are still worth having — they pin migrations that must work — but their stated reasons refer to code that isn't there.

The file's module docstring is the model here: it describes the narrowing as history and says why. The bodies didn't follow. Fix this →


Smaller things

  • _refresh_loses_full_text(cached, fresh) is now exactly needs_full_text(fresh). The body ignores cached entirely, so the module has two names for one predicate and the signature implies a comparison that doesn't happen. Both call sites already have self. Keeping the name for its docstring is a fair reason — it's where the history lives — but a line saying "cached is unused: kept so the signature still reads as the question being asked" would stop the next reader looking for the comparison.
  • _RefreshLoss's second branch is unreachable. Both call sites log only after _refresh_loses_full_text returns True, which is true exactly when fresh.content_type in NEEDS_FULL_TEXT_TYPES — the condition the first branch tests. So "N characters of X against M cached" can never render, and the class now lazily formats a single constant form. It becomes live again the moment finding 1's warning exists, which is an argument for doing that one first.
  • Broken cross-reference. reference_fetcher.py:483 points at ~linkml_reference_validator.etl.extract.is_stub_notice, but etl/extract/__init__.py exports only MIN_FULLTEXT_CHARS, Extractor, ExtractorRegistry and the three extractors — the function lives at etl.extract.xml.is_stub_notice. It's the load-bearing citation for removing the size check, so worth having it resolve.
  • --no-full-text now pins every enriched entry. With fetch_full_text=False the enrichment chain never runs, so every stale full_text_* entry refreshes to abstract_only and hits the guard: one warning per reference per run, forever, and cache reference exiting 1. Preserving is still the right answer — the alternative is wiping full text because the user asked not to fetch any — but the two remedies the message offers are wrong in that mode: the source is reachable, and --force destroys the text. One sentence in the troubleshooting section, or skipping the recommendation when full-text fetching is disabled.
  • cli/cache.py:165 — "refused for returning less than the cache already held" is now only ever "returned no full text". Not wrong, just looser than the rule, and the rule is finally simple enough to state exactly.

Tests

What remains after the trimming is the right set: the deletion claim over all three full-text types, the serve/keep split per type, both served_stale branches, the --force warning and its cost asserted on the file, the exit-1-every-run consequence, and the four migrations that must not be refused. The CLI tests asserting "no current entry" — the invariant both failure paths share — rather than one path's phrasing is still the right call, and the comment explaining why the old assertion was wrong is worth more than the assertion.

The gaps are the ones in the findings: nothing asserts the cover-page overwrite is visible (finding 1), and one test in the file currently asserts nothing at all (finding 3).

I could not run the suite — uv run pytest was declined by the sandbox on this pass as on the previous five, so everything above is read off the source. Finding 3's vacuity claim is traced through _is_stale_cache_entry and the stamped=True fixture rather than observed; given how the last several traced findings went, it's worth confirming by deleting the test's body and watching it still pass.
· branch fix/no-scraping-no-bronze

@cmungall

Copy link
Copy Markdown
Member Author

All three findings are right, and I have taken them as evidence about the design rather than fixing them in place. I said last round that a seventh round of findings in the same spot would be a signal the ratio wants to be simpler rather than better tuned. That is what this is, so the shrink comparison is gone.

Finding 3 is what settled it. test_a_verified_html_entry_keeps_the_strict_bar uses fresh=270 against cached=16,800, and the two bars are 8,400 and 3,360 — below both, so it passes whether _is_unverified_html exists or not. The single test covering the mechanism of finding 2 could not observe it. That is not a bad test so much as a sign that the thing it was trying to pin had become too intricate to pin.

What the ratio was actually buying

case type rule alone
bronze entry, provider now declines REFUSED
PMID:9177246, the reCAPTCHA case REFUSED
page scrape → clean XML (round 3 finding) allowed
page scrape → clean HTML (round 6 finding) allowed
plain-text API body → XML (round 4 finding) allowed
cover-page PDF (round 3 finding) allowed

The rule alone refuses both cases that motivated the guard and allows all four migrations the ratio wrongly blocked. Its entire net contribution was the cover-page PDF, and the price was four rounds of defects — every one of which failed in the worse direction, because a refusal is never written, so never stamped, so re-refused on every later run. A guard meant to protect the cache pinned it permanently at its pre-migration content.

Your finding 1 makes that concrete in a way I had not seen: with the whole-prefix subtraction, an extractor that merely trims a trailing section loses everything, with certainty, while the same loss mid-document sails through. I was about to add a fourth special case to a heuristic whose three existing special cases had each been wrong once.

The change

_refresh_loses_full_text is now fresh.content_type in NEEDS_FULL_TEXT_TYPES. Removed: SHRINK_PRESERVE_RATIO, UPGRADE_SHRINK_RATIO, FULL_TEXT_QUALITY, _extracted_lengths, _is_quality_upgrade, _is_unverified_html, and the eight tests that pinned them. 283 deletions against 86 insertions.

All three of your findings are closed by deletion, along with rounds 4, 5 and 6's.

The gap this leaves

The cover-page PDF is now allowed, and I would rather that be visible than implicit, so test_a_cover_page_pdf_is_not_refused_by_this_guard asserts it and says why, with a note to invert the test if PDF stub detection ever lands.

Your own review pointed at where it belongs: is_stub_notice already asks "is this text an article?" in the acceptance layer, and is XML-only by design. That is the right home — a wrong answer there costs one skipped fetch, where a wrong answer in the preservation layer costs a cache that can never migrate. Happy to open that as an issue, or to do it here if you would rather it not ship with the gap.

Smaller things

  • access_type_for_oa_status now strips as well as lowercases, matching states_a_licence. Agreed on the reasoning — two functions forty lines apart disagreeing about normalisation is how the wrong one gets "fixed" later.
  • _extracted_lengths' three-mechanism loop — moot, deleted.
  • Three spellings of the membership test — still there, still deliberate. I would rather not rename two more sets in this change.
  • OPEN_LICENCES and mit — agreed, and if a fourth appears the prefix rule should probably become data.

1105 passing (four fewer than last round, which is the eight deleted tests less the four added), mypy, Ruff, 126 doctests and mkdocs build --strict clean, checked against the pushed tree.

@cmungall
cmungall force-pushed the fix/no-scraping-no-bronze branch from bc36540 to d1a2f40 Compare September 17, 2026 23:58
@claude

claude Bot commented Sep 17, 2026 •

Copy link
Copy Markdown
Contributor

Claude finished @cmungall's task in 3m 1s —— View job


Review: Fetch only openly-licensed files, and never demote a cache entry

  • Read the diff against origin/main at the new head
  • Re-check the findings from the previous pass
  • Review the simplified guard and the new reporter
  • Review test coverage
  • Post review

The deletion is the right call and it is complete: nothing in src, tests or docs still names SHRINK_PRESERVE_RATIO, UPGRADE_SHRINK_RATIO, FULL_TEXT_QUALITY, _extracted_lengths, _is_quality_upgrade or _is_unverified_html. _refresh_loses_full_text is one line and its docstring carries the history rather than the arithmetic, which is the version a reader six months from now can actually check. Your table of what the ratio was buying — one case caught, four wrongly refused, each refusal permanent — is the argument, and it is in the source rather than only in the PR.

Finding 3 from last round is closed thoroughly: test_a_verified_html_entry_keeps_the_strict_bar is gone, REAL_ABSTRACT/_apply_location/_cached_like_production are gone, every section banner has tests under it, and the two stale docstrings now explain the tests in terms of the rule that exists. Finding 2's contradictions in _preserve_cached_full_text and docs/troubleshooting.md:883 are both corrected.

Finding 1 you implemented rather than deferred, which I think was right. Two things in the reporter it introduced.


1. The new report fires on the refused path too, and says the opposite of what happened

_preserve_cached_full_text:427 calls _report_shrinking_refresh before the refusal is decided, and the reporter asks only about length:

self._report_shrinking_refresh(normalized_reference_id, fresh)   # line 427

cached = self._load_from_disk(..., allow_stale=True, allow_stale_html=True)
if cached is None or self.needs_full_text(cached):
    return None
if not self._refresh_loses_full_text(cached, fresh):
    return None
logger.warning("Refresh of %s returned %s; keeping the cached %s entry ...")

_report_shrinking_refresh:470-484 tests cached is None, needs_full_text(cached) and the ratio — nothing about whether fresh still holds full text. An abstract is both shorter and not full text, so on a refused refresh both warnings fire, in this order:

WARNING  Refresh of PMID:9177246 replaced the cached full_text_html entry
         (18464 characters) with a much shorter one: no full text
         (abstract_only). Written as usual, since a shorter extraction is
         often a cleaner one -- but check it if quoted excerpts stop verifying.
WARNING  Refresh of PMID:9177246 returned no full text (abstract_only);
         keeping the cached full_text_html entry rather than overwriting it.
         It stays stale, so a later run will try again.

The first is false in every clause that matters: nothing was replaced, nothing was written, and the advice to check the new text describes text that does not exist. And this is not an edge case — it is the PR's motivating entry, on the path the whole guard exists for, since an abstract is essentially always under 20% of a cached body. Every preserved entry now emits a contradictory pair, every run.

REPORT_SHRINK_RATIO's own docstring already states the missing clause — "A refresh keeping full text but returning less than this share of the cached length is reported" — so the constant documents the rule and the function drops the first half of it. Your test_a_cover_page_pdf_is_not_refused_but_is_reported uses any("much shorter" in ...), so it cannot see the extra warning on the other path, and the preserve tests don't look at caplog at all.

The fix also collapses a second problem for free. The reporter re-loads and re-parses the cache file the guard is about to load two lines later, so every non-force fetch that reaches this method now costs two reads where it previously cost one — and this method runs on every reference the fetch actually goes to the source for, not only preserved ones. Taking cached as a parameter and calling after the _refresh_loses_full_text(cached, fresh) check returns False fixes both: one read, and the report only where a write actually happens. Fix this →

2. The report compares whole records, so the abstract defeats it at exactly the size where it matters

Round 7's finding 1 was that the ratio measured len(content) while the record is abstract + "\n\n" + text (_apply_full_text_location:826). _extracted_lengths is gone with the rest, and _report_shrinking_refresh:479-481 is back to comparing whole records — now with the same 0.2 bar the relaxed ratio used.

The consequence has flipped to the safe direction (a missed warning, not a permanent refusal), which is why this is a note on a new feature rather than a repeat of the finding. But it silences the reporter on the case it was added for. Using your own measurement from round 7 — 2,924 abstract-only entries, median 2,024 characters:

cached record 0.2 bar cover sheet + median abstract reported?
20,488 4,098 2,626 yes
15,000 3,000 2,626 yes
10,024 2,005 2,626 no

So for an ordinary short paper the cover-page overwrite is silent again, and the median abstract is most of the fresh side. test_a_cover_page_pdf_is_not_refused_but_is_reported passes because _fetch_returning:80-93 builds ReferenceContent directly and so carries no abstract prefix — the same blind spot that hid the original. Subtracting the abstract prefix is more machinery than a log line deserves; the cheaper honest options are to compare against cached_length minus the shared abstract only when both records have one, or simply to raise the bar, since the cost of a spurious warning is now one line rather than a blocked migration. Either way, a test that drives fresh through _apply_full_text_location is what makes the choice real. Fix this →


Smaller things

  • docs/troubleshooting.md says "Sizes are not compared." That is now true of the refusal and false of the module: the new warning compares them, and a user who meets "a much shorter one" in their log will search the docs for it and find a sentence saying it cannot happen. The paragraph needs one clause, not a rewrite — sizes are not compared to decide a refusal, and the report exists.
  • The broken cross-reference survived. reference_fetcher.py:543 still points at ~linkml_reference_validator.etl.extract.is_stub_notice; etl/extract/__init__.py:24-31 exports only MIN_FULLTEXT_CHARS, Extractor, ExtractorRegistry and the three extractors, so the target is etl.extract.xml.is_stub_notice. It is the load-bearing citation for removing the size check and it is repeated in the test docstring at test_no_cache_downgrade.py:421.
  • "see the issue linked from the PR" (reference_fetcher.py:546) is still the only pointer to the deferred PDF stub work. Once this merges, that sentence sends a reader to a PR to find an issue. A bare #NN survives the move; if the issue doesn't exist yet, saying "no issue yet" is more useful than a dangling indirection.
  • _RefreshLoss's second branch is live again — good, that was the argument for doing this finding before the dead-branch note. It does mean the shrink message prints the cached length twice: "the cached full_text_html entry (18464 characters) with a much shorter one: 602 characters of full_text_pdf against 18464 cached." Either the %d or the against N cached tail is redundant.
  • _load_from_disk's allow_stale docstring (reference_fetcher.py:1190) says _preserve_cached_full_text uses it "when it yielded less". It yields less no longer — it yields none — and there are now two more callers on that path (_report_shrinking_refresh, _warn_forced_discard).
  • cli/cache.py:165 — "refused for returning less than the cache already held" is still looser than the rule, which is finally simple enough to state exactly: it returned no full text. Not wrong, and the test correctly asserts the shared invariant rather than the wording, so this is a sentence rather than a change.

Tests

What remains is the set that matches the rule as written: the deletion claim over all three full-text types, the serve/keep split per type, both served_stale branches, --force warning and its cost on the file, the exit-1-every-run consequence, the four migrations that must not be refused, and the accepted gap pinned with an instruction to invert it. _write_cache's docstring explaining why stamped=True needs the format-specific stamp is the kind of note that stops the next person re-deriving a bug.

The two gaps map onto the findings: nothing asserts what is not logged on the refusal path (finding 1), and nothing builds a fresh record shaped the way _apply_full_text_location shapes it (finding 2) — that second one has now hidden a defect twice, so it may be worth making _fetch_returning carry an abstract prefix by default rather than adding a special-case helper again.

I could not run the suite — uv run python was declined by the sandbox on this pass as on the previous six, so everything above is read off the source. Finding 1 is the call order at line 427 against the guard clauses at 470-484, which is directly readable; finding 2 is arithmetic on your round-7 abstract-length measurement, not an observed run. Given the record, finding 1 is worth confirming by running the existing preserve test with caplog at WARNING and counting the records.
· branch fix/no-scraping-no-bronze

@cmungall

Copy link
Copy Markdown
Member Author

Finding 1 is a distinction I had collapsed, and you are right that it does not follow. "Don't refuse on size" and "don't mention size" are separate conclusions, and only the first one was supported by the evidence.

What made refusing harmful was that it is permanent: never written, never stamped, re-refused on every later run. A warning has none of those properties — the write proceeds, the entry is stamped, the migration completes, cache reference still exits 0 — and a wrong guess costs one log line instead of a cache that can never migrate. So REPORT_SHRINK_RATIO is back as a report only:

Refresh of PMID:9177246 replaced the cached full_text_html entry (20,801
characters) with a much shorter one: 602 characters of full_text_pdf against
20,801 cached. Written as usual, since a shorter extraction is often a cleaner
one -- but check it if quoted excerpts stop verifying.

test_a_cover_page_pdf_is_not_refused_but_is_reported now asserts both halves: the write happens, and it is not silent.

2 and 3 — cleanup debt from my own deletion

Both fair, and both the kind of thing that only shows up when someone reads the result rather than the diff.

The _preserve_cached_full_text docstring still promised the removed rule while cross-referencing the predicate that contradicts it, eighty lines away, and it is the one a reader reaches first. Fixed, along with the --force sentence in the docs — there is no "shorter refresh" to accept any more, only an abstract.

The test file kept the shape of the ratio after the ratio had gone: three dead helpers, three empty section banners, two docstrings explaining failures in terms of a "strict ratio" and a "quality ranking" that no longer exist, and test_a_verified_html_entry_keeps_the_strict_bar, which passed vacuously because stamped=True means fetch returns from disk and never reaches the guard at all. That last one is the one I am glad you caught: a test whose name asserts a policy the branch had just removed is worse than no test, because the next reader takes it as evidence the policy exists.

On the docstring's forward reference

Agreed — "see the issue linked from the PR" is unresolvable once this merges. I have left it for now because I do not have an issue number to put there yet; filing the PDF-stub-detection issue and replacing the sentence with a bare #NN is the right close, and I would rather do that than write a number I have guessed.

CI

The red tick on this run is not the branch. tests/test_geo_integration.py::TestGEOLiveValidation::test_validate_title_match_real_geo is a live-network test in a file this PR does not touch, and it failed with esearch failed for GSE67472: HTTP Error 500 — NCBI returning a 500 for about ten minutes. The same test passed on the previous commit of this branch. Worth a re-run rather than a fix here.

There is an irony in it that is also a small observation: a PR about transient upstream failures being recorded as facts has a suite that does the same thing to itself. A live test that fails on a 500 is reporting NCBI's availability, not the code's correctness, and it will keep doing so.

1091 passing with the live-network tests deselected, mypy, Ruff, 126 doctests and mkdocs build --strict clean, checked against the pushed tree.

@cmungall
cmungall force-pushed the fix/no-scraping-no-bronze branch from d1a2f40 to 0f7ed3a Compare September 18, 2026 00:12
@cmungall

Copy link
Copy Markdown
Member Author

Both findings are bugs in the report I added last round, and finding 1 is worse than the description. Reproduced before fixing:

### refused path (abstract_only refresh)
WARN> Refresh of PMID:1 replaced the cached full_text_html entry (19,799 characters)
      with a much shorter one: no full text (abstract_only). Written as usual...
WARN> Refresh of PMID:1 returned no full text (abstract_only); keeping the cached
      full_text_html entry rather than overwriting it.
   entry preserved: True

Two warnings, back to back, one saying the entry was replaced and the next saying it was kept. Not merely a message on the wrong path — a self-contradicting pair, which is worse than the silence the report was added to fix.

It now fires inside the branch that returns without preserving, which is the only place a report of a write can be true.

2. The abstract, again

Reproduced too: a cover-page PDF carrying the abstract wrote in silence against a 10k cached entry — precisely the case the report exists for, at precisely the article size where it is most likely.

This is the same defect as round 6's finding 1, reappearing in the code that replaced the thing it was found in. Worth naming, since the lesson is not "fix the arithmetic" but "this comparison keeps forgetting that _apply_full_text_location concatenates".

_text_after_abstract splits on the first blank line, which is the exact join that produced the record. Deliberately narrower than the round 6 version, which subtracted the whole common prefix and made the verdict depend on where the text differed rather than how much was lost — your finding 1 of round 7. An abstract containing its own blank line under-subtracts, leaving the comparison closer to the whole record and so less likely to report, which is the safe direction for a message.

Both repros now behave:

### refused path        -> one warning, "keeping the cached entry", no claim of a write
### cover-page, 10k     -> "replaced ... with a much shorter one: 2,616 characters
                            of full_text_pdf against 10,079 cached"

1093 passing with live-network tests deselected, mypy, Ruff, 126 doctests and mkdocs build --strict clean, checked against the pushed tree. CI is green on the previous head — the NCBI 500 from the last round cleared on its own, as expected.

@claude

claude Bot commented Sep 18, 2026 •

Copy link
Copy Markdown
Contributor

Claude finished @cmungall's task in 5m 22s —— View job


Review: Fetch only openly-licensed files, and never demote a cache entry

  • Read the diff against origin/main at the new head
  • Re-check the findings from the previous pass
  • Review the guard and the shrink reporter
  • Review test coverage
  • Post review

Both of last round's findings are closed, and closed at the root rather than at the symptom. _report_shrinking_refresh now takes cached as a parameter and is called after _refresh_loses_full_text returns False (reference_fetcher.py:451-455), so the report describes a write that happened and the second cache read is gone; test_a_refused_refresh_does_not_also_claim_it_was_written asserts the negative, which is the assertion that was missing. _text_after_abstract is the right shape for the prefix problem — small, documented with the direction of its own error, and pinned by a test that builds the fresh record the way _apply_full_text_location builds it.

The two rules either side of the guard are unchanged and still look right. I have no correctness finding on the guard, the licence allowlist or the provider changes this round. What is left is about messages and docs — which is not a small category in a PR whose thesis is that a message misdescribing what happened is the defect.


1. The shrink report prints three numbers, and the two it shows the reader are not the two it decided on

_report_shrinking_refresh:491-504 decides on abstract-subtracted lengths and then renders a mixture:

cached_length = len(_text_after_abstract(cached.content))          # abstract removed
if len(_text_after_abstract(fresh.content)) >= cached_length * REPORT_SHRINK_RATIO:
    return
logger.warning(
    "... the cached %s entry (%d characters) with a much shorter one: %s ...",
    cached.content_type, cached_length, _RefreshLoss(fresh, cached),
)

but _RefreshLoss.__str__:145-149 renders len(self._fresh.content) and len(self._cached.content) — whole records, abstract included. So one sentence quotes the cached entry at two different sizes, and the fresh figure is the one the ratio did not use:

... replaced the cached full_text_html entry (18464 characters) with a much
shorter one: 2602 characters of full_text_pdf against 20464 cached.

At that size it merely reads oddly. At the size the reporter was reinstated for it reads as wrong. Cached abstract 2,000 + body 3,000; refresh returns the abstract plus a 500-character cover sheet:

  • decision: 500 < 3000 × 0.2 → warn;
  • message: "the cached full_text_html entry (3000 characters) with a much shorter one: 2500 characters of full_text_pdf against 5000 cached."

2,500 against 5,000 is half. A curator reading that cannot reconstruct why "much shorter" fired, and the natural conclusion — that the threshold is somewhere near 50% — is wrong. That branch of _RefreshLoss is reachable from this call site only (the refusal and --force paths both hit content_type in NEEDS_FULL_TEXT_TYPES and take the first branch), so it can be switched to _text_after_abstract with no other caller to consider — or the against N cached tail dropped, since the %d already states it. Fix this →

_warn_forced_discard:530 has the same split the other way — it prints len(cached.content or ""), the whole record, where the shrink report prints the subtracted length for the same entry. Worth landing on one convention.

2. The one warning a user will paste into a search box is the one the docs deny

docs/troubleshooting.md says, of the refresh rule: "Sizes are not compared." That is true of the refusal and false of the module — REPORT_SHRINK_RATIO compares them and emits "...with a much shorter one...". The paragraph goes on to explain at length why comparing sizes was a mistake, so a reader who hits the warning and searches the docs finds a passage explaining that it cannot happen.

The report is otherwise undocumented: it is the only user-visible output of this PR with no entry in the troubleshooting section, and it is specifically aimed at a curator whose quoted evidence stopped verifying — i.e. at someone who is already reading that page. One clause on the existing sentence (sizes are not compared to decide a refusal) plus two lines saying the warning exists and what to do with it would close it. Fix this →

3. cache reference's failure message describes the rule as it was two rounds ago

cli/cache.py:164-168:

Failed to cache PMID:1: it could not be re-fetched, or the refresh was refused for returning less than the cache already held. No entry was written… Re-run when the source is reachable, or pass --force to accept the refresh as it stands.

Neither clause is true of the code now. The refusal is no longer "less than" anything — _refresh_loses_full_text is a rule about kind, and the refused refresh returned no full text. And on this path --force does not accept a shorter refresh; it replaces an article body with an abstract, which _warn_forced_discard exists to announce and docs/troubleshooting.md now words correctly ("--force accepts the abstract-only refresh in its place"). The CLI string is the copy a user actually meets, and it is the one still carrying the old rule. The rule is finally simple enough to state exactly: the refresh came back with no full text, and --force will discard what is cached. Fix this →


Smaller things

  • _text_after_abstract's "exactly the join that produced it" is true only when there is an abstract. _apply_full_text_location:772 writes bare text when abstract is falsy — a source that returned no abstract, or a full_text_* record that came straight from PMC — and then the partition lands on the first paragraph break in the body and drops the opening paragraph. The docstring's safe-direction argument covers the fresh side (under-subtraction → larger fresh → less likely to report) but not the cached side, where the same under-subtraction raises the bar and makes a report more likely. It decides a log line either way, so this is a docstring sentence rather than a code change.
  • The is_stub_notice cross-reference still does not resolve (reference_fetcher.py:557). etl/extract/__init__.py:24-31 exports MIN_FULLTEXT_CHARS, Extractor, ExtractorRegistry and the three extractors; the function is etl.extract.xml.is_stub_notice. It is the load-bearing citation for removing the size check and it is repeated in tests/test_no_cache_downgrade.py:423.
  • "see the issue linked from the PR" (reference_fetcher.py:561) — you said you'd rather file the issue than guess a number, which is right; just don't let it merge as-is, since after the merge that sentence points at a PR to find an issue.
  • _load_from_disk's allow_stale docstring (reference_fetcher.py:1206) still says _preserve_cached_full_text uses it "when it yielded less". It yields none, not less, and _warn_forced_discard is now a third caller on that path.
  • The guard reads only the public cache, by _load_from_disk's deliberate design. So "a refresh may improve an entry; it may never demote one" is a public-cache invariant, and a private research-cache entry (Zotero, cache enrich) has no equivalent protection. Nothing in this PR makes that worse, and the only production caller of apply_full_text_location passes private=True, so it is scope rather than a hole — but the docstring states the invariant without the qualifier.
  • A pdf_url that answers with HTML still reaches the HTML path. _materialize:820 sniffs the bytes and routes to HTMLExtractor, so the "we only ever request a file" rule constrains what is asked for, not what comes back. What makes that safe is HTMLExtractor.extract_full_text's landing-page rejection (extract/html.py:107-112) plus html_full_text_version, which is the same structural-defence argument _fetch_pmc_html's new docstring makes. Given that docstring exists, the symmetric note on the OA path would be cheap.

Tests

The set matches the rule as written, and the three tests added since the last pass are the right three: test_a_refused_refresh_does_not_also_claim_it_was_written pins a negative (no test did before), test_the_report_ignores_the_abstract_both_records_carry builds the fresh record the way production builds it, and test_a_cover_page_pdf_is_not_refused_but_is_reported asserts both halves of the accepted gap in one place, with the instruction to invert it when PDF stub detection lands. _write_cache's docstring explaining why stamped=True needs the format-specific stamp is the note that stops the next person re-deriving a bug.

Gaps, both matching findings above: nothing asserts what numbers the shrink warning contains — only that it contains "much shorter" — which is why finding 1 is invisible to the suite; and nothing exercises a record without an abstract through _text_after_abstract.

I could not run the suite: the sandbox declined uv run pytest and python3 -m pytest on this pass, as on the previous six. Everything above is read off the source — findings 1 and 3 are direct reads of the format strings against the code that feeds them, finding 2 is a read of the docs against REPORT_SHRINK_RATIO. None of this round's items is a traced execution path, so there is nothing here that needs reproducing before acting on it.
· branch fix/no-scraping-no-bronze

@cmungall
cmungall force-pushed the fix/no-scraping-no-bronze branch from 0f7ed3a to 72c2a3e Compare September 18, 2026 00:28
@claude

claude Bot commented Sep 18, 2026 •

Copy link
Copy Markdown
Contributor

Claude finished @cmungall's task in 3m 52s —— View job


Review: Fetch only openly-licensed files, and never demote a cache entry

  • Read the diff against origin/main at the new head
  • Re-check the findings from the previous pass
  • Review the guard and the shrink reporter
  • Review test coverage
  • Post review

All three of last round's items are closed. _RefreshLoss.__str__ now renders _text_after_abstract(fresh.content) and the caller says "N characters of article text", so the sentence quotes one pair of numbers and they are the pair the ratio compared — test_the_shrink_report_quotes_the_numbers_it_decided_on pins it. _warn_forced_discard uses the same subtracted length, so the two messages now agree. docs/troubleshooting.md qualifies the claim ("Sizes are not compared to decide a refusal") and documents the warning with a worked example and what to do about it. The is_stub_notice cross-reference resolves (etl.extract.xml.is_stub_notice) and the dangling "see the issue linked from the PR" is gone, replaced by a named test.

The two rules either side of the guard are unchanged and still look right, and I have nothing further on the guard's arithmetic — it no longer has any.

One finding a layer up from where the last several rounds have been, then two smaller ones.


1. A licensing decline is now recorded as full_text_attempted: true — the PR's own thesis, one layer up

This PR's argument is that a fetch outcome was being written as a fact about the article. _enrich_with_full_text (reference_fetcher.py:681-700) has exactly that shape, and this PR newly routes traffic into it:

if location.access_type not in (None, "open"):
    logger.info("Ignoring non-public full text from provider '%s' for %s", ...)
    continue
...
if not had_error:
    content.full_text_attempted = True

Three outcomes, two states. had_error covers a transient failure, so it stays retryable. Everything else is folded into full_text_attempted, whose documented meaning (_maybe_retry_full_text:622) is "a prior clean run already concluded none is available". But a bronze location that was skipped on licence, and a DOI whose only OA location is a page the provider now declines to scrape, are neither errors nor absences — they are decisions this project made about material that exists.

Both are new arrivals. Before this PR a bronze location carried access_type=None and was applied, and oa_url produced a location; now the first is skipped at line 681 and the second returns None from locate, and in both cases the chain falls through to line 700. The record is then saved with extractor_version (_save_to_disk:1091), so it is current, so the next run returns it from disk at fetch:304 and _maybe_retry_full_text short-circuits on the flag. The chain is not re-run for that reference again until EXTRACTOR_CACHE_VERSION bumps.

The consequence is slow but real: bronze→gold is a conversion publishers actually make, and a landing-page-only record gains a pdf_url when a repository copy is deposited. Neither is noticed. cache enrich is unaffected — locate_full_text deliberately ignores the flag — so this is confined to ordinary validation, which is also where it matters.

The fix is the same shape as had_error: a third state. A chain that declined a location on policy, or found only a page, has not established that the article has no full text, so it should leave the flag unset the way a provider exception does. That does cost a chain re-run per process for those references, which is the trade had_error already makes. Neither new test file touches full_text_attempted, so nothing currently pins which meaning the flag carries. Fix this →

2. The --force remedy in cache reference is true on one of the two paths it is printed on

cli/cache.py:164-168 was rewritten last round specifically so the message would be true of both served_stale branches, and the descriptive half now is. The prescriptive half is not:

Re-run when the source serves full text again, or pass --force to replace the cached text with what the refresh returned.

On the preserve path that is exactly right. On _stale_fallback's path — the source yielded nothing — there is no refresh result to accept: _stale_fallback:382 returns FetchOutcome(content=None) under force_refresh, so --force writes nothing and the command fails differently, as test_forcing_a_refresh_against_a_stale_entry_reports_plain_failure already documents. A user whose source is down reads a sentence offering to install something that does not exist.

The comment above the echo states the rule the message is meant to follow — "the message has to be true of both", "rather than a guess at which path ran" — and the advice clause is the one place it guesses. The shared truth is narrower: --force will not recover full text on either path, and on one of them it discards what is cached. Fix this →

3. _RefreshLoss no longer uses cached

Making the length branch subtract the abstract from fresh and dropping the against N cached tail (reference_fetcher.py:149-160) left self._cached unread on both branches. The slot, the constructor parameter and the argument at all three call sites (474, 516, 545) are now dead, and the class docstring still frames it as a comparison. A one-argument _RefreshLoss(fresh) says what it does; keeping the two-argument form implies the message relates the two records, which after this round's fix it deliberately does not. Fix this →


Smaller things

  • --no-full-text turns the guard into a permanent failure with two wrong remedies. With fetch_full_text=False, fetch:338 never runs the chain, so every stale full_text_* entry refreshes to abstract_only and hits the guard: one warning per reference per run and cache reference exiting 1, forever. Preserving is the right answer — the alternative is wiping full text because the user asked not to fetch any — but in that mode the source is serving full text, and --force destroys the article body in exchange for nothing. Neither the warning nor the CLI message nor the docs distinguishes it. Cheapest honest fix is to skip the --force recommendation when full-text fetching is disabled; a doc sentence would do.
  • _preserve_cached_full_text's docstring is careful to scope the invariant to the public cache, which is right and was not said before. Worth carrying the same qualifier into docs/troubleshooting.md, which states "a refresh may improve an entry; it may never demote one" without it — the private research cache written by cache enrich has no equivalent protection.
  • _text_after_abstract is called up to four times per reported refresh (twice in _report_shrinking_refresh, once in the caller's %d, once inside _RefreshLoss), each doing a partition over a 20 KB string. Irrelevant at this scale; noting it only because the laziness machinery around _RefreshLoss exists to avoid exactly this kind of work, and here the eager path does it anyway before deciding to log.
  • OpenAlexProvider.locate reads oa_status from open_access and licence from best_oa_location. Correct — they are the work-level and location-level fields respectively — but the two names sit adjacent at openalex.py:81-82 with nothing saying they come from different objects, and the access_type_for_oa_status docstring's "known miss" note is about precisely that mismatch. One word at the call site would connect them.
  • The SELF_EVIDENTLY_OPEN_OA_STATUSES scope paragraph, states_a_licence's allowlist rationale with the OpenAlex vocabulary counts behind it, _fetch_pmc_html's structural-defence docstring, and the new OpenAlexProvider note that the no-scraping rule constrains what is asked for rather than what comes back — these are the four places a future reader will need, and they are all present. The full_text_url comment separating secrecy from licensing is the clearest of them.

Tests

test_no_cache_downgrade.py covers the rule as written with no scaffolding left from the removed one: the deletion claim over all three full-text types, the serve/keep split per type, both served_stale branches, the four migrations that must not be refused, --force's warning and its cost asserted on the file, the exit-1-every-run consequence, the accepted cover-page gap with an instruction to invert it, the abstract-prefix case built through _apply_full_text_location, and now the numbers in the warning itself. test_text_after_abstract_handles_a_record_with_no_abstract closes the last round's note on the no-abstract case.

The gap is finding 1: neither new file asserts anything about full_text_attempted, so which of the three outcomes the flag is meant to represent is currently unpinned, and this PR changed which of them reach it.

I could not run the suite — python3 -m pytest and a which uv probe were both declined by the sandbox, as on the previous seven passes. Everything above is read off the source. Finding 1 is a traced path (_enrich_with_full_text:681 → :700 → _save_to_disk:1091 → fetch:304 → _maybe_retry_full_text:629) rather than an observed run, so on the record of the last several rounds it is worth reproducing before acting on it; findings 2 and 3 are direct reads of the code against its own comments.
· branch fix/no-scraping-no-bronze

@cmungall

Copy link
Copy Markdown
Member Author

All three taken, and finding 1 was the one worth having: the message was not merely inconsistent, it was misleading about its own threshold. My test's captured output before the fix:

... replaced the cached full_text_html entry (3005 characters) with a much
shorter one: 2518 characters of full_text_pdf against 5023 cached.

2,518 of 5,023 is half. A curator reading that would infer a threshold near 50%, which is wrong by a factor of two and a half, and the cached entry is quoted at two different sizes in one sentence.

Landed on the convention you suggested, across all three call sites rather than just the one: every message now reports article text with the shared abstract subtracted, which is the quantity all of them decide on and the one at stake in each. _RefreshLoss drops the against N cached tail entirely, since the caller's %d already states it.

... replaced the cached full_text_html entry (3005 characters of article text)
with a much shorter one: 500 characters of full_text_pdf.

test_the_shrink_report_quotes_the_numbers_it_decided_on asserts both figures and asserts the whole-record ones are absent, which is the gap you identified — the previous test only checked the string "much shorter" appeared.

2. The docs denied their own warning

Fixed, and this was the one I would have been most annoyed to ship: a curator whose evidence stopped verifying searches the docs for the warning they just saw, and finds a passage explaining that sizes are not compared. The sentence is now qualified to the refusal, and the report has its own short section with a worked example and what to do about it — which is usually nothing, and occasionally re-fetch.

3. The CLI message

Both clauses were two rounds stale. Reworded to the rule as it now stands, and --force now says what it costs rather than describing it as accepting a shorter refresh.

Smaller things

  • _text_after_abstract's conditional claim — you are right, and the asymmetry is worth having written down: under-subtracting on the fresh side makes a report less likely, on the cached side more likely. Both decide a log line. Also pinned by a test for the no-abstract record.
  • is_stub_notice cross-reference — fixed to etl.extract.xml.is_stub_notice, in both places.
  • The dangling "see the issue linked from the PR" — replaced rather than resolved. Rather than file an issue and cite a number, the docstring now says where the fix belongs and names the test that pins the gap, so it stays true after the merge with nothing to chase.
  • allow_stale's docstring — corrected, and _warn_forced_discard added as the third caller.
  • The public-cache qualifier on the invariant — added.
  • The OA path's symmetric note — added to OpenAlexProvider.locate. You are right that "we only ask for a file" constrains the request and not the response, and that the HTML extractor's landing-page rejection plus html_full_text_version is what makes the sniffed-HTML case safe. Worth saying where _fetch_pmc_html says the same thing.

1095 passing with live-network tests deselected, mypy, Ruff, 126 doctests and mkdocs build --strict clean, against the pushed tree.

Ten rounds in, this is the first with no correctness finding, and the guard is now one rule plus one advisory line. Unless something surfaces here, I would merge on this one.

@cmungall
cmungall force-pushed the fix/no-scraping-no-bronze branch from 72c2a3e to 992d02a Compare September 18, 2026 01:33
@claude

claude Bot commented Sep 18, 2026 •

Copy link
Copy Markdown
Contributor

Claude finished @cmungall's task in 5m 21s —— View job


Review: Fetch only openly-licensed files, and never demote a cache entry

  • Read the diff against origin/main at the new head
  • Re-check the findings from the previous pass
  • Review the guard, the reporter and the OA policy
  • Review test coverage
  • Post review

All three of the previous pass's findings are closed. full_text_attempted is no longer set when the chain declined something — FullTextLocation.declined distinguishes "we refused this" from "there is none", both providers emit it for a landing-page-only record, the access_type gate sets the same flag, and test_a_bronze_decline_... / test_a_landing_page_only_record_... pin both arrivals. The cache reference remedy now says which of the two cases --force resolves and which it cannot, which is the honest version. _RefreshLoss is single-argument and its docstring no longer claims a comparison.

The two rules either side of the guard are unchanged, and the guard itself is one line with its history in the docstring. I have nothing further on it.

The new finding is in the flag fix — which I think is the right change, and stops one step short of the case that motivated the whole PR.


1. The declined rule is applied to the OA indexes and not to PMC, where the same laundering still happens — and PMC is the host in the bug report

_enrich_with_full_text now has three outcomes and three states, which is right. But a provider can only reach the declined state by returning a location, and PMCFullTextProvider.locate returns a bare None for every failure it has:

  • _resolve_pmcid (pmc.py:86-88) catches Exception, logs "Failed to link PMID:%s to PMC", and returns None. An Entrez outage during elink is therefore indistinguishable from "this PMID has no PMC copy".
  • _fetch_pmc_html (pmc.py:130-131) returns None on any non-200 — including the 429 that PMC answers a rate-limited client with.

Both land at locate → None → _enrich_with_full_text:682 continue → no had_error, no declined_on_policy → full_text_attempted = True at line 727. The record is then saved with the current stamp, so fetch:303 returns it from disk and _maybe_retry_full_text:628 short-circuits on the flag. That reference is not looked at again until EXTRACTOR_CACHE_VERSION moves.

So: an abstract_only PMID whose article does have a PMC body, fetched during a rate-limit window, is permanently recorded as having no full text. That is the PR's own sentence — "the failure was recorded as a fact about the article" — in the provider whose reCAPTCHA interstitial is the PR's evidence. _preserve_cached_full_text covers the case where something was already cached; this is the case where nothing was, which the PR body correctly says is where "failing loses nothing" — except that it now fails once, permanently, where before #62's stamp it also did.

Pre-existing, so not a regression, and I would not block on it. But this PR is the one that states the rule ("only a genuine absence may set it") and builds the vocabulary to express the exception, and the cheapest half is small and unambiguous: a non-200 from _fetch_pmc_html and a swallowed elink exception are transient failures, not absences, and should reach had_error the way a raised locate already does. The 200-with-no-article-body case is genuinely undecidable and can stay as it is. Fix this →

2. cache enrich does not know about declined, so it counts a refusal as a find

locate_full_text (reference_fetcher.py:731-746) is documented as "the inventory primitive used by cache enrich --dry-run" and returns the provider's location unfiltered. enrich_command tests only location is None (cli/cache.py:307), so a declined location — no url, no text — falls through to found += 1 at line 311.

With --provider openalex or --provider unpaywall, a landing-page-only reference now prints

DOI:10.1/x	found	openalex:-

in a dry run, and unusable under --apply (_materialize:847 returns nothing for a URL-less location, so nothing crashes and nothing is written). Found: N is inflated by exactly the references the provider refused. The default provider is zotero, which never declines, so this is confined to the two index providers — but those are selectable and the command's entire output is an inventory, which is now wrong for them.

One clause next to the is None check, reported as its own row (declined rather than not_found, since the distinction is the whole point of the new field), settles it. Nothing in either new test file exercises enrich with a declined location. Fix this →

3. The cost of leaving the flag unset is stated as per-process; it is per-run, permanent, and now applies to a large population

The comment at reference_fetcher.py:725-726 justifies the change with "one chain re-run per process for those references, which is the trade had_error already makes." The trade is the same in shape and not in size.

had_error fires on a transient failure, so it clears itself on the next run. A policy decline never clears: bronze stays bronze and a page-only record stays page-only until the article's OA status actually changes, which may be never. So every run re-enters _maybe_retry_full_text:633 for every such reference and walks the whole chain — pmc, epmc_preprint, unpaywall, openalex by default. Each provider opens with time.sleep(config.rate_limit_delay) (0.5s default), so a bronze DOI costs on the order of two seconds of sleep plus up to four HTTP requests, on every validation run, forever. Bronze and landing-page-only together are a large share of OA records — the PR body says so itself, as the reason those references now resolve abstract_only.

Two things follow. The obvious one is wall clock on a corpus. The less obvious one is that the PR's own diagnosis is that rate limiting is what produces the interstitial that produces the bad fetch — so permanently raising steady-state query volume against those hosts pushes on the mechanism the change exists to defend against. That is the self-reinforcing loop noted a few rounds ago, arriving through a second door.

Keeping the record retryable is right; retrying it every run is a stronger claim than the finding needed. Recording the decline in metadata against the extractor version — retry once per version bump, not once per run — keeps the correctness and drops the cost. At minimum, this belongs in docs/troubleshooting.md and the PR's "Behaviour changes to expect" list, where it currently appears in neither. Fix this →


Smaller things

  • test_a_genuine_absence_is_still_recorded does not test what it names. tests/test_open_access_policy.py:474 patches FullTextProviderRegistry.get to return None, so every provider is skipped at reference_fetcher.py:671 as unregistered and none is ever consulted. Its docstring says "A provider that ran cleanly and found nothing at all — no location, no decline", and it would pass unchanged if location is None also set declined_on_policy. A MagicMock whose locate returns None — the shape _fetch_with_location already provides — tests the stated claim. It's the only test guarding the flag's positive case, which is the thing keeping the chain from re-running forever.
  • FullTextLocation's class docstring still describes two forms. models.py:721-722: "A provider returns either a downloadable url (PDF/HTML/XML) or inline text it has already extracted." There is now a third, carrying neither, and it is the one a reader needs warning about before writing location.url. The field comment says it well; the class docstring is where someone looks first, and its doctest would take one line.
  • The access_type gate also declines Zotero. reference_fetcher.py:695-702 sets declined_on_policy for any non-open type, so user_library triggers it too. That is defensible — a private-library hit is not evidence the public web has nothing — but the comment at 712-728 names only "a bronze PDF refused on licence, or a landing page", so a reader with zotero configured will not expect their whole cache to become permanently retryable. One clause.
  • oa_status and licence come from different objects and are assigned adjacently. openalex.py:93-94 reads the status from the work-level open_access and the licence from best_oa_location; unpaywall.py:75-76 has the same split. That mismatch is exactly what access_type_for_oa_status's "known miss" paragraph is about, and the two lines that create it say nothing. A trailing comment on either line would connect them.
  • The cache reference failure message is now five sentences with a parenthetical. Every clause is true, which was the hard part and took three rounds. It is also the longest string in the CLI, for a condition a user may hit on many references in one run. Not a change request — just noting that "true of both paths" and "short" pulled against each other here and truth won, which is the right winner.

Tests

test_open_access_policy.py now covers the licence vocabulary across its middle, both providers' landing-page refusal, the bronze/green/unknown status matrix, the join between the mark and the gate, the full_text_url allowlist including an unknown fifth value, and both new arrivals at the full_text_attempted flag. test_no_cache_downgrade.py covers the deletion claim per type, the serve/keep split, both served_stale branches, --force's warning and its cost on the file, the four migrations that must not be refused, the accepted cover-page gap with an instruction to invert it, and — since last round — the numbers inside the warning and the abstract prefix built the way production builds it.

Gaps map onto the findings: nothing covers a PMC transient failure's effect on the flag (finding 1), nothing covers cache enrich with a declined location (finding 2), and nothing pins how often a declined reference re-consults the chain (finding 3).

I could not run the suite — uv run pytest and a bare python3 -V probe were both declined by the sandbox, as on the previous seven passes. Everything above is read off the source. Findings 2 and 3 are direct reads (cli/cache.py:307-311 against the new declined field; the comment at reference_fetcher.py:725 against _maybe_retry_full_text). Finding 1 is a traced path — pmc.py:130 → locate → _enrich_with_full_text:682 → :727 → _maybe_retry_full_text:628 — not an observed run, so on the record of the last several rounds it is worth reproducing before acting on it.
· branch fix/no-scraping-no-bronze

@cmungall

Copy link
Copy Markdown
Member Author

Finding 1 is the sharpest of the eleven rounds, and it lands squarely on this change's own argument. I had said I would merge on the last round; that was premature.

full_text_attempted means "a clean run concluded none is available" and _maybe_retry_full_text never runs the chain again once it is set. Folding a licence decline or a refused landing page into it records a decision this project made as a fact about the article — which is the defect the whole PR is about, one layer up. And as you say, both are new arrivals: this change is what routes them there.

The consequence for the repository that prompted this is concrete. It has ~600 bronze entries; under the behaviour I was about to ship, every one would have been stamped "no full text available" and never looked at again, including the ones that convert to gold.

The fix

Both cases now leave the flag unset, alongside had_error. The landing-page half needed a small model change, which is worth explaining rather than just listing:

The bronze case is easy — the location is in hand and gets declined at the access_type check. But for a landing page the provider had already returned a bare None, and the chain cannot distinguish "I found a candidate and refused it" from "there is nothing here". So FullTextLocation gained a declined field. A declined location carries no url and no text, so nothing is ever fetched; it exists so the decision is legible to the chain rather than inferred from an absence.

Tests: a bronze-only DOI and a landing-page-only DOI are each cached abstract_only without the flag, and a provider that ran cleanly and found nothing still sets it — the flag has to keep meaning something or the chain re-runs forever.

What that broke, which is worth reporting

Three of my own tests asserted locate(...) is None — the mechanism rather than the behaviour. Two I caught while making the change; the third, test_locate_does_not_fall_back_to_oa_url in test_fulltext_providers.py, is the one I inverted back in round 1 and missed because it lives in a different file. My suite caught it. All three now assert what actually matters: no url, no text, nothing fetchable.

I swept the rest — the remaining locate(...) is None assertions are genuine no-candidate cases (no DOI, is_oa: false, no PMCID) and are correct as they stand.

2. The --force remedy

Right, and the asymmetry is the point: on the preserve path there is a refresh result to install, and on _stale_fallback's path there is nothing, so the sentence offered a remedy that does not exist to precisely the user whose source is down. Reworded to say which case it resolves and why it cannot resolve the other.

3. _RefreshLoss

Dead parameter since the last round's change. Removed, along with the __slots__ entry.

1098 passing with live-network tests deselected, mypy, Ruff, 208 doctests and mkdocs build --strict clean, against the pushed tree.

I will stop forecasting merge-readiness — that is twice now — and just report what each round turns up.

Three changes, one policy: material a user may lawfully read is not
automatically material a project may redistribute, and a checked-in reference
cache is redistribution. That is the contract PR #61 drew for Zotero private
libraries; OpenAlex and Unpaywall were not honouring it.

Do not scrape landing pages. Both providers used `pdf_url or landing_url`, so a
record with no file fell back to the article *page* and downloaded it. Hosts
refuse this: PMC answers a rate-limited request with a reCAPTCHA interstitial
served on an HTTP 200, so no status check downstream can tell it from article
text. Measured on PMID:9177246, the same fetch returned real full text on 2 of
5 consecutive attempts; the other 3 cached the bot page's rejection as
"no full text exists". Providers now return None when they have only a page.

Bronze is not open. `oa_status: bronze` means free to read on the publisher's
site under no open licence. New `access_type_for_oa_status` maps gold/diamond/
hybrid to "open" and everything else -- bronze, unrecognised, missing -- to
"publisher_free". Nothing else was needed: `_enrich_with_full_text` already
skips non-open locations. Unknown statuses fail closed, because an unknown
licence is not a grant.

A declined location is not an absence. full_text_attempted means "a clean run
concluded no full text is available", and _maybe_retry_full_text never runs the
chain again once it is set -- so folding a licence decline or a refused landing
page into it would record a decision this project made as a fact about the
article, which is this change's own thesis one layer up. Bronze-to-gold is a
conversion publishers make, and a page-only DOI gains a repository PDF; neither
would ever be looked at again. Both now record a `full_text_declined` reason instead of the absence flag.
Recording rather than leaving nothing is the difference between retryable and
retried: a decline never clears the way a transient error does, so an unset flag
would re-walk the four-provider chain on every run for every such reference --
measured on one real corpus, 23,465 eligible entries and roughly thirteen hours
of rate-limit sleep per run, aimed at the hosts whose rate limiting produces the
interstitial this change defends against. The decline is re-examined when the
entry is next re-fetched.

The same laundering is fixed in PMC, which is the host in the original report: a
429 and a swallowed elink exception both returned None, which the chain read as
a clean absence, so an article with a PMC body fetched during a rate-limit window
was permanently recorded as having none. Both now raise into the existing
had_error path; the undecidable 200-with-no-body case is unchanged. FullTextLocation gained a `declined` field so a provider can
say it refused a candidate rather than returning a bare None, which the chain
cannot tell from finding nothing; a declined location carries no url or text and
is never fetched.

The licence rule governs files found by asking an OA index. It deliberately
does not govern the pmc and epmc_preprint providers, which ask an archive's own
API for a document it serves for machine retrieval -- a stronger warrant than an
index's summary of a third-party host, and routing them through it would decline
most PMID full text for want of a licence field their API does not return. The
same green deposit can be declined from OpenAlex and admitted from PMC; the
asymmetry is recorded rather than left implicit. One page fetch also survives, in
the pmc provider, and is safe for a reason the oa_url fallback never had: it
returns text only from a div.article-body or div.tsec, which an interstitial
does not carry.

Green is decided by its licence, not by the status. The status names a
repository rather than a permission -- a PMC author manuscript is free to read
under a funder policy whose redistribution terms vary by publisher, which is the
same shape of claim as bronze with a different host. A green location is open
when it states a licence and not when it merely states its address. "States a
licence" is an allowlist, not a non-empty field: OpenAlex's vocabulary includes
other-oa (5.9M works) and publisher-specific-oa, and Unpaywall documents
implied-oa, all of which are truthy strings naming the *absence* of a licence
statement -- and a bare funder-policy repository deposit is exactly where they
appear, so a truthiness test would reopen the route this rule closes.

The source URL is recorded for an allowlist of access types (unset, open,
publisher_free) rather than suppressed for a denylist, so an access type this
version has not heard of stays suppressed -- the same fail-closed default this
change applies to an unknown oa_status and an unknown licence. A non-open access
type no longer discards the source URL. That rule was written
for a private-library endpoint, where a localhost Zotero attachment address is
session-specific and meaningless elsewhere; a publisher link is a stable public
address, and what licensing keeps out of the public cache is the text rather
than where it came from. A known miss
is recorded in the docstring: a work's oa_status is its best across locations,
so a work marked bronze may carry an openly-licensed repository copy this code
never inspects.

A refresh may improve an entry; it may never demote one. Without this the first
change would itself destroy data: the #62 extractor stamp makes every pre-stamp
entry stale, so the next run re-fetches, and with scraping removed those entries
would come back abstract-only and overwrite themselves. `_stale_fallback` does
not cover it -- that fires only when the source yields nothing, and here the
abstract is something. `_preserve_cached_full_text` refuses a write that comes
back with no full text at all, leaves the entry stale so a later run retries,
and steps aside for `force_refresh`, which warns before it discards.

The rule is about kind, not size. An earlier revision of this branch also
refused a refresh whose text was a fraction of the cached length, to catch a PDF
whose text layer is a publisher cover sheet. It caught that one case and
mis-handled four others -- a scraped page replaced by a clean XML body, by a
clean HTML body, a plain-text API body replaced by XML, and a re-extraction that
merely trimmed a trailing section -- because a shorter extraction is usually a
better one and a length comparison cannot tell those apart. Every one of those
failed in the worse direction: a refused entry is never written, so it is never
stamped, so every later run re-fetched and re-refused it. Judging whether text
*is* an article belongs in the acceptance layer, alongside is_stub_notice, where
a wrong answer costs one skipped fetch rather than a cache that can never
migrate. The cover-page gap is recorded by a test rather than left implicit.

Not refusing on size is not the same as not mentioning it. A refusal is
permanent -- never written, never stamped, re-refused every run -- while a log
line lets the write proceed and costs one line if it is wrong. So a refresh that
keeps full text but returns a small fraction of the cached length is reported and
written, which restores the observability the ratio provided incidentally
without restoring the harm it caused. It is reported only on the branch that
writes, since a report of a write is false anywhere else, and it measures the
text after the abstract both records carry, since a median abstract alone clears
a fifth of a short article. Every message quotes that same subtracted length, so
the figures a reader sees are the figures the threshold compared.

Keeping an entry and serving it are separate questions, and the method asks
both. Whether it may be overwritten is asked with allow_stale_html=True, because
a stale HTML entry is still somebody's data. Whether its text may be served as
evidence is asked without that bypass, so _load_from_disk's refusal of stale
HTML -- "it may be a repository landing page" -- still holds. A pre-fix entry
scraped from a landing page is therefore kept on disk and withheld from
validation, which falls back to the freshly fetched abstract.

Built on #84: the guard returns a FetchOutcome, and both of its branches set
served_stale. That command's message now covers both paths -- it could not be
re-fetched, or the refresh was refused -- rather than naming only the first,
which was untrue in the new branch. A preserved entry makes it exit 1 on every
run until --force, which the docs now say.

Verified end to end on a real cache entry: an 18,464-character `full_text_html`
record survives a refresh that returns only an abstract, the file on disk is
untouched, and the loss is a WARNING rather than a silent rewrite.

Tests: 17 new across two files, written before the implementation.
`test_locate_falls_back_to_oa_url` asserted the scraping this removes; it is
inverted rather than deleted, so the file records why a landing page is not a
located full text.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@cmungall
cmungall force-pushed the fix/no-scraping-no-bronze branch from 992d02a to 467a8d2 Compare September 18, 2026 01:50
@claude

claude Bot commented Sep 18, 2026 •

Copy link
Copy Markdown
Contributor

Claude finished @cmungall's task in 4m 0s —— View job


Review: Fetch only openly-licensed files, and never demote a cache entry

  • Read the diff against origin/main at the new head
  • Re-check the findings from the previous pass
  • Review the new declined field and the flag logic
  • Review test coverage
  • Post review

All three of the previous pass's findings are closed. PMCFullTextProvider now raises TransientFullTextError for an elink outage and a non-200, so the chain's had_error path keeps the record retryable — and doing it by raising into the existing handler, rather than inventing a second signal, is the smaller change. enrich_command reports declined as its own outcome so Found: is not inflated. And finding 3 you went past documenting: full_text_declined makes the decline retryable-at-version-bump rather than retried-every-run, which is the answer I was reaching for, and the 23,465-entry / thirteen-hours-of-sleep measurement is the argument for it.

Three things, all in the interaction between the two new signals.


1. A decline and a transient error in the same run: the decline wins, so the transient failure stops being retried

_enrich_with_full_text:728-740:

if declined_on_policy:
    content.full_text_declined = declined_on_policy
elif not had_error:
    content.full_text_attempted = True

had_error is not consulted when anything was declined. So a run in which one provider failed transiently and another declined on policy records the decline — and _maybe_retry_full_text:627 short-circuits on content.full_text_declined, so the chain is not re-walked until EXTRACTOR_CACHE_VERSION moves. The transient failure has been folded into a decision, which is the same laundering one layer further in: a decline is a durable fact about policy, an outage is not, and the record now carries the durable one.

The combination is not exotic — it is the motivating article. Default chain is ["pmc", "epmc_preprint", "unpaywall", "openalex"] (models.py:514). For a PMID that also has a DOI, fetched inside a rate-limit window:

  • pmc raises TransientFullTextError (the new 429 path) → had_error = True;
  • unpaywall/openalex see a landing-page-only record → declined = "landing_page_only";
  • written with full_text_declined, current stamp, chain not re-entered.

The PMC body that was one un-rate-limited run away is now out of reach until a version bump. That is the defect this commit's other half exists to prevent, arriving through the half that was added alongside it.

One line — if declined_on_policy and not had_error: — leaves the record fully retryable when the run was not clean, which is what had_error has always meant, and costs nothing in the common case where no provider failed. test_a_transient_pmc_failure_is_not_recorded_as_an_absence uses a single-provider chain, so nothing currently exercises the two signals together. Fix this →

2. A decline reached through the retry path is computed and thrown away, so those entries re-walk the chain every run anyway

_maybe_retry_full_text:633-636 decides whether to persist by comparing a three-field tuple:

before = (content.content, content.content_type, content.full_text_attempted)
content = self._enrich_with_full_text(content)
after = (content.content, content.content_type, content.full_text_attempted)
if after != before:
    self._save_by_access(content)

full_text_declined is not in it. A retry whose only outcome is a decline leaves all three fields unchanged, so after == before and nothing is written. The marker exists in memory for the rest of the process and is gone on the next run, which re-loads the entry, re-enters the chain, re-declines, and discards it again — the exact per-run re-walk the field was added to stop, minus the memory cache.

The population is specific but is the one this release creates. An entry written with had_error true carries neither flag and a current stamp, so fetch:303 returns it from disk and _maybe_retry_full_text is the only route the chain reaches for it. Those are precisely the entries whose migration run hit a rate-limit window — the same window that produces the declines. test_a_decline_is_remembered_so_the_chain_is_not_rewalked_every_run starts from an empty cache, so it exercises fetch's own _save_by_access and never this path.

Adding content.full_text_declined to both tuples is the fix; the tuple is already the "did anything worth persisting change" predicate and this is now something worth persisting. Fix this →

3. A 404 from the PMC article page is now a transient failure, and a transient failure is retried forever

_fetch_pmc_html:144-152 raises on any non-200. The comment argues the 429 case, which is right. But the same branch now catches the permanent ones: a PMCID whose article page is withdrawn, embargoed, or simply absent answers 404 or 403, and that is a fact about the article, not about the connection.

The consequence is the mirror of the bug being fixed, and it has the shape the PR spent several rounds eliminating elsewhere. had_error leaves full_text_attempted unset and (correctly) sets no decline, so the record is re-fetched on every run, forever: elink sleep, efetch sleep, page fetch, 404, discard. That is the per-run chain re-walk that finding 3 of the last round measured at thirteen hours on your corpus, reached by a different door — and unlike a decline it has no marker to stop it, because it is classified as transient.

The status code already distinguishes them: 429 and 5xx (and arguably 403, which PMC uses when blocking) are "ask me again"; 404 and 410 are "there is no page here" and should return None as before, letting locate fall through to a clean absence. Nothing currently tests a non-429 non-200. Fix this →


Smaller things

  • Half of test_a_transient_pmc_failure_is_not_recorded_as_an_absence does not test the change. The elink_outage case patches _resolve_pmcid itself with side_effect=RuntimeError (tests/test_open_access_policy.py:637-643), so the new raise TransientFullTextError inside that method never executes — the test passes identically against the old return None. It asserts the chain's pre-existing had_error behaviour, not the fix. The rate_limit half does exercise the real code (it patches requests.get and lets _fetch_pmc_html run), which is the shape the other half wants: patch Entrez.elink to raise and let _resolve_pmcid decide. Same class as test_a_verified_html_entry_keeps_the_strict_bar from round 8 — a test whose name asserts the new policy while passing under the old one.
  • TransientFullTextError lives in pmc.py but its docstring describes a chain-wide contract ("how a provider says 'ask me again'"), and any other provider adopting it would import from pmc. base.py is where the other shared vocabulary of this PR ended up (access_type_for_oa_status, states_a_licence), and it is where a provider author will look.
  • _maybe_retry_full_text's docstring still lists three reasons it leaves a record alone; there are now four. The new one is the least self-evident of them, and it is the one with a version-bump escape hatch worth naming.
  • cache enrich prints a declined row but no Declined: total. Scanned:, Found:, Applied: and Errors: all get one. For a command whose output is an inventory, the count that says "N references were refused on policy" is the headline number of this release, and it is the one a user has to grep -c for.
  • locate_full_text's docstring explains that it deliberately ignores full_text_attempted; it now also returns declined locations to its caller, which is new and is what finding 2 of last round was about. One clause pointing at the declined field would save the next caller from writing location.url unguarded — FullTextLocation's own docstring now says "check declined before reading url", and this is the method that hands one back.
  • The SELF_EVIDENTLY_OPEN_OA_STATUSES scope paragraph, states_a_licence's allowlist rationale, _fetch_pmc_html's structural-defence docstring, _refresh_loses_full_text's account of why the size rule was removed, and full_text_declined's field comment on why a decision must not be recorded as a fact — these are the five places a future reader needs, and all five say why rather than what. The _refresh_loses_full_text docstring in particular is the best artefact of the eleven rounds: it is the only record that the four blocked migrations happened.

Tests

test_open_access_policy.py now covers the licence vocabulary across its middle, both providers' landing-page refusal as a declined location rather than an absence, the bronze/green/unknown status matrix, the join between the mark and the gate, the full_text_url allowlist with an unknown fifth value, both new arrivals at full_text_attempted, the decline surviving a second run, and cache enrich not counting a refusal as a find. test_no_cache_downgrade.py is unchanged and still matches the one-line rule.

Gaps map onto the findings: nothing runs a chain where one provider errors and another declines (finding 1), nothing reaches the chain through _maybe_retry_full_text with a declining provider (finding 2), and nothing exercises a non-429 non-200 from PMC (finding 3).

I could not run the suite — uv run pytest and a bare python3 -c probe were both declined by the sandbox, as on the previous eight passes. Everything above is read off the source. Findings 1 and 2 are direct reads (reference_fetcher.py:728-740; the tuple at :633-636 against the field written at :738); finding 3 is a traced consequence of the new raise rather than an observed run, so on the record of the last several rounds it is worth confirming with a 404 before acting on it.
· branch fix/no-scraping-no-bronze

@cmungall

Copy link
Copy Markdown
Member Author

Finding 3 is the one that mattered, and it is a cost in my last change rather than a pre-existing one. I measured it against the corpus that prompted this work rather than reasoning about it:

references_cache/
  entries that need full text        38,861
    ...currently flagged attempted   23,465

At four providers and a 0.5s opening sleep each, leaving the flag unset makes those 23,465 entries re-walk the chain on every run: roughly thirteen hours of sleep alone per validation, before the ~94,000 HTTP requests — aimed, as you say, at exactly the hosts whose rate limiting produces the interstitial this change exists to defend against. I would have shipped something that made that repository's validation unusable and fed the failure it fixes.

Taken your remedy: the decline is recorded as full_text_declined: <reason> on the cache entry and re-examined when the entry is next re-fetched. Retryable, not retried. Two tests: two successive fetches in separate fetchers consult the chain once, and a recorded decline still reads as a decline rather than as an absence.

1. PMC

Reproduced before acting, per your note:

after a PMC 429:
   full_text_attempted = True    <- "no full text exists"

A rate limit recorded as a fact about the article, in the host whose interstitial is this PR's evidence. You are right that it is pre-existing and right that this is the PR that should fix it, since it is the one that states the rule and builds the vocabulary for the exception.

Took the cheap half exactly as scoped. A non-200 from _fetch_pmc_html and a swallowed elink exception now raise TransientFullTextError into the chain's existing had_error path. The 200-with-no-article-body case is genuinely undecidable and is untouched. Parametrized test for both.

2. cache enrich

Fixed: a declined location is reported as its own row rather than counted. declined rather than not_found, since the distinction is the point of the field.

Smaller things

  • test_a_genuine_absence_is_still_recorded — you were right, and it was the worst kind of wrong: it patched the registry to return None, so no provider was ever consulted, and it guarded the one case that keeps the chain from re-running forever. Now uses a provider that runs and returns None, with locate.assert_called() so it cannot silently stop exercising the path again.
  • FullTextLocation's docstring — now describes three forms and says to check declined before reading url.
  • The Zotero clause — added; a private-library hit is not evidence the public web has nothing, and a reader with zotero configured should not have to infer that.
  • The oa_status/licence split — commented at both sites, pointing at the known-miss paragraph.
  • The cache reference message length — noted, and agreed truth was the right winner. Leaving it.

CI

The 3.13 failure is the same live-network test as two rounds ago, same cause: esearch failed for GSE67472: HTTP Error 500. Different test in the same file this time (test_validate_title_mismatch_real_geo rather than ..._match_...), which is consistent with an NCBI window rather than anything in the branch.

1103 passing with live-network tests deselected, mypy, Ruff, 208 doctests and mkdocs build --strict clean, against the pushed tree.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant