- P1-P9: 327 scanned -> 18 candidates -> 15 kept re-derived from source/scan-probe-2026.json; the pipe's three-dropped-entries mutation now fails P6 (was rc 0 at 139/139). - B7-B10: claim_role graded against roles-expected.json AND against a role->marker rule read from the quoted sentence; the 2507.02001v1 relabel now fails B8 and B10 (was rc 0 at 169/169). - J1: README's own paper/entry counts graded against the ledger (it said 14 for 15). - run_verdict.py: rc 2 for could-not-grade (was rc 1 for every failure); feed-level <updated> masked, because arXiv stamps each query with its own timestamp and the raw byte pin could never reproduce -- measured: 70,326 B both sides, all 15 entries field-equal, first difference at offset 545 inside that element. - mutation_battery.py: scratch from --scratch/TMPDIR and removed on exit (was a hard-coded /root/.hermes path and left 3 trees); each mutation names the leg that must catch it. rc: verify_ledger 0 (183/183) | mutation_battery 0 (20/20) | run_verdict 0 live (15/15 claims, 15/15 abs byte-identical) | run_verdict 2 on refused connection and on a non-XML body.
193 lines
7.8 KiB
Python
193 lines
7.8 KiB
Python
#!/usr/bin/env python3
|
|
"""Live re-fetch verdict: does arXiv still serve exactly what the ledger pinned?
|
|
|
|
Re-fetches the Atom feed for the ledger's ids and every abstract page, compares
|
|
sha256 with the pinned bytes, and re-checks every claim sentence against the
|
|
live abstract. Needs network.
|
|
|
|
Usage: python3 run_verdict.py [ledger_dir] [--api URL] [--abs URL] [--timeout SECS]
|
|
|
|
Exit 0 = live arXiv == pinned ledger.
|
|
Exit 1 = live bytes differ from the ledger (a real drift, with the entries named).
|
|
Exit 2 = could not grade: the endpoint refused, timed out, or served a body that
|
|
is not an Atom feed. An outage must not read as a byte mismatch, which
|
|
is what this script did before queue item Q72 -- every failure, network
|
|
or content, came back as rc 1.
|
|
"""
|
|
import hashlib
|
|
import json
|
|
import re
|
|
import sys
|
|
import time
|
|
import urllib.error
|
|
import urllib.parse
|
|
import urllib.request
|
|
import xml.etree.ElementTree as ET
|
|
from pathlib import Path
|
|
|
|
NS = {"a": "http://www.w3.org/2005/Atom"}
|
|
API = "https://export.arxiv.org/api/query"
|
|
ABS = "https://export.arxiv.org/abs/"
|
|
UA = "sphinx-archive-lane/1.0 (live verdict)"
|
|
|
|
_args = sys.argv[1:]
|
|
_timeout = 90
|
|
for _a in _args:
|
|
if _a.startswith("--api="):
|
|
API = _a.split("=", 1)[1]
|
|
elif _a.startswith("--abs="):
|
|
ABS = _a.split("=", 1)[1]
|
|
elif _a.startswith("--timeout="):
|
|
_timeout = float(_a.split("=", 1)[1])
|
|
_pos = [a for a in _args if not a.startswith("--")]
|
|
ROOT = Path(_pos[0] if _pos else Path(__file__).resolve().parent).resolve()
|
|
L = json.loads((ROOT / "ledger.json").read_text())
|
|
entries = L["entries"]
|
|
|
|
|
|
class Ungradable(Exception):
|
|
"""The endpoint could not be read; nothing about the ledger was learned."""
|
|
|
|
|
|
# arXiv stamps every Atom response with the time of the request:
|
|
# <updated>2026-10-26T00:27:06Z</updated> (feed level, first <updated>)
|
|
# Two consecutive identical requests an hour apart differed in exactly that
|
|
# element and in nothing else (same 70,326 B, all 15 entries field-equal,
|
|
# first byte difference at offset 545 inside the element). A byte pin on the
|
|
# raw feed therefore fails on the second request by construction, so the live
|
|
# leg masks that one element in BOTH bodies and then demands byte equality of
|
|
# everything else -- entry order, ids, titles, summaries, dates, categories.
|
|
FEED_UPDATED = re.compile(rb"(<updated>)[^<]*(</updated>)")
|
|
|
|
|
|
def mask_feed_updated(b):
|
|
return FEED_UPDATED.sub(rb"\1<request-time>\2", b, count=1)
|
|
|
|
|
|
def fetch(url):
|
|
req = urllib.request.Request(url, headers={"User-Agent": UA})
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=_timeout) as r:
|
|
body = r.read()
|
|
except urllib.error.HTTPError as ex:
|
|
raise Ungradable(f"HTTP {ex.code} {ex.reason} from {url}")
|
|
except urllib.error.URLError as ex:
|
|
raise Ungradable(f"unreachable {url}: {ex.reason}")
|
|
except Exception as ex: # timeout, reset, TLS
|
|
raise Ungradable(f"{type(ex).__name__} reading {url}: {ex}")
|
|
if not body:
|
|
raise Ungradable(f"empty body from {url}")
|
|
return body
|
|
|
|
|
|
def atom_root(body, url):
|
|
try:
|
|
root = ET.fromstring(body)
|
|
except ET.ParseError as ex:
|
|
raise Ungradable(f"body from {url} is not XML ({ex}); {len(body)} B, starts {body[:40]!r}")
|
|
return root
|
|
|
|
|
|
def norm(s):
|
|
return re.sub(r"\s+", " ", s).strip()
|
|
|
|
|
|
def main():
|
|
fails = [] # rc 1: the live bytes disagree with the ledger
|
|
ungraded = [] # rc 2: we could not see the live bytes at all
|
|
ids = ",".join(e["arxiv_id"] for e in entries)
|
|
try:
|
|
live_atom = fetch(API + "?" + urllib.parse.urlencode({"id_list": ids, "start": 0, "max_results": len(entries) + 5}))
|
|
root = atom_root(live_atom, API)
|
|
except Ungradable as ex:
|
|
print(f" [UNGRADED] {ex}")
|
|
print("VERDICT: UNGRADED -- live arXiv could not be read; nothing claimed about the ledger")
|
|
return 2
|
|
pinned_src = next(s for s in entries[0]["sources"] if s["kind"] == "arxiv_api_atom")
|
|
try:
|
|
pinned_atom = (ROOT / pinned_src["path"]).read_bytes()
|
|
except OSError as ex:
|
|
print(f" [UNGRADED] pinned Atom unreadable: {ex}")
|
|
print("VERDICT: UNGRADED -- pinned bytes missing; nothing to compare against")
|
|
return 2
|
|
raw_live, raw_pin = hashlib.sha256(live_atom).hexdigest(), hashlib.sha256(pinned_atom).hexdigest()
|
|
ok = hashlib.sha256(mask_feed_updated(live_atom)).hexdigest() == hashlib.sha256(mask_feed_updated(pinned_atom)).hexdigest()
|
|
print(f" [{'PASS' if ok else 'FAIL'}] live Atom content identical to pinned, feed <updated> masked "
|
|
f"({raw_live[:12]} vs {raw_pin[:12]})")
|
|
if raw_live != raw_pin and ok:
|
|
print(" [note] raw bytes differ only in the feed-level <updated> stamp arXiv sets per request")
|
|
if not ok:
|
|
fails.append("atom content differs")
|
|
a, b = mask_feed_updated(live_atom), mask_feed_updated(pinned_atom)
|
|
i = next((k for k in range(min(len(a), len(b))) if a[k] != b[k]), min(len(a), len(b)))
|
|
print(f" [note] first masked difference at offset {i}: live={a[i:i + 60]!r} pinned={b[i:i + 60]!r}")
|
|
|
|
live = {}
|
|
for el in root.findall("a:entry", NS):
|
|
live[el.find("a:id", NS).text.strip().rsplit("/", 1)[-1]] = el
|
|
if not live:
|
|
print(f" [UNGRADED] live feed parsed but carries 0 entries for {len(entries)} ids")
|
|
print("VERDICT: UNGRADED -- empty live feed")
|
|
return 2
|
|
|
|
n_claim = n_abs = n_ver = 0
|
|
for e in entries:
|
|
aid = e["arxiv_id"]
|
|
el = live.get(aid)
|
|
if el is None:
|
|
print(f" [FAIL] {aid} missing from live feed")
|
|
fails.append(f"{aid} missing")
|
|
continue
|
|
if norm(e["claim_sentence"]) in norm(el.find("a:summary", NS).text):
|
|
n_claim += 1
|
|
else:
|
|
print(f" [FAIL] {aid} claim sentence not in live abstract")
|
|
fails.append(f"{aid} claim")
|
|
try:
|
|
b = fetch(ABS + aid)
|
|
except Ungradable as ex:
|
|
print(f" [UNGRADED] {aid} abs page: {ex}")
|
|
ungraded.append(f"{aid} abs unreadable")
|
|
continue
|
|
if hashlib.sha256(b).hexdigest() == next(s["sha256"] for s in e["sources"] if s["kind"] == "arxiv_abs_page"):
|
|
n_abs += 1
|
|
else:
|
|
print(f" [FAIL] {aid} live abs page differs from pinned")
|
|
fails.append(f"{aid} abs")
|
|
# newest version of the unversioned id
|
|
base = aid.split("v")[0]
|
|
try:
|
|
u = atom_root(fetch(API + "?" + urllib.parse.urlencode({"id_list": base, "start": 0, "max_results": 1})), API)
|
|
got = u.findall("a:entry", NS)
|
|
if len(got) != 1:
|
|
print(f" [note] version probe for {base} returned {len(got)} entries; not graded")
|
|
else:
|
|
gid = got[0].find("a:id", NS)
|
|
newest = gid.text.strip().rsplit("/", 1)[-1] if gid is not None and gid.text else ""
|
|
if newest and newest != aid:
|
|
print(f" [note] {aid} is not the newest version; arXiv now has {newest}")
|
|
except (Ungradable, AttributeError) as ex:
|
|
print(f" [note] version probe failed for {base}: {ex}")
|
|
time.sleep(1)
|
|
|
|
n = len(entries)
|
|
print(f" claims present live: {n_claim}/{n}")
|
|
print(f" abs pages identical live: {n_abs}/{n}")
|
|
print(f" abs pages unreadable: {len(ungraded)}")
|
|
print(f" [{'PASS' if n_claim == n else 'FAIL'}] all claims present in live abstracts")
|
|
print(f" [{'PASS' if n_abs == n and not ungraded else 'FAIL'}] all abs pages byte-identical live")
|
|
if n_claim != n:
|
|
fails.append("claims missing live")
|
|
if fails:
|
|
print("VERDICT:", f"LIVE DRIFT: {fails}")
|
|
return 1
|
|
if ungraded:
|
|
print("VERDICT:", f"UNGRADED: {ungraded} -- drift not observed, so not claimed")
|
|
return 2
|
|
print("VERDICT:", "LIVE ARXIV MATCHES LEDGER")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|