mirror of
https://github.com/Imbad0202/academic-research-skills.git
synced 2026-09-14 13:51:17 +08:00
f38d73f8c1
* 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>
281 lines
10 KiB
Python
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
|