Spaces:
Running
Running
Merge origin/main into docs/session-2026-10-21
Browse filesBrings the harvest (#24, #41, #42) and the libsql fix (#45) under the redesign
stack so the session's docs can be written against both.
- .github/workflows/daily-refresh.yml +21 -1
- docs/CURRENT-STATE.md +1 -1
- scripts/daily_refresh.py +41 -6
- scripts/ingest_arxiv.py +328 -79
- scripts/ingest_backends.py +50 -5
- scripts/net_deadline.py +40 -0
- tests/test_daily_refresh.py +43 -0
- tests/test_ingest_safety.py +147 -13
- tests/test_net_deadline.py +67 -0
- tests/test_oai_harvest.py +138 -0
.github/workflows/daily-refresh.yml
CHANGED
|
@@ -18,6 +18,14 @@
|
|
| 18 |
# collide with it.
|
| 19 |
#
|
| 20 |
# Budget: ~900 papers/day encoded on CPU, roughly 10-20 minutes a run.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
|
| 22 |
name: Daily refresh
|
| 23 |
|
|
@@ -29,6 +37,12 @@ on:
|
|
| 29 |
lookback_days:
|
| 30 |
description: "Days to look back (re-runs skip stored papers)"
|
| 31 |
default: "3"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
skip_citations:
|
| 33 |
description: "Skip the citation refresh"
|
| 34 |
type: boolean
|
|
@@ -46,7 +60,9 @@ jobs:
|
|
| 46 |
refresh:
|
| 47 |
if: vars.DAILY_REFRESH == 'on'
|
| 48 |
runs-on: ubuntu-latest
|
| 49 |
-
|
|
|
|
|
|
|
| 50 |
|
| 51 |
steps:
|
| 52 |
- uses: actions/checkout@v7
|
|
@@ -75,7 +91,11 @@ jobs:
|
|
| 75 |
TURSO_DB_TOKEN: ${{ secrets.TURSO_DB_TOKEN }}
|
| 76 |
S2_API_KEY: ${{ secrets.S2_API_KEY }}
|
| 77 |
INGEST_DEVICE: cpu
|
|
|
|
|
|
|
| 78 |
run: |
|
| 79 |
python scripts/daily_refresh.py \
|
| 80 |
--lookback-days "${{ inputs.lookback_days || '3' }}" \
|
|
|
|
|
|
|
| 81 |
${{ inputs.skip_citations && '--skip-citations' || '' }}
|
|
|
|
| 18 |
# collide with it.
|
| 19 |
#
|
| 20 |
# Budget: ~900 papers/day encoded on CPU, roughly 10-20 minutes a run.
|
| 21 |
+
#
|
| 22 |
+
# Catching up after a gap: run it by hand with `today` set to a past day, a
|
| 23 |
+
# lookback covering the days since the previous run, and a longer
|
| 24 |
+
# `ingest_timeout_min`, then step `today` forward. A GitHub runner encodes about
|
| 25 |
+
# 2,400 papers an hour (measured 2026-10-20), and arXiv adds roughly 770 a day in
|
| 26 |
+
# the configured categories, so a week is about two hours: today=2026-10-07,
|
| 27 |
+
# lookback_days=9, ingest_timeout_min=240. A run that hits its limit keeps every
|
| 28 |
+
# page it finished; the next run skips them.
|
| 29 |
|
| 30 |
name: Daily refresh
|
| 31 |
|
|
|
|
| 37 |
lookback_days:
|
| 38 |
description: "Days to look back (re-runs skip stored papers)"
|
| 39 |
default: "3"
|
| 40 |
+
today:
|
| 41 |
+
description: "Run as if on this day, YYYY-MM-DD, to catch up in short windows (empty = today)"
|
| 42 |
+
default: ""
|
| 43 |
+
ingest_timeout_min:
|
| 44 |
+
description: "Fail the run if one ingest attempt takes longer (catch-up runs need more)"
|
| 45 |
+
default: "60"
|
| 46 |
skip_citations:
|
| 47 |
description: "Skip the citation refresh"
|
| 48 |
type: boolean
|
|
|
|
| 60 |
refresh:
|
| 61 |
if: vars.DAILY_REFRESH == 'on'
|
| 62 |
runs-on: ubuntu-latest
|
| 63 |
+
# A ceiling, not the stall guard: the ingest has its own limit (above) and
|
| 64 |
+
# every network call its own deadline. GitHub allows 360.
|
| 65 |
+
timeout-minutes: 300
|
| 66 |
|
| 67 |
steps:
|
| 68 |
- uses: actions/checkout@v7
|
|
|
|
| 91 |
TURSO_DB_TOKEN: ${{ secrets.TURSO_DB_TOKEN }}
|
| 92 |
S2_API_KEY: ${{ secrets.S2_API_KEY }}
|
| 93 |
INGEST_DEVICE: cpu
|
| 94 |
+
TODAY: ${{ inputs.today }} # through env, never pasted into the script
|
| 95 |
+
INGEST_TIMEOUT_MIN: ${{ inputs.ingest_timeout_min || '60' }}
|
| 96 |
run: |
|
| 97 |
python scripts/daily_refresh.py \
|
| 98 |
--lookback-days "${{ inputs.lookback_days || '3' }}" \
|
| 99 |
+
--ingest-timeout-min "$INGEST_TIMEOUT_MIN" \
|
| 100 |
+
${TODAY:+--today "$TODAY"} \
|
| 101 |
${{ inputs.skip_citations && '--skip-citations' || '' }}
|
docs/CURRENT-STATE.md
CHANGED
|
@@ -49,7 +49,7 @@ and deployed. Never copy old corpus counts or benchmark results into current cla
|
|
| 49 |
| Figure warm-up | Behind `FIGURE_PREWARM` (off, on). Reads papers and prepares their first figure before anyone opens them, from two sources: papers people were shown (every feed page and the top 5 search results call `figure_prewarm.enqueue()`; queue of 300, oldest dropped) and, every 6 hours, papers Turso dates in the last 2 days (batches of 300). Every request takes the background lane, one per 15 s at most; 800 papers a day; stops while the breaker is open; one runner per container (file lock). Queue, reads today and the cap in `/healthz/sources` | `app/figure_prewarm.py` |
|
| 50 |
| Search thumbnails | Behind `SEARCH_FIGURES` (off, preview, on). A small figure (7.5rem; 4.75rem on a phone) beside a search result whose paper has one saved, via `card_data.enrich()` (local reads only). The classic card's markup is unchanged without a figure. The thumbnail repeats the title's §3.11 link with `#sec-fig`, out of the tab order. Browser tests: `tests/test_e2e_search.py` | `partials/paper_card.html`, `app/routers/search.py` |
|
| 51 |
| Feature switches | Redesign features read `off`, `shadow`, `preview` or `on` from Space variables; `preview` shows only to a browser that visited `?preview=<PREVIEW_TOKEN>` | `app/features.py` |
|
| 52 |
-
| Corpus freshness | 1,853,777 papers through 2026-09-29 (2026-10-01). New papers go to `arxiv_recent` + Turso via `scripts/ingest_arxiv.py`; the sidecar catches up at the next publish + deploy. A daily job exists (`scripts/daily_refresh.py`, `.github/workflows/daily-refresh.yml`) but is off until `DAILY_REFRESH=on` | PHASE7 §8 |
|
| 53 |
| Citations | Semantic Scholar counts for the whole corpus, refreshed 2026-10-01 (99.47% found); the daily job refreshes the last three months | `scripts/refresh_citations.py` |
|
| 54 |
|
| 55 |
## Constants worth protecting
|
|
|
|
| 49 |
| Figure warm-up | Behind `FIGURE_PREWARM` (off, on). Reads papers and prepares their first figure before anyone opens them, from two sources: papers people were shown (every feed page and the top 5 search results call `figure_prewarm.enqueue()`; queue of 300, oldest dropped) and, every 6 hours, papers Turso dates in the last 2 days (batches of 300). Every request takes the background lane, one per 15 s at most; 800 papers a day; stops while the breaker is open; one runner per container (file lock). Queue, reads today and the cap in `/healthz/sources` | `app/figure_prewarm.py` |
|
| 50 |
| Search thumbnails | Behind `SEARCH_FIGURES` (off, preview, on). A small figure (7.5rem; 4.75rem on a phone) beside a search result whose paper has one saved, via `card_data.enrich()` (local reads only). The classic card's markup is unchanged without a figure. The thumbnail repeats the title's §3.11 link with `#sec-fig`, out of the tab order. Browser tests: `tests/test_e2e_search.py` | `partials/paper_card.html`, `app/routers/search.py` |
|
| 51 |
| Feature switches | Redesign features read `off`, `shadow`, `preview` or `on` from Space variables; `preview` shows only to a browser that visited `?preview=<PREVIEW_TOKEN>` | `app/features.py` |
|
| 52 |
+
| Corpus freshness | 1,853,777 papers through 2026-09-29 (2026-10-01). New papers go to `arxiv_recent` + Turso via `scripts/ingest_arxiv.py` (`--source oai`, arXiv's OAI-PMH feed, for the daily job; `--source search` for backfills), and the OAI path writes each paper's licence to Turso `paper_licences`. The sidecar catches up at the next publish + deploy. A daily job exists (`scripts/daily_refresh.py`, `.github/workflows/daily-refresh.yml`) but is off until `DAILY_REFRESH=on` | PHASE7 §8 |
|
| 53 |
| Citations | Semantic Scholar counts for the whole corpus, refreshed 2026-10-01 (99.47% found); the daily job refreshes the last three months | `scripts/refresh_citations.py` |
|
| 54 |
|
| 55 |
## Constants worth protecting
|
scripts/daily_refresh.py
CHANGED
|
@@ -24,7 +24,9 @@ Stages, each with a check that fails the run
|
|
| 24 |
4. verify new Qdrant points == new Turso rows == papers the ingest
|
| 25 |
reported; sampled new vectors are 1024-dim unit vectors and
|
| 26 |
their Turso rows are complete
|
| 27 |
-
5.
|
|
|
|
|
|
|
| 28 |
|
| 29 |
Re-running is always safe: the ingest skips papers already stored and point
|
| 30 |
ids are derived from the arXiv id, so overlapping runs cannot collide.
|
|
@@ -53,6 +55,9 @@ from pathlib import Path
|
|
| 53 |
import httpx
|
| 54 |
|
| 55 |
HERE = Path(__file__).resolve().parent
|
|
|
|
|
|
|
|
|
|
| 56 |
REQUIRED_ENV = ("QDRANT_RECENT_URL", "QDRANT_RECENT_API_KEY", "TURSO_URL", "TURSO_DB_TOKEN")
|
| 57 |
COLLECTION = os.getenv("QDRANT_RECENT_COLLECTION", "arxiv_recent")
|
| 58 |
|
|
@@ -147,13 +152,19 @@ def verify_counts(before: dict, after: dict, ingest_new: int | None) -> list[str
|
|
| 147 |
|
| 148 |
# ── stages ───────────────────────────────────────────────────────────────────
|
| 149 |
|
| 150 |
-
def stage_ingest(since: str, until: str) -> tuple[int | None, str]:
|
| 151 |
state = Path(tempfile.mkdtemp()) / "state.json"
|
| 152 |
-
cmd = [sys.executable, str(HERE / "ingest_arxiv.py"), "--
|
| 153 |
-
"--state", str(state)]
|
| 154 |
log = ""
|
| 155 |
for attempt in (1, 2):
|
| 156 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 157 |
log += p.stdout + p.stderr
|
| 158 |
print(p.stdout[-4000:], p.stderr[-2000:], sep="\n", flush=True)
|
| 159 |
if p.returncode == 0:
|
|
@@ -200,6 +211,27 @@ def stage_citations(st: Stores, prefix: str) -> dict:
|
|
| 200 |
return {"papers": len(ids), "found": found, "changed": len(changes)}
|
| 201 |
|
| 202 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 203 |
def stage_sample_new(st: Stores, after_rowid: int, n: int = 5) -> list[str]:
|
| 204 |
"""Spot-check up to n papers this run added."""
|
| 205 |
rows = st.t("SELECT arxiv_id, title, authors, abstract_preview, categories, primary_topic "
|
|
@@ -234,6 +266,8 @@ def main() -> int:
|
|
| 234 |
ap.add_argument("--max-recent-points", type=int, default=600_000,
|
| 235 |
help="refuse to write past this many points (free-tier headroom)")
|
| 236 |
ap.add_argument("--today", default="", help="YYYY-MM-DD, for re-running a past day")
|
|
|
|
|
|
|
| 237 |
ap.add_argument("--dry-run", action="store_true")
|
| 238 |
ap.add_argument("--skip-citations", action="store_true")
|
| 239 |
args = ap.parse_args()
|
|
@@ -257,7 +291,7 @@ def main() -> int:
|
|
| 257 |
return _finish(summary, 0)
|
| 258 |
|
| 259 |
t0 = time.time()
|
| 260 |
-
ingest_new, _log = stage_ingest(since, until)
|
| 261 |
summary["ingest"] = {"new_papers": ingest_new, "minutes": round((time.time() - t0) / 60, 1)}
|
| 262 |
|
| 263 |
if not args.skip_citations:
|
|
@@ -267,6 +301,7 @@ def main() -> int:
|
|
| 267 |
|
| 268 |
after = st.counts()
|
| 269 |
summary["after"] = after
|
|
|
|
| 270 |
problems = verify_counts(before, after, ingest_new) + stage_sample_new(st, before["turso_max_rowid"])
|
| 271 |
summary["problems"] = problems
|
| 272 |
return _finish(summary, 1 if problems else 0)
|
|
|
|
| 24 |
4. verify new Qdrant points == new Turso rows == papers the ingest
|
| 25 |
reported; sampled new vectors are 1024-dim unit vectors and
|
| 26 |
their Turso rows are complete
|
| 27 |
+
5. licences how many new papers have a `paper_licences` row, and how many
|
| 28 |
+
of those may show figures (reported, never fails the run)
|
| 29 |
+
6. summary printed as JSON, and to $GITHUB_STEP_SUMMARY when set
|
| 30 |
|
| 31 |
Re-running is always safe: the ingest skips papers already stored and point
|
| 32 |
ids are derived from the arXiv id, so overlapping runs cannot collide.
|
|
|
|
| 55 |
import httpx
|
| 56 |
|
| 57 |
HERE = Path(__file__).resolve().parent
|
| 58 |
+
sys.path.insert(0, str(HERE.parent))
|
| 59 |
+
from app import licences # noqa: E402 (standard library only)
|
| 60 |
+
|
| 61 |
REQUIRED_ENV = ("QDRANT_RECENT_URL", "QDRANT_RECENT_API_KEY", "TURSO_URL", "TURSO_DB_TOKEN")
|
| 62 |
COLLECTION = os.getenv("QDRANT_RECENT_COLLECTION", "arxiv_recent")
|
| 63 |
|
|
|
|
| 152 |
|
| 153 |
# ── stages ───────────────────────────────────────────────────────────────────
|
| 154 |
|
| 155 |
+
def stage_ingest(since: str, until: str, timeout_s: int = 3600) -> tuple[int | None, str]:
|
| 156 |
state = Path(tempfile.mkdtemp()) / "state.json"
|
| 157 |
+
cmd = [sys.executable, str(HERE / "ingest_arxiv.py"), "--source", "oai",
|
| 158 |
+
"--since", since, "--until", until, "--state", str(state)]
|
| 159 |
log = ""
|
| 160 |
for attempt in (1, 2):
|
| 161 |
+
try:
|
| 162 |
+
p = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout_s)
|
| 163 |
+
except subprocess.TimeoutExpired as e:
|
| 164 |
+
out = (e.stdout or b"").decode(errors="replace") if isinstance(e.stdout, bytes) else (e.stdout or "")
|
| 165 |
+
err = (e.stderr or b"").decode(errors="replace") if isinstance(e.stderr, bytes) else (e.stderr or "")
|
| 166 |
+
print(out[-4000:], err[-6000:], sep="\n", flush=True) # includes faulthandler stacks
|
| 167 |
+
raise SystemExit(f"ingest exceeded {timeout_s // 60} min; stacks above") from None
|
| 168 |
log += p.stdout + p.stderr
|
| 169 |
print(p.stdout[-4000:], p.stderr[-2000:], sep="\n", flush=True)
|
| 170 |
if p.returncode == 0:
|
|
|
|
| 211 |
return {"papers": len(ids), "found": found, "changed": len(changes)}
|
| 212 |
|
| 213 |
|
| 214 |
+
def licence_coverage(codes: list[str | None]) -> dict:
|
| 215 |
+
"""One code per new paper; None or "" when it has no licence row."""
|
| 216 |
+
known = [c for c in codes if c]
|
| 217 |
+
figures = sum(1 for c in known if licences.allows_figures(c))
|
| 218 |
+
n = len(codes)
|
| 219 |
+
return {"new_papers": n, "with_licence": len(known),
|
| 220 |
+
"with_licence_share": round(len(known) / n, 3) if n else None,
|
| 221 |
+
"open": sum(1 for c in known if licences.is_open(c)),
|
| 222 |
+
"allow_figures": figures, "allow_figures_share": round(figures / n, 3) if n else None,
|
| 223 |
+
"figure_policy": licences.figure_policy()}
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
def stage_licences(st: Stores, after_rowid: int) -> dict:
|
| 227 |
+
try:
|
| 228 |
+
rows = st.t("SELECT l.licence FROM papers p LEFT JOIN paper_licences l ON l.arxiv_id = p.arxiv_id "
|
| 229 |
+
"WHERE p.rowid > ?", (after_rowid,))
|
| 230 |
+
except RuntimeError as e: # no table yet: no licence has been written
|
| 231 |
+
return {"error": str(e)[:160]}
|
| 232 |
+
return licence_coverage([r[0] for r in rows])
|
| 233 |
+
|
| 234 |
+
|
| 235 |
def stage_sample_new(st: Stores, after_rowid: int, n: int = 5) -> list[str]:
|
| 236 |
"""Spot-check up to n papers this run added."""
|
| 237 |
rows = st.t("SELECT arxiv_id, title, authors, abstract_preview, categories, primary_topic "
|
|
|
|
| 266 |
ap.add_argument("--max-recent-points", type=int, default=600_000,
|
| 267 |
help="refuse to write past this many points (free-tier headroom)")
|
| 268 |
ap.add_argument("--today", default="", help="YYYY-MM-DD, for re-running a past day")
|
| 269 |
+
ap.add_argument("--ingest-timeout-min", type=int, default=60,
|
| 270 |
+
help="fail the run if one ingest attempt takes longer")
|
| 271 |
ap.add_argument("--dry-run", action="store_true")
|
| 272 |
ap.add_argument("--skip-citations", action="store_true")
|
| 273 |
args = ap.parse_args()
|
|
|
|
| 291 |
return _finish(summary, 0)
|
| 292 |
|
| 293 |
t0 = time.time()
|
| 294 |
+
ingest_new, _log = stage_ingest(since, until, args.ingest_timeout_min * 60)
|
| 295 |
summary["ingest"] = {"new_papers": ingest_new, "minutes": round((time.time() - t0) / 60, 1)}
|
| 296 |
|
| 297 |
if not args.skip_citations:
|
|
|
|
| 301 |
|
| 302 |
after = st.counts()
|
| 303 |
summary["after"] = after
|
| 304 |
+
summary["licences"] = stage_licences(st, before["turso_max_rowid"])
|
| 305 |
problems = verify_counts(before, after, ingest_new) + stage_sample_new(st, before["turso_max_rowid"])
|
| 306 |
summary["problems"] = problems
|
| 307 |
return _finish(summary, 1 if problems else 0)
|
scripts/ingest_arxiv.py
CHANGED
|
@@ -46,19 +46,50 @@ from __future__ import annotations
|
|
| 46 |
|
| 47 |
import argparse
|
| 48 |
import json
|
|
|
|
| 49 |
import os
|
| 50 |
import re
|
| 51 |
import sys
|
| 52 |
import time
|
|
|
|
| 53 |
import urllib.parse
|
| 54 |
import urllib.request
|
| 55 |
import xml.etree.ElementTree as ET
|
| 56 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
ARXIV_API = "https://export.arxiv.org/api/query"
|
| 58 |
NS = {"atom": "http://www.w3.org/2005/Atom",
|
| 59 |
"arxiv": "http://arxiv.org/schemas/atom",
|
| 60 |
"opensearch": "http://a9.com/-/spec/opensearch/1.1/"}
|
| 61 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
# arXiv asks for roughly one request every three seconds.
|
| 63 |
ARXIV_DELAY = 3.5
|
| 64 |
PAGE = 200 # results per API call; arXiv caps this well below its documented max
|
|
@@ -91,8 +122,8 @@ def parse_entry(entry) -> dict | None:
|
|
| 91 |
return None
|
| 92 |
arxiv_id = m.group(1)
|
| 93 |
|
| 94 |
-
title =
|
| 95 |
-
abstract =
|
| 96 |
if len(title) < 5 or len(abstract) < 20:
|
| 97 |
return None
|
| 98 |
|
|
@@ -120,6 +151,26 @@ def parse_entry(entry) -> dict | None:
|
|
| 120 |
}
|
| 121 |
|
| 122 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
def fetch_page(category: str, since: str, until: str, start: int) -> tuple[list[dict], int | None]:
|
| 124 |
"""One page of results. Returns (papers, total_available); total is None
|
| 125 |
when the request failed, so a failure is never mistaken for an empty
|
|
@@ -133,16 +184,16 @@ def fetch_page(category: str, since: str, until: str, start: int) -> tuple[list[
|
|
| 133 |
"sortBy": "submittedDate",
|
| 134 |
"sortOrder": "ascending",
|
| 135 |
})
|
| 136 |
-
for attempt in range(
|
| 137 |
try:
|
| 138 |
-
with
|
| 139 |
xml = r.read()
|
| 140 |
break
|
| 141 |
except Exception as e:
|
| 142 |
-
if attempt ==
|
| 143 |
print(f" [{category}] fetch failed: {str(e)[:90]}")
|
| 144 |
return [], None
|
| 145 |
-
time.sleep(
|
| 146 |
root = ET.fromstring(xml)
|
| 147 |
total_el = root.find("opensearch:totalResults", NS)
|
| 148 |
total = int(total_el.text) if total_el is not None and total_el.text else 0
|
|
@@ -162,37 +213,238 @@ def _midpoint(since: str, until: str) -> str:
|
|
| 162 |
return (a + (b - a) / 2).isoformat()
|
| 163 |
|
| 164 |
|
| 165 |
-
def
|
| 166 |
-
|
|
|
|
| 167 |
|
| 168 |
-
|
| 169 |
-
|
|
|
|
|
|
|
|
|
|
| 170 |
"""
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 179 |
|
| 180 |
|
| 181 |
def window_key(category: str, windows: list[list[str]], w: list[str]) -> str:
|
| 182 |
return category if len(windows) == 1 else f"{category}@{w[0]}..{w[1]}"
|
| 183 |
|
| 184 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 185 |
# ── Stores ───────────��───────────────────────────────────────────────────────
|
| 186 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 187 |
def turso_execute(url: str, token: str, stmts: list[dict]) -> None:
|
| 188 |
payload = json.dumps({
|
| 189 |
"requests": [{"type": "execute", "stmt": s} for s in stmts]
|
| 190 |
+ [{"type": "close"}]}).encode()
|
| 191 |
req = urllib.request.Request(
|
| 192 |
-
|
| 193 |
headers={"Authorization": f"Bearer {token}",
|
| 194 |
"Content-Type": "application/json"})
|
| 195 |
-
with urllib.request.urlopen(req, timeout=180) as r:
|
| 196 |
data = json.loads(r.read())
|
| 197 |
for res in data.get("results", []):
|
| 198 |
if res.get("type") == "error":
|
|
@@ -206,10 +458,10 @@ def turso_existing(url: str, token: str, ids: list[str]) -> set[str]:
|
|
| 206 |
payload = json.dumps({"requests": [{"type": "execute", "stmt": stmt},
|
| 207 |
{"type": "close"}]}).encode()
|
| 208 |
req = urllib.request.Request(
|
| 209 |
-
|
| 210 |
headers={"Authorization": f"Bearer {token}",
|
| 211 |
"Content-Type": "application/json"})
|
| 212 |
-
with urllib.request.urlopen(req, timeout=180) as r:
|
| 213 |
data = json.loads(r.read())
|
| 214 |
res = data["results"][0]
|
| 215 |
if res.get("type") == "error":
|
|
@@ -231,8 +483,15 @@ def main() -> int:
|
|
| 231 |
help="fetch, parse and encode, but write nothing. Validates "
|
| 232 |
"the GPU path without needing database credentials.")
|
| 233 |
ap.add_argument("--state", default="data/ingest_state.json")
|
|
|
|
|
|
|
| 234 |
args = ap.parse_args()
|
| 235 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 236 |
cats = ([c.strip() for c in args.categories.split(",") if c.strip()]
|
| 237 |
or DEFAULT_CATEGORIES)
|
| 238 |
|
|
@@ -258,68 +517,58 @@ def main() -> int:
|
|
| 258 |
|
| 259 |
seen_total = new_total = 0
|
| 260 |
t0 = time.time()
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
if
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
new_total += len(fresh)
|
| 305 |
-
start += len(papers)
|
| 306 |
-
state[key] = start
|
| 307 |
-
os.makedirs(os.path.dirname(args.state) or ".", exist_ok=True)
|
| 308 |
-
json.dump(state, open(args.state, "w"))
|
| 309 |
-
|
| 310 |
-
el = time.time() - t0
|
| 311 |
-
print(f" {key:<16} {start:>6}/{total:<7} seen={seen_total:,} "
|
| 312 |
-
f"new={new_total:,} {el/60:.1f} min", flush=True)
|
| 313 |
-
|
| 314 |
-
if start >= total or (args.limit and seen_total >= args.limit):
|
| 315 |
-
break
|
| 316 |
-
if args.limit and seen_total >= args.limit:
|
| 317 |
-
break
|
| 318 |
|
| 319 |
print(f"\ndone: examined {seen_total:,}, new {new_total:,} "
|
| 320 |
f"in {(time.time()-t0)/60:.1f} min")
|
|
|
|
|
|
|
|
|
|
| 321 |
if args.limit:
|
| 322 |
return 0
|
|
|
|
|
|
|
| 323 |
return report_coverage(cats, state)
|
| 324 |
|
| 325 |
|
|
|
|
| 46 |
|
| 47 |
import argparse
|
| 48 |
import json
|
| 49 |
+
from collections import deque
|
| 50 |
import os
|
| 51 |
import re
|
| 52 |
import sys
|
| 53 |
import time
|
| 54 |
+
import unicodedata
|
| 55 |
import urllib.parse
|
| 56 |
import urllib.request
|
| 57 |
import xml.etree.ElementTree as ET
|
| 58 |
|
| 59 |
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
| 60 |
+
sys.path.insert(1, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 61 |
+
from net_deadline import deadline # noqa: E402
|
| 62 |
+
from app.licences import code_from_url # noqa: E402 (standard library only)
|
| 63 |
+
|
| 64 |
ARXIV_API = "https://export.arxiv.org/api/query"
|
| 65 |
NS = {"atom": "http://www.w3.org/2005/Atom",
|
| 66 |
"arxiv": "http://arxiv.org/schemas/atom",
|
| 67 |
"opensearch": "http://a9.com/-/spec/opensearch/1.1/"}
|
| 68 |
|
| 69 |
+
# arXiv asks clients to identify themselves. On 2026-10-01, after a heavy night,
|
| 70 |
+
# oaipmh.arxiv.org answered ListRecords with HTTP 406 for a while (curl got 200
|
| 71 |
+
# at the same moment), then served the identical request normally: a temporary
|
| 72 |
+
# block, treated like a rate limit below.
|
| 73 |
+
USER_AGENT = "ResearchIT-ingest/1.0 (+https://github.com/siddhm11/ResearchIT-priv)"
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def _arxiv_get(url: str, timeout: int):
|
| 77 |
+
return urllib.request.urlopen(
|
| 78 |
+
urllib.request.Request(url, headers={"User-Agent": USER_AGENT, "Accept": "*/*"}),
|
| 79 |
+
timeout=timeout)
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
# Status codes that mean "slow down / blocked for now": wait and retry.
|
| 83 |
+
SOFT_BLOCK = (403, 406, 429, 503)
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def _permanent(error: Exception) -> bool:
|
| 87 |
+
"""A malformed or missing-resource request (400, 404, 422...) fails at once;
|
| 88 |
+
a soft block (SOFT_BLOCK) is waited out."""
|
| 89 |
+
code = getattr(error, "code", None)
|
| 90 |
+
return isinstance(code, int) and 400 <= code < 500 and code not in SOFT_BLOCK
|
| 91 |
+
|
| 92 |
+
|
| 93 |
# arXiv asks for roughly one request every three seconds.
|
| 94 |
ARXIV_DELAY = 3.5
|
| 95 |
PAGE = 200 # results per API call; arXiv caps this well below its documented max
|
|
|
|
| 122 |
return None
|
| 123 |
arxiv_id = m.group(1)
|
| 124 |
|
| 125 |
+
title = clean_text(_text(entry, "atom:title"))
|
| 126 |
+
abstract = clean_text(_text(entry, "atom:summary"))
|
| 127 |
if len(title) < 5 or len(abstract) < 20:
|
| 128 |
return None
|
| 129 |
|
|
|
|
| 151 |
}
|
| 152 |
|
| 153 |
|
| 154 |
+
FETCH_ATTEMPTS = 6
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def retry_wait(error: Exception, attempt: int) -> float:
|
| 158 |
+
"""Seconds to wait before retrying a failed arXiv request.
|
| 159 |
+
|
| 160 |
+
A rate limit (429/503) gets arXiv's Retry-After when it sends one, else an
|
| 161 |
+
exponential back-off from 30 s, capped at 5 min: on 2026-10-01 retries 5,
|
| 162 |
+
10 and 15 s apart failed every time once arXiv had started throttling.
|
| 163 |
+
Other errors (timeouts, resets) retry sooner.
|
| 164 |
+
"""
|
| 165 |
+
code = getattr(error, "code", None)
|
| 166 |
+
if code in SOFT_BLOCK:
|
| 167 |
+
after = (getattr(error, "headers", None) or {}).get("Retry-After", "")
|
| 168 |
+
if str(after).strip().isdigit():
|
| 169 |
+
return min(300.0, float(after))
|
| 170 |
+
return min(300.0, 30.0 * 2 ** attempt)
|
| 171 |
+
return 5.0 * (attempt + 1)
|
| 172 |
+
|
| 173 |
+
|
| 174 |
def fetch_page(category: str, since: str, until: str, start: int) -> tuple[list[dict], int | None]:
|
| 175 |
"""One page of results. Returns (papers, total_available); total is None
|
| 176 |
when the request failed, so a failure is never mistaken for an empty
|
|
|
|
| 184 |
"sortBy": "submittedDate",
|
| 185 |
"sortOrder": "ascending",
|
| 186 |
})
|
| 187 |
+
for attempt in range(FETCH_ATTEMPTS):
|
| 188 |
try:
|
| 189 |
+
with deadline(150, f"arXiv {category} @{start}"), _arxiv_get(url, 120) as r:
|
| 190 |
xml = r.read()
|
| 191 |
break
|
| 192 |
except Exception as e:
|
| 193 |
+
if _permanent(e) or attempt == FETCH_ATTEMPTS - 1:
|
| 194 |
print(f" [{category}] fetch failed: {str(e)[:90]}")
|
| 195 |
return [], None
|
| 196 |
+
time.sleep(retry_wait(e, attempt))
|
| 197 |
root = ET.fromstring(xml)
|
| 198 |
total_el = root.find("opensearch:totalResults", NS)
|
| 199 |
total = int(total_el.text) if total_el is not None and total_el.text else 0
|
|
|
|
| 213 |
return (a + (b - a) / 2).isoformat()
|
| 214 |
|
| 215 |
|
| 216 |
+
def walk(cats, since, until, state, handle_page, save_state, *, fetch=None,
|
| 217 |
+
limit: int = 0, delay: float = ARXIV_DELAY, log=print) -> tuple[int, int]:
|
| 218 |
+
"""Fetch every category's window page by page; returns (examined, new).
|
| 219 |
|
| 220 |
+
A window is split by date only when its own first page reports more than
|
| 221 |
+
MAX_RESULTS_PER_QUERY results, so small windows (the daily case) cost no
|
| 222 |
+
extra requests. Probing every category up front doubled the requests and
|
| 223 |
+
drew 19 minutes of HTTP 429 back-off from arXiv on 2026-10-01. Splits are
|
| 224 |
+
recorded in `state` so a resumed run keeps the same windows.
|
| 225 |
"""
|
| 226 |
+
fetch = fetch or fetch_page
|
| 227 |
+
queue: deque = deque()
|
| 228 |
+
for cat in cats:
|
| 229 |
+
windows = state.setdefault(f"{cat}#windows", [[since, until]])
|
| 230 |
+
queue.extend((cat, w) for w in windows)
|
| 231 |
+
seen = new = 0
|
| 232 |
+
t0 = time.time()
|
| 233 |
+
while queue:
|
| 234 |
+
cat, w = queue.popleft()
|
| 235 |
+
windows = state[f"{cat}#windows"]
|
| 236 |
+
key = window_key(cat, windows, w)
|
| 237 |
+
start = int(state.get(key, 0))
|
| 238 |
+
while True:
|
| 239 |
+
papers, total = fetch(cat, w[0], w[1], start)
|
| 240 |
+
if delay:
|
| 241 |
+
time.sleep(delay)
|
| 242 |
+
if total is not None and start == 0 and total > MAX_RESULTS_PER_QUERY:
|
| 243 |
+
mid = _midpoint(w[0], w[1])
|
| 244 |
+
if mid not in (w[0], w[1]):
|
| 245 |
+
halves = [[w[0], mid], [mid, w[1]]]
|
| 246 |
+
i = windows.index(w)
|
| 247 |
+
windows[i:i + 1] = halves
|
| 248 |
+
save_state()
|
| 249 |
+
queue.extendleft(reversed([(cat, h) for h in halves]))
|
| 250 |
+
log(f" {cat}: {total:,} results in {w[0]}..{w[1]} -> split at {mid}")
|
| 251 |
+
break
|
| 252 |
+
if total is not None:
|
| 253 |
+
state[f"{key}#total"] = total
|
| 254 |
+
if not papers:
|
| 255 |
+
break
|
| 256 |
+
seen += len(papers)
|
| 257 |
+
new += handle_page(papers)
|
| 258 |
+
start += len(papers)
|
| 259 |
+
state[key] = start
|
| 260 |
+
save_state()
|
| 261 |
+
log(f" {key:<16} {start:>6}/{total:<7} seen={seen:,} new={new:,} "
|
| 262 |
+
f"{(time.time() - t0) / 60:.1f} min")
|
| 263 |
+
if start >= (total or 0) or (limit and seen >= limit):
|
| 264 |
+
break
|
| 265 |
+
if limit and seen >= limit:
|
| 266 |
+
break
|
| 267 |
+
return seen, new
|
| 268 |
|
| 269 |
|
| 270 |
def window_key(category: str, windows: list[list[str]], w: list[str]) -> str:
|
| 271 |
return category if len(windows) == 1 else f"{category}@{w[0]}..{w[1]}"
|
| 272 |
|
| 273 |
|
| 274 |
+
# ── OAI-PMH harvest (the daily path) ─────────────────────────────────────────
|
| 275 |
+
#
|
| 276 |
+
# arXiv's OAI-PMH interface is built for incremental harvesting: everything
|
| 277 |
+
# changed between two dates, a whole archive per request stream, ~1,300
|
| 278 |
+
# records a page. A day across our seven archives is ~10-15 requests, where
|
| 279 |
+
# the search API needs one query per category (~100). Measured parity against
|
| 280 |
+
# rows written from the search API (901 overlapping papers, 2026-09-29):
|
| 281 |
+
# identical embedding text once NFKC-normalised (the search API sends U+03C6
|
| 282 |
+
# where OAI sends U+03D5) and identical categories once arXiv's alias pairs
|
| 283 |
+
# are collapsed as the search API does.
|
| 284 |
+
|
| 285 |
+
OAI_API = "https://oaipmh.arxiv.org/oai"
|
| 286 |
+
OAI_NS = {"oai": "http://www.openarchives.org/OAI/2.0/", "a": "http://arxiv.org/OAI/arXiv/"}
|
| 287 |
+
# OAI lists both halves of an alias pair; the search API keeps the canonical one.
|
| 288 |
+
CATEGORY_ALIASES = {"math.IT": "cs.IT", "cs.SY": "eess.SY", "cs.NA": "math.NA",
|
| 289 |
+
"stat.TH": "math.ST", "math.MP": "math-ph", "q-fin.EC": "econ.GN"}
|
| 290 |
+
|
| 291 |
+
|
| 292 |
+
def clean_text(text: str) -> str:
|
| 293 |
+
return unicodedata.normalize("NFKC", re.sub(r"\s+", " ", text or "").strip())
|
| 294 |
+
|
| 295 |
+
|
| 296 |
+
def canonical_categories(categories: str) -> str:
|
| 297 |
+
cats = categories.split()
|
| 298 |
+
present, out = set(cats), []
|
| 299 |
+
for c in cats:
|
| 300 |
+
if c in CATEGORY_ALIASES and CATEGORY_ALIASES[c] in present:
|
| 301 |
+
continue # canonical is listed too: keep it at its own position
|
| 302 |
+
c = CATEGORY_ALIASES.get(c, c)
|
| 303 |
+
if c not in out:
|
| 304 |
+
out.append(c)
|
| 305 |
+
return " ".join(out)
|
| 306 |
+
|
| 307 |
+
|
| 308 |
+
def oai_set(category: str) -> str:
|
| 309 |
+
"""The archive-level OAI set holding `category`."""
|
| 310 |
+
archive = category.split(".")[0]
|
| 311 |
+
if archive in ("cs", "math", "stat", "q-bio", "econ", "eess", "q-fin"):
|
| 312 |
+
return archive
|
| 313 |
+
return "physics" # hep-*, astro-ph, quant-ph, cond-mat, physics.*
|
| 314 |
+
|
| 315 |
+
|
| 316 |
+
def parse_oai_record(rec) -> dict | None:
|
| 317 |
+
header = rec.find("oai:header", OAI_NS)
|
| 318 |
+
if header is not None and header.get("status") == "deleted":
|
| 319 |
+
return None
|
| 320 |
+
m = rec.find(".//a:arXiv", OAI_NS)
|
| 321 |
+
if m is None:
|
| 322 |
+
return None
|
| 323 |
+
title = clean_text(m.findtext("a:title", "", OAI_NS))
|
| 324 |
+
abstract = clean_text(m.findtext("a:abstract", "", OAI_NS))
|
| 325 |
+
if len(title) < 5 or len(abstract) < 20:
|
| 326 |
+
return None
|
| 327 |
+
authors = []
|
| 328 |
+
for au in m.findall("a:authors/a:author", OAI_NS):
|
| 329 |
+
parts = (au.findtext("a:forenames", "", OAI_NS), au.findtext("a:keyname", "", OAI_NS),
|
| 330 |
+
au.findtext("a:suffix", "", OAI_NS))
|
| 331 |
+
authors.append(" ".join(p.strip() for p in parts if p and p.strip()))
|
| 332 |
+
cats = canonical_categories(" ".join((m.findtext("a:categories", "", OAI_NS) or "").split()))
|
| 333 |
+
licence_url = (m.findtext("a:license", "", OAI_NS) or "").strip()
|
| 334 |
+
return {
|
| 335 |
+
"arxiv_id": m.findtext("a:id", "", OAI_NS).strip(),
|
| 336 |
+
"title": title,
|
| 337 |
+
"authors": ", ".join(authors[:20]),
|
| 338 |
+
"abstract": abstract,
|
| 339 |
+
"categories": cats,
|
| 340 |
+
"primary_topic": cats.split()[0] if cats else "",
|
| 341 |
+
"update_date": (m.findtext("a:updated", "", OAI_NS) or m.findtext("a:created", "", OAI_NS) or "")[:10],
|
| 342 |
+
"licence_url": licence_url, # "" when the record names none
|
| 343 |
+
"licence": code_from_url(licence_url),
|
| 344 |
+
}
|
| 345 |
+
|
| 346 |
+
|
| 347 |
+
def fetch_oai_page(set_spec: str, since: str, until: str, token: str | None):
|
| 348 |
+
"""One ListRecords page: (papers, next_token, complete_list_size, records).
|
| 349 |
+
|
| 350 |
+
`records` counts every record on the page (deleted ones too, as arXiv's
|
| 351 |
+
completeListSize does); it is None when the request failed."""
|
| 352 |
+
params = ({"verb": "ListRecords", "resumptionToken": token} if token else
|
| 353 |
+
{"verb": "ListRecords", "metadataPrefix": "arXiv", "set": set_spec,
|
| 354 |
+
"from": since, "until": until})
|
| 355 |
+
url = f"{OAI_API}?" + urllib.parse.urlencode(params)
|
| 356 |
+
for attempt in range(FETCH_ATTEMPTS):
|
| 357 |
+
try:
|
| 358 |
+
with deadline(240, f"OAI {set_spec}"), _arxiv_get(url, 180) as r:
|
| 359 |
+
xml = r.read()
|
| 360 |
+
break
|
| 361 |
+
except Exception as e:
|
| 362 |
+
if _permanent(e) or attempt == FETCH_ATTEMPTS - 1:
|
| 363 |
+
print(f" [oai {set_spec}] fetch failed: {str(e)[:90]}")
|
| 364 |
+
return [], None, None, None
|
| 365 |
+
time.sleep(retry_wait(e, attempt))
|
| 366 |
+
root = ET.fromstring(xml)
|
| 367 |
+
err = root.find("oai:error", OAI_NS)
|
| 368 |
+
if err is not None:
|
| 369 |
+
if err.get("code") == "noRecordsMatch":
|
| 370 |
+
return [], None, 0, 0
|
| 371 |
+
print(f" [oai {set_spec}] error {err.get('code')}: {(err.text or '')[:90]}")
|
| 372 |
+
return [], None, None, None
|
| 373 |
+
papers = [p for p in (parse_oai_record(r) for r in root.iter(f"{{{OAI_NS['oai']}}}record")) if p]
|
| 374 |
+
tok = root.find(".//oai:resumptionToken", OAI_NS)
|
| 375 |
+
size = tok.get("completeListSize") if tok is not None else None
|
| 376 |
+
records = sum(1 for _ in root.iter(f"{{{OAI_NS['oai']}}}record"))
|
| 377 |
+
next_token = ((tok.text or "").strip() or None) if tok is not None else None
|
| 378 |
+
return papers, next_token, (int(size) if size and size.isdigit() else None), records
|
| 379 |
+
|
| 380 |
+
|
| 381 |
+
def harvest(cats, since: str, until: str, min_month: str, state: dict, handle_page, save_state,
|
| 382 |
+
*, fetch=None, delay: float = ARXIV_DELAY, log=print) -> tuple[int, int]:
|
| 383 |
+
"""Harvest every archive set holding `cats`; returns (examined, new).
|
| 384 |
+
|
| 385 |
+
Keeps records with at least one of `cats` and a new-style id from
|
| 386 |
+
`min_month` (YYMM) on. OAI's <created> is not the first-version date for
|
| 387 |
+
revised papers (1810.04449 showed 2026-09-10), so the id decides "new".
|
| 388 |
+
A set is complete only when its last page carries no resumption token;
|
| 389 |
+
`state["oai:<set>"]` records that for the coverage report.
|
| 390 |
+
"""
|
| 391 |
+
fetch = fetch or fetch_oai_page
|
| 392 |
+
wanted = set(cats)
|
| 393 |
+
seen = new = 0
|
| 394 |
+
for set_spec in sorted({oai_set(c) for c in cats}):
|
| 395 |
+
state[f"oai:{set_spec}"] = "started"
|
| 396 |
+
token, records, expected = None, 0, None
|
| 397 |
+
while True:
|
| 398 |
+
papers, token, size, got = fetch(set_spec, since, until, token)
|
| 399 |
+
if delay:
|
| 400 |
+
time.sleep(delay)
|
| 401 |
+
if got is None:
|
| 402 |
+
state[f"oai:{set_spec}"] = "failed"
|
| 403 |
+
break
|
| 404 |
+
records += got
|
| 405 |
+
expected = size if size is not None else expected
|
| 406 |
+
keep = [p for p in papers
|
| 407 |
+
if wanted & set(p["categories"].split())
|
| 408 |
+
and re.match(r"\d{4}\.\d{4,5}$", p["arxiv_id"]) and p["arxiv_id"][:4] >= min_month]
|
| 409 |
+
seen += len(keep)
|
| 410 |
+
if keep:
|
| 411 |
+
new += handle_page(keep)
|
| 412 |
+
log(f" oai {set_spec:<8} records={records:,}{'/' + format(expected, ',') if expected else ''} "
|
| 413 |
+
f"kept={seen:,} new={new:,}")
|
| 414 |
+
if not token:
|
| 415 |
+
ok = expected is None or records == expected
|
| 416 |
+
state[f"oai:{set_spec}"] = "done" if ok else f"short {records}/{expected}"
|
| 417 |
+
break
|
| 418 |
+
save_state()
|
| 419 |
+
return seen, new
|
| 420 |
+
|
| 421 |
+
|
| 422 |
+
def report_oai_coverage(cats, state: dict) -> int:
|
| 423 |
+
sets = sorted({oai_set(c) for c in cats})
|
| 424 |
+
bad = [f"{s}={state.get(f'oai:{s}')}" for s in sets if state.get(f"oai:{s}") != "done"]
|
| 425 |
+
if bad:
|
| 426 |
+
print(f"coverage: {len(bad)} OAI sets incomplete -> re-run: " + ", ".join(bad))
|
| 427 |
+
return 3
|
| 428 |
+
print(f"coverage: all {len(sets)} OAI sets harvested to the end")
|
| 429 |
+
return 0
|
| 430 |
+
|
| 431 |
+
|
| 432 |
# ── Stores ───────────��───────────────────────────────────────────────────────
|
| 433 |
|
| 434 |
+
def _pipeline_url(url: str) -> str:
|
| 435 |
+
"""Turso's HTTP endpoint. Turso hands out libsql:// URLs, which urllib can't open."""
|
| 436 |
+
return url.replace("libsql://", "https://", 1).rstrip("/") + "/v2/pipeline"
|
| 437 |
+
|
| 438 |
+
|
| 439 |
def turso_execute(url: str, token: str, stmts: list[dict]) -> None:
|
| 440 |
payload = json.dumps({
|
| 441 |
"requests": [{"type": "execute", "stmt": s} for s in stmts]
|
| 442 |
+ [{"type": "close"}]}).encode()
|
| 443 |
req = urllib.request.Request(
|
| 444 |
+
_pipeline_url(url), data=payload,
|
| 445 |
headers={"Authorization": f"Bearer {token}",
|
| 446 |
"Content-Type": "application/json"})
|
| 447 |
+
with deadline(210, "Turso request"), urllib.request.urlopen(req, timeout=180) as r:
|
| 448 |
data = json.loads(r.read())
|
| 449 |
for res in data.get("results", []):
|
| 450 |
if res.get("type") == "error":
|
|
|
|
| 458 |
payload = json.dumps({"requests": [{"type": "execute", "stmt": stmt},
|
| 459 |
{"type": "close"}]}).encode()
|
| 460 |
req = urllib.request.Request(
|
| 461 |
+
_pipeline_url(url), data=payload,
|
| 462 |
headers={"Authorization": f"Bearer {token}",
|
| 463 |
"Content-Type": "application/json"})
|
| 464 |
+
with deadline(210, "Turso request"), urllib.request.urlopen(req, timeout=180) as r:
|
| 465 |
data = json.loads(r.read())
|
| 466 |
res = data["results"][0]
|
| 467 |
if res.get("type") == "error":
|
|
|
|
| 483 |
help="fetch, parse and encode, but write nothing. Validates "
|
| 484 |
"the GPU path without needing database credentials.")
|
| 485 |
ap.add_argument("--state", default="data/ingest_state.json")
|
| 486 |
+
ap.add_argument("--source", choices=("search", "oai"), default="search",
|
| 487 |
+
help="search: per-category query API (backfills); oai: OAI-PMH (daily)")
|
| 488 |
args = ap.parse_args()
|
| 489 |
|
| 490 |
+
# If a run stalls, its log names the line: dump every thread's stack every
|
| 491 |
+
# INGEST_STACK_DUMP_S seconds (default 10 min) for as long as it runs.
|
| 492 |
+
import faulthandler
|
| 493 |
+
faulthandler.dump_traceback_later(int(os.getenv("INGEST_STACK_DUMP_S", "600")), repeat=True)
|
| 494 |
+
|
| 495 |
cats = ([c.strip() for c in args.categories.split(",") if c.strip()]
|
| 496 |
or DEFAULT_CATEGORIES)
|
| 497 |
|
|
|
|
| 517 |
|
| 518 |
seen_total = new_total = 0
|
| 519 |
t0 = time.time()
|
| 520 |
+
licence_tally: dict[str, int] = {}
|
| 521 |
+
|
| 522 |
+
def handle_page(papers: list[dict]) -> int:
|
| 523 |
+
ids = [p["arxiv_id"] for p in papers]
|
| 524 |
+
if args.source == "oai":
|
| 525 |
+
for p in papers:
|
| 526 |
+
code = p["licence"] or ("unrecognised" if p["licence_url"] else "none")
|
| 527 |
+
licence_tally[code] = licence_tally.get(code, 0) + 1
|
| 528 |
+
if args.dry_run:
|
| 529 |
+
return len(ids)
|
| 530 |
+
if args.encode_only:
|
| 531 |
+
vecs = encoder.encode([f"{p['title'][:256]} {p['abstract'][:1024]}" for p in papers])
|
| 532 |
+
d, _s = vecs[0]
|
| 533 |
+
nz = sum(len(sp) for _dv, sp in vecs) / len(vecs)
|
| 534 |
+
print(f" encoded {len(vecs)}: dense dim={len(d)} norm={sum(x * x for x in d) ** 0.5:.4f}"
|
| 535 |
+
f" | sparse avg {nz:.0f} terms/doc", flush=True)
|
| 536 |
+
return len(ids)
|
| 537 |
+
have = turso_existing(turso_url, turso_tok, ids)
|
| 538 |
+
todo = [p for p in papers if p["arxiv_id"] not in have]
|
| 539 |
+
if todo:
|
| 540 |
+
vecs = encoder.encode([f"{p['title'][:256]} {p['abstract'][:1024]}" for p in todo])
|
| 541 |
+
upserter.upsert(todo, vecs) # their licences ride in the same Turso batch
|
| 542 |
+
# Papers stored before licences were kept get theirs when OAI lists them again.
|
| 543 |
+
upserter.write_licences([p for p in papers if p["arxiv_id"] in have])
|
| 544 |
+
return len(todo)
|
| 545 |
+
|
| 546 |
+
def save_state() -> None:
|
| 547 |
+
os.makedirs(os.path.dirname(args.state) or ".", exist_ok=True)
|
| 548 |
+
json.dump(state, open(args.state, "w"))
|
| 549 |
+
|
| 550 |
+
if args.source == "oai":
|
| 551 |
+
# OAI dates are inclusive datestamps; --until is exclusive like the search
|
| 552 |
+
# path. Papers are "new" from the month before --since (weekend and
|
| 553 |
+
# announcement lag), judged by id; re-runs skip stored papers anyway.
|
| 554 |
+
from datetime import date, timedelta
|
| 555 |
+
oai_until = (date.fromisoformat(args.until) - timedelta(days=1)).isoformat()
|
| 556 |
+
first = date.fromisoformat(args.since).replace(day=1) - timedelta(days=1)
|
| 557 |
+
seen_total, new_total = harvest(cats, args.since, oai_until, first.strftime("%y%m"), state,
|
| 558 |
+
handle_page, save_state, log=lambda m: print(m, flush=True))
|
| 559 |
+
else:
|
| 560 |
+
seen_total, new_total = walk(cats, args.since, args.until, state, handle_page, save_state,
|
| 561 |
+
limit=args.limit, log=lambda m: print(m, flush=True))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 562 |
|
| 563 |
print(f"\ndone: examined {seen_total:,}, new {new_total:,} "
|
| 564 |
f"in {(time.time()-t0)/60:.1f} min")
|
| 565 |
+
if licence_tally:
|
| 566 |
+
print("licences: " + ", ".join(f"{k}={v:,}" for k, v in
|
| 567 |
+
sorted(licence_tally.items(), key=lambda kv: -kv[1])))
|
| 568 |
if args.limit:
|
| 569 |
return 0
|
| 570 |
+
if args.source == "oai":
|
| 571 |
+
return report_oai_coverage(cats, state)
|
| 572 |
return report_coverage(cats, state)
|
| 573 |
|
| 574 |
|
scripts/ingest_backends.py
CHANGED
|
@@ -28,9 +28,13 @@ from __future__ import annotations
|
|
| 28 |
import hashlib
|
| 29 |
import json
|
| 30 |
import os
|
|
|
|
| 31 |
import urllib.error
|
| 32 |
import urllib.request
|
| 33 |
|
|
|
|
|
|
|
|
|
|
| 34 |
BGE_MODEL = os.getenv("BGE_M3_MODEL", "BAAI/bge-m3")
|
| 35 |
# Same pin as app/config.BGE_M3_PINS: new papers must be encoded with the weights
|
| 36 |
# that encoded the rest of the corpus.
|
|
@@ -130,9 +134,35 @@ class Encoder:
|
|
| 130 |
return results
|
| 131 |
|
| 132 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
class Upserter:
|
| 134 |
"""Writes to Qdrant, Zilliz and Turso."""
|
| 135 |
|
|
|
|
|
|
|
| 136 |
def __init__(self):
|
| 137 |
self.qurl = os.environ.get("QDRANT_RECENT_URL", "").rstrip("/")
|
| 138 |
self.qkey = os.environ.get("QDRANT_RECENT_API_KEY", "")
|
|
@@ -146,7 +176,7 @@ class Upserter:
|
|
| 146 |
self.ztok = os.environ.get("ZILLIZ_TOKEN", "")
|
| 147 |
if WRITE_ZILLIZ and not (self.zuri and self.ztok):
|
| 148 |
raise SystemExit("INGEST_WRITE_ZILLIZ=1 needs ZILLIZ_URI and ZILLIZ_TOKEN")
|
| 149 |
-
self.turl = os.environ["TURSO_URL"].rstrip("/")
|
| 150 |
self.ttok = os.environ["TURSO_DB_TOKEN"]
|
| 151 |
|
| 152 |
# ── Qdrant ───────────────────────────────────────────────────────────
|
|
@@ -156,7 +186,7 @@ class Upserter:
|
|
| 156 |
f"{self.qurl}{path}", data=data, method=method,
|
| 157 |
headers={"api-key": self.qkey, "Content-Type": "application/json"})
|
| 158 |
try:
|
| 159 |
-
with urllib.request.urlopen(req, timeout=timeout) as r:
|
| 160 |
return json.loads(r.read())
|
| 161 |
except urllib.error.HTTPError as e:
|
| 162 |
# A missing point id is a 404, which urllib raises rather than
|
|
@@ -173,7 +203,7 @@ class Upserter:
|
|
| 173 |
f"{self.zuri}{path}", data=json.dumps(body).encode(),
|
| 174 |
headers={"Authorization": f"Bearer {self.ztok}",
|
| 175 |
"Content-Type": "application/json"})
|
| 176 |
-
with urllib.request.urlopen(req, timeout=timeout) as r:
|
| 177 |
return json.loads(r.read())
|
| 178 |
|
| 179 |
# ── Turso ────────────────────────────────────────────────────────────
|
|
@@ -185,13 +215,26 @@ class Upserter:
|
|
| 185 |
f"{self.turl}/v2/pipeline", data=payload,
|
| 186 |
headers={"Authorization": f"Bearer {self.ttok}",
|
| 187 |
"Content-Type": "application/json"})
|
| 188 |
-
with urllib.request.urlopen(req, timeout=timeout) as r:
|
| 189 |
data = json.loads(r.read())
|
| 190 |
for res in data.get("results", []):
|
| 191 |
if res.get("type") == "error":
|
| 192 |
raise RuntimeError(str(res.get("error"))[:200])
|
| 193 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 194 |
# ── Public ──────────────────────��────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
|
| 195 |
def qdrant_existing(self, ids: list[str]) -> set[str]:
|
| 196 |
"""arxiv_ids of `ids` that already have a point in the collection.
|
| 197 |
|
|
@@ -250,4 +293,6 @@ class Upserter:
|
|
| 250 |
{"type": "text", "value": topic_label(p["categories"])},
|
| 251 |
{"type": "text", "value": p["update_date"]}],
|
| 252 |
})
|
| 253 |
-
|
|
|
|
|
|
|
|
|
| 28 |
import hashlib
|
| 29 |
import json
|
| 30 |
import os
|
| 31 |
+
import sys
|
| 32 |
import urllib.error
|
| 33 |
import urllib.request
|
| 34 |
|
| 35 |
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
| 36 |
+
from net_deadline import deadline # noqa: E402
|
| 37 |
+
|
| 38 |
BGE_MODEL = os.getenv("BGE_M3_MODEL", "BAAI/bge-m3")
|
| 39 |
# Same pin as app/config.BGE_M3_PINS: new papers must be encoded with the weights
|
| 40 |
# that encoded the rest of the corpus.
|
|
|
|
| 134 |
return results
|
| 135 |
|
| 136 |
|
| 137 |
+
# Licences live in their own table: `INSERT OR REPLACE INTO papers` rewrites
|
| 138 |
+
# whole rows, so a licence column there would vanish on any re-ingest (trap T5).
|
| 139 |
+
# Written only by the OAI path; the search API gives no licence.
|
| 140 |
+
LICENCE_DDL = ("CREATE TABLE IF NOT EXISTS paper_licences (arxiv_id TEXT PRIMARY KEY, "
|
| 141 |
+
"licence TEXT NOT NULL, licence_url TEXT, source TEXT, updated_at TEXT)")
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def licence_stmts(papers: list[dict]) -> list[dict]:
|
| 145 |
+
"""Upserts for the papers whose record named a licence. A row that already
|
| 146 |
+
says the same is left alone, so daily re-harvests write nothing."""
|
| 147 |
+
return [{
|
| 148 |
+
"sql": ("INSERT INTO paper_licences (arxiv_id, licence, licence_url, source, updated_at) "
|
| 149 |
+
"VALUES (?, ?, ?, 'oai', datetime('now')) "
|
| 150 |
+
"ON CONFLICT(arxiv_id) DO UPDATE SET licence = excluded.licence, "
|
| 151 |
+
"licence_url = excluded.licence_url, source = excluded.source, "
|
| 152 |
+
"updated_at = excluded.updated_at "
|
| 153 |
+
"WHERE paper_licences.licence IS NOT excluded.licence "
|
| 154 |
+
"OR paper_licences.licence_url IS NOT excluded.licence_url"),
|
| 155 |
+
"args": [{"type": "text", "value": p["arxiv_id"]},
|
| 156 |
+
{"type": "text", "value": p.get("licence") or ""},
|
| 157 |
+
{"type": "text", "value": p["licence_url"]}],
|
| 158 |
+
} for p in papers if p.get("licence_url")]
|
| 159 |
+
|
| 160 |
+
|
| 161 |
class Upserter:
|
| 162 |
"""Writes to Qdrant, Zilliz and Turso."""
|
| 163 |
|
| 164 |
+
_licence_table = False # created on the run's first licence write
|
| 165 |
+
|
| 166 |
def __init__(self):
|
| 167 |
self.qurl = os.environ.get("QDRANT_RECENT_URL", "").rstrip("/")
|
| 168 |
self.qkey = os.environ.get("QDRANT_RECENT_API_KEY", "")
|
|
|
|
| 176 |
self.ztok = os.environ.get("ZILLIZ_TOKEN", "")
|
| 177 |
if WRITE_ZILLIZ and not (self.zuri and self.ztok):
|
| 178 |
raise SystemExit("INGEST_WRITE_ZILLIZ=1 needs ZILLIZ_URI and ZILLIZ_TOKEN")
|
| 179 |
+
self.turl = os.environ["TURSO_URL"].replace("libsql://", "https://", 1).rstrip("/")
|
| 180 |
self.ttok = os.environ["TURSO_DB_TOKEN"]
|
| 181 |
|
| 182 |
# ── Qdrant ───────────────────────────────────────────────────────────
|
|
|
|
| 186 |
f"{self.qurl}{path}", data=data, method=method,
|
| 187 |
headers={"api-key": self.qkey, "Content-Type": "application/json"})
|
| 188 |
try:
|
| 189 |
+
with deadline(timeout + 30, path), urllib.request.urlopen(req, timeout=timeout) as r:
|
| 190 |
return json.loads(r.read())
|
| 191 |
except urllib.error.HTTPError as e:
|
| 192 |
# A missing point id is a 404, which urllib raises rather than
|
|
|
|
| 203 |
f"{self.zuri}{path}", data=json.dumps(body).encode(),
|
| 204 |
headers={"Authorization": f"Bearer {self.ztok}",
|
| 205 |
"Content-Type": "application/json"})
|
| 206 |
+
with deadline(timeout + 30, path), urllib.request.urlopen(req, timeout=timeout) as r:
|
| 207 |
return json.loads(r.read())
|
| 208 |
|
| 209 |
# ── Turso ────────────────────────────────────────────────────────────
|
|
|
|
| 215 |
f"{self.turl}/v2/pipeline", data=payload,
|
| 216 |
headers={"Authorization": f"Bearer {self.ttok}",
|
| 217 |
"Content-Type": "application/json"})
|
| 218 |
+
with deadline(timeout + 30, "Turso pipeline"), urllib.request.urlopen(req, timeout=timeout) as r:
|
| 219 |
data = json.loads(r.read())
|
| 220 |
for res in data.get("results", []):
|
| 221 |
if res.get("type") == "error":
|
| 222 |
raise RuntimeError(str(res.get("error"))[:200])
|
| 223 |
|
| 224 |
+
def _with_licences(self, stmts: list[dict], papers: list[dict]) -> None:
|
| 225 |
+
"""Turso `stmts` plus the licence rows for `papers`, in one pipeline."""
|
| 226 |
+
lic = licence_stmts(papers)
|
| 227 |
+
if lic and not self._licence_table:
|
| 228 |
+
lic.insert(0, {"sql": LICENCE_DDL})
|
| 229 |
+
if stmts or lic:
|
| 230 |
+
self._t(stmts + lic)
|
| 231 |
+
self._licence_table = self._licence_table or bool(lic)
|
| 232 |
+
|
| 233 |
# ── Public ──────────────────────��────────────────────────────────────
|
| 234 |
+
def write_licences(self, papers: list[dict]) -> None:
|
| 235 |
+
"""Licence rows for papers already stored."""
|
| 236 |
+
self._with_licences([], papers)
|
| 237 |
+
|
| 238 |
def qdrant_existing(self, ids: list[str]) -> set[str]:
|
| 239 |
"""arxiv_ids of `ids` that already have a point in the collection.
|
| 240 |
|
|
|
|
| 293 |
{"type": "text", "value": topic_label(p["categories"])},
|
| 294 |
{"type": "text", "value": p["update_date"]}],
|
| 295 |
})
|
| 296 |
+
# Licence rows after the paper rows: Turso is the resume marker, and a
|
| 297 |
+
# failed licence write is redone when the re-run finds the paper stored.
|
| 298 |
+
self._with_licences(stmts, papers)
|
scripts/net_deadline.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""A hard wall-clock limit for one blocking network call.
|
| 2 |
+
|
| 3 |
+
Socket timeouts bound each individual read, not the request: a server that
|
| 4 |
+
trickles bytes, or a library call made without a timeout, can block forever.
|
| 5 |
+
On 2026-10-01 a daily-refresh ingest sat 20 minutes at 0% CPU in an SSL read
|
| 6 |
+
(_buffered_readline -> SSLSocket.read -> poll) while arXiv answered other
|
| 7 |
+
requests in 0.2 s. SIGALRM interrupts a blocked syscall, and the handler's
|
| 8 |
+
exception aborts the call instead of letting it resume.
|
| 9 |
+
|
| 10 |
+
Main thread only (where the ingest makes all its requests); elsewhere it is a
|
| 11 |
+
no-op rather than an error.
|
| 12 |
+
"""
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import contextlib
|
| 16 |
+
import signal
|
| 17 |
+
import threading
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class DeadlineExceeded(TimeoutError):
|
| 21 |
+
pass
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
@contextlib.contextmanager
|
| 25 |
+
def deadline(seconds: float, what: str = "network call"):
|
| 26 |
+
if (seconds <= 0 or not hasattr(signal, "SIGALRM")
|
| 27 |
+
or threading.current_thread() is not threading.main_thread()):
|
| 28 |
+
yield
|
| 29 |
+
return
|
| 30 |
+
|
| 31 |
+
def _expire(_signum, _frame):
|
| 32 |
+
raise DeadlineExceeded(f"{what} exceeded {seconds:.0f}s")
|
| 33 |
+
|
| 34 |
+
previous = signal.signal(signal.SIGALRM, _expire)
|
| 35 |
+
signal.setitimer(signal.ITIMER_REAL, seconds)
|
| 36 |
+
try:
|
| 37 |
+
yield
|
| 38 |
+
finally:
|
| 39 |
+
signal.setitimer(signal.ITIMER_REAL, 0)
|
| 40 |
+
signal.signal(signal.SIGALRM, previous)
|
tests/test_daily_refresh.py
CHANGED
|
@@ -55,3 +55,46 @@ def test_workflow_is_gated_serialised_and_passes_every_required_secret():
|
|
| 55 |
assert re.search(r"(?m)^ contents: read$", text)
|
| 56 |
for key in dr.REQUIRED_ENV + ("S2_API_KEY",):
|
| 57 |
assert re.search(rf"(?m)^ {key}: \$\{{\{{ secrets\.{key} \}}\}}$", text), key
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
assert re.search(r"(?m)^ contents: read$", text)
|
| 56 |
for key in dr.REQUIRED_ENV + ("S2_API_KEY",):
|
| 57 |
assert re.search(rf"(?m)^ {key}: \$\{{\{{ secrets\.{key} \}}\}}$", text), key
|
| 58 |
+
# a catch-up day reaches the script through env, never pasted into the shell
|
| 59 |
+
assert "TODAY: ${{ inputs.today }}" in text and '${TODAY:+--today "$TODAY"}' in text
|
| 60 |
+
assert "inputs.today }} \\" not in text
|
| 61 |
+
# catch-up runs can raise the ingest limit; the job ceiling sits above it
|
| 62 |
+
assert "INGEST_TIMEOUT_MIN: ${{ inputs.ingest_timeout_min || '60' }}" in text
|
| 63 |
+
assert '--ingest-timeout-min "$INGEST_TIMEOUT_MIN"' in text
|
| 64 |
+
assert re.search(r"(?m)^ timeout-minutes: 300$", text)
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def test_ingest_that_overruns_fails_the_run_with_its_output(monkeypatch, capsys):
|
| 68 |
+
import subprocess
|
| 69 |
+
import pytest
|
| 70 |
+
|
| 71 |
+
def overrun(*a, **k):
|
| 72 |
+
raise subprocess.TimeoutExpired(cmd="ingest", timeout=60, output="partial log", stderr="Thread 0x1 stack")
|
| 73 |
+
|
| 74 |
+
monkeypatch.setattr(dr.subprocess, "run", overrun)
|
| 75 |
+
with pytest.raises(SystemExit, match="exceeded 1 min"):
|
| 76 |
+
dr.stage_ingest("2026-09-28", "2026-10-02", timeout_s=60)
|
| 77 |
+
assert "Thread 0x1 stack" in capsys.readouterr().out
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def test_the_daily_ingest_uses_the_oai_harvest(monkeypatch):
|
| 81 |
+
import subprocess
|
| 82 |
+
seen = []
|
| 83 |
+
|
| 84 |
+
def run(cmd, **k):
|
| 85 |
+
seen.append(cmd)
|
| 86 |
+
return subprocess.CompletedProcess(cmd, 0, "done: examined 1,204, new 873 in 9.1 min\n", "")
|
| 87 |
+
|
| 88 |
+
monkeypatch.setattr(dr.subprocess, "run", run)
|
| 89 |
+
assert dr.stage_ingest("2026-10-17", "2026-10-21")[0] == 873
|
| 90 |
+
cmd = seen[0]
|
| 91 |
+
assert cmd[cmd.index("--source") + 1] == "oai"
|
| 92 |
+
assert cmd[cmd.index("--since") + 1] == "2026-10-17" and cmd[cmd.index("--until") + 1] == "2026-10-21"
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def test_licence_coverage_counts_new_papers_with_a_licence(monkeypatch):
|
| 96 |
+
monkeypatch.setenv("FIGURE_LICENCES", "CC-BY-4.0,CC0-1.0")
|
| 97 |
+
got = dr.licence_coverage(["CC-BY-4.0", "ARXIV-NONEXCLUSIVE-1.0", "CC-BY-NC-SA-4.0", None, "CC0-1.0"])
|
| 98 |
+
assert got["new_papers"] == 5 and got["with_licence"] == 4 and got["with_licence_share"] == 0.8
|
| 99 |
+
assert got["open"] == 3 and got["allow_figures"] == 2 and got["allow_figures_share"] == 0.4
|
| 100 |
+
assert dr.licence_coverage([])["with_licence_share"] is None # a quiet day is not an error
|
tests/test_ingest_safety.py
CHANGED
|
@@ -80,21 +80,60 @@ def test_failed_fetch_reports_unknown_total(monkeypatch):
|
|
| 80 |
assert ingest_arxiv.fetch_page("cs.CV", "2026-08-01", "2026-08-02", 0) == ([], None)
|
| 81 |
|
| 82 |
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
def
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
|
| 92 |
|
| 93 |
-
def
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
|
|
|
| 98 |
|
| 99 |
|
| 100 |
def test_coverage_checks_every_window(capsys):
|
|
@@ -114,3 +153,98 @@ def test_point_ids_are_stable_disjoint_and_fit_uint64():
|
|
| 114 |
assert len(set(ids)) == len(ids) # 300k ids, no collision
|
| 115 |
assert min(ids) >= 1 << 62 > 300_000 # never meets sequential ids
|
| 116 |
assert max(ids) < 1 << 63 # Qdrant ids are unsigned 64-bit
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
assert ingest_arxiv.fetch_page("cs.CV", "2026-08-01", "2026-08-02", 0) == ([], None)
|
| 81 |
|
| 82 |
|
| 83 |
+
class FakeArxiv:
|
| 84 |
+
"""Papers spread evenly over days; fails pages that pass arXiv's 10k cap."""
|
| 85 |
+
def __init__(self, per_day: dict, fail=()):
|
| 86 |
+
self.per_day, self.fail, self.calls = per_day, set(fail), []
|
| 87 |
+
|
| 88 |
+
def __call__(self, cat, since, until, start):
|
| 89 |
+
from datetime import date, timedelta
|
| 90 |
+
self.calls.append((cat, since, until, start))
|
| 91 |
+
if cat in self.fail:
|
| 92 |
+
return [], None
|
| 93 |
+
a, b = date.fromisoformat(since), date.fromisoformat(until)
|
| 94 |
+
ids = [f"{cat}:{(a + timedelta(d)).isoformat()}:{i}"
|
| 95 |
+
for d in range((b - a).days) for i in range(self.per_day[cat])]
|
| 96 |
+
if start + 200 > 10_000 and len(ids) > 10_000:
|
| 97 |
+
return [], None # the real API's 500
|
| 98 |
+
return [{"arxiv_id": x} for x in ids[start:start + 200]], len(ids)
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def _walk(fake, cats, state=None):
|
| 102 |
+
state = {} if state is None else state
|
| 103 |
+
handled = []
|
| 104 |
+
seen, new = ingest_arxiv.walk(cats, "2026-07-29", "2026-10-01", state,
|
| 105 |
+
lambda ps: handled.extend(p["arxiv_id"] for p in ps) or len(ps),
|
| 106 |
+
lambda: None, fetch=fake, delay=0, log=lambda m: None)
|
| 107 |
+
return state, handled, seen, new
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def test_small_category_costs_only_its_own_pages():
|
| 111 |
+
fake = FakeArxiv({"cs.RO": 10}) # 640 papers, 4 pages
|
| 112 |
+
state, handled, seen, new = _walk(fake, ["cs.RO"])
|
| 113 |
+
assert len(fake.calls) == 4 and seen == new == 640 == len(set(handled))
|
| 114 |
+
assert ingest_arxiv.report_coverage(["cs.RO"], state) == 0
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def test_oversized_window_splits_before_processing_and_covers_everything():
|
| 118 |
+
fake = FakeArxiv({"cs.AI": 175}) # 11,200 papers in 64 days
|
| 119 |
+
state, handled, seen, _ = _walk(fake, ["cs.AI"])
|
| 120 |
+
assert state["cs.AI#windows"] == [["2026-07-29", "2026-08-30"], ["2026-08-30", "2026-10-01"]]
|
| 121 |
+
assert seen == len(handled) == len(set(handled)) == 11_200 # nothing twice, nothing lost
|
| 122 |
+
assert ingest_arxiv.report_coverage(["cs.AI"], state) == 0
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def test_failed_requests_leave_a_coverage_gap():
|
| 126 |
+
fake = FakeArxiv({"cs.CL": 5, "cs.CV": 5}, fail={"cs.CL"})
|
| 127 |
+
state, _h, _s, _n = _walk(fake, ["cs.CL", "cs.CV"])
|
| 128 |
+
assert ingest_arxiv.report_coverage(["cs.CL", "cs.CV"], state) == 3
|
| 129 |
|
| 130 |
|
| 131 |
+
def test_resumed_run_keeps_the_recorded_split():
|
| 132 |
+
fake = FakeArxiv({"cs.AI": 175})
|
| 133 |
+
state, _h, _s, _n = _walk(fake, ["cs.AI"])
|
| 134 |
+
fake2 = FakeArxiv({"cs.AI": 175})
|
| 135 |
+
_st, handled, seen, _n = _walk(fake2, ["cs.AI"], state=state)
|
| 136 |
+
assert seen == 0 and all(c[3] > 0 for c in fake2.calls) # resumes at the end, no re-split
|
| 137 |
|
| 138 |
|
| 139 |
def test_coverage_checks_every_window(capsys):
|
|
|
|
| 153 |
assert len(set(ids)) == len(ids) # 300k ids, no collision
|
| 154 |
assert min(ids) >= 1 << 62 > 300_000 # never meets sequential ids
|
| 155 |
assert max(ids) < 1 << 63 # Qdrant ids are unsigned 64-bit
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def test_rate_limits_back_off_for_minutes_and_honour_retry_after():
|
| 159 |
+
import urllib.error
|
| 160 |
+
def http(code, retry_after=None):
|
| 161 |
+
headers = {"Retry-After": retry_after} if retry_after else {}
|
| 162 |
+
return urllib.error.HTTPError("u", code, "x", headers, None)
|
| 163 |
+
assert ingest_arxiv.retry_wait(http(429), 0) == 30
|
| 164 |
+
assert ingest_arxiv.retry_wait(http(429), 2) == 120
|
| 165 |
+
assert ingest_arxiv.retry_wait(http(429), 5) == 300 # capped
|
| 166 |
+
assert ingest_arxiv.retry_wait(http(503, "45"), 0) == 45 # server's own advice
|
| 167 |
+
assert ingest_arxiv.retry_wait(TimeoutError("x"), 0) == 5 # transient: retry soon
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def test_fetch_page_retries_a_rate_limit_then_succeeds(monkeypatch):
|
| 171 |
+
import io, urllib.error
|
| 172 |
+
calls, waits = [], []
|
| 173 |
+
feed = (b'<feed xmlns="http://www.w3.org/2005/Atom" xmlns:opensearch="http://a9.com/-/spec/opensearch/1.1/">'
|
| 174 |
+
b'<opensearch:totalResults>0</opensearch:totalResults></feed>')
|
| 175 |
+
|
| 176 |
+
def urlopen(url, timeout):
|
| 177 |
+
calls.append(url)
|
| 178 |
+
if len(calls) < 3:
|
| 179 |
+
raise urllib.error.HTTPError(url, 429, "Too Many Requests", {}, None)
|
| 180 |
+
return io.BytesIO(feed)
|
| 181 |
+
|
| 182 |
+
monkeypatch.setattr(ingest_arxiv.urllib.request, "urlopen", urlopen)
|
| 183 |
+
monkeypatch.setattr(ingest_arxiv.time, "sleep", waits.append)
|
| 184 |
+
assert ingest_arxiv.fetch_page("cs.RO", "2026-09-28", "2026-10-02", 0) == ([], 0)
|
| 185 |
+
assert waits == [30, 60] and len(calls) == 3
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
# ── licences ─────────────────────────────────────────────────────────────────
|
| 189 |
+
|
| 190 |
+
def _licensed(aid, url):
|
| 191 |
+
p = _papers(aid)[0]
|
| 192 |
+
return {**p, "licence_url": url, "licence": "CC-BY-4.0" if "creativecommons" in url else ""}
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
def test_licence_rows_ride_in_the_same_batch_as_the_paper_rows(monkeypatch):
|
| 196 |
+
up, calls = _upserter(monkeypatch, present=set())
|
| 197 |
+
papers = [_licensed("2610.00001", "http://creativecommons.org/licenses/by/4.0/"), _papers("2610.00002")[0]]
|
| 198 |
+
up.upsert(papers, [([0.1] * 4, {})] * 2)
|
| 199 |
+
(batch,) = calls["t"]
|
| 200 |
+
sqls = [s["sql"] for s in batch]
|
| 201 |
+
assert [s.split()[0:3] for s in sqls[:2]] == [["INSERT", "OR", "REPLACE"]] * 2 # papers first
|
| 202 |
+
assert sqls[2] == ingest_backends.LICENCE_DDL # then the table, once
|
| 203 |
+
assert sqls[3].startswith("INSERT INTO paper_licences") and "ON CONFLICT(arxiv_id) DO UPDATE" in sqls[3]
|
| 204 |
+
assert [a["value"] for a in batch[3]["args"]] == ["2610.00001", "CC-BY-4.0",
|
| 205 |
+
"http://creativecommons.org/licenses/by/4.0/"]
|
| 206 |
+
assert len(batch) == 4 # the paper without a licence gets no row
|
| 207 |
+
|
| 208 |
+
up.upsert([_licensed("2610.00003", "http://creativecommons.org/licenses/by/4.0/")], [([0.1] * 4, {})])
|
| 209 |
+
assert ingest_backends.LICENCE_DDL not in [s["sql"] for s in calls["t"][1]] # created once per run
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
def test_stored_papers_get_licence_rows_and_nothing_else(monkeypatch):
|
| 213 |
+
up, calls = _upserter(monkeypatch, present=set())
|
| 214 |
+
up._licence_table = True
|
| 215 |
+
up.write_licences(_papers("2610.00004")) # no licence in the record: no call at all
|
| 216 |
+
assert calls["t"] == []
|
| 217 |
+
up.write_licences([_licensed("2610.00005", "http://arxiv.org/licenses/nonexclusive-distrib/1.0/")])
|
| 218 |
+
(batch,) = calls["t"]
|
| 219 |
+
assert len(batch) == 1 and batch[0]["sql"].startswith("INSERT INTO paper_licences")
|
| 220 |
+
# an unchanged row is left alone, so daily re-harvests write nothing
|
| 221 |
+
assert "WHERE paper_licences.licence IS NOT excluded.licence" in batch[0]["sql"]
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
def test_the_licence_upsert_updates_only_rows_that_changed(tmp_path):
|
| 225 |
+
import sqlite3
|
| 226 |
+
db = sqlite3.connect(tmp_path / "t.db")
|
| 227 |
+
db.execute(ingest_backends.LICENCE_DDL)
|
| 228 |
+
|
| 229 |
+
def write(url, code):
|
| 230 |
+
(s,) = ingest_backends.licence_stmts([{"arxiv_id": "2610.00006", "licence_url": url, "licence": code}])
|
| 231 |
+
db.execute(s["sql"], [a["value"] for a in s["args"]])
|
| 232 |
+
return db.total_changes
|
| 233 |
+
|
| 234 |
+
first = write("http://arxiv.org/licenses/nonexclusive-distrib/1.0/", "ARXIV-NONEXCLUSIVE-1.0")
|
| 235 |
+
assert write("http://arxiv.org/licenses/nonexclusive-distrib/1.0/", "ARXIV-NONEXCLUSIVE-1.0") == first
|
| 236 |
+
write("http://creativecommons.org/licenses/by/4.0/", "CC-BY-4.0")
|
| 237 |
+
assert db.execute("SELECT licence, source FROM paper_licences").fetchall() == [("CC-BY-4.0", "oai")]
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
@pytest.mark.parametrize("url", ["libsql://db-org.turso.io", "https://db-org.turso.io/", "https://db-org.turso.io"])
|
| 241 |
+
def test_turso_urls_work_in_either_form(url, monkeypatch):
|
| 242 |
+
"""Turso hands out libsql:// URLs; urllib only opens https://."""
|
| 243 |
+
assert ingest_arxiv._pipeline_url(url) == "https://db-org.turso.io/v2/pipeline"
|
| 244 |
+
monkeypatch.setenv("QDRANT_RECENT_URL", "https://recent.example")
|
| 245 |
+
monkeypatch.setenv("QDRANT_RECENT_API_KEY", "k")
|
| 246 |
+
monkeypatch.setenv("QDRANT_URL", "https://primary.example")
|
| 247 |
+
monkeypatch.setenv("TURSO_URL", url)
|
| 248 |
+
monkeypatch.setenv("TURSO_DB_TOKEN", "t")
|
| 249 |
+
monkeypatch.setattr(ingest_backends, "WRITE_ZILLIZ", False)
|
| 250 |
+
assert ingest_backends.Upserter().turl == "https://db-org.turso.io"
|
tests/test_net_deadline.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""scripts/net_deadline.py interrupts calls that socket timeouts cannot."""
|
| 2 |
+
import socket
|
| 3 |
+
import sys
|
| 4 |
+
import threading
|
| 5 |
+
import time
|
| 6 |
+
import urllib.request
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
|
| 9 |
+
import pytest
|
| 10 |
+
|
| 11 |
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
|
| 12 |
+
from net_deadline import DeadlineExceeded, deadline # noqa: E402
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
@pytest.fixture
|
| 16 |
+
def silent_server():
|
| 17 |
+
"""Accepts connections and never sends a byte: the hang seen on 2026-10-01."""
|
| 18 |
+
srv = socket.socket()
|
| 19 |
+
srv.bind(("127.0.0.1", 0))
|
| 20 |
+
srv.listen(5)
|
| 21 |
+
conns = []
|
| 22 |
+
stop = threading.Event()
|
| 23 |
+
|
| 24 |
+
def run():
|
| 25 |
+
srv.settimeout(0.2)
|
| 26 |
+
while not stop.is_set():
|
| 27 |
+
try:
|
| 28 |
+
conns.append(srv.accept()[0])
|
| 29 |
+
except OSError:
|
| 30 |
+
pass
|
| 31 |
+
|
| 32 |
+
t = threading.Thread(target=run, daemon=True)
|
| 33 |
+
t.start()
|
| 34 |
+
yield f"http://127.0.0.1:{srv.getsockname()[1]}/"
|
| 35 |
+
stop.set()
|
| 36 |
+
t.join(1)
|
| 37 |
+
for c in conns:
|
| 38 |
+
c.close()
|
| 39 |
+
srv.close()
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def test_deadline_interrupts_a_read_the_socket_timeout_would_allow(silent_server):
|
| 43 |
+
t0 = time.monotonic()
|
| 44 |
+
with pytest.raises(DeadlineExceeded):
|
| 45 |
+
with deadline(1.0, "test"), urllib.request.urlopen(silent_server, timeout=100) as r:
|
| 46 |
+
r.read()
|
| 47 |
+
assert time.monotonic() - t0 < 5
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def test_no_stray_alarm_after_a_call_that_finishes():
|
| 51 |
+
with deadline(0.3, "fast"):
|
| 52 |
+
pass
|
| 53 |
+
time.sleep(0.5) # an alarm left armed would raise here
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def test_off_the_main_thread_it_is_a_no_op():
|
| 57 |
+
out = []
|
| 58 |
+
|
| 59 |
+
def worker():
|
| 60 |
+
with deadline(0.1, "thread"):
|
| 61 |
+
time.sleep(0.3)
|
| 62 |
+
out.append("ok")
|
| 63 |
+
|
| 64 |
+
t = threading.Thread(target=worker)
|
| 65 |
+
t.start()
|
| 66 |
+
t.join()
|
| 67 |
+
assert out == ["ok"]
|
tests/test_oai_harvest.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""OAI-PMH harvesting in scripts/ingest_arxiv.py."""
|
| 2 |
+
import io
|
| 3 |
+
import sys
|
| 4 |
+
import xml.etree.ElementTree as ET
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
|
| 8 |
+
import ingest_arxiv as ia # noqa: E402
|
| 9 |
+
|
| 10 |
+
PAGE = """<OAI-PMH xmlns="http://www.openarchives.org/OAI/2.0/"><ListRecords>
|
| 11 |
+
<record><header><identifier>oai:arXiv.org:2609.02991</identifier><datestamp>2026-09-29</datestamp></header>
|
| 12 |
+
<metadata><arXiv xmlns="http://arxiv.org/OAI/arXiv/"><id>2609.02991</id><created>2026-09-02</created>
|
| 13 |
+
<authors><author><keyname>Robert</keyname><forenames>Christian P.</forenames></author>
|
| 14 |
+
<author><keyname>Smith</keyname><forenames>Ann</forenames><suffix>Jr</suffix></author></authors>
|
| 15 |
+
<title>Uncertainty sets
|
| 16 |
+
for robust learning</title><categories>cs.LG cs.NA math.NA stat.ML</categories>
|
| 17 |
+
<abstract> We study $ϕ$-divergence uncertainty sets and function approximation in depth. </abstract>
|
| 18 |
+
</arXiv></metadata></record>
|
| 19 |
+
<record><header status="deleted"><identifier>oai:arXiv.org:2609.00001</identifier><datestamp>2026-09-29</datestamp></header></record>
|
| 20 |
+
<resumptionToken completeListSize="2" cursor="0"></resumptionToken>
|
| 21 |
+
</ListRecords></OAI-PMH>"""
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def test_record_is_normalised_like_the_search_api():
|
| 25 |
+
root = ET.fromstring(PAGE)
|
| 26 |
+
recs = list(root.iter("{http://www.openarchives.org/OAI/2.0/}record"))
|
| 27 |
+
p = ia.parse_oai_record(recs[0])
|
| 28 |
+
assert p["arxiv_id"] == "2609.02991"
|
| 29 |
+
assert p["title"] == "Uncertainty sets for robust learning"
|
| 30 |
+
assert "$\u03c6$-divergence" in p["abstract"] and "\u03d5" not in p["abstract"] # NFKC
|
| 31 |
+
assert p["abstract"].startswith("We study") and " " not in p["abstract"]
|
| 32 |
+
assert p["categories"] == "cs.LG math.NA stat.ML" # cs.NA alias dropped, order kept
|
| 33 |
+
assert p["primary_topic"] == "cs.LG"
|
| 34 |
+
assert p["authors"] == "Christian P. Robert, Ann Smith Jr"
|
| 35 |
+
assert p["update_date"] == "2026-09-02"
|
| 36 |
+
assert ia.parse_oai_record(recs[1]) is None # deleted
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def test_alias_collapsing_keeps_the_canonical_position():
|
| 40 |
+
assert ia.canonical_categories("eess.AS cs.AI cs.SD cs.SY eess.SP eess.SY") == "eess.AS cs.AI cs.SD eess.SP eess.SY"
|
| 41 |
+
assert ia.canonical_categories("cs.IT cs.DC math.IT") == "cs.IT cs.DC"
|
| 42 |
+
assert ia.canonical_categories("math.IT cs.LG") == "cs.IT cs.LG" # alias alone maps in place
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def test_sets_cover_every_configured_category():
|
| 46 |
+
sets = {ia.oai_set(c) for c in ia.DEFAULT_CATEGORIES}
|
| 47 |
+
assert sets == {"cs", "stat", "math", "q-bio", "econ", "eess", "physics"}
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def _paper(aid, cats):
|
| 51 |
+
return {"arxiv_id": aid, "title": "t" * 6, "authors": "a", "abstract": "x" * 30,
|
| 52 |
+
"categories": cats, "primary_topic": cats.split()[0], "update_date": "2026-09-29"}
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def test_harvest_filters_pages_through_and_marks_complete_sets():
|
| 56 |
+
pages = {
|
| 57 |
+
("cs", None): ([_paper("2609.00010", "cs.CL"), _paper("2609.00011", "cs.OS"), # cs.OS not configured
|
| 58 |
+
_paper("1810.04449", "cs.LG"), _paper("2512.00001", "cs.LG")], # old ids
|
| 59 |
+
"tok1", 5, 4),
|
| 60 |
+
("cs", "tok1"): ([_paper("2609.00012", "cs.CV")], None, 5, 1),
|
| 61 |
+
("stat", None): ([_paper("2609.00020", "stat.ML math.ST")], None, 1, 1),
|
| 62 |
+
}
|
| 63 |
+
fake = lambda s, since, until, tok: pages[(s, tok)]
|
| 64 |
+
handled, state = [], {}
|
| 65 |
+
seen, new = ia.harvest(["cs.CL", "cs.CV", "stat.ML"], "2026-09-28", "2026-09-30", "2608", state,
|
| 66 |
+
lambda ps: handled.extend(p["arxiv_id"] for p in ps) or len(ps),
|
| 67 |
+
lambda: None, fetch=fake, delay=0, log=lambda m: None)
|
| 68 |
+
assert handled == ["2609.00010", "2609.00012", "2609.00020"] and seen == new == 3
|
| 69 |
+
assert state == {"oai:cs": "done", "oai:stat": "done"}
|
| 70 |
+
assert ia.report_oai_coverage(["cs.CL", "stat.ML"], state) == 0
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def test_short_or_failed_sets_fail_coverage():
|
| 74 |
+
pages = {("cs", None): ([], None, 9, 4), # arXiv promised 9 records, 4 arrived
|
| 75 |
+
("stat", None): ([], None, None, None)} # request failed
|
| 76 |
+
state = {}
|
| 77 |
+
ia.harvest(["cs.CL", "stat.ML"], "2026-09-28", "2026-09-30", "2608", state, lambda ps: 0,
|
| 78 |
+
lambda: None, fetch=lambda s, a, b, t: pages[(s, t)], delay=0, log=lambda m: None)
|
| 79 |
+
assert state == {"oai:cs": "short 4/9", "oai:stat": "failed"}
|
| 80 |
+
assert ia.report_oai_coverage(["cs.CL", "stat.ML"], state) == 3
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def test_a_day_with_no_records_is_complete(monkeypatch):
|
| 84 |
+
body = (b'<OAI-PMH xmlns="http://www.openarchives.org/OAI/2.0/">'
|
| 85 |
+
b'<error code="noRecordsMatch">no records</error></OAI-PMH>')
|
| 86 |
+
monkeypatch.setattr(ia.urllib.request, "urlopen", lambda url, timeout: io.BytesIO(body))
|
| 87 |
+
assert ia.fetch_oai_page("econ", "2026-09-28", "2026-09-30", None) == ([], None, 0, 0)
|
| 88 |
+
state = {}
|
| 89 |
+
ia.harvest(["econ.TH"], "2026-09-28", "2026-09-30", "2608", state, lambda ps: 0, lambda: None,
|
| 90 |
+
delay=0, log=lambda m: None)
|
| 91 |
+
assert state == {"oai:econ": "done"}
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def test_parsing_the_sample_page_via_fetch(monkeypatch):
|
| 95 |
+
monkeypatch.setattr(ia.urllib.request, "urlopen", lambda url, timeout: io.BytesIO(PAGE.encode()))
|
| 96 |
+
papers, token, size, records = ia.fetch_oai_page("cs", "2026-09-28", "2026-09-30", None)
|
| 97 |
+
assert [p["arxiv_id"] for p in papers] == ["2609.02991"] and token is None and size == 2 and records == 2
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def test_requests_identify_themselves_and_bad_requests_are_not_retried(monkeypatch):
|
| 101 |
+
import urllib.error
|
| 102 |
+
seen, waits = [], []
|
| 103 |
+
|
| 104 |
+
def urlopen(req, timeout):
|
| 105 |
+
seen.append(req.get_header("User-agent"))
|
| 106 |
+
raise urllib.error.HTTPError(req.full_url, 400, "Bad Request", {}, None)
|
| 107 |
+
|
| 108 |
+
monkeypatch.setattr(ia.urllib.request, "urlopen", urlopen)
|
| 109 |
+
monkeypatch.setattr(ia.time, "sleep", waits.append)
|
| 110 |
+
assert ia.fetch_oai_page("cs", "2026-09-28", "2026-09-30", None) == ([], None, None, None)
|
| 111 |
+
assert seen == [ia.USER_AGENT] and waits == [] # one attempt, no back-off
|
| 112 |
+
assert "Python-urllib" not in ia.USER_AGENT
|
| 113 |
+
assert ia._permanent(urllib.error.HTTPError("u", 404, "x", {}, None))
|
| 114 |
+
assert not ia._permanent(urllib.error.HTTPError("u", 429, "x", {}, None))
|
| 115 |
+
assert not ia._permanent(urllib.error.HTTPError("u", 406, "x", {}, None)) # was a temporary block
|
| 116 |
+
assert ia.retry_wait(urllib.error.HTTPError("u", 406, "x", {}, None), 0) == 30
|
| 117 |
+
assert not ia._permanent(TimeoutError("x"))
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def _record_with_licence(licence: str | None) -> ET.Element:
|
| 121 |
+
tag = f"<license>{licence}</license>" if licence is not None else ""
|
| 122 |
+
return ET.fromstring(
|
| 123 |
+
'<record xmlns="http://www.openarchives.org/OAI/2.0/"><header><identifier>oai:arXiv.org:2610.00042'
|
| 124 |
+
'</identifier></header><metadata><arXiv xmlns="http://arxiv.org/OAI/arXiv/"><id>2610.00042</id>'
|
| 125 |
+
'<created>2026-10-19</created><authors><author><keyname>Lee</keyname></author></authors>'
|
| 126 |
+
'<title>Figures with licences</title><categories>cs.CV</categories>'
|
| 127 |
+
f'<abstract>An abstract long enough to keep the record.</abstract>{tag}</arXiv></metadata></record>')
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def test_the_licence_is_read_from_the_record():
|
| 131 |
+
cc = ia.parse_oai_record(_record_with_licence("http://creativecommons.org/licenses/by/4.0/"))
|
| 132 |
+
assert cc["licence"] == "CC-BY-4.0" and cc["licence_url"] == "http://creativecommons.org/licenses/by/4.0/"
|
| 133 |
+
default = ia.parse_oai_record(_record_with_licence(" http://arxiv.org/licenses/nonexclusive-distrib/1.0/ "))
|
| 134 |
+
assert default["licence"] == "ARXIV-NONEXCLUSIVE-1.0"
|
| 135 |
+
odd = ia.parse_oai_record(_record_with_licence("https://example.org/my-licence"))
|
| 136 |
+
assert odd["licence"] == "" and odd["licence_url"] == "https://example.org/my-licence" # kept to remap later
|
| 137 |
+
none = ia.parse_oai_record(list(ET.fromstring(PAGE).iter("{http://www.openarchives.org/OAI/2.0/}record"))[0])
|
| 138 |
+
assert none["licence"] == "" and none["licence_url"] == ""
|