siddhm11 commited on
Commit
5938f71
·
2 Parent(s): 36c792ef894733

Merge origin/main into docs/session-2026-10-21

Browse files

Brings 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 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
- timeout-minutes: 120
 
 
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. summary printed as JSON, and to $GITHUB_STEP_SUMMARY when set
 
 
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"), "--since", since, "--until", until,
153
- "--state", str(state)]
154
  log = ""
155
  for attempt in (1, 2):
156
- p = subprocess.run(cmd, capture_output=True, text=True)
 
 
 
 
 
 
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 = re.sub(r"\s+", " ", _text(entry, "atom:title"))
95
- abstract = re.sub(r"\s+", " ", _text(entry, "atom:summary"))
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(4):
137
  try:
138
- with urllib.request.urlopen(url, timeout=120) as r:
139
  xml = r.read()
140
  break
141
  except Exception as e:
142
- if attempt == 3:
143
  print(f" [{category}] fetch failed: {str(e)[:90]}")
144
  return [], None
145
- time.sleep(5 * (attempt + 1))
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 plan_windows(category: str, since: str, until: str, probe=None) -> list[list[str]]:
166
- """Date windows for one category, each small enough for one arXiv query.
 
167
 
168
- Costs one request per window probed. A window that cannot be split further
169
- (a single day) is returned as is and will surface in the coverage report.
 
 
 
170
  """
171
- probe = probe or fetch_page
172
- _papers, total = probe(category, since, until, 0)
173
- time.sleep(ARXIV_DELAY if probe is fetch_page else 0)
174
- mid = _midpoint(since, until)
175
- if total is None or total <= MAX_RESULTS_PER_QUERY or mid in (since, until):
176
- return [[since, until]]
177
- return (plan_windows(category, since, mid, probe)
178
- + plan_windows(category, mid, until, probe))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- f"{url.rstrip('/')}/v2/pipeline", data=payload,
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
- f"{url.rstrip('/')}/v2/pipeline", data=payload,
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
- units = []
263
- for cat in cats:
264
- windows = state.get(f"{cat}#windows")
265
- if windows is None:
266
- windows = plan_windows(cat, args.since, args.until)
267
- state[f"{cat}#windows"] = windows
268
- units += [(cat, window_key(cat, windows, w), w) for w in windows]
269
-
270
- for cat, key, (w_since, w_until) in units:
271
- start = int(state.get(key, 0))
272
- while True:
273
- papers, total = fetch_page(cat, w_since, w_until, start)
274
- time.sleep(ARXIV_DELAY)
275
- if total is not None:
276
- state[f"{key}#total"] = total
277
- if not papers:
278
- break
279
-
280
- seen_total += len(papers)
281
- ids = [p["arxiv_id"] for p in papers]
282
-
283
- if args.dry_run:
284
- fresh = ids
285
- elif args.encode_only:
286
- fresh = ids
287
- vecs = encoder.encode([
288
- f"{p['title'][:256]} {p['abstract'][:1024]}" for p in papers])
289
- d, s = vecs[0]
290
- nz = sum(len(sp) for _dv, sp in vecs) / len(vecs)
291
- norm = sum(x * x for x in d) ** 0.5
292
- print(f" encoded {len(vecs)}: dense dim={len(d)} "
293
- f"norm={norm:.4f} | sparse avg {nz:.0f} terms/doc",
294
- flush=True)
295
- else:
296
- have = turso_existing(turso_url, turso_tok, ids)
297
- fresh = [i for i in ids if i not in have]
298
- todo = [p for p in papers if p["arxiv_id"] in set(fresh)]
299
- if todo:
300
- vecs = encoder.encode([
301
- f"{p['title'][:256]} {p['abstract'][:1024]}" for p in todo])
302
- upserter.upsert(todo, vecs)
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
- self._t(stmts)
 
 
 
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
- def test_windows_split_until_each_fits_one_query(monkeypatch):
84
- # 11,092 papers over 64 days, spread evenly: two halves of ~5.5k fit.
85
- def probe(cat, since, until, start):
86
- from datetime import date
87
- days = (date.fromisoformat(until) - date.fromisoformat(since)).days
88
- return [], round(11092 * days / 64)
89
- w = ingest_arxiv.plan_windows("cs.AI", "2026-07-29", "2026-10-01", probe=probe)
90
- assert w == [["2026-07-29", "2026-08-30"], ["2026-08-30", "2026-10-01"]]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91
 
92
 
93
- def test_small_or_failed_probe_keeps_one_window():
94
- assert ingest_arxiv.plan_windows("cs.RO", "2026-07-29", "2026-10-01",
95
- probe=lambda *a: ([], 3273)) == [["2026-07-29", "2026-10-01"]]
96
- assert ingest_arxiv.plan_windows("cs.RO", "2026-07-29", "2026-10-01",
97
- probe=lambda *a: ([], None)) == [["2026-07-29", "2026-10-01"]]
 
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"] == ""