mirror of
https://github.com/Imbad0202/academic-research-skills.git
synced 2026-09-14 13:51:17 +08:00
cdd48d916d
* docs: single data-flow map + DF-1..DF-3 coverage lint (#758) Add docs/DATA_FLOWS.md — one row per network touchpoint (trigger, payload class, recipient, credentials, off switch) and one row per local store (path, content, TTL, deletion), with an explicit scope statement (the Claude session itself is platform-governed; nothing publishes autonomously). Covers the four gate resolvers, the standalone Chinese-literature resolver (NOT in the gate), the consent-bound claim-standing discovery adapters, both cross-model transports (API and citation-only Codex subscription), the SessionStart update check, the manual smoke tests, and the v3.9.4 timeline bootstrap — the last one surfaced by the new lint itself on first run (it was absent from the #758 issue enumeration). Inbound links from README, SECURITY.md (in-scope exfiltration anchor), and THIRD_PARTY.md (core-suite vs third-party contrast). Lint (same-PR drift-point discipline): scripts/check_data_flows.py — DF-1 every non-test scripts/*.py importing a network module (AST scan, so no-call guards naming urllib.request in strings do not count) must be named on the map; DF-2 same for curl-invoking shell scripts; DF-3 README/SECURITY/THIRD_PARTY keep a rendered resolving inbound link (fences + HTML comments stripped with the semantics converged in the PR #768 review; consolidation into a shared helper is follow-up). 12 mutation tests; wired into spec-consistency.yml + pytest manifest. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EosnA4RdUYgbF2KmZ1DTmc * refactor: apply /simplify pass (4-agent, deduped) (#758) Doc: the four gate resolvers collapse into a 4-column sub-table under one shared trigger/payload/off-switch lead (the wide table kept only heterogeneous touchpoints); the exhaustiveness sentence is bounded to what DF-1/DF-2 actually detect (direct imports + curl; spawned-CLI and session-tooling paths held by review); "Nothing here publishes" now inherits POSITIONING.md and its not-a-runtime-guarantee qualifier; the subscription-free note is trimmed to its rationale; Related gains the SETUP bullet as the tunables authority. Coverage: docs/SETUP.md becomes the fourth DF-3-pinned inbound surface (pointer added in the cache section); the four translated READMEs mirror the README pointer; docs/DATA_FLOWS.md registers into check_spec_consistency.py relative-link validation. Lint: DF-1 module vocabulary rebuilt as the network subset of the no-call envelope FORBIDDEN_IMPORTS (deviations documented: dotted urllib.request/http.client instead of bare urllib/http; ssl excluded); scan is now recursive into scripts/ subpackages. Tunable constants in verification_cache.py gain update-both comments. Tests: the three hollow assert-baseline tests become real mutations (name-based test exemption, uncomment-curl, from-urllib idiom); recursive-scan and SETUP-surface tests added (17 total). Skipped with reason: endpoint-hostname lint (near-zero event rate, composed-URL false-fire risk); row-id shrink constant (review-owned per degradation-registry precedent); markdown-helper consolidation with check_control_availability.py (whichever PR merges second extracts the shared module — recorded in both PR bodies). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EosnA4RdUYgbF2KmZ1DTmc * fix: close codex R1 findings — 8 P2 + 4 P3 (#758) Doc accuracy (6): Chinese-literature row rewritten (callable client, no CLI; PubMed path sends the required NCBI contact email + bibliographic search coordinates); codex-transport payload names citation_context (can contain unpublished manuscript text); update check documented as one curl transfer per 24 h with redirects and the ARS_UPDATE_CHECK_REMOTE_URL override; retraction-status SQLite cache added to local stores (caller-supplied path, 30-day stale threshold, no auto-expiry); discovery adapters credentials corrected (fixed User-Agent, resolver env keys not consumed); resolver payload narrowed to identifiers + title query strings; update-check state content corrected (state label + two version strings). Lint mis-pass/mis-fire (4): DF-1/DF-2 coverage now requires the full repo-relative path (basename-substring collision closed); DF-2 is recursive over scripts/ and hooks/, recognizes path-qualified curl, and masks quoted spans before the comment strip; DF-3 strips inline code spans before link extraction (a backticked link does not render). Five mutation tests added (22 total). The code-span rule is a divergence from check_control_availability.py to be carried over at the declared helper consolidation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EosnA4RdUYgbF2KmZ1DTmc * fix: close codex R2 findings — 4 P2 + 1 P3 (#758) - DF-2 scans command-substitution bodies BEFORE quote masking, so resp="$(curl ...)" — a real network call inside double quotes — fires (mutation test added; suite now genuinely 22, correcting the prior commit message which said 22 when 21 were collected). - Map gains the Codex audit wrapper row (scripts/run_codex_audit.sh: human/CI/hook-invoked only, sends deliverable + supporting file contents through the local Codex CLI login). - Update check re-bounded: at most one SUCCESSFUL check per 24 h; a failed attempt writes no state and may retry next session. - Cache TTL wording corrected: expiry is a cache miss, not deletion; expired rows persist until invalidated or the file is deleted. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EosnA4RdUYgbF2KmZ1DTmc * fix: full comment lines execute nothing — DF-2 substitution scan (#758) The R2 command-substitution scan ran before any comment handling, so a full comment line containing $(curl ...) false-fired — surfaced by the codex R3 pass (timed out mid-review, but its transcript had already demonstrated the false fire). Comment-only lines are now skipped before the substitution scan; a $(curl) inside a trailing inline comment remains a documented accepted edge. Mutation test added (23 total). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EosnA4RdUYgbF2KmZ1DTmc * fix: close codex R3 findings — command-position curl + image links + retention wording (#758) - DF-2 rebuilt around COMMAND POSITION: curl counts only as the first non-assignment token of a segment (pipes/separators/substitution openers), so `command -v curl` preflights and `echo curl` no longer false-fire; VAR=x curl still fires; wrapper-prefixed invocations (sudo/timeout) are documented accepted edges. - DF-3 link grammar excludes image syntax —  renders no anchor and cannot keep the acceptance criterion green. - Cache retention wording includes the overwrite path: expired rows persist until overwritten by re-verification, invalidated, or the file is deleted. - Three mutation tests added (26 total). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EosnA4RdUYgbF2KmZ1DTmc * fix: close codex R4 finding — curl behind shell control words (#758) The command-position head-token scan now skips shell control words (if/elif/while/until/then/else/do/!/time/exec) before naming the head, so `if curl …; then` and `while ! curl …; do` fire while `if true; then` stays quiet. Two mutation tests (28 total). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EosnA4RdUYgbF2KmZ1DTmc * fix: close codex R5 finding — option tokens after control words (#758) `time -p curl …` / option-bearing exec forms: the head scan now skips `-`-prefixed option tokens alongside assignments and control words, so the option cannot shadow the command head. Mutation test added (29 total). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EosnA4RdUYgbF2KmZ1DTmc * fix: close codex R6 finding — harness spawned-CLI paths scoped out (#758) Three maintainer-only measurement scripts reach the network through locally authenticated CLIs (dispatch_e4_panel via claude -p, run_review_criteria_constructive_value via Codex, check_ranking_lift via gh api). They are not user-facing feature paths, so instead of diluting the touchpoint tables they are now an explicit named scope exclusion — the exhaustiveness claim no longer silently spans them. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EosnA4RdUYgbF2KmZ1DTmc * fix: close codex R7 finding — boundary count wording (#758) "Two boundaries" became three after the R6 harness exclusion; the count is removed rather than maintained. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EosnA4RdUYgbF2KmZ1DTmc --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
277 lines
11 KiB
Python
277 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
"""Persistent verification cache for the bibliographic resolvers (Delta 2).
|
|
|
|
A local SQLite-backed cache so the same paper cited across multiple drafts
|
|
does not re-hit Crossref / OpenAlex / Semantic Scholar / arXiv every run.
|
|
|
|
Cache key: (citation_key, resolver_name, query_form).
|
|
- resolver_name ∈ {crossref, openalex, semantic_scholar, arxiv}
|
|
- query_form is the canonical-form DOI / arXiv ID / title-query string the
|
|
resolver was keyed on (the caller passes whichever it used).
|
|
Cache value: the resolver's structured response (any JSON-serializable dict)
|
|
plus a verification_timestamp.
|
|
|
|
TTL: entries older than 90 days are treated as a miss. The 90-day window is a
|
|
guess pending empirical tuning (spec OQ-1, deferred).
|
|
|
|
Concurrency: SQLite WAL mode (single-writer-many-readers). The audit pipeline
|
|
is single-process; multi-user shared cache is out of scope (spec Delta 2).
|
|
|
|
Spec: docs/design/2026-05-21-v3.10-182-promote-citation-gate-spec.md §2 Delta 2.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import sqlite3
|
|
from contextlib import closing
|
|
from datetime import datetime, timedelta, timezone
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
# Spec OQ-1: 90-day window is a guess, deferred for empirical tuning.
|
|
# Also documented user-facing in docs/DATA_FLOWS.md and docs/SETUP.md —
|
|
# update both when tuning.
|
|
_TTL_DAYS = 90
|
|
|
|
# #541 staleness advisory (Ren et al. arXiv:2607.13104 §6.2.3 "scheduled review
|
|
# and attenuation"): entries older than this many days are still HITS (the TTL
|
|
# above is the only miss boundary) but carry an advisory flag so stale evidence
|
|
# is visible exactly where it is used. 0 disables the advisory. Advisory-only —
|
|
# never a gate input.
|
|
_STALE_ADVISORY_ENV = "ARS_CACHE_STALE_ADVISORY_DAYS"
|
|
# Default also documented in docs/DATA_FLOWS.md — update both when tuning.
|
|
_STALE_ADVISORY_DEFAULT_DAYS = 30
|
|
|
|
|
|
def stale_advisory_days() -> int:
|
|
"""The #541 advisory threshold in days (env-overridable; 0 disables).
|
|
|
|
A malformed or negative override falls back to the default rather than
|
|
erroring: the advisory is a convenience layer and must never break a run.
|
|
"""
|
|
raw = os.environ.get(_STALE_ADVISORY_ENV)
|
|
if raw is None:
|
|
return _STALE_ADVISORY_DEFAULT_DAYS
|
|
try:
|
|
value = int(raw)
|
|
except ValueError:
|
|
return _STALE_ADVISORY_DEFAULT_DAYS
|
|
if value < 0:
|
|
return _STALE_ADVISORY_DEFAULT_DAYS
|
|
return value
|
|
|
|
_DEFAULT_PATH = Path.home() / ".cache" / "ars" / "verification.db"
|
|
_ENV_PATH = "ARS_VERIFICATION_CACHE_PATH"
|
|
|
|
_SCHEMA = """
|
|
CREATE TABLE IF NOT EXISTS verification_cache (
|
|
citation_key TEXT NOT NULL,
|
|
resolver_name TEXT NOT NULL,
|
|
query_form TEXT NOT NULL,
|
|
response_json TEXT NOT NULL,
|
|
verification_timestamp TEXT NOT NULL,
|
|
PRIMARY KEY (citation_key, resolver_name, query_form)
|
|
)
|
|
"""
|
|
|
|
|
|
def _parse_ts(verification_timestamp: str) -> datetime | None:
|
|
"""Parse a stored ISO timestamp defensively (#541): naive timestamps
|
|
(written by older/other tools) are read as UTC; malformed strings return
|
|
None (callers treat the row as a miss / skip it — never abort)."""
|
|
try:
|
|
stored = datetime.fromisoformat(verification_timestamp)
|
|
except (ValueError, TypeError):
|
|
return None
|
|
if stored.tzinfo is None:
|
|
stored = stored.replace(tzinfo=timezone.utc)
|
|
return stored
|
|
|
|
|
|
def _resolve_path(path: str | None) -> Path:
|
|
"""Explicit arg wins over the env override, which wins over the default."""
|
|
if path is not None:
|
|
return Path(path)
|
|
env = os.environ.get(_ENV_PATH)
|
|
if env:
|
|
return Path(env)
|
|
return _DEFAULT_PATH
|
|
|
|
|
|
class VerificationCache:
|
|
"""SQLite-backed (citation_key, resolver_name, query_form) → response cache.
|
|
|
|
Single-process use. Each method opens a short-lived connection so the
|
|
cache holds no long-lived file handle across pipeline stages.
|
|
"""
|
|
|
|
def __init__(self, path: str | None = None) -> None:
|
|
self.path = str(_resolve_path(path))
|
|
Path(self.path).parent.mkdir(parents=True, exist_ok=True)
|
|
# `closing(...) as conn, conn` — the outer ctx closes the handle, the
|
|
# inner (the connection itself) commits/rolls back. Honors the
|
|
# short-lived-connection contract (no leaked handle across stages).
|
|
with closing(self._connect()) as conn, conn:
|
|
conn.execute(_SCHEMA)
|
|
|
|
def _connect(self) -> sqlite3.Connection:
|
|
conn = sqlite3.connect(self.path)
|
|
# WAL: single-writer-many-readers safety (spec Delta 2). Persistent
|
|
# journal-mode pragma — set on every connection (cheap; no-op when
|
|
# already WAL).
|
|
conn.execute("PRAGMA journal_mode=WAL")
|
|
return conn
|
|
|
|
def get(
|
|
self, citation_key: str, resolver_name: str, query_form: str,
|
|
) -> dict[str, Any] | None:
|
|
"""Return the cached response, or None on miss / expired entry.
|
|
|
|
An entry whose verification_timestamp is older than the TTL is a
|
|
miss (the caller then makes the live call and re-populates).
|
|
"""
|
|
with closing(self._connect()) as conn:
|
|
row = conn.execute(
|
|
"SELECT response_json, verification_timestamp "
|
|
"FROM verification_cache "
|
|
"WHERE citation_key = ? AND resolver_name = ? AND query_form = ?",
|
|
(citation_key, resolver_name, query_form),
|
|
).fetchone()
|
|
if row is None:
|
|
return None
|
|
response_json, ts = row
|
|
if self._is_expired(ts):
|
|
return None
|
|
# #331: a corrupted payload (not decodable) or a non-dict value (e.g.
|
|
# written by an older/other tool) is a miss, not a hard error — the
|
|
# documented contract is "malformed cache payload = miss". Returning None
|
|
# forces a clean live recompute instead of aborting verification with a
|
|
# JSONDecodeError or handing the caller a shape it cannot read.
|
|
try:
|
|
value = json.loads(response_json)
|
|
except (json.JSONDecodeError, TypeError):
|
|
return None
|
|
if not isinstance(value, dict):
|
|
return None
|
|
return value
|
|
|
|
def put(
|
|
self,
|
|
citation_key: str,
|
|
resolver_name: str,
|
|
query_form: str,
|
|
response: dict[str, Any],
|
|
) -> None:
|
|
"""Store (or overwrite) the resolver response, stamping it now (UTC)."""
|
|
now = datetime.now(timezone.utc).isoformat()
|
|
with closing(self._connect()) as conn, conn:
|
|
conn.execute(
|
|
"INSERT OR REPLACE INTO verification_cache "
|
|
"(citation_key, resolver_name, query_form, response_json, "
|
|
" verification_timestamp) VALUES (?, ?, ?, ?, ?)",
|
|
(
|
|
citation_key,
|
|
resolver_name,
|
|
query_form,
|
|
json.dumps(response),
|
|
now,
|
|
),
|
|
)
|
|
|
|
def invalidate(self, citation_key: str) -> None:
|
|
"""Drop every cached entry (all resolvers, all query forms) for a
|
|
citation. Backs the /ars-cache-invalidate command. No-op when the
|
|
citation has no cached rows."""
|
|
with closing(self._connect()) as conn, conn:
|
|
conn.execute(
|
|
"DELETE FROM verification_cache WHERE citation_key = ?",
|
|
(citation_key,),
|
|
)
|
|
|
|
def entry_age_days(self, citation_key: str) -> float | None:
|
|
"""Age in days of the OLDEST live (non-expired) cached row for a
|
|
citation, across resolvers and query forms — deliberately the most
|
|
conservative citation-level signal (#541): an unexpired row for an
|
|
obsolete query form can flag a citation whose latest verification is
|
|
fresh. That false-positive direction is accepted (advisory-only; a
|
|
stale warning that prompts one unnecessary look is cheaper than a
|
|
missed stale source). None when the citation has no live rows.
|
|
Malformed timestamps are skipped; naive timestamps are read as UTC;
|
|
clock-skewed future timestamps clamp to age 0.
|
|
"""
|
|
with closing(self._connect()) as conn:
|
|
rows = conn.execute(
|
|
"SELECT verification_timestamp FROM verification_cache "
|
|
"WHERE citation_key = ?",
|
|
(citation_key,),
|
|
).fetchall()
|
|
ages = []
|
|
now = datetime.now(timezone.utc)
|
|
for (ts,) in rows:
|
|
if self._is_expired(ts):
|
|
continue
|
|
stored = _parse_ts(ts)
|
|
if stored is None:
|
|
continue
|
|
ages.append(max(0.0, (now - stored).total_seconds() / 86400.0))
|
|
return max(ages) if ages else None
|
|
|
|
def row_age_days(
|
|
self, citation_key: str, resolver_name: str, query_form: str,
|
|
) -> float | None:
|
|
"""Age in days of ONE live cached row (#541 per-row form — backs the
|
|
stale-revalidate bypass, which must judge exactly the row it would
|
|
serve). None on miss / expired / malformed timestamp. Future
|
|
timestamps clamp to 0."""
|
|
with closing(self._connect()) as conn:
|
|
row = conn.execute(
|
|
"SELECT verification_timestamp FROM verification_cache "
|
|
"WHERE citation_key = ? AND resolver_name = ? AND query_form = ?",
|
|
(citation_key, resolver_name, query_form),
|
|
).fetchone()
|
|
if row is None or self._is_expired(row[0]):
|
|
return None
|
|
stored = _parse_ts(row[0])
|
|
if stored is None:
|
|
return None
|
|
return max(
|
|
0.0, (datetime.now(timezone.utc) - stored).total_seconds() / 86400.0
|
|
)
|
|
|
|
def stale_report(
|
|
self, citation_keys: list[str],
|
|
) -> dict[str, dict[str, Any]]:
|
|
"""Per-citation #541 staleness report for the integrity gate.
|
|
|
|
Returns {citation_key: {"cache_age_days": float,
|
|
"cache_stale_advisory": bool}} for keys that have at least one live
|
|
cached row; keys with no live rows are omitted (nothing cache-served =
|
|
nothing to warn about). `cache_stale_advisory` is True iff the
|
|
threshold is enabled (>0) and the oldest live row exceeds it.
|
|
Advisory-only: callers MUST NOT gate on this report.
|
|
"""
|
|
threshold = stale_advisory_days()
|
|
report: dict[str, dict[str, Any]] = {}
|
|
for key in citation_keys:
|
|
age = self.entry_age_days(key)
|
|
if age is None:
|
|
continue
|
|
rounded = round(age, 1)
|
|
# The flag is computed from the SAME rounded value the report
|
|
# emits, so the emitted pair is always self-consistent (a raw age
|
|
# of threshold+epsilon that rounds down to the threshold does not
|
|
# flag).
|
|
report[key] = {
|
|
"cache_age_days": rounded,
|
|
"cache_stale_advisory": bool(threshold and rounded > threshold),
|
|
}
|
|
return report
|
|
|
|
@staticmethod
|
|
def _is_expired(verification_timestamp: str) -> bool:
|
|
stored = _parse_ts(verification_timestamp)
|
|
if stored is None:
|
|
return True # malformed timestamp = expired = miss, never an abort
|
|
return datetime.now(timezone.utc) - stored > timedelta(days=_TTL_DAYS)
|