Files
Edward Cheng-I Wu f38d73f8c1 feat(cache): #541 staleness advisory + opt-in live re-validation for the citation-verification cache (#557)
* feat(cache): #541 staleness advisory + opt-in live re-validation for the citation-verification cache

VerificationCache gains entry_age_days/stale_report (oldest-live-row age per
citation; ARS_CACHE_STALE_ADVISORY_DAYS threshold, default 30, 0 disables,
malformed falls back). Phase A A0.5 emits ADV-CACHE-<n> advisory rows for
stale cache-served verifications (checkpoint display via the #547 template;
never gates); ARS_CACHE_REVALIDATE=1 verifies HIGH-IMPACT-supporting stale
references live (#549 tier composition). Invalidation cascade documented at
the command + gate. Summary schema gains optional cache_age_days +
cache_stale_advisory. 5 new pytest cases; SETUP en/zh-TW document both envs.

External motivation: Ren et al. (2026, arXiv:2607.13104 §6.2.3) — scheduled
review and attenuation; staleness/inconsistency as the signature failure mode
of integrated external knowledge.

Closes #541

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hi625UBf6GX7aeWJpJpSnE

* fix(cache): #541 codex round 1 — real cache-through wiring at the gate, robustness hardening, prose aligned to executable reality

P1: verify_citation/verify_passport run cache-through by default (closes the
#182 Delta-2 forward-decl): four resolvers route via new detailed
contamination-signals wrappers (interoperable cache keys; queried_by kept on
hits; bool-only signal layer preserved byte-for-byte via require_queried_by
parametrization); CLI gains --no-cache; ARS_CACHE_REVALIDATE=1 re-verifies
stale rows live per-row at the gate. P1: the HIGH-IMPACT tier coupling is
dropped (tiers are assigned at E1, unavailable at A0; gate-level stale-row
revalidation replaces it, cost documented). P2s: naive timestamps read as UTC,
malformed rows are misses, clock-skew clamps to 0, advisory flag computed from
the emitted rounded value; conservative oldest-live-row contract stated in the
schema; checkpoint template vocabulary extended to ADV-CACHE-<n> (lock
re-hashed); cascade made unconditional; §6.2.3 framed as design inference.
CLI tests isolated from the real user cache (autouse tmp fixture).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hi625UBf6GX7aeWJpJpSnE

* fix(cache): #541 codex round 2 — omitted-argument sentinel defaults + typed hit validation for the gate

P1: verify_citation/verify_passport use an _UNSET sentinel — omission
constructs the default VerificationCache and derives revalidation from
ARS_CACHE_REVALIDATE; explicit cache=None stays the live opt-out. The
documented non-CLI gate path now caches/revalidates by default. P2: gate
callers require typed hit payloads (matched: real bool, queried_by in
{id,title}) so a malformed row can never launder into lookup_verified; the
bool signal wrapper keeps its historical looser criteria. Gate/transport test
suites gain autouse tmp-cache isolation (default-on can never touch the real
user cache) + sentinel/env-revalidation/malformed-payload cases (145 green).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hi625UBf6GX7aeWJpJpSnE

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 14:30:49 +08:00

281 lines
10 KiB
Python

#!/usr/bin/env python3
"""Tests for the persistent verification cache (Delta 2).
Spec: docs/design/2026-05-21-v3.10-182-promote-citation-gate-spec.md §2 Delta 2.
The cache is a local SQLite-backed store keyed by
(citation_key, resolver_name, query_form). All tests point
ARS_VERIFICATION_CACHE_PATH at a tmp file so no real ~/.cache/ars/ db is
touched and runs are isolated.
"""
from __future__ import annotations
import sqlite3
from datetime import datetime, timedelta, timezone
import pytest
@pytest.fixture()
def cache(tmp_path, monkeypatch):
from verification_cache import VerificationCache
db = tmp_path / "verification.db"
monkeypatch.setenv("ARS_VERIFICATION_CACHE_PATH", str(db))
return VerificationCache()
def test_put_then_get_round_trip(cache):
cache.put("smith2024", "crossref", "10.5555/abc", {"matched": True})
got = cache.get("smith2024", "crossref", "10.5555/abc")
assert got is not None
assert got["matched"] is True
def test_miss_returns_none(cache):
assert cache.get("nobody2024", "crossref", "10.5555/none") is None
def test_distinct_resolvers_same_citation_are_independent(cache):
cache.put("smith2024", "crossref", "10.5555/abc", {"src": "cr"})
cache.put("smith2024", "arxiv", "1706.03762", {"src": "ax"})
assert cache.get("smith2024", "crossref", "10.5555/abc")["src"] == "cr"
assert cache.get("smith2024", "arxiv", "1706.03762")["src"] == "ax"
def test_distinct_query_form_same_resolver_independent(cache):
cache.put("smith2024", "crossref", "10.5555/abc", {"via": "doi"})
cache.put("smith2024", "crossref", "smith attention 2024", {"via": "title"})
assert cache.get("smith2024", "crossref", "10.5555/abc")["via"] == "doi"
assert cache.get(
"smith2024", "crossref", "smith attention 2024"
)["via"] == "title"
def test_put_overwrites_same_key(cache):
cache.put("smith2024", "crossref", "10.5555/abc", {"matched": False})
cache.put("smith2024", "crossref", "10.5555/abc", {"matched": True})
assert cache.get("smith2024", "crossref", "10.5555/abc")["matched"] is True
def test_expired_entry_returns_none(tmp_path, monkeypatch):
"""An entry older than the TTL (90 days) is a miss."""
from verification_cache import VerificationCache, _TTL_DAYS
db = tmp_path / "verification.db"
monkeypatch.setenv("ARS_VERIFICATION_CACHE_PATH", str(db))
c = VerificationCache()
c.put("old2020", "crossref", "10.5555/old", {"matched": True})
# Backdate the stored verification_timestamp past the TTL by writing
# directly to the underlying row.
stale = (
datetime.now(timezone.utc) - timedelta(days=_TTL_DAYS + 1)
).isoformat()
conn = sqlite3.connect(str(db))
conn.execute(
"UPDATE verification_cache SET verification_timestamp = ? "
"WHERE citation_key = ?",
(stale, "old2020"),
)
conn.commit()
conn.close()
assert c.get("old2020", "crossref", "10.5555/old") is None
def test_fresh_entry_within_ttl_returns_value(tmp_path, monkeypatch):
from verification_cache import VerificationCache, _TTL_DAYS
db = tmp_path / "verification.db"
monkeypatch.setenv("ARS_VERIFICATION_CACHE_PATH", str(db))
c = VerificationCache()
c.put("recent2026", "arxiv", "2605.18661", {"matched": True})
fresh = (
datetime.now(timezone.utc) - timedelta(days=_TTL_DAYS - 1)
).isoformat()
conn = sqlite3.connect(str(db))
conn.execute(
"UPDATE verification_cache SET verification_timestamp = ? "
"WHERE citation_key = ?",
(fresh, "recent2026"),
)
conn.commit()
conn.close()
got = c.get("recent2026", "arxiv", "2605.18661")
assert got is not None and got["matched"] is True
def test_invalidate_removes_all_resolvers_for_citation(cache):
cache.put("smith2024", "crossref", "10.5555/abc", {"src": "cr"})
cache.put("smith2024", "arxiv", "1706.03762", {"src": "ax"})
cache.put("jones2024", "crossref", "10.5555/xyz", {"src": "cr2"})
cache.invalidate("smith2024")
assert cache.get("smith2024", "crossref", "10.5555/abc") is None
assert cache.get("smith2024", "arxiv", "1706.03762") is None
# Other citations untouched.
assert cache.get("jones2024", "crossref", "10.5555/xyz")["src"] == "cr2"
def test_invalidate_unknown_citation_is_noop(cache):
# Should not raise.
cache.invalidate("never-stored")
def test_env_path_override_is_honored(tmp_path, monkeypatch):
from verification_cache import VerificationCache
db = tmp_path / "custom" / "mycache.db"
monkeypatch.setenv("ARS_VERIFICATION_CACHE_PATH", str(db))
c = VerificationCache()
c.put("k", "crossref", "q", {"v": 1})
assert db.exists(), "cache file must be created at the env-override path"
def test_explicit_path_arg_overrides_env(tmp_path, monkeypatch):
from verification_cache import VerificationCache
env_db = tmp_path / "env.db"
arg_db = tmp_path / "arg.db"
monkeypatch.setenv("ARS_VERIFICATION_CACHE_PATH", str(env_db))
c = VerificationCache(path=str(arg_db))
c.put("k", "crossref", "q", {"v": 1})
assert arg_db.exists()
assert not env_db.exists(), "explicit path arg must win over env var"
def test_wal_mode_enabled(cache):
"""SQLite WAL mode for single-writer-many-readers safety (spec Delta 2)."""
conn = sqlite3.connect(cache.path)
mode = conn.execute("PRAGMA journal_mode").fetchone()[0]
conn.close()
assert mode.lower() == "wal"
def test_response_roundtrips_nested_structure(cache):
"""Resolver responses are arbitrary JSON-serializable dicts."""
payload = {
"matched": True,
"resolver_outcomes": {"crossref": {"status": "matched"}},
"title": "Attention Is All You Need",
"year": 2017,
}
cache.put("vaswani2017", "crossref", "10.5555/abc", payload)
assert cache.get("vaswani2017", "crossref", "10.5555/abc") == payload
def test_corrupted_json_payload_is_miss(tmp_path, monkeypatch):
"""#331 P3: a row whose response_json is not decodable JSON is a miss
(return None → live recompute), not a JSONDecodeError that aborts
verification. Contract: 'malformed cache payload = miss'."""
from verification_cache import VerificationCache
db = tmp_path / "verification.db"
monkeypatch.setenv("ARS_VERIFICATION_CACHE_PATH", str(db))
c = VerificationCache()
c.put("bad2024", "crossref", "10.5555/bad", {"matched": True})
conn = sqlite3.connect(str(db))
conn.execute(
"UPDATE verification_cache SET response_json = ? WHERE citation_key = ?",
("{not valid json", "bad2024"),
)
conn.commit()
conn.close()
assert c.get("bad2024", "crossref", "10.5555/bad") is None
def test_non_dict_payload_is_miss(tmp_path, monkeypatch):
"""#331 P3: a row decoding to a non-dict value (e.g. a bare list/string from
an older or manual writer) is a miss — the caller's `"matched" in cached`
join logic expects a dict. Return None rather than hand back a shape the
caller cannot read."""
from verification_cache import VerificationCache
db = tmp_path / "verification.db"
monkeypatch.setenv("ARS_VERIFICATION_CACHE_PATH", str(db))
c = VerificationCache()
c.put("list2024", "crossref", "10.5555/list", {"matched": True})
conn = sqlite3.connect(str(db))
conn.execute(
"UPDATE verification_cache SET response_json = ? WHERE citation_key = ?",
('["matched", true]', "list2024"), # valid JSON, but a list not a dict
)
conn.commit()
conn.close()
assert c.get("list2024", "crossref", "10.5555/list") is None
# ---------------------------------------------------------------------------
# #541 staleness advisory (Ren et al. arXiv:2607.13104 §6.2.3)
# ---------------------------------------------------------------------------
def _backdate(cache, citation_key, days):
"""Rewrite every row for a key to `days` days ago."""
ts = (datetime.now(timezone.utc) - timedelta(days=days)).isoformat()
with sqlite3.connect(cache.path) as conn:
conn.execute(
"UPDATE verification_cache SET verification_timestamp = ? "
"WHERE citation_key = ?", (ts, citation_key))
def test_stale_threshold_default_env_zero_and_malformed(monkeypatch):
import verification_cache as vc
monkeypatch.delenv(vc._STALE_ADVISORY_ENV, raising=False)
assert vc.stale_advisory_days() == vc._STALE_ADVISORY_DEFAULT_DAYS
monkeypatch.setenv(vc._STALE_ADVISORY_ENV, "7")
assert vc.stale_advisory_days() == 7
monkeypatch.setenv(vc._STALE_ADVISORY_ENV, "0")
assert vc.stale_advisory_days() == 0
monkeypatch.setenv(vc._STALE_ADVISORY_ENV, "not-a-number")
assert vc.stale_advisory_days() == vc._STALE_ADVISORY_DEFAULT_DAYS
monkeypatch.setenv(vc._STALE_ADVISORY_ENV, "-5")
assert vc.stale_advisory_days() == vc._STALE_ADVISORY_DEFAULT_DAYS
def test_entry_age_none_without_rows(cache):
assert cache.entry_age_days("absent") is None
def test_entry_age_oldest_live_row_wins(cache):
cache.put("k1", "crossref", "10.1/x", {"matched": True})
_backdate(cache, "k1", 40)
cache.put("k1", "openalex", "10.1/x", {"matched": True})
age = cache.entry_age_days("k1")
assert age is not None
assert age > 39 # the oldest (40d) row wins over the fresh one
def test_entry_age_ignores_expired_rows(cache):
cache.put("k2", "crossref", "10.1/y", {"matched": True})
_backdate(cache, "k2", 120) # past the 90-day TTL -> not a live row
assert cache.entry_age_days("k2") is None
def test_stale_report_flags_omits_and_disable(cache, monkeypatch):
import verification_cache as vc
monkeypatch.delenv(vc._STALE_ADVISORY_ENV, raising=False)
cache.put("fresh", "crossref", "10.1/f", {"matched": True})
cache.put("old", "crossref", "10.1/o", {"matched": True})
_backdate(cache, "old", 40)
report = cache.stale_report(["fresh", "old", "absent"])
assert "absent" not in report # nothing cache-served -> nothing to warn
assert report["fresh"]["cache_stale_advisory"] is False
assert report["old"]["cache_stale_advisory"] is True
assert report["old"]["cache_age_days"] > 39
monkeypatch.setenv(vc._STALE_ADVISORY_ENV, "0") # disabled
report = cache.stale_report(["old"])
assert report["old"]["cache_stale_advisory"] is False