diff --git a/.gitea/workflows/check.yml b/.gitea/workflows/check.yml
new file mode 100644
index 0000000..5fbeb95
--- /dev/null
+++ b/.gitea/workflows/check.yml
@@ -0,0 +1,35 @@
+name: Metadata integrity checks
+on:
+ push:
+ branches: [main, 'codex/**']
+jobs:
+ metadata:
+ runs-on: ces-control
+ env:
+ ARCHIVE_SHA: ${{ github.sha }}
+ PYTHONDONTWRITEBYTECODE: '1'
+ steps:
+ - name: Verify metadata storage and query commands
+ run: |
+ python3 -B - <<'PY'
+ import io, os, pathlib, re, subprocess, tarfile, tempfile, urllib.request
+ sha = os.environ['ARCHIVE_SHA']
+ if not re.fullmatch(r'[0-9a-fA-F]{40,64}', sha):
+ raise SystemExit('Invalid source commit')
+ # Public repository archive: no owner credentials or runner SSH keys.
+ url = 'https://gitea.entroia.com/Zayden-Jung/paper-atlas/archive/' + sha + '.tar.gz'
+ with urllib.request.urlopen(url, timeout=60) as response:
+ archive = response.read(16*1024*1024+1)
+ if len(archive)>16*1024*1024:
+ raise SystemExit('Unexpectedly large source archive')
+ with tempfile.TemporaryDirectory(prefix='paper-atlas-ci-') as tmp:
+ root = pathlib.Path(tmp)
+ with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:
+ tar.extractall(root, filter='data')
+ candidates = list(root.glob('*/test_import_arxiv.py'))
+ if len(candidates)!=1:
+ raise SystemExit('Unexpected repository archive layout')
+ src = candidates[0].parent
+ subprocess.run(['python3','-B','-m','unittest','discover','-v'],cwd=src,check=True)
+ subprocess.run(['python3','-B','query.py','--help'],cwd=src,check=True)
+ PY
diff --git a/.gitignore b/.gitignore
index adfd07a..b0a58ff 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,8 +3,15 @@ __pycache__/
*.sqlite
*.sqlite-*
*.zip
+*.gz
+*.bz2
+*.jsonl
*.part
+*.part.json
+*.download.lock
+*.maintenance.lock
.env
.DS_Store
raw/
data/
+collected/
diff --git a/README.md b/README.md
index 17edc73..02d83d0 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,7 @@ Paper Atlas 是一个**长期维护、按需或按批次运行的研究数据工
目标包括:完整历史元数据导入、逐年会议目录、跨平台论文去重、公开拒稿/撤稿信息、平台标签与自定义标签隔离、可恢复更新和覆盖率报告。
-**目前仍在开发,不能视为完整数据集。** 已实现 SQLite 数据结构、arXiv 元数据 ZIP 流式导入、断点恢复、DOI/标识合并和只读查询脚本。服务器上进行过合成样本验证;全量快照下载与历史导入尚未完成。会议适配器、跨平台模糊匹配复核流程、完整覆盖率核验与 CI 仍待实现。`venues.json` 的 41 个会议是候选覆盖范围,不代表已完成采集,也不是与 COLM 比较的客观排名。
+**目前仍在开发,不能视为完整数据集。** 已实现 SQLite 数据结构、arXiv 元数据 ZIP 流式导入、断点恢复、跨平台标识合并及审计、PMLR、COLM、NeurIPS、ACL Anthology、ICLR 官方公开程序、CVF/ECVA 视觉会议元数据采集、带清单核验的会议导入和只读查询。PeerRead 提供公开 ICLR 2017 明确决定的历史补充;近期 ICLR 公开决定快照已解析,实际主库进度以运行日志与统计为准。arXiv 元数据 ZIP 已完整下载(1,878,831,586 字节),主批次读取 3,233,620 行至 EOF;主库有 3,233,527 条 arXiv 来源记录,93 行差额仍需按原始 ID 独立核验。这些不是去重后的全库论文数,最终全库审计尚未完成。其他会议适配器、历史缺失届次、完整投稿历史、跨平台疑似重复复核和覆盖率核验仍待完成。回归检查在远端执行,已配置 ces-control:host 对应的 CI 工作流,实际 CI 结果以 Gitea Actions 为准。`venues.json` 的 41 个会议是候选覆盖范围,不代表已完成采集,也不是与 COLM 比较的客观排名。
## 数据范围与来源
@@ -30,6 +30,22 @@ Paper Atlas 是一个**长期维护、按需或按批次运行的研究数据工
- [AAAI](https://ojs.aaai.org/index.php/AAAI/issue/archive)
- [DBLP](https://dblp.org/db/conf/)
+## 候选会议覆盖目录
+
+| 领域 | 会议系列 | 主要发现入口 |
+| --- | --- | --- |
+| 机器学习、理论 | NeurIPS、ICML、ICLR、COLM、COLT、AISTATS、UAI、CLeaR | OpenReview、PMLR、NeurIPS、COLM 官方论文集 |
+| 自然语言 | ACL、EMNLP、NAACL、EACL、COLING、CoNLL | ACL Anthology |
+| 视觉 | CVPR、ICCV、ECCV | CVF、ECVA |
+| 综合 AI | AAAI、IJCAI、ECAI | 官方论文集、DBLP |
+| 检索与数据 | KDD、SIGIR、WWW、WSDM、RecSys、CIKM、ICDM、SDM、ECML-PKDD | 官方出版方、DBLP,具体年份入口待逐项登记 |
+| 机器人 | ICRA、IROS、RSS、CoRL | 官方论文集、PMLR、DBLP |
+| 规划、知识与智能体 | ICAPS、KR、AAMAS | 官方论文集、DBLP |
+| 语音与音频 | ICASSP、INTERSPEECH、ISMIR | 官方论文集、ISCA、DBLP |
+| 相关专门领域 | MICCAI、FAccT | 官方出版方、DBLP |
+
+这些是领域覆盖候选,不构成统一排名,也不能据此声称已全部入库。逐年目录以真实举办届次和正式出版清单为准:双年会议不补造不存在的年份;主会、Findings、workshop 分开。`edition_sources` 保存实际采集状态,不能用候选名单代替覆盖率报告。
+
## 数据模型
| 表 | 用途 |
@@ -37,22 +53,24 @@ Paper Atlas 是一个**长期维护、按需或按批次运行的研究数据工
| `papers` / `papers_fts` | 稳定论文实体、代表性标题摘要、FTS5 全文检索 |
| `identifiers` | arXiv ID、DOI 等唯一外部标识 |
| `source_records` / `sources` | 平台记录、原平台标题摘要与索引元数据、来源 URL 和抓取时间 |
-| `venues` / `editions` | 会议或期刊系列、年份与 track,区分主会、Findings 和 workshop |
+| `venues` / `venue_aliases` / `editions` | 会议或期刊系列、年份与 track,区分主会、Findings 和 workshop,联合会议用别名查询,不重复生成事件 |
| `edition_sources` | 每届每个来源的覆盖状态、预计/已导入数量、错误与断点 |
-| `submissions` / `decisions` | 多次投稿及带证据的决定历史,支持改投、撤稿和决定变更 |
+| `submissions` / `submission_identifiers` / `submission_sources` | 投稿事件、按届次限定的原平台强标识、多来源证据;同稿的不同投稿尝试分别保留 |
+| `decisions` / `decision_sources` | 明确决定、原始决定日期、来源观察日期及证据,不把缺失决定编造成拒稿 |
+| `submission_merges` / `decision_merges` | 投稿事件与重复决定观察的合并审计,保留来源关联并避免重用已合并的编号 |
| `tags` / `paper_tags` | 平台标签与人工标签,使用命名空间隔离 |
-| `match_candidates` | 证据不充分的潜在重复记录及人工复核状态 |
+| `match_candidates` / `paper_merges` | 潜在重复复核状态、确定合并审计 |
| `ingest_runs` | 导入批次、状态、错误和恢复位置 |
决定状态为 `accepted`、`rejected`、`desk_rejected`、`withdrawn`、`under_review`、`unknown`。一篇论文可以对应多次投稿和多个决定,不能用单一“最终会议”字段覆盖历史。查询中按观察时间选择最近状态;后续分析需要同时检查来源覆盖率。
-当前确定性去重使用 arXiv ID 和单个 DOI。保留各平台源记录,避免合并后丢失出处。规范化标题只建检索索引,尚未实现模糊匹配流程;**现阶段不保证跨平台去重已经完成**。不同平台标签不自动等同,自定义标签应使用 `custom:*` 命名空间。
+当前确定性去重使用 arXiv ID、单个 DOI 和 OpenReview forum ID;跨平台同标题还需至少两位作者吻合,或作者吻合且摘要相同,才自动合并。保留各平台源记录,避免合并后丢失出处。规范化标题只建检索索引,尚未实现模糊匹配流程;**现阶段不保证跨平台去重已经完成**。不同平台标签不自动等同,自定义标签应使用 `custom:*` 命名空间。
## 运行与查询
-依赖 Python 3 标准库和带 FTS5 的 SQLite,目前无第三方 Python 依赖,无项目构建步骤。服务器已检查为 Python 3.14.4。所有运行数据放在仓库外,不提交数据库、下载包或访问凭据。
+基础导入和查询依赖 Python 3 标准库和带 FTS5 的 SQLite;PMLR 采集另需 `requirements.txt` 中锁定的 PyYAML 6.0.3。无项目构建步骤。服务器已检查为 Python 3.14.4。所有运行数据放在仓库外,不提交数据库、下载包或访问凭据。
-服务器暂存目录:`/data/paper-index/`,快照目录:`/data/paper-index/raw/`,运行代码目录:`/data/paper-index/code/`。这些路径不是对外访问的 API。
+服务器暂存目录:`/data/paper-index/`,快照目录:`/data/paper-index/raw/`,运行代码目录:`/data/paper-index/code/`。这些路径不是对外访问的 API。本地工作区只用于编辑代码、文档和 Git;采集、全量导入和运行检查均在用户确认的服务器执行。
下载完成并验证 ZIP 后,在服务器运行:
@@ -80,7 +98,201 @@ python3 -B query.py --db /data/paper-index/papers.sqlite --tag arxiv:category=cs
python3 -B query.py --db /data/paper-index/papers.sqlite --venue COLM --year 2025 --status rejected
```
-查询连接使用 SQLite 只读模式,结果输出 JSON。当前没有公共 Web 页面或网络查询服务。
+查询连接使用 SQLite 只读模式,结果输出 JSON。`--stats` 中 `papers` 是按当前规则去重后的论文实体数,`records_by_source` 是各平台已入库记录数,`decision_events_by_status` 是决定事件数。采集文件 manifest 的 `count` 是已采集数量,不能等同已入库数量,也不能把各平台记录数相加当成独立论文数。
+
+全部目标会议的覆盖报告会保留尚无入库记录的项目,避免只展示已经做完的会议:
+
+```sh
+python3 -B coverage_report.py --db /data/paper-index/papers.sqlite \
+ --out /data/paper-index/collected/venue-coverage-current.json
+```
+
+报告按会议、年份和 track 列出投稿数、独立论文数及来源清单状态,包含联合会议别名。没有数据的目标明确标为 no_imported_records;已有数据也保持“历史待独立核验”,不会因单个文件导入通过就宣称历年覆盖完成。同一论文可以出现于多个会议或年份,各会议论文数也不能直接相加作为全库数量。
+
+用 `--paper 531782` 可查看单篇论文的所有已知来源、平台标签、投稿届次及带证据的决定历史。会议筛选不区分大小写,状态筛选使用该次投稿的最新已知决定。当前没有公共 Web 页面或网络查询服务。
+
+## 官方会议元数据采集
+
+```sh
+# 单卷示例:ICML 2025,读取官方 gh-pages 元数据,不下载论文正文
+python3 -B collect_pmlr.py --cache /data/paper-index/raw/http-cache \
+ --out /data/paper-index/collected --volume 267
+
+# 待主库完成 arXiv 导入、且没有其他写入进程后,合并会议记录
+python3 -B import_records.py --db /data/paper-index/papers.sqlite \
+ /data/paper-index/collected/pmlr-v267.jsonl
+```
+
+采集输出 JSONL 和对应 manifest,只有明确来源标签才产生相应决定:官方论文集记录为 accepted;PeerRead 使用其明确的 accepted 布尔标签,缺失标签保留 unknown。清单数目不一致时整个文件导入回滚,记录失败批次,不能标为覆盖完成。多个历史卷的全量覆盖仍需单独核验。旧卷可能把 PDF 放入归档;采集器对前 50 卷使用 Git partial clone 和 sparse checkout,仅取 `_posts` 和卷配置,不执行出版方项目代码。较新归档若超出 64 MiB 限额,也转为此方式。已对原本超限的 AISTATS 2009(第 5 卷)验证采集 84 条且工作区无 PDF。旧 YAML 的 `!binary` 文本、空文档尾标记会安全解析;带年份的 AISTATS/COLT 等历史 shortname 会规范化,避免名称别名漏届;缺失 year 时仅从官方会议 start 日期提取年份。`pmlr-volume-status.json` 逐卷保存结果和候选范围归属,`pmlr-last-run.json` 记录本批实际请求范围,局部重试不能证明全历史完成。缓存默认有效 24 小时;同一次批次可复用缓存,后续更新会重新读取动态入口。
+
+PMLR 的同一出版 ID 可能对应多份 Git 元数据,例如 CoRL 2023 的两篇论文分别存在不同日期的副本。只有论文内容与标识完全一致、差异限于元数据日期时才合为一条,并保留源文件路径和日期版本;冲突副本会使该卷采集失败,保留上一份有效文件。manifest 分别记录 metadata_post_count、duplicate_post_count 和论文 count。
+
+会议导入器还会独立检查每条记录的会议、年份、track 与 manifest 是否一致,并从实际记录计算缺失摘要数;旧 manifest 即使声称摘要完整,也不能让缺失摘要的来源被标为 complete。缺失的源摘要与合并后论文实体是否从其他来源补得摘要分别统计。
+
+PeerRead 采集仅选择作者公开发布的 ICLR 2017 投稿元数据,固定到源仓库 commit。只依据明确的布尔 `accepted` 标签形成 accepted/rejected;空标签为 unknown,不使用其他数据分区中的 probably-rejected 推断。不会保存 reviews 或 parsed_pdfs 正文。
+
+```sh
+python3 -B collect_peerread.py --cache /data/paper-index/raw/http-cache \
+ --out /data/paper-index/collected
+```
+
+数据任务以一次性 systemd 服务运行:`paper-atlas-arxiv-import`、`paper-atlas-pmlr-collect`、`paper-atlas-peerread-collect`;`paper-atlas-pmlr-verify` 用于全索引复核,`paper-atlas-conference-merge` 用于单写入合并。它们不是定时任务。主库只允许一个写入进程;会议采集先写独立 JSONL,待 arXiv 主批次结束后再合并;也可以使用 `merge_conferences.py` 在保存断点处暂停本项目的 arXiv unit、顺序合并会议清单并恢复。该操作应作为独立的一次性 systemd 任务运行,避免 SSH 断开影响恢复;不能同时启动多个合并任务。运行日志位于 `/data/paper-index/*-import.log`、`/data/paper-index/*-collect.log`。任何服务启动成功都不等于批次已完成,需检查批次状态、清单与完整性结果。标签按来源刷新使用 `paper_tags_source_evidence` 索引,避免每条论文都扫描整张标签表。运行中的 arXiv 可以通过同一协调脚本的 `--schema-only` 模式在断点更新索引后恢复;此模式要求已有 arXiv unit 正在运行。
+
+### 投稿事件身份与旧数据回填
+
+论文实体与投稿事件分开去重。同一篇论文可以在同一年向同一会议投稿多次,不能仅凭 paper_id、会议、标题相同就把投稿历史压成一次。跨来源只有原平台 forum ID、已验证的投稿编号,或正式发表稿的同届同 track 出版 DOI 等强证据才能关联同一事件。程序展示的内部编号仅在经过独立核验并带有 program_submission_number_verified 标记时才能用作投稿编号。
+
+新版 ICLR manifest 的 parser_version 为 2,明确区别投稿编号、forum 和展示事件编号。旧版文件应先用缓存重新采集;事件证据回填只接受已入库、与 manifest 的会议/年份/track 和数量一致的文件。队列 JSON 包含 files 及对应的 file_sha256,文件变化后必须重新核验,不能继续使用旧哈希。
+
+```sh
+python3 -B merge_conferences.py --db /data/paper-index/papers.sqlite \
+ --archive /data/paper-index/raw/arxiv-metadata.zip \
+ --backfill-queue /data/paper-index/collected/submission-evidence-queue.json
+```
+
+协调器只维护本项目的 arXiv unit,并对维护过程加互斥锁。若 arXiv 正在运行,会保存断点、应用 schema、回填来源身份、导入本次附加文件、检查外键后恢复;若批次已经读至 EOF 并标记 complete,则不再启动它。若导入停下但未完成,会报错要求先调查,不以“unit 不存在”推断成功或重新全量运行。应以一次性 systemd 任务运行该命令,保持单写入。
+
+首次升级会给已有投稿与决定补上来源关联;身份回填不重新导入标题摘要,不更改原抓取时间。确认同一次投稿后,可以合并镜像事件并保留所有决定及来源。当前稿件的原始强标识也会写入 source_records 元数据,后续源 URL 更新会同步保存。
+
+通用 JSONL 导入支持 decision_events 数组及 observed_at、decision_date、submitted_at。没有取得明确决定时,可用 unknown 状态和空数组仅保存投稿,不生成虚假的决定;`--status unknown` 也能查询这些投稿。历史快照使用其实际观察时间,不能因今天导入就覆盖较新来源的状态;同一观察时间内按原始决定日期排序。反复导入相同证据只补来源关联,不重复制造决定事件。全量结束后仍须单独核验重复候选、冲突决定及来源覆盖,迁移检查不能代替最终全库审计。
+
+### 固定版本的补充元数据快照
+
+`download_snapshot.py` 流式下载到 `.part`,核对完整 HTTP 响应、预期大小和可信来源发布的校验和后,才发布正式文件及 manifest。已存在的正式文件会先重新核验;错误校验和不会覆盖它。默认保留 8 GiB 空闲空间,同一输出只允许一个下载进程。
+
+DBLP 官方月度快照用于补充出版目录和历史届次,元数据按 CC0 发布。固定版本可复现,不把每日变动下载链接当作同一快照:
+
+```sh
+python3 -B download_snapshot.py \
+ --url https://drops.dagstuhl.de/storage/artifacts/dblp/xml/2026/dblp-2026-11-01.xml.gz \
+ --md5 18ad5db59592406ec74289975c4b44cb \
+ --output /data/paper-index/raw/dblp-2026-11-01.xml.gz
+```
+
+[官方版本与校验值](https://doi.org/10.4230/dblp.xml.2026-11-01)。下载器另外计算 SHA-256,记录来源 URL、大小和核验时间。DBLP 没有提供的摘要不能编造,出版目录也不能作为拒稿证据。
+
+DBLP 目录盘点先核验固定快照与[官方 DTD](https://doi.org/10.4230/dblp.xml.dtd.2023-06-28),再流式读取 XML,直至 gzip EOF 检查通过:
+
+```sh
+python3 -B download_snapshot.py \
+ --url https://drops.dagstuhl.de/storage/artifacts/dblp/xml/2023/dblp-2023-06-28.dtd \
+ --md5 96e4283e71886dec2173718957293648 \
+ --output /data/paper-index/raw/dblp-2023-06-28.dtd
+python3 -B catalogue_dblp.py \
+ --archive /data/paper-index/raw/dblp-2026-11-01.xml.gz \
+ --dtd /data/paper-index/raw/dblp-2023-06-28.dtd \
+ --out /data/paper-index/collected/dblp
+```
+
+`dblp-catalogue.sqlite` 保存论文集目录、按 crossref/年份/booktitle 的记录数量、各目录族数量及少量样本;`dblp-ai-candidates.jsonl` 保存候选会议族的原始索引元数据。存在多个出版卷字段的记录另存 `volume_anomalies`,保留全部值供归属核验;统计中仍只计一条,不任取第一个字段或展开成多篇。候选范围包含历史名称与 workshop,尚未赋予主会归属或录用状态。后续须依据具体论文集和届次核对目标范围、track 与出版标识,才能生成入库文件;不会把同一目录下的所有记录都视为主会。该盘点不下载 PDF,也不更改论文主库。
+
+XML 只允许校验过的本地 DTD;官方通用名 `dblp.dtd` 与月度快照使用的 `dblp-2023-06-28.dtd` 均解析到同一份已校验文件。拒绝其他外部实体和内部 DTD 子集,不访问 XML 声明的任意网络地址,并遵循源 XML 的字符编码。临时目录库或 gzip 检查失败时不发布正式候选文件,需先调查残留的 `.part` 状态,再决定是否重新运行。
+
+公开 ICLR 投稿快照的固定 Release 供后续筛选投稿和明确决定;它是第三方保存的 OpenReview 历史副本,不等于最新官方全量数据:
+
+```sh
+python3 -B download_snapshot.py \
+ --url https://github.com/qhjqhj00/iclr-openreview-reviews/releases/download/v1.0/iclr.tar.bz2 \
+ --sha256 350071daf25ebf1aef854ee1d5dc3a81834e1c468917c82a4f78af5548de34fc \
+ --expected-bytes 512940722 \
+ --output /data/paper-index/raw/iclr-openreview-v1.0.tar.bz2
+```
+
+[快照来源与复现脚本](https://github.com/qhjqhj00/iclr-openreview-reviews)。原包包含评审回复;解析只筛选公开投稿的索引、标题、摘要、平台标签及带证据的明确决定,不提取评审正文、作者邮件或论文全文。必须核对原始 invitation、readers、签名及决定字段,不能根据评分或录用名单缺席推断拒稿。
+
+优先处理近期三届,再单独补全 2020–2023;不以 2017 年的 PeerRead 样本代表近期拒稿覆盖:
+
+```sh
+python3 -B collect_openreview_snapshot.py \
+ --archive /data/paper-index/raw/iclr-openreview-v1.0.tar.bz2 \
+ --out /data/paper-index/collected --year 2024 --year 2025 --year 2026
+```
+
+解析器先核验固定 SHA-256,再流式读取 tar,以临时 SQLite 按年份和 forum 关联投稿及决定,兼容回复先于投稿的归档顺序。只接受明确对 everyone 公开的投稿和具有会议主席签名的 Decision;保留原始决定值、note ID、决定日期、invitation 和签名证据。无明确决定的投稿保留 unknown,不产生拒稿推断。无法关联到公开投稿元数据的公开决定另存 unmatched-decisions 文件,供复核,不擅自创建论文。输出清单分别统计投稿状态、决定事件、缺失摘要和排除原因;重复副本去重,内容冲突会中止。导入时若同一编号指向不同 forum,或同一 forum 指向不同投稿编号,会报冲突并回滚,不强制合并。
+
+该版本是 2026-05-08 发布的第三方历史副本,不代表平台当前状态或全部投稿;缺失决定及公开性筛选均需核验。采集成功也不等于主库已更新,后续应通过单写入协调器导入,并核对同届官方程序与公开决定的差异。
+
+入库前按真实 forum 对照同届官方程序,生成逐年状态、名单缺口、历史决定差异和文本版本差异报告,以及带 SHA-256 的现有程序身份回填队列:
+
+```sh
+python3 -B audit_iclr_snapshot.py --root /data/paper-index/collected \
+ --year 2024 --year 2025 --year 2026 \
+ --report /data/paper-index/collected/iclr-recent-audit.json
+```
+
+该检查要求清单和记录的届次、数量、摘要缺失统计一致,拒绝重复 forum、编号冲突及缺失公开决定证据。报告中的名单差异和 unknown 不会转换成拒稿。不同时间来源的决定保留各自证据,强 forum 标识负责关联同一次投稿;报告通过不等于平台全量覆盖或最终全库去重已经完成。生成的 `*-backfill-queue.json` 仅用于已入库程序记录的身份回填,快照文件仍须由协调器单独导入。
+
+下载异常后先检查对应进程或 CI 任务确已终止,再使用同一参数增加 `--resume`。只有断点身份一致且服务器实际返回正确 Range 响应才续传;不支持时保留断点并报错,不自动重下。正式文件与下载清单均放在仓库外,不提交大型来源包。
+
+### COLM 与 NeurIPS 官方摘要
+
+```sh
+python3 -B collect_proceedings.py --source colm \
+ --cache /data/paper-index/raw/http-cache --out /data/paper-index/collected
+python3 -B collect_proceedings.py --source neurips \
+ --cache /data/paper-index/raw/http-cache --out /data/paper-index/collected
+```
+
+公共元数据请求对连接重置、超时及不完整响应最多尝试三次,保留原有有效缓存;HTTP 403 等访问拒绝会直接记录失败。声明 Content-Length 与实际响应不符时不发布缓存,已有缓存也受字节上限约束。旧页面没有赋值的 HTML 属性按空值处理,避免某一个空 class 属性阻断整届采集。
+
+采集器从官方根目录发现实际发表届次,仅访问索引和 Abstract HTML 页面,不请求 PDF、补充材料或源码。可用重复 `--year` 限定实际存在的届次;不存在的年份会报错,局部重试保留其他届次的已有状态。每个来源串行请求,实际网络响应后等待 1 秒;缓存命中不额外等待。
+
+COLM 当前目录提供 2024–2026 三届 pre-proceedings,按会议年份保存,保留平台实际出版日期和出版状态,不把晚补刊日期当作会议年份。NeurIPS 当前目录提供 1987–2025,并包含 2025 独立卷及 2021 独立 Datasets and Benchmarks 索引。主会、datasets_benchmarks、position、creative_ai 分开出文件,不混为同一个 track。跨平台自标识仅从出版方 paper-actions 区域链接与 citation 元数据提取,不把摘要里的引用链接当作当前论文 ID。manifest 的 `parser_version` 至少为 2 才允许入库;旧文件须利用缓存重新采集,避免错误身份合并。首次真实样本核对:COLM 2024 官方目录 299 个摘要入口;NeurIPS 1987 为 90 个,作者与摘要均已验证解析。样本成功不代表所有页面采集完成。
+
+`colm-index-status.json`、`neurips-index-status.json` 保存逐索引状态;对应 `*-last-run.json` 保存本次实际选择范围。每个 JSONL 的 manifest 记录索引入口数、track 记录数和缺失摘要数。论文数量与官方页面声明不一致会失败,缺失摘要会标记 partial;不会把页脚、姓名政策或其他正文当作摘要。
+
+这两个采集器只写采集文件,合并仍通过单写入协调流程。全量历史覆盖和所有未实现会议仍待最终核验。
+
+### ACL Anthology 权威 XML
+
+```sh
+python3 -B collect_acl.py \
+ --checkout /data/paper-index/raw/metadata-checkouts/acl-anthology \
+ --out /data/paper-index/collected
+```
+
+`metadata_checkout.py` 对官方仓库做只包含 `data/xml` 的 partial clone/sparse checkout,不获取 PDF,不执行仓库 hooks 或构建。每批固定到实际 Git commit。遍历 XML 的 volume venue 字段选择 ACL、EMNLP、NAACL、EACL、COLING、CoNLL;Findings 按所属会议独立成 track,不按文件名把其他同场 workshop 冒充主会。
+
+新旧 Anthology ID 按官方规则构造,并核对源 DOI。保留 C69、D19-50 至 D19-66、W 系列的历史编号例外,排除编号 0 的前言。XML 混合标记通过 itertext 提取;无摘要保留为空并标记 partial。主会、Findings、workshop、demo、tutorial、industry、shared_task 分开。
+
+联合举办会议保存一个会议事件及多个名称别名。例如 LREC-COLING 的论文可用 COLING 或 LREC 查询,仍只有一次该届投稿记录。可用 `--track main` 限定主会,避免把 Findings 或演示算入主会;别名数据在正常单写入合并时应用 schema。
+
+`acl-collection-status.json` 记录每个 XML 及其 volume 的处理结果和排除原因;`acl-last-run.json` 记录源 commit 与本批范围。`--collection P17` 可限定一个确实存在的 collection。已验证真实 ACL 2017 长文 195 条、短文 107 条,二者摘要完整;其他 track 按源实际缺失情况记录,不能凭样本宣称整个历史完成。
+
+### ICLR 官方公开程序元数据
+
+```sh
+python3 -B collect_iclr.py --cache /data/paper-index/raw/http-cache \
+ --out /data/paper-index/collected
+# 入库后按官方主题检索;主题值以实际来源为准
+python3 -B query.py --db /data/paper-index/papers.sqlite \
+ --venue ICLR --tag 'iclr_program:topic=Reinforcement Learning->Deep RL'
+```
+
+从 ICLR 官方年份导航登记届次,读取对应 Papers 页面明确引用的 orals-posters/abstracts JSON。核对声明数量、分页状态及唯一 event ID;只下载公开程序元数据,不访问用户资料、PDF、海报、幻灯片或视频。`--year` 可限制年份;清单中的已采集数与数据库已入库数分别统计。
+
+官方程序中的 event 数不是论文数:同一篇文章可以有 poster 和 oral 两场展示。程序还会给负 sourceid 的 oral 展示生成占位 OpenReview 链接,这些链接不能作为真实论文标识。采集器核对原投稿编号、标题与作者后合并展示,优先使用正编号记录的真实论文链接;原展示编号和类型保留在源元数据中。
+
+同一投稿编号的展示标题可能有小幅修订。只有负编号 oral 与正编号原稿对应、至少两位作者完全一致且标题相似度满足严格阈值时,才保留为同稿的标题变体;该规则仅在一届官方程序内部生效,不用于跨平台模糊合并。
+
+只将 Conference 来源的论文视为 ICLR 主会录用,排除 Blog 和期刊转会展示,并在审计清单保存排除计数。2020/2021 旧数据允许依据官方主会 paper program 形成录用证据;无 forum ID 时保留 program 身份和匹配候选,不伪造标识。历史年度返回 404 会登记 unavailable,未来未发表届次登记 not_yet_published,其他访问或解析失败登记 blocked;这些状态都不能宣称该年已覆盖。
+
+主题、关键词使用 `iclr_program:topic`、`iclr_program:keyword` 命名空间。通用导入器按源记录刷新标签证据,保留同一论文其他源记录及 `custom:*` 标签。每年的 JSONL、manifest、`iclr-program-year-status.json` 和 `iclr-program-last-run.json` 保存实际范围、独立展示数、主会展示数、论文数、缺失摘要与缺失 forum 数。录用程序不能提供拒稿全集,公开投稿及拒稿补充仍需独立来源。
+
+### CVF / ECVA 视觉会议元数据
+
+```sh
+python3 -B collect_vision.py --source cvf \
+ --cache /data/paper-index/raw/http-cache --out /data/paper-index/collected
+python3 -B collect_vision.py --source ecva \
+ --cache /data/paper-index/raw/http-cache --out /data/paper-index/collected
+```
+
+CVF 从官方根目录发现 CVPR、ICCV 主会与 Findings,分别保存 track;不把 workshop 或 WACV 算入这些会议。优先跟随页面明确提供的 All Papers 链接,避免只收录默认显示的第一天。ECVA 从官方总目录按论文 URL 的会议与年份分组,当前总目录列有 ECCV 2018、2020、2022、2024。不存在于来源的 `--venue`、`--year` 或二者组合会报错,不补造双年会议届次,也不能因此推断旧届次未举办。
+
+只访问论文索引与摘要 HTML/PHP 页面。作者优先取 citation 元数据,旧页面尊重声明编码;无 citation 作者时取索引中的作者行,排除论文集出版信息。DOI 只取该论文条目所属的 DOI 链接或 citation 字段;冲突会失败,不从摘要引用中抽取当前论文 ID。不请求 PDF、补充材料、视频或代码。
+
+每份清单记录索引入口数、论文数、缺失摘要与作者数,缺失内容保留为空并标记 partial。存在未被标题条目解析的论文链接时会失败,防止静默漏项。逐来源的 discovery、index-status、last-run 文件分别保存发现目录、逐届结果和实际请求范围。可重复使用 `--year` 或 `--venue` 限定重试;其他届次状态会保留。官方开放目录之外的早期届次、尚未刊出的新届次和独立出版方历史数据仍需后续补齐,采集器完成不代表全历史覆盖已完成。
## 长期维护与更新
@@ -96,4 +308,6 @@ python3 -B query.py --db /data/paper-index/papers.sqlite --venue COLM --year 202
2026-11-06 从目标服务器测试:Kaggle 元数据 GET 可下载;OpenReview 会议 group 接口可读,但 notes 接口返回 `ChallengeRequiredError`。拒稿记录导入尚受此限制,不能用录用名单替代全部投稿,也不能声称已覆盖全部拒稿。需通过平台支持的访问方式或有合法来源的公开数据导出继续补全。
-全量导入、会议适配器、正式 runner 配置与 PR 交付状态应随实现及时更新本 README。
+已核验的采集文件计数:ICLR 2020–2026 主会 15,534 条、COLM 2024–2026 共 1,573 条,摘要均已取得;候选范围内的 PMLR 文件为 31,428 条,已剔除两份重复出版元数据。ACL Anthology 已选文件为 63,297 条,其中 15,147 条源记录缺摘要。这些是采集文件的来源记录数,不能相加当作去重论文数,也不能代替主库实时统计;实际入库量使用 query.py --stats 查看。
+
+代码在 `codex/metadata-index` 工作分支交付,[Draft PR #1](https://gitea.entroia.com/Zayden-Jung/paper-atlas/pulls/1) 尚未合并,等待全量就绪后用户手动合并。各提交分别由远程 CI 验证,结果以对应提交的 Gitea Actions 检查为准。全量导入、其他会议适配器、去重复核和覆盖率核验继续进行,应随实际结果更新此说明。
diff --git a/audit_iclr_snapshot.py b/audit_iclr_snapshot.py
new file mode 100644
index 0000000..8501d78
--- /dev/null
+++ b/audit_iclr_snapshot.py
@@ -0,0 +1,91 @@
+"""Cross-check collected public snapshots against same-edition official programs."""
+import argparse
+from collections import Counter
+import hashlib
+import json
+from pathlib import Path
+
+from index_core import author_keys,normalized
+from submission_evidence import identities_for_row,timestamp
+
+
+def load(path,source,year):
+ path=Path(path);manifest=json.loads(path.with_suffix('.manifest.json').read_text())
+ minimum=2 if source=='iclr_program' else 1
+ if manifest.get('parser_version',0)=3 and parts[0]=='conf' else ''
+
+
+def catalogue(archive,dtd,out,expected_sha=XML_SHA,expected_dtd_md5=DTD_MD5):
+ archive=Path(archive);out=Path(out);dtd=Path(dtd)
+ identity=digest_file(archive);dtd_identity=digest_file(dtd)
+ if identity['sha256']!=expected_sha:raise ValueError('DBLP snapshot checksum mismatch')
+ if dtd_identity['md5']!=expected_dtd_md5:raise ValueError('DBLP DTD checksum mismatch')
+ if dtd.stat().st_size>128*1024:raise ValueError('Unexpectedly large DTD')
+ out.mkdir(parents=True,exist_ok=True)
+ target=out/'dblp-catalogue.sqlite';stage=out/'dblp-catalogue.sqlite.part'
+ candidate=out/'dblp-ai-candidates.jsonl';part=candidate.with_suffix('.part')
+ if stage.exists():raise ValueError('Unfinished catalogue exists; inspect it before another run')
+ c=sqlite3.connect(stage)
+ c.executescript('PRAGMA journal_mode=DELETE; PRAGMA synchronous=NORMAL; CREATE TABLE proceedings(key TEXT PRIMARY KEY,family TEXT,metadata_json TEXT); CREATE INDEX proceedings_family ON proceedings(family); CREATE TABLE volume_counts(crossref TEXT,year TEXT,booktitle TEXT,record_type TEXT,count INTEGER,PRIMARY KEY(crossref,year,booktitle,record_type)); CREATE TABLE family_counts(family TEXT,record_type TEXT,count INTEGER,PRIMARY KEY(family,record_type)); CREATE TABLE family_samples(family TEXT,seq INTEGER,metadata_json TEXT,PRIMARY KEY(family,seq)); CREATE TABLE volume_anomalies(key TEXT PRIMARY KEY,metadata_json TEXT);')
+ totals=Counter();families=Counter();volumes=Counter();samples=Counter();staged=Counter();seen=0
+
+ def flush():
+ c.executemany('INSERT INTO volume_counts VALUES(?,?,?,?,?) ON CONFLICT(crossref,year,booktitle,record_type) DO UPDATE SET count=count+excluded.count',((*key,count) for key,count in volumes.items()))
+ volumes.clear();c.commit()
+
+ try:
+ with part.open('w') as output:
+ for row in records(archive,dtd.read_bytes()):
+ seen+=1;kind=row['record_type'];key=row['key'];fields=row['fields'];prefix=family(key)
+ if not key:raise ValueError('Bibliographic record lacks a key')
+ totals[kind]+=1
+ if kind=='proceedings':
+ c.execute('INSERT INTO proceedings VALUES(?,?,?)',(key,prefix,json.dumps(row,ensure_ascii=False)))
+ if prefix:
+ families[(prefix,kind)]+=1
+ if kind=='inproceedings':
+ crossrefs=fields.get('crossref') or [''];years=fields.get('year') or [''];books=fields.get('booktitle') or ['']
+ if any(len(values)!=1 for values in (crossrefs,years,books)):
+ c.execute('INSERT INTO volume_anomalies VALUES(?,?)',(key,json.dumps(row,ensure_ascii=False)))
+ totals['multiple_volume_fields']+=1
+ group=tuple(values[0] if len(values)==1 else '@multiple:'+json.dumps(values,ensure_ascii=False) for values in (crossrefs,years,books))
+ volumes[(*group,kind)]+=1
+ if samples[prefix]<2:
+ c.execute('INSERT INTO family_samples VALUES(?,?,?)',(prefix,samples[prefix],json.dumps(row,ensure_ascii=False)));samples[prefix]+=1
+ if prefix in FAMILIES:
+ output.write(json.dumps(row,ensure_ascii=False)+'\n');staged[prefix]+=1
+ if seen%50000==0:
+ flush();print(json.dumps({'source':'dblp','records_scanned':seen,'candidate_records':sum(staged.values())}),flush=True)
+ flush()
+ c.executemany('INSERT INTO family_counts VALUES(?,?,?)',((*key,count) for key,count in families.items()));c.commit()
+ if c.execute('PRAGMA quick_check').fetchall()!=[('ok',)]:raise ValueError('Catalogue integrity failed')
+ c.close();c=None
+ manifest={'source':'dblp','status':'catalogued_candidates_pending_scope_review','source_release':RELEASE,'snapshot_sha256':identity['sha256'],
+ 'dtd_md5':dtd_identity['md5'],'finished_at':timestamp(),'records_scanned':seen,'record_types':{kind:totals[kind] for kind in ('proceedings','inproceedings')},
+ 'multiple_volume_field_records':totals['multiple_volume_fields'],
+ 'candidate_records':sum(staged.values()),'candidate_family_counts':dict(staged),'candidate_sha256':digest_file(part)['sha256'],
+ 'file':str(candidate),'catalogue':str(target),'note':'Candidate families include workshops and historical names. No acceptance labels or normalized venue assignments have been inferred.'}
+ stage.replace(target);part.replace(candidate)
+ manifest_path=candidate.with_suffix('.manifest.json');manifest_part=manifest_path.with_suffix('.part')
+ manifest_part.write_text(json.dumps(manifest,indent=2));manifest_part.replace(manifest_path)
+ print(json.dumps(manifest),flush=True);return manifest
+ finally:
+ if c is not None:c.close()
+
+
+if __name__=='__main__':
+ parser=argparse.ArgumentParser();parser.add_argument('--archive',required=True);parser.add_argument('--dtd',required=True);parser.add_argument('--out',required=True)
+ args=parser.parse_args();catalogue(args.archive,args.dtd,args.out)
diff --git a/collect_acl.py b/collect_acl.py
new file mode 100644
index 0000000..cfd9976
--- /dev/null
+++ b/collect_acl.py
@@ -0,0 +1,131 @@
+"""Collect authoritative ACL Anthology XML metadata, never PDFs."""
+import argparse
+import json
+import re
+import xml.etree.ElementTree as ET
+from pathlib import Path
+
+from metadata_checkout import checkout_metadata
+
+VENUES={'acl':'ACL','emnlp':'EMNLP','naacl':'NAACL','eacl':'EACL','coling':'COLING','conll':'CoNLL'}
+
+
+def text(element):
+ return ' '.join(''.join(element.itertext()).split()) if element is not None else ''
+
+
+def paper_id(collection,volume,number):
+ if not str(number).isdigit() or not re.fullmatch(r'[A-Za-z0-9_-]+',str(volume)):
+ raise ValueError('Invalid Anthology paper numbering')
+ if re.fullmatch(r'\d{4}\.[a-z0-9]+',collection):
+ return f'{collection}-{volume}.{int(number)}'
+ if not re.fullmatch(r'[A-Z]\d{2}',collection) or not str(volume).isdigit():
+ raise ValueError('Unsupported historical identifier')
+ small=collection.startswith('W') or collection=='C69' or (collection=='D19' and 50<=int(volume)<=66)
+ width=2 if small else 1
+ digits=2 if small else 3
+ if len(str(int(volume)))>width or len(str(int(number)))>digits:
+ raise ValueError('Historical identifier exceeds documented width')
+ return f'{collection}-{int(volume):0{width}d}{int(number):0{digits}d}'
+
+
+def volume_scope(meta,volume):
+ codes=[text(n).lower() for n in meta.findall('venue')]
+ book=text(meta.find('booktitle'))
+ lower=book.lower()
+ selected=sorted({VENUES[v] for v in codes if v in VENUES})
+ if 'findings' in codes:
+ associated=volume.split('-')[0].lower()
+ if associated in VENUES:selected=[VENUES[associated]]
+ if not selected:
+ return None
+ joint_names=sorted({(VENUES|{'lrec':'LREC'})[v] for v in codes if v in VENUES or v=='lrec'})
+ if len(joint_names)>1:selected=joint_names
+ track='main'
+ if 'findings' in codes or 'findings of' in lower:track='findings'
+ elif 'shared task' in lower:track='shared_task'
+ elif 'workshop' in lower or 'student research' in lower:track='workshop'
+ elif 'demonstration' in lower or 'system demonstrations' in lower:track='demo'
+ elif 'tutorial' in lower:track='tutorial'
+ elif 'industry' in lower:track='industry'
+ return '+'.join(selected),selected,track
+
+
+def collect_xml(path,out,commit):
+ root=ET.parse(path).getroot()
+ collection=root.attrib['id']
+ results=[]
+ for volume in root.findall('volume'):
+ vid=volume.attrib['id']
+ meta=volume.find('meta')
+ if meta is None:raise ValueError('Volume metadata missing')
+ scope=volume_scope(meta,vid)
+ key=f'{collection}-{vid}'
+ if scope is None:
+ results.append({'volume':key,'status':'excluded_by_venue','venue_codes':[text(n) for n in meta.findall('venue')],'booktitle':text(meta.find('booktitle')),'year':text(meta.find('year'))})
+ continue
+ venue,aliases,track=scope
+ year_text=text(meta.find('year'))
+ if not re.fullmatch(r'\d{4}',year_text):raise ValueError('No explicit official volume year: '+key)
+ year=int(year_text)
+ records=[]
+ frontmatter=0
+ for paper in volume.findall('paper'):
+ pid=paper.attrib['id']
+ if int(pid)==0:
+ frontmatter+=1
+ continue
+ identifier=paper_id(collection,vid,pid)
+ doi=text(paper.find('doi'))
+ if doi.startswith('10.18653/v1/') and doi.split('10.18653/v1/',1)[1].casefold()!=identifier.casefold():
+ raise ValueError('Official DOI disagrees with constructed Anthology ID: '+identifier)
+ title=text(paper.find('title'))
+ if not title:raise ValueError('Missing paper title: '+identifier)
+ authors=[{'given':text(a.find('first')),'family':text(a.find('last'))} for a in paper.findall('author')]
+ records.append({'source':'acl_anthology','external_id':identifier,'url':f'https://aclanthology.org/{identifier}/','title':title,'abstract':text(paper.find('abstract')) or None,'authors':authors,'identifiers':{'acl':identifier,'doi':doi},'venue':venue,'venue_aliases':aliases if len(aliases)>1 else [],'year':year,'track':track,'status':'accepted','raw_decision':'listed in authoritative ACL Anthology proceedings XML','metadata':{'collection':collection,'volume':vid,'booktitle':text(meta.find('booktitle')),'venue_codes':[text(n) for n in meta.findall('venue')],'pages':text(paper.find('pages')),'publisher':text(meta.find('publisher')),'metadata_commit':commit,'source_xml':f'https://github.com/acl-org/acl-anthology/blob/{commit}/data/xml/{collection}.xml'}})
+ if not records:
+ results.append({'volume':key,'status':'unavailable','reason':'no research paper elements after excluding front matter'})
+ continue
+ target=Path(out)/f'acl-{key}.jsonl'
+ target.parent.mkdir(parents=True,exist_ok=True)
+ part=target.with_suffix('.part')
+ with part.open('w') as output:
+ for record in records:output.write(json.dumps(record,ensure_ascii=False)+'\n')
+ part.replace(target)
+ missing=sum(not row['abstract'] for row in records)
+ manifest={'source':'acl_anthology','venue':venue,'year':year,'track':track,'count':len(records),'xml_paper_elements':len(volume.findall('paper')),'excluded_frontmatter':frontmatter,'missing_abstract_count':missing,'list_url':f'https://aclanthology.org/{key}/','metadata_commit':commit,'file':str(target),'status':'partial' if missing else 'collected'}
+ target.with_suffix('.manifest.json').write_text(json.dumps(manifest,indent=2))
+ results.append(manifest)
+ print(json.dumps(manifest),flush=True)
+ return results
+
+
+if __name__=='__main__':
+ parser=argparse.ArgumentParser()
+ parser.add_argument('--checkout',required=True)
+ parser.add_argument('--out',required=True)
+ parser.add_argument('--collection',action='append')
+ args=parser.parse_args()
+ commit=checkout_metadata('https://github.com/acl-org/acl-anthology.git',args.checkout,['/data/xml/'])
+ out=Path(args.out);out.mkdir(parents=True,exist_ok=True)
+ status_file=out/'acl-collection-status.json'
+ catalogue=json.loads(status_file.read_text()) if status_file.exists() else {}
+ paths=sorted((Path(args.checkout)/'data/xml').glob('*.xml'))
+ if not paths:raise SystemExit('No official XML files found')
+ if args.collection and set(args.collection)-{p.stem for p in paths}:parser.error('Requested collection is absent')
+ errors=[]
+ processed=0
+ for path in paths:
+ if args.collection and path.stem not in args.collection:continue
+ try:catalogue[path.stem]={'status':'processed','metadata_commit':commit,'volumes':collect_xml(path,args.out,commit)}
+ except Exception as exc:
+ catalogue[path.stem]={'status':'blocked','metadata_commit':commit,'error':str(exc)}
+ errors.append(path.stem)
+ print(json.dumps({'collection':path.stem,'error':str(exc)}),flush=True)
+ processed+=1
+ if processed%25==0:
+ part=status_file.with_suffix('.part');part.write_text(json.dumps(catalogue,indent=2));part.replace(status_file)
+ print(json.dumps({'xml_collections_processed':processed}),flush=True)
+ part=status_file.with_suffix('.part');part.write_text(json.dumps(catalogue,indent=2));part.replace(status_file)
+ (out/'acl-last-run.json').write_text(json.dumps({'metadata_commit':commit,'collection_filter':args.collection,'blocked_collections':errors},indent=2))
+ if errors:raise SystemExit(1)
diff --git a/collect_iclr.py b/collect_iclr.py
new file mode 100644
index 0000000..6eb4eb0
--- /dev/null
+++ b/collect_iclr.py
@@ -0,0 +1,247 @@
+"""Collect public ICLR program metadata, excluding blog/journal presentations."""
+import argparse
+from collections import Counter, defaultdict
+from datetime import datetime, timezone
+from difflib import SequenceMatcher
+import json
+from pathlib import Path
+import re
+from urllib.error import HTTPError
+from urllib.parse import parse_qs, urljoin, urlsplit
+
+from collect_pmlr import fetch
+from collect_proceedings import Document, clean
+from index_core import author_keys, normalized
+
+ROOT = 'https://iclr.cc/'
+SOURCE = 'iclr_program'
+
+
+class NotYetPublished(ValueError):
+ pass
+
+
+def discover(html):
+ years = {}
+ for node in Document(html).root.walk():
+ text = clean(node.text())
+ if node.tag == 'a' and re.fullmatch(r'20\d{2}', text):
+ url = urljoin(ROOT, node.attrs.get('href', ''))
+ if urlsplit(url).netloc == 'iclr.cc':
+ years[int(text)] = url
+ if not years:
+ raise ValueError('No ICLR years in the official navigation')
+ return sorted(years.items())
+
+
+def asset_urls(html, page_url, year):
+ refs = re.findall(r'''["']([^"'\s]+\.json)["']''', html)
+ result = {}
+ for kind in ('orals-posters', 'abstracts'):
+ matches = {urljoin(page_url, ref) for ref in refs
+ if urlsplit(urljoin(page_url, ref)).netloc == 'iclr.cc'
+ and urlsplit(urljoin(page_url, ref)).path == f'/static/virtual/data/iclr-{year}-{kind}.json'}
+ if len(matches) > 1:
+ raise ValueError('Ambiguous official program asset')
+ if matches:
+ result[kind] = matches.pop()
+ if 'orals-posters' not in result:
+ raise ValueError('Official page has no supported program JSON reference')
+ return result
+
+
+def main_event(row, year):
+ source = row.get('sourceurl')
+ if source:
+ parsed = urlsplit(source)
+ return (parsed.netloc == 'openreview.net' and parsed.path == '/group'
+ and parse_qs(parsed.query).get('id') == [f'ICLR.cc/{year}/Conference'])
+ # These historical official paper lists predate sourceurl/decision fields.
+ return year in (2020, 2021) and row.get('eventtype') in ('Poster', 'Oral', 'Spotlight')
+
+
+def source_number(row):
+ value = row.get('sourceid')
+ return int(value) if re.fullmatch(r'-?[1-9]\d*', str(value)) else None
+
+
+def identifiers(row):
+ # The program creates negative-ID oral events with fabricated forum URLs.
+ if (source_number(row) or 0) < 0:
+ return {}
+ links = [row.get('paper_url'), row.get('paper_pdf_url')]
+ links += [media.get('uri') for media in row.get('eventmedia', [])
+ if str(media.get('name') or '').casefold() in ('openreview', 'paper')]
+ forums = set()
+ for link in links:
+ if not isinstance(link, str):
+ continue
+ parsed = urlsplit(link)
+ if parsed.netloc == 'openreview.net' and parsed.path in ('/forum', '/pdf'):
+ values = parse_qs(parsed.query).get('id', [])
+ if len(values) == 1 and re.fullmatch(r'[\w-]+', values[0]) and not re.match(r'20\d{2}-Oral-', values[0]):
+ forums.add(values[0])
+ if len(forums) > 1:
+ raise ValueError('Conflicting paper self-links in one program event')
+ return {'openreview': forums.pop()} if forums else {}
+
+
+def parse_program(data, abstracts, year, index_url):
+ if not isinstance(data, dict) or not isinstance(data.get('results'), list):
+ raise ValueError('Expected a program results array with a declared count')
+ events = data['results']
+ if data.get('next') or data.get('previous') or data.get('count') != len(events):
+ raise ValueError('Program JSON is paginated or its count differs from events')
+ if not isinstance(abstracts, dict):
+ raise ValueError('Expected an abstract mapping keyed by event ID')
+ event_ids = [str(row['id']) for row in events]
+ if len(event_ids) != len(set(event_ids)):
+ raise ValueError('Duplicate event IDs in program JSON')
+ grouped = defaultdict(list)
+ excluded = Counter()
+ for row in events:
+ if not main_event(row, year):
+ excluded[str(row.get('sourceurl') or 'unclassified')] += 1
+ continue
+ decision = row.get('decision')
+ if decision and not (str(decision).casefold().startswith('accept') or decision in ('Poster', 'Oral', 'Spotlight')):
+ raise ValueError('Unexpected decision in an accepted conference program')
+ number = source_number(row)
+ identity = ('submission', abs(number)) if number else ('forum', identifiers(row).get('openreview'))
+ if identity == ('forum', None):
+ identity = ('event', row['id'])
+ grouped[identity].append(row)
+ records = []
+ missing_identity = 0
+ for identity, group in grouped.items():
+ # Verify the negative oral event really describes its positive counterpart.
+ group.sort(key=lambda r: ((source_number(r) or 0) < 0, str(r['id'])))
+ primary = group[0]
+ title = normalized(primary.get('name', ''))
+ authors = [author_keys([a['fullname'] for a in row.get('authors', [])]) for row in group]
+ title_match = all(normalized(row.get('name', '')) == title or (
+ identity[0] == 'submission' and (source_number(row) or 0) < 0
+ and (source_number(primary) or 0) > 0 and len(authors[0]) >= 2
+ and SequenceMatcher(None, title, normalized(row.get('name', ''))).ratio() >= 0.85)
+ for row in group)
+ if not title or not title_match or any(a != authors[0] for a in authors):
+ raise ValueError('Program submission ID collides across different papers')
+ own_ids = {}
+ for row in group:
+ for scheme, value in identifiers(row).items():
+ if scheme in own_ids and own_ids[scheme] != value:
+ raise ValueError('Conflicting identifiers across presentation events')
+ own_ids[scheme] = value
+ abstract = next((text for row in group
+ if (text := row.get('abstract') or abstracts.get(str(row['id'])))), None)
+ if abstract is not None and not isinstance(abstract, str):
+ raise ValueError('Nontext abstract')
+ presentation_ids = [row['id'] for row in group]
+ forum = own_ids.get('openreview')
+ external = f'{year}/{forum}' if forum else f'{year}/program-{identity[1]}'
+ missing_identity += not bool(forum)
+ page = urljoin(ROOT, primary.get('virtualsite_url') or f'/virtual/{year}/{str(primary.get("eventtype", "Poster")).lower()}/{primary["id"]}')
+ topics = sorted({str(row['topic']).strip() for row in group if row.get('topic')})
+ keywords = sorted({str(keyword).strip() for row in group for keyword in row.get('keywords', []) if keyword})
+ records.append({'source': SOURCE, 'external_id': external, 'url': page,
+ 'title': clean(primary['name']), 'abstract': abstract.strip() if abstract else None,
+ 'authors': [a['fullname'] for a in primary.get('authors', [])],
+ 'identifiers': own_ids, 'venue': 'ICLR', 'year': year, 'track': 'main',
+ 'status': 'accepted', 'raw_decision': str(primary.get('decision') or 'listed in official conference paper program'),
+ 'evidence_url': index_url, 'list_url': index_url,
+ 'tags': [{'namespace': SOURCE+':'+kind, 'value': value}
+ for kind, values in [('topic', topics), ('keyword', keywords)] for value in values],
+ 'metadata': {'program_event_ids': presentation_ids,
+ 'program_identity_type': identity[0],
+ 'presentation_titles': sorted({clean(r['name']) for r in group}),
+ 'program_source_id': identity[1], 'presentation_types': sorted({str(r.get('event_type') or r.get('eventtype')) for r in group}),
+ 'raw_decisions': sorted({str(r.get('decision')) for r in group if r.get('decision')}),
+ 'identity_state': 'forum' if forum else 'program_only'}})
+ # Different source numbers can still identify the same forum. Keep one record.
+ by_id = {}
+ for row in records:
+ previous = by_id.get(row['external_id'])
+ if previous:
+ if normalized(previous['title']) != normalized(row['title']) or author_keys(previous['authors']) != author_keys(row['authors']):
+ raise ValueError('One forum identifies different program papers')
+ previous['metadata']['program_event_ids'] += row['metadata']['program_event_ids']
+ previous['metadata']['presentation_types'] = sorted(set(previous['metadata']['presentation_types'] + row['metadata']['presentation_types']))
+ previous['abstract'] = previous['abstract'] or row['abstract']
+ else:
+ by_id[row['external_id']] = row
+ result = list(by_id.values())
+ if not result:
+ raise ValueError('No verified main-conference papers in program')
+ return result, {'program_event_count': len(events), 'main_event_count': sum(map(len, grouped.values())),
+ 'excluded_events_by_source': dict(excluded), 'missing_forum_count': missing_identity,
+ 'missing_abstract_count': sum(not r['abstract'] for r in result)}
+
+
+def collect_year(year, cache, out):
+ page = f'https://iclr.cc/virtual/{year}/papers.html'
+ assets = asset_urls(fetch(page, cache, 16*1024**2, delay=1).decode(), page, year)
+ data = json.loads(fetch(assets['orals-posters'], cache, 32*1024**2, delay=1))
+ if data.get('count') == 0 and data.get('results') == [] and year > datetime.now(timezone.utc).year:
+ raise NotYetPublished('Official program is empty for a future conference')
+ abstracts = {}
+ abstract_asset_status = 'not_referenced'
+ if 'abstracts' in assets:
+ try:
+ abstracts = json.loads(fetch(assets['abstracts'], cache, 32*1024**2, delay=1))
+ abstract_asset_status = 'available'
+ except HTTPError as exc:
+ if exc.code != 404:
+ raise
+ # Historical programs embed abstracts but retain a stale split-file URL.
+ exc.close()
+ abstract_asset_status = 'unavailable_404'
+ records, audit = parse_program(data, abstracts, year, page)
+ path = Path(out)/f'iclr-program-{year}-main.jsonl'
+ part = path.with_suffix('.part')
+ part.write_text(''.join(json.dumps(row, ensure_ascii=False)+'\n' for row in records))
+ part.replace(path)
+ manifest = {'source': SOURCE, 'parser_version': 2, 'venue': 'ICLR', 'year': year, 'track': 'main',
+ 'count': len(records), 'list_url': page, 'asset_urls': assets,
+ 'abstract_asset_status': abstract_asset_status, 'file': str(path),
+ 'status': 'partial' if audit['missing_abstract_count'] else 'collected', **audit}
+ path.with_suffix('.manifest.json').write_text(json.dumps(manifest, indent=2))
+ return manifest
+
+
+def main():
+ parser = argparse.ArgumentParser()
+ parser.add_argument('--cache', required=True)
+ parser.add_argument('--out', required=True)
+ parser.add_argument('--year', type=int, action='append')
+ args = parser.parse_args()
+ out = Path(args.out); out.mkdir(parents=True, exist_ok=True)
+ years = discover(fetch(ROOT, args.cache, 4*1024**2, delay=1).decode())
+ if args.year and set(args.year)-{year for year, _ in years}:
+ parser.error('Requested year is absent from the official navigation')
+ status_path = out/'iclr-program-year-status.json'
+ catalogue = json.loads(status_path.read_text()) if status_path.exists() else {}
+ requested = []
+ for year, conference_url in years:
+ if args.year and year not in args.year:
+ continue
+ requested.append(year)
+ try:
+ result = collect_year(year, args.cache, out)
+ except NotYetPublished as exc:
+ result = {'year': year, 'status': 'not_yet_published', 'error': str(exc)}
+ except HTTPError as exc:
+ result = {'year': year, 'status': 'not_yet_published' if year > datetime.now(timezone.utc).year and exc.code == 404 else 'unavailable' if exc.code == 404 else 'blocked', 'error': str(exc)}
+ exc.close()
+ except Exception as exc:
+ result = {'year': year, 'status': 'blocked', 'error': str(exc)}
+ result['conference_url'] = conference_url
+ catalogue[str(year)] = result
+ part = status_path.with_suffix('.part'); part.write_text(json.dumps(catalogue, indent=2)); part.replace(status_path)
+ print(json.dumps(result), flush=True)
+ (out/'iclr-program-last-run.json').write_text(json.dumps({'requested_years': requested, 'year_filter': args.year}, indent=2))
+ if any(catalogue[str(y)]['status'] == 'blocked' for y in requested):
+ raise SystemExit(1)
+
+
+if __name__ == '__main__':
+ main()
diff --git a/collect_openreview_snapshot.py b/collect_openreview_snapshot.py
new file mode 100644
index 0000000..b2c3302
--- /dev/null
+++ b/collect_openreview_snapshot.py
@@ -0,0 +1,200 @@
+"""Extract public paper metadata and signed Decision notes from a pinned release."""
+import argparse
+from collections import Counter,defaultdict
+from contextlib import closing
+from datetime import datetime,timezone
+import json
+from pathlib import Path
+import re
+import sqlite3
+import tarfile
+import tempfile
+
+from download_snapshot import digest_file
+
+
+SOURCE='openreview_snapshot'
+SHA256='350071daf25ebf1aef854ee1d5dc3a81834e1c468917c82a4f78af5548de34fc'
+RELEASE='https://github.com/qhjqhj00/iclr-openreview-reviews/releases/tag/v1.0'
+AS_OF='2026-05-08T04:50:23.000Z'
+EXPECTED={2020:2213,2021:2594,2022:2617,2023:3792,2024:7404,2025:11672,2026:19814}
+
+
+def value(content,key,default=None):
+ result=content.get(key,default)
+ return result.get('value',default) if isinstance(result,dict) else result
+
+
+def invitations(note):
+ result=note.get('invitations') or []
+ if isinstance(result,str):result=[result]
+ if note.get('invitation'):result=list(result)+[note['invitation']]
+ return [i for i in result if isinstance(i,str)]
+
+
+def public(note):
+ return isinstance(note.get('readers'),list) and 'everyone' in note['readers']
+
+
+def date_ms(raw):
+ if raw is None:return None
+ return datetime.fromtimestamp(int(raw)/1000,timezone.utc).isoformat(timespec='milliseconds').replace('+00:00','Z')
+
+
+def decision_status(raw):
+ key=' '.join(raw.casefold().replace('_',' ').split())
+ if key.startswith(('desk reject','desk-reject')):return 'desk_rejected'
+ if key in ('accept','accepted','poster','oral','spotlight') or key.startswith(('accept (','accept:','accept -')):return 'accepted'
+ if key in ('reject','rejected','reject and resubmit') or key.startswith(('reject (','reject:','reject -')):return 'rejected'
+ if key in ('withdraw','withdrawn','withdrawal'):return 'withdrawn'
+ return 'unknown'
+
+
+def paper_record(note,year):
+ if not public(note):return None,'not_public'
+ base=f'ICLR.cc/{year}/Conference/-/'
+ if not any(i in (base+'Submission',base+'Blind_Submission') for i in invitations(note)):return None,'wrong_invitation'
+ forum=note.get('id')
+ if not isinstance(forum,str) or not re.fullmatch(r'[A-Za-z0-9_-]+',forum) or note.get('forum') not in (None,forum):return None,'not_submission_root'
+ content=note.get('content') or {};title=value(content,'title');abstract=value(content,'abstract')
+ if not isinstance(title,str) or not title.strip():return None,'missing_title'
+ if abstract is not None and not isinstance(abstract,str):return None,'nontext_abstract'
+ authors=value(content,'authors',[]) or []
+ if isinstance(authors,str):authors=[a.strip() for a in authors.split(',')]
+ if not isinstance(authors,list):return None,'invalid_authors'
+ authors=[a.strip() for a in authors if isinstance(a,str) and a.strip() and not re.fullmatch(r'anonymous(?: authors?)?',a.strip(),re.I)]
+ number=note.get('number');number=int(number) if re.fullmatch(r'[1-9]\d*',str(number)) else None
+ identifiers={'openreview':forum}
+ for field in ('doi','arxiv','arxiv_id'):
+ own=value(content,field)
+ if not isinstance(own,str):continue
+ own=own.strip()
+ if field=='doi':
+ own=re.sub(r'^https?://(?:dx\.)?doi\.org/','',own,flags=re.I).lower()
+ if re.fullmatch(r'10\.\d{4,9}/\S+',own):identifiers['doi']=own
+ else:
+ own=re.sub(r'^https?://arxiv.org/(?:abs|pdf)/','',own).removesuffix('.pdf')
+ if re.fullmatch(r'(?:\d{4}\.\d{4,5}|[a-zA-Z.-]+/\d{7})(?:v\d+)?',own):identifiers['arxiv']=own
+ tags=[];index_fields={}
+ for field in ('keywords','primary_area','secondary_area','Please_choose_the_closest_area_that_your_submission_falls_into'):
+ raw=value(content,field)
+ if raw is None:continue
+ index_fields[field]=raw
+ terms=raw if isinstance(raw,list) else re.split(r'[,;\n]',raw) if field=='keywords' and isinstance(raw,str) else [raw]
+ namespace=SOURCE+':'+('keyword' if field=='keywords' else field)
+ tags.extend({'namespace':namespace,'value':term.strip()} for term in terms if isinstance(term,str) and term.strip())
+ ids=[{'scheme':'openreview','value':forum}]
+ if number:ids.append({'scheme':'iclr_number','value':str(number)})
+ return {'source':SOURCE,'external_id':f'{year}/{forum}','url':'https://openreview.net/forum?id='+forum,
+ 'title':' '.join(title.split()),'abstract':abstract.strip() if abstract else None,'authors':authors,
+ 'identifiers':identifiers,'submission_identifiers':ids,'venue':'ICLR','year':year,'track':'main',
+ 'submitted_at':date_ms(note.get('cdate') or note.get('tcdate')),'observed_at':AS_OF,'tags':tags,
+ 'metadata':{'forum':forum,'source_submission_number':number,'original_invitations':invitations(note),
+ 'public_readers_verified':True,'index_fields':index_fields,'venue':value(content,'venue'),
+ 'venueid':value(content,'venueid'),'snapshot_release':RELEASE,
+ 'snapshot_observation_upper_bound':AS_OF}},None
+
+
+def decision_record(note,year):
+ if not public(note):return None,'not_public'
+ base=f'ICLR.cc/{year}/Conference/'
+ if not any(i.startswith(base) and i.endswith('/Decision') for i in invitations(note)):return None,'not_decision'
+ if not any(isinstance(s,str) and s.startswith(base) and s.rsplit('/',1)[-1] in ('Program_Chairs','Area_Chairs','Senior_Area_Chairs') for s in note.get('signatures',[])):
+ return None,'unverified_signature'
+ if note.get('ddate'):return None,'deleted_decision'
+ forum=note.get('forum');identifier=note.get('id')
+ if not all(isinstance(v,str) and re.fullmatch(r'[A-Za-z0-9_-]+',v) for v in (forum,identifier)):return None,'invalid_identity'
+ if note.get('_paper_forum',forum)!=forum:return None,'forum_mismatch'
+ raw=value(note.get('content') or {},'decision')
+ if not isinstance(raw,str) or not raw.strip():return None,'missing_decision_value'
+ return {'id':identifier,'forum':forum,'status':decision_status(raw),'raw_value':raw,
+ 'evidence_url':f'https://openreview.net/forum?id={forum}¬eId={identifier}',
+ 'observed_at':AS_OF,'decision_date':date_ms(note.get('cdate') or note.get('tcdate') or note.get('mdate')),
+ 'source_decision_id':identifier,'authority':{'invitations':invitations(note),
+ 'signatures':note.get('signatures',[]),'public_readers_verified':True}},None
+
+
+def collect(archive,out,years,expected_sha=SHA256):
+ actual=digest_file(Path(archive))
+ if actual['sha256']!=expected_sha:raise ValueError('Snapshot checksum mismatch before parsing')
+ if expected_sha==SHA256 and actual['bytes']!=512940722:raise ValueError('Unexpected pinned snapshot size')
+ out=Path(out);out.mkdir(parents=True,exist_ok=True);years=set(years)
+ counts=defaultdict(Counter);members=set()
+ with tempfile.TemporaryDirectory(prefix='openreview-stage-',dir=out) as tmp,closing(sqlite3.connect(Path(tmp)/'metadata.sqlite')) as c:
+ c.executescript('PRAGMA synchronous=OFF; CREATE TABLE papers(year INTEGER,forum TEXT,payload TEXT,PRIMARY KEY(year,forum)); CREATE TABLE decisions(year INTEGER,id TEXT,forum TEXT,payload TEXT,PRIMARY KEY(year,id)); CREATE INDEX decisions_forum ON decisions(year,forum);')
+ with tarfile.open(archive,mode='r|bz2') as tar:
+ for member in tar:
+ match=re.fullmatch(r'(?:\./)?data/iclr_(20\d{2})/(papers|reviews)\.jsonl',member.name)
+ if not match or int(match[1]) not in years:continue
+ year=int(match[1]);kind=match[2]
+ if not member.isfile() or (year,kind) in members:raise ValueError('Invalid or repeated metadata member')
+ members.add((year,kind));lines=0
+ with tar.extractfile(member) as stream:
+ while line:=stream.readline(16*1024**2+1):
+ if len(line)>16*1024**2:raise ValueError('Unexpectedly large metadata line')
+ if not line.strip():continue
+ lines+=1;counts[year][kind+'_lines']+=1
+ if kind=='reviews' and b'/Decision' not in line:continue
+ note=json.loads(line)
+ row,reason=paper_record(note,year) if kind=='papers' else decision_record(note,year)
+ if row is None:
+ counts[year][kind+'_excluded_'+reason]+=1;continue
+ if kind=='papers':
+ forum=row['metadata']['forum'];payload=json.dumps(row,ensure_ascii=False)
+ old=c.execute('SELECT payload FROM papers WHERE year=? AND forum=?',(year,forum)).fetchone()
+ if old and old[0]!=payload:raise ValueError('Conflicting copies of one submission')
+ if old:counts[year]['duplicate_papers']+=1
+ else:c.execute('INSERT INTO papers VALUES(?,?,?)',(year,forum,payload))
+ else:
+ payload=json.dumps(row,ensure_ascii=False);old=c.execute('SELECT payload FROM decisions WHERE year=? AND id=?',(year,row['id'])).fetchone()
+ if old and old[0]!=payload:raise ValueError('Conflicting copies of one decision note')
+ if old:counts[year]['duplicate_decisions']+=1
+ else:c.execute('INSERT INTO decisions VALUES(?,?,?,?)',(year,row['id'],row['forum'],payload))
+ if lines%5000==0:print(json.dumps({'source':SOURCE,'year':year,'member':kind,'lines':lines}),flush=True)
+ c.commit();print(json.dumps({'source':SOURCE,'year':year,'member':kind,'lines':lines,'status':'member_processed'}),flush=True)
+ if members!={(year,kind) for year in years for kind in ('papers','reviews')}:raise ValueError('Selected year metadata members are missing')
+ if expected_sha==SHA256:
+ for year in years:
+ if counts[year]['papers_lines']!=EXPECTED.get(year):raise ValueError('Pinned release paper count differs from published inventory')
+ status_path=out/'openreview-snapshot-year-status.json';catalogue=json.loads(status_path.read_text()) if status_path.exists() else {}
+ for year in sorted(years,reverse=True):
+ target=out/f'openreview-snapshot-{year}-main.jsonl';part=target.with_suffix('.part')
+ statuses=Counter();event_counts=Counter();missing=0;written=0
+ with part.open('w') as output:
+ for forum,payload in c.execute('SELECT forum,payload FROM papers WHERE year=? ORDER BY forum',(year,)):
+ row=json.loads(payload)
+ events=[json.loads(r[0]) for r in c.execute('SELECT payload FROM decisions WHERE year=? AND forum=?',(year,forum))]
+ events.sort(key=lambda event:(event['decision_date'] or '',event['id']))
+ row['decision_events']=events;row['status']=events[-1]['status'] if events else 'unknown'
+ row['metadata']['snapshot_sha256']=actual['sha256'];row['metadata']['decision_note_ids']=[e['id'] for e in events]
+ row['metadata']['decision_authority']=[{'note_id':e['id'],**e['authority']} for e in events]
+ output.write(json.dumps(row,ensure_ascii=False)+'\n');written+=1;missing+=not bool(row['abstract']);statuses[row['status']]+=1
+ event_counts.update(e['status'] for e in events)
+ if not written:raise ValueError('No verified public submissions for selected year')
+ unmatched=c.execute('SELECT count(*) FROM decisions d WHERE d.year=? AND NOT EXISTS(SELECT 1 FROM papers p WHERE p.year=d.year AND p.forum=d.forum)',(year,)).fetchone()[0]
+ unmatched_path=None
+ if unmatched:
+ unresolved=out/f'openreview-snapshot-{year}-unmatched-decisions.jsonl'
+ with unresolved.with_suffix('.part').open('w') as output:
+ for payload, in c.execute('SELECT payload FROM decisions d WHERE d.year=? AND NOT EXISTS(SELECT 1 FROM papers p WHERE p.year=d.year AND p.forum=d.forum)',(year,)):
+ event=json.loads(payload);event.update(year=year,reason='public decision lacks public submission metadata in snapshot',snapshot_sha256=actual['sha256'])
+ output.write(json.dumps(event,ensure_ascii=False)+'\n')
+ unresolved.with_suffix('.part').replace(unresolved);unmatched_path=str(unresolved)
+ part.replace(target)
+ manifest={'source':SOURCE,'parser_version':1,'venue':'ICLR','year':year,'track':'main','count':written,
+ 'list_url':RELEASE,'snapshot_sha256':actual['sha256'],'snapshot_observation_upper_bound':AS_OF,
+ 'missing_abstract_count':missing,'submission_status_counts':dict(statuses),'decision_event_counts':dict(event_counts),
+ 'unmatched_public_decisions':unmatched,'archive_counts':dict(counts[year]),'file':str(target),
+ 'unmatched_evidence_file':unmatched_path,
+ 'status':'partial' if missing or statuses['unknown'] or unmatched or any('_excluded_' in k for k in counts[year]) else 'collected'}
+ target.with_suffix('.manifest.json').write_text(json.dumps(manifest,indent=2));catalogue[str(year)]=manifest
+ print(json.dumps(manifest),flush=True)
+ stage=status_path.with_suffix('.part');stage.write_text(json.dumps(catalogue,indent=2));stage.replace(status_path)
+ (out/'openreview-snapshot-last-run.json').write_text(json.dumps({'requested_years':sorted(years),'sha256':actual['sha256'],'release':RELEASE,'observed_at_upper_bound':AS_OF},indent=2))
+ return {year:catalogue[str(year)] for year in years}
+
+
+if __name__=='__main__':
+ parser=argparse.ArgumentParser();parser.add_argument('--archive',required=True);parser.add_argument('--out',required=True)
+ parser.add_argument('--year',type=int,action='append');args=parser.parse_args()
+ collect(args.archive,args.out,args.year or sorted(EXPECTED))
diff --git a/collect_peerread.py b/collect_peerread.py
new file mode 100644
index 0000000..ca18641
--- /dev/null
+++ b/collect_peerread.py
@@ -0,0 +1,71 @@
+"""Collect explicit public ICLR decisions from the authors' PeerRead release.
+
+Review bodies and parsed full paper text are deliberately not persisted.
+"""
+import argparse
+import json
+import time
+import urllib.request
+from collections import defaultdict
+from pathlib import Path
+
+from collect_pmlr import fetch
+
+
+def collect(cache,out):
+ root = Path(out)
+ root.mkdir(parents=True,exist_ok=True)
+ commit = json.loads(fetch('https://api.github.com/repos/allenai/PeerRead/commits/master',cache))['sha']
+ tree = json.loads(fetch(f'https://api.github.com/repos/allenai/PeerRead/git/trees/{commit}?recursive=1',cache))
+ if tree.get('truncated'):
+ raise RuntimeError('Truncated repository tree')
+ paths = [v['path'] for v in tree['tree'] if v['path'].startswith('data/iclr_2017/') and '/reviews/' in v['path'] and v['path'].endswith('.json')]
+ if not paths:
+ raise RuntimeError('No explicit ICLR2017 metadata files found')
+ cache_dir = root/'peerread-normalized'/commit
+ cache_dir.mkdir(parents=True,exist_ok=True)
+ grouped = defaultdict(list)
+ for number,path in enumerate(paths,1):
+ external = '/'.join(path.split('/')[1:])
+ normalized_cache = cache_dir/(path.replace('/','_'))
+ if normalized_cache.exists():
+ record = json.loads(normalized_cache.read_text())
+ else:
+ raw_url = f'https://raw.githubusercontent.com/allenai/PeerRead/{commit}/{path}'
+ with urllib.request.urlopen(urllib.request.Request(raw_url,headers={'User-Agent':'PaperAtlas/0.1 metadata research'}),timeout=60) as response:
+ payload = response.read(3*1024**2+1)
+ if len(payload)>3*1024**2:
+ raise RuntimeError('Unexpected metadata size')
+ data = json.loads(payload)
+ conference = data.get('conference','')
+ if 'iclr' not in conference.lower() or '2017' not in conference:
+ raise RuntimeError('Unexpected conference metadata: '+conference)
+ accepted = data.get('accepted')
+ status = ('accepted' if accepted else 'rejected') if isinstance(accepted,bool) else 'unknown'
+ authors = data.get('authors') or []
+ if isinstance(authors,str):
+ authors = [author.strip() for author in authors.split(',') if author.strip()]
+ record = {'source':'peerread','external_id':external,'url':f'https://github.com/allenai/PeerRead/blob/{commit}/{path}','title':data['title'],'abstract':data.get('abstract'),'authors':authors,'identifiers':{},'venue':'ICLR','year':2017,'track':'workshop' if 'workshop' in conference.lower() else 'main','status':status,'raw_decision':json.dumps({'accepted':accepted}),'metadata':{'conference':conference,'dataset_commit':commit,'dataset_path':path,'original_id':data.get('id'),'accepted':accepted,'decision_provenance':'PeerRead authors public release; explicit label, not inferred from publication absence'}}
+ normalized_cache.write_text(json.dumps(record,ensure_ascii=False))
+ time.sleep(0.25)
+ grouped[record['track']].append(record)
+ if number%25==0:
+ print(json.dumps({'source':'peerread','collected':number,'expected':len(paths)}),flush=True)
+ for track,records in grouped.items():
+ target = root/f'peerread-iclr2017-{track}.jsonl'
+ part = target.with_suffix('.part')
+ with part.open('w') as output:
+ for record in records:
+ output.write(json.dumps(record,ensure_ascii=False)+'\n')
+ part.replace(target)
+ manifest = {'source':'peerread','venue':'ICLR','year':2017,'track':track,'count':len(records),'list_url':f'https://github.com/allenai/PeerRead/tree/{commit}/data/iclr_2017','dataset_commit':commit,'file':str(target),'decision_counts':{status:sum(r['status']==status for r in records) for status in ('accepted','rejected','unknown')}}
+ target.with_suffix('.manifest.json').write_text(json.dumps(manifest,indent=2))
+ print(json.dumps(manifest),flush=True)
+
+
+if __name__=='__main__':
+ parser = argparse.ArgumentParser()
+ parser.add_argument('--cache',required=True)
+ parser.add_argument('--out',required=True)
+ args = parser.parse_args()
+ collect(args.cache,args.out)
diff --git a/collect_pmlr.py b/collect_pmlr.py
new file mode 100644
index 0000000..a92422f
--- /dev/null
+++ b/collect_pmlr.py
@@ -0,0 +1,236 @@
+"""Collect official PMLR metadata, never the PDFs linked by the metadata."""
+import argparse
+import hashlib
+from http.client import IncompleteRead
+import io
+import json
+import re
+import os
+import subprocess
+import ssl
+import tarfile
+import time
+import urllib.error
+import urllib.request
+from pathlib import Path
+
+import yaml
+
+
+class MetadataLoader(yaml.SafeLoader):
+ pass
+
+
+def binary_text(loader,node):
+ return loader.construct_yaml_binary(node).decode('utf-8')
+
+
+MetadataLoader.add_constructor('!binary',binary_text)
+MetadataLoader.add_constructor('tag:yaml.org,2002:binary',binary_text)
+
+
+def load_metadata(value):
+ documents=[doc for doc in yaml.load_all(value,Loader=MetadataLoader) if doc is not None]
+ if len(documents)!=1 or not isinstance(documents[0],dict):
+ raise ValueError('Expected exactly one nonempty metadata mapping')
+ return documents[0]
+
+
+def venue_key(shortname):
+ return re.sub(r'[\s_-]*(?:19|20)\d{2}$','',str(shortname).strip().upper())
+
+
+def fetch(url,cache,limit=64*1024**2,delay=0.5):
+ cache = Path(cache)
+ cache.mkdir(parents=True,exist_ok=True)
+ path = cache/hashlib.sha256(url.encode()).hexdigest()
+ if path.exists() and time.time()-path.stat().st_mtime < 24*3600:
+ if path.stat().st_size>limit:raise RuntimeError('Metadata response exceeds byte limit')
+ return path.read_bytes()
+ for attempt in range(3):
+ try:
+ request = urllib.request.Request(url,headers={'User-Agent':'PaperAtlas/0.1 (public scholarly metadata research)'})
+ with urllib.request.urlopen(request,timeout=90) as response:
+ if response.status != 200:
+ raise RuntimeError('Expected complete HTTP 200 response')
+ expected=int(response.headers.get('Content-Length','0'))
+ if expected>limit:
+ raise RuntimeError('Metadata response exceeds byte limit')
+ data = response.read(limit+1)
+ if len(data)>limit:
+ raise RuntimeError('Metadata response exceeds byte limit')
+ if expected and len(data)!=expected:
+ raise IncompleteRead(data,max(0,expected-len(data)))
+ part = path.with_suffix('.part')
+ part.write_bytes(data)
+ part.replace(path)
+ path.with_suffix('.json').write_text(json.dumps({'url':url,'sha256':hashlib.sha256(data).hexdigest(),'bytes':len(data),'retrieved_at':time.strftime('%Y-%m-%dT%H:%M:%SZ',time.gmtime())}))
+ time.sleep(delay)
+ return data
+ except urllib.error.HTTPError as exc:
+ if exc.code not in (429,500,502,503,504) or attempt==2:
+ exc.close()
+ raise
+ retry_after=exc.headers.get('Retry-After','1');exc.close()
+ try:retry_after=int(retry_after)
+ except ValueError:retry_after=5
+ time.sleep(min(30,max(2**attempt,retry_after)))
+ except (urllib.error.URLError,TimeoutError,ConnectionError,IncompleteRead,ssl.SSLError) as exc:
+ reason=getattr(exc,'reason',exc)
+ if isinstance(reason,ssl.SSLCertVerificationError) or attempt==2:raise
+ time.sleep(min(30,2**(attempt+1)))
+
+
+def metadata_texts(volume,cache):
+ archive = None
+ if volume>50:
+ try:
+ archive = fetch(f'https://codeload.github.com/mlresearch/v{volume}/tar.gz/refs/heads/gh-pages',cache)
+ except RuntimeError as exc:
+ if 'exceeds byte limit' not in str(exc):
+ raise
+ if archive is not None:
+ with tarfile.open(fileobj=io.BytesIO(archive),mode='r:gz') as tar:
+ for member in tar:
+ if member.isfile() and '/_posts/' in member.name and member.name.endswith(('.md','.markdown','.html')):
+ if member.size>8*1024**2:
+ raise RuntimeError('Unexpectedly large metadata member')
+ yield member.name,tar.extractfile(member).read().decode('utf-8-sig')
+ return
+ # Old volumes may contain PDFs. Partial clone plus sparse checkout retrieves
+ # trees and metadata blobs only, without checking out or executing publisher code.
+ checkout = Path(cache)/'metadata-checkouts'/f'pmlr-v{volume}'
+ checkout.parent.mkdir(parents=True,exist_ok=True)
+ env = dict(os.environ,GIT_TERMINAL_PROMPT='0')
+ def git(*args):
+ result = subprocess.run(['git','-c','credential.helper=',*args],env=env,capture_output=True,text=True,timeout=180)
+ if result.returncode:
+ raise RuntimeError('Public metadata Git operation failed: '+result.stderr[-500:])
+ return result.stdout
+ if not checkout.exists():
+ git('clone','--filter=blob:none','--depth','1','--single-branch','--branch','gh-pages','--no-checkout',f'https://github.com/mlresearch/v{volume}.git',str(checkout))
+ git('-C',str(checkout),'sparse-checkout','set','--no-cone','/_posts/','/_config.yml')
+ git('-C',str(checkout),'checkout','gh-pages')
+ else:
+ if git('-C',str(checkout),'status','--porcelain').strip():
+ raise RuntimeError('Metadata checkout has local changes; refusing to overwrite')
+ git('-C',str(checkout),'fetch','--filter=blob:none','--depth','1','origin','gh-pages')
+ git('-C',str(checkout),'checkout','--detach','FETCH_HEAD')
+ posts = checkout/'_posts'
+ for path in sorted(posts.rglob('*')):
+ if path.is_file() and path.suffix in ('.md','.markdown','.html'):
+ if path.stat().st_size>8*1024**2:
+ raise RuntimeError('Unexpectedly large metadata member')
+ yield str(path.relative_to(checkout)),path.read_text(encoding='utf-8-sig')
+
+
+def collect_volume(volume,cache,out,allowed=None):
+ config_url = f'https://raw.githubusercontent.com/mlresearch/v{volume}/gh-pages/_config.yml'
+ config = load_metadata(fetch(config_url,cache))
+ venue = venue_key(config.get('shortname',''))
+ if allowed and venue not in allowed:
+ return {'source':'pmlr','volume':volume,'status':'excluded_by_registry','raw_shortname':venue,'booktitle':str(config.get('booktitle',''))}
+ year_value = config.get('year')
+ if not year_value:
+ year_value = str(config.get('start',''))[:4]
+ if not re.fullmatch(r'\d{4}',str(year_value)):
+ raise ValueError('No explicit year or conference start date')
+ year = int(year_value)
+ title = str(config.get('booktitle',''))
+ track = 'workshop' if 'workshop' in title.lower() else 'main'
+ path = Path(out)/f'pmlr-v{volume}.jsonl'
+ path.parent.mkdir(parents=True,exist_ok=True)
+ part = path.with_suffix('.part')
+ records = {}
+ duplicate_posts = 0
+ with part.open('w') as output:
+ for member_name,content in metadata_texts(volume,cache):
+ parts = re.split(r'(?m)^---\s*$',content,maxsplit=2)
+ if len(parts)<3 or parts[0].strip():
+ raise RuntimeError('Missing YAML frontmatter: '+member_name)
+ row = load_metadata(parts[1])
+ if row.get('layout') not in ('inproceedings','article'):
+ continue
+ if not row.get('title'):
+ raise ValueError('Paper metadata has no title: '+member_name)
+ if not row.get('id'):
+ raise ValueError('Paper metadata has no publication ID: '+member_name)
+ external = f"v{volume}/{row['id']}"
+ ids = {}
+ for key in ('doi','openreview','arxiv'):
+ value = row.get(key)
+ if value:
+ value = str(value)
+ if key=='arxiv':
+ value = re.sub(r'^https?://arxiv.org/(abs|pdf)/','',value).removesuffix('.pdf')
+ if key=='openreview':
+ value = value.split('id=')[-1].split('&')[0]
+ ids[key] = value
+ record = {'source':'pmlr','external_id':external,'url':f"https://proceedings.mlr.press/v{volume}/{row.get('id')}.html",'title':row['title'],'abstract':row.get('abstract'),'authors':row.get('author',[]),'identifiers':ids,'venue':venue,'year':year,'track':track,'status':'accepted','list_url':f'https://proceedings.mlr.press/v{volume}/','metadata':{key:row[key] for key in ('id','volume','date','page','doi','openreview','arxiv','publisher') if key in row}}
+ version={'path':member_name,'date':row.get('date')}
+ if external in records:
+ previous=records[external]
+ if ({k:v for k,v in previous.items() if k!='metadata'} != {k:v for k,v in record.items() if k!='metadata'}
+ or {k:v for k,v in previous['metadata'].items() if k not in ('date','source_versions')} != {k:v for k,v in record['metadata'].items() if k!='date'}):
+ raise ValueError('Conflicting metadata posts share a publication ID: '+external)
+ previous['metadata']['source_versions'].append(version)
+ if str(row.get('date') or '')>str(previous['metadata'].get('date') or ''):
+ previous['metadata']['date']=row['date']
+ duplicate_posts+=1
+ continue
+ record['metadata']['source_versions']=[version]
+ records[external]=record
+ for record in records.values():
+ output.write(json.dumps(record,ensure_ascii=False,default=str)+'\n')
+ count=len(records)
+ missing_abstracts=sum(not (record.get('abstract') or '').strip() for record in records.values())
+ if count==0:
+ part.unlink(missing_ok=True)
+ raise RuntimeError('No papers parsed; refusing an empty successful collection')
+ part.replace(path)
+ result = {'source':'pmlr','parser_version':2,'venue':venue,'year':year,'track':track,'volume':volume,'count':count,'metadata_post_count':count+duplicate_posts,'duplicate_post_count':duplicate_posts,'missing_abstract_count':missing_abstracts,'list_url':f'https://proceedings.mlr.press/v{volume}/','file':str(path),'status':'partial' if missing_abstracts else 'collected'}
+ path.with_suffix('.manifest.json').write_text(json.dumps(result,indent=2))
+ print(json.dumps(result),flush=True)
+ return result
+
+
+def main():
+ parser = argparse.ArgumentParser()
+ parser.add_argument('--cache',required=True)
+ parser.add_argument('--out',required=True)
+ parser.add_argument('--volume',type=int,action='append')
+ parser.add_argument('--registry',default=str(Path(__file__).with_name('venues.json')))
+ args = parser.parse_args()
+ allowed = {venue_key(v['acronym']) for v in json.loads(Path(args.registry).read_text())['venues']}
+ volumes = args.volume
+ if not volumes:
+ index = fetch('https://proceedings.mlr.press/',args.cache).decode()
+ volumes = sorted(set(int(v) for v in re.findall(r'href=["\'](?:https?://proceedings\.mlr\.press/|\./|/)?v(\d+)/?["\']',index)))
+ if not volumes:
+ raise RuntimeError('PMLR index yielded no volumes')
+ Path(args.out).mkdir(parents=True,exist_ok=True)
+ catalogue_path=Path(args.out)/'pmlr-volume-status.json'
+ catalogue=json.loads(catalogue_path.read_text()) if catalogue_path.exists() else {}
+ errors = []
+ for volume in volumes:
+ try:
+ result=collect_volume(volume,args.cache,args.out,None if args.volume else allowed)
+ result['selection_mode']='explicit' if args.volume else 'registry'
+ result['selected_by_registry']=result.get('venue',result.get('raw_shortname')) in allowed
+ catalogue[str(volume)]=result
+ except Exception as exc:
+ error = {'volume':volume,'status':'blocked','error':str(exc)}
+ errors.append(error)
+ catalogue[str(volume)]=error
+ print(json.dumps(error),flush=True)
+ part=catalogue_path.with_suffix('.part')
+ part.write_text(json.dumps(catalogue,indent=2))
+ part.replace(catalogue_path)
+ (Path(args.out)/'pmlr-errors.json').write_text(json.dumps(errors,indent=2))
+ (Path(args.out)/'pmlr-last-run.json').write_text(json.dumps({'requested_volumes':volumes,'selection_mode':'explicit' if args.volume else 'registry','errors':errors},indent=2))
+ if errors:
+ raise SystemExit(1)
+
+
+if __name__=='__main__':
+ main()
diff --git a/collect_proceedings.py b/collect_proceedings.py
new file mode 100644
index 0000000..6c19bf3
--- /dev/null
+++ b/collect_proceedings.py
@@ -0,0 +1,216 @@
+"""Collect COLM/NeurIPS official abstract pages without downloading PDFs."""
+import argparse
+import json
+import re
+import time
+from collections import defaultdict
+from html.parser import HTMLParser
+from pathlib import Path
+from urllib.parse import urljoin,urlsplit
+
+from collect_pmlr import fetch
+
+ROOTS={'colm':'https://proceedings.colm.cc/','neurips':'https://proceedings.neurips.cc/'}
+
+
+class Node:
+ def __init__(self,tag='',attrs=None):
+ self.tag=tag
+ self.attrs={key:value or '' for key,value in attrs or []}
+ self.children=[]
+
+ def text(self):
+ if self.tag in ('script','style'):
+ return ''
+ content=''.join(child.text() if isinstance(child,Node) else child for child in self.children)
+ return ' '+content+' ' if self.tag in ('p','div','section','li','br') else content
+
+ def walk(self):
+ yield self
+ for child in self.children:
+ if isinstance(child,Node):
+ yield from child.walk()
+
+
+class Document(HTMLParser):
+ VOID={'area','base','br','col','embed','hr','img','input','link','meta','param','source','track','wbr'}
+
+ def __init__(self,html):
+ super().__init__(convert_charrefs=True)
+ self.root=Node()
+ self.stack=[self.root]
+ self.feed(html)
+
+ def handle_starttag(self,tag,attrs):
+ node=Node(tag,attrs)
+ self.stack[-1].children.append(node)
+ if tag not in self.VOID:
+ self.stack.append(node)
+
+ def handle_endtag(self,tag):
+ for index in range(len(self.stack)-1,0,-1):
+ if self.stack[index].tag==tag:
+ del self.stack[index:]
+ break
+
+ def handle_data(self,data):
+ self.stack[-1].children.append(data)
+
+
+def clean(text):
+ return ' '.join(text.split())
+
+
+def track_key(text):
+ text=text.lower()
+ if 'dataset' in text or 'benchmark' in text:
+ return 'datasets_benchmarks'
+ if 'creative' in text:
+ return 'creative_ai'
+ if 'position' in text:
+ return 'position'
+ return 'main'
+
+
+def paper_links(html,index_url):
+ doc=Document(html)
+ links=sorted({urljoin(index_url,n.attrs['href']) for n in doc.root.walk() if n.tag=='a' and '-Abstract' in n.attrs.get('href','') and urlsplit(urljoin(index_url,n.attrs['href'])).path.endswith('.html') and urlsplit(urljoin(index_url,n.attrs['href'])).netloc==urlsplit(index_url).netloc})
+ declared=next((match for n in doc.root.walk() if n.tag in ('p','span','div') and not any(child.tag=='a' for child in n.walk()) and (match:=re.fullmatch(r'([\d,]+)\s+papers',clean(n.text()),re.I))),None)
+ if not links:
+ raise ValueError('Official index contains no abstract-page links')
+ if declared and int(declared[1].replace(',',''))!=len(links):
+ raise ValueError('Declared paper count differs from unique abstract-page links')
+ return links
+
+
+def parse_paper(html,url,source,year,index_url):
+ nodes=list(Document(html).root.walk())
+ meta=defaultdict(list)
+ for node in nodes:
+ if node.tag=='meta':
+ meta[node.attrs.get('name','').lower()].append(node.attrs.get('content',''))
+ title=next(iter(meta['citation_title']),None)
+ if not title:
+ title=next((clean(n.text()) for n in nodes if n.tag=='h1'),None)
+ abstract=next((clean(n.text()) for n in nodes if 'paper-abstract' in n.attrs.get('class','').split()),None)
+ if not abstract:
+ for index,node in enumerate(nodes):
+ if node.tag in ('h2','h3','h4','h5') and clean(node.text()).lower()=='abstract':
+ following=[]
+ covered=set()
+ for candidate in nodes[index+1:]:
+ if candidate.tag in ('h1','h2','h3','h4','h5'):
+ break
+ if candidate.tag=='p' and candidate not in covered:
+ following.append(clean(candidate.text()))
+ covered.update(candidate.walk())
+ abstract=' '.join(following).strip() or None
+ break
+ if not title:
+ raise ValueError('Missing title on '+url)
+ # Never fall back to footer/policy text when the abstract is absent.
+ authors=[]
+ for author in meta['citation_author']:
+ if ',' in author:
+ family,given=author.split(',',1)
+ author=given.strip()+' '+family.strip()
+ authors.append(clean(author))
+ track_text=' '.join(clean(n.text()) for n in nodes if 'paper-track' in n.attrs.get('class','').split())
+ track=track_key(track_text or url)
+ identifiers={}
+ if meta['citation_doi']:
+ identifiers['doi']=meta['citation_doi'][0]
+ action_nodes=[]
+ for container in nodes:
+ if 'paper-actions' in container.attrs.get('class','').split():
+ action_nodes.extend(container.walk())
+ # Abstract links can cite other papers; only labelled self links identify this one.
+ for node in action_nodes:
+ if node.tag!='a':
+ continue
+ link=urljoin(url,node.attrs.get('href',''))
+ parsed=urlsplit(link)
+ if parsed.netloc in ('arxiv.org','www.arxiv.org') and re.match(r'^/(abs|pdf)/',parsed.path):
+ identifiers['arxiv']=re.sub(r'^/(abs|pdf)/','',parsed.path).removesuffix('.pdf')
+ if parsed.netloc=='openreview.net' and parsed.path=='/forum':
+ from urllib.parse import parse_qs
+ forum=parse_qs(parsed.query).get('id')
+ if forum: identifiers['openreview']=forum[0]
+ venue='COLM' if source=='colm' else 'NeurIPS'
+ return {'source':source,'external_id':urlsplit(url).path,'url':url,'title':clean(title),'abstract':abstract,'authors':authors,'identifiers':identifiers,'venue':venue,'year':year,'track':track,'status':'accepted','raw_decision':'listed in official proceedings','list_url':index_url,'metadata':{'citation':dict(meta),'publication_state':'pre-proceedings' if 'pre-proceedings' in html.lower() else 'proceedings'}}
+
+
+def discover(source,cache):
+ root=ROOTS[source]
+ html=fetch(root,cache,8*1024**2,delay=1.0).decode()
+ result={}
+ for node in Document(html).root.walk():
+ if node.tag!='a':continue
+ url=urljoin(root,node.attrs.get('href',''))
+ parsed=urlsplit(url)
+ match=re.search(r'/paper(?:_files/paper)?/((?:19|20)\d{2})(?:/|$)',parsed.path)
+ if parsed.netloc==urlsplit(root).netloc and match and not any(term in parsed.path for term in ('/hash/','/file/','.pdf','-Abstract')):
+ result[url]=int(match[1])
+ if source=='neurips' and parsed.netloc=='datasets-benchmarks-proceedings.neurips.cc' and match:
+ result[url]=int(match[1])
+ if not result:
+ raise ValueError('No official year indexes found')
+ return sorted(result.items(),key=lambda entry:(entry[1],entry[0]))
+
+
+def collect_index(source,index_url,year,cache,out):
+ html=fetch(index_url,cache,16*1024**2,delay=1.0).decode()
+ links=paper_links(html,index_url)
+ grouped=defaultdict(list)
+ missing=[]
+ for number,url in enumerate(links,1):
+ record=parse_paper(fetch(url,cache,2*1024**2,delay=1.0).decode(),url,source,year,index_url)
+ if not record['abstract']:missing.append(url)
+ grouped[record['track']].append(record)
+ if number%25==0:
+ print(json.dumps({'source':source,'year':year,'collected':number,'expected':len(links)}),flush=True)
+ results=[]
+ for track,records in grouped.items():
+ # Distinct official index URLs may publish multiple volumes in one year.
+ import hashlib
+ suffix=hashlib.sha256(index_url.encode()).hexdigest()[:10]
+ path=Path(out)/f'{source}-{year}-{track}-{suffix}.jsonl'
+ path.parent.mkdir(parents=True,exist_ok=True)
+ part=path.with_suffix('.part')
+ with part.open('w') as output:
+ for record in records:output.write(json.dumps(record,ensure_ascii=False)+'\n')
+ part.replace(path)
+ missing_count=sum(not r['abstract'] for r in records)
+ result={'source':source,'parser_version':2,'venue':records[0]['venue'],'year':year,'track':track,'count':len(records),'index_link_count':len(links),'index_url':index_url,'list_url':index_url,'missing_abstract_count':missing_count,'file':str(path),'status':'collected' if not missing_count else 'partial'}
+ path.with_suffix('.manifest.json').write_text(json.dumps(result,indent=2))
+ results.append(result)
+ print(json.dumps(result),flush=True)
+ return results
+
+
+if __name__=='__main__':
+ parser=argparse.ArgumentParser()
+ parser.add_argument('--source',choices=ROOTS,required=True)
+ parser.add_argument('--cache',required=True)
+ parser.add_argument('--out',required=True)
+ parser.add_argument('--year',type=int,action='append')
+ args=parser.parse_args()
+ out=Path(args.out);out.mkdir(parents=True,exist_ok=True)
+ status_path=out/f'{args.source}-index-status.json'
+ catalogue=json.loads(status_path.read_text()) if status_path.exists() else {}
+ indexes=discover(args.source,args.cache)
+ if args.year and set(args.year)-{year for _,year in indexes}:
+ parser.error('Requested year is absent from the current official proceedings index')
+ requested=[]
+ for url,year in indexes:
+ if args.year and year not in args.year:continue
+ requested.append(url)
+ try:
+ files=collect_index(args.source,url,year,args.cache,args.out)
+ catalogue[url]={'status':'partial' if any(f['status']=='partial' for f in files) else 'collected','files':files}
+ except Exception as exc:
+ catalogue[url]={'status':'blocked','error':str(exc),'year':year}
+ print(json.dumps(catalogue[url]),flush=True)
+ part=status_path.with_suffix('.part');part.write_text(json.dumps(catalogue,indent=2));part.replace(status_path)
+ (out/f'{args.source}-last-run.json').write_text(json.dumps({'requested_indexes':requested,'year_filter':args.year},indent=2))
+ if any(catalogue[url]['status']=='blocked' for url in requested):raise SystemExit(1)
diff --git a/collect_vision.py b/collect_vision.py
new file mode 100644
index 0000000..9709222
--- /dev/null
+++ b/collect_vision.py
@@ -0,0 +1,189 @@
+"""Collect CVF/ECVA public titles, abstracts and bibliographic identifiers."""
+import argparse
+from collections import defaultdict
+import json
+from pathlib import Path
+import re
+import time
+from urllib.parse import parse_qs, unquote, urljoin, urlsplit
+
+from collect_pmlr import fetch
+from collect_proceedings import Document, Node, clean
+
+ROOTS={'cvf':'https://openaccess.thecvf.com/','ecva':'https://www.ecva.net/papers.php'}
+HOSTS={'openaccess.thecvf.com','www.ecva.net','ecva.net'}
+
+
+def decode_html(data):
+ try:
+ return data.decode('utf-8-sig')
+ except UnicodeDecodeError:
+ charset=re.search(br'charset\s*=\s*["\x27]?([\w-]+)',data[:4096],re.I)
+ if not charset:
+ raise
+ return data.decode(charset[1].decode('ascii'))
+
+
+def publication(url):
+ parsed=urlsplit(url)
+ match=re.search(r'(CVPR|ICCV|ECCV)[_/]?(20\d{2})',parsed.path,re.I)
+ if parsed.netloc in HOSTS and '/html/' in parsed.path and parsed.path.endswith(('.html','.php')) and match:
+ return match[1].upper(),int(match[2])
+
+
+def doi_link(url):
+ parsed=urlsplit(url)
+ value=None
+ if parsed.netloc in ('doi.org','dx.doi.org'):
+ value=unquote(parsed.path.lstrip('/'))
+ elif parsed.netloc=='link.springer.com' and parsed.path.startswith('/chapter/'):
+ value=unquote(parsed.path.removeprefix('/chapter/'))
+ return value.lower() if value and re.fullmatch(r'10\.\d{4,9}/\S+',value) else None
+
+
+def index_entries(html,index_url):
+ records={}
+ raw_count=0
+ for dl in (n for n in Document(html).root.walk() if n.tag=='dl'):
+ current=None
+ for node in (n for n in dl.children if isinstance(n,Node)):
+ if node.tag=='dt':
+ current=None
+ links=[urljoin(index_url,n.attrs['href']) for n in node.walk() if n.tag=='a' and n.attrs.get('href') and publication(urljoin(index_url,n.attrs['href']))]
+ if not links:
+ continue
+ if len(set(links))!=1:
+ raise ValueError('Ambiguous publication links in one index title')
+ url=links[0];raw_count+=1
+ if url in records and records[url]['index_title']!=clean(node.text()):
+ raise ValueError('Different index titles share a publication URL')
+ current=records.setdefault(url,{'url':url,'index_title':clean(node.text()),'index_authors':None,'doi':None})
+ elif node.tag=='dd' and current is not None:
+ text=clean(node.text())
+ # Author rows precede download/bibliography rows.
+ if current['index_authors'] is None and text and not text.startswith('['):
+ current['index_authors']=text
+ for link in (n for n in node.walk() if n.tag=='a' and clean(n.text()).casefold()=='doi'):
+ value=doi_link(urljoin(index_url,link.attrs.get('href','')))
+ if value:
+ if current['doi'] and current['doi']!=value:
+ raise ValueError('Conflicting DOI links in one index entry')
+ current['doi']=value
+ if not records:
+ raise ValueError('No official paper entries found')
+ all_links={urljoin(index_url,n.attrs['href']) for n in Document(html).root.walk() if n.tag=='a' and n.attrs.get('href') and publication(urljoin(index_url,n.attrs['href']))}
+ if all_links!=set(records):raise ValueError('Paper links exist outside parsed title entries')
+ return list(records.values()),raw_count
+
+
+def discover(source,cache):
+ root=ROOTS[source]
+ html=decode_html(fetch(root,cache,32*1024**2,delay=1))
+ if source=='ecva':
+ entries,_=index_entries(html,root)
+ groups=defaultdict(list)
+ for entry in entries:
+ venue,year=publication(entry['url'])
+ if venue=='ECCV':groups[(venue,year)].append(entry)
+ return [{'venue':venue,'year':year,'track':'main','index_url':root,'entries':entries}
+ for (venue,year),entries in sorted(groups.items(),key=lambda item:item[0][1])]
+ indexes={}
+ for node in Document(html).root.walk():
+ if node.tag!='a':continue
+ url=urljoin(root,node.attrs.get('href',''));parsed=urlsplit(url)
+ match=re.fullmatch(r'/(CVPR|ICCV)(20\d{2})(_findings)?(?:\.py)?/?',parsed.path)
+ if parsed.netloc=='openaccess.thecvf.com' and match:
+ indexes[url]={'venue':match[1],'year':int(match[2]),'track':'findings' if match[3] else 'main','index_url':url}
+ if not indexes:raise ValueError('No official CVPR/ICCV indexes found')
+ return sorted(indexes.values(),key=lambda row:(row['year'],row['venue'],row['track']!='main',row['track']))
+
+
+def parse_paper(html,entry,source,venue,year,track,index_url):
+ nodes=list(Document(html).root.walk());meta=defaultdict(list)
+ for node in nodes:
+ if node.tag=='meta':meta[node.attrs.get('name','').lower()].append(node.attrs.get('content',''))
+ title=next(iter(meta['citation_title']),None) or next((clean(n.text()) for n in nodes if n.attrs.get('id')=='papertitle'),None)
+ if not title:raise ValueError('Missing official paper title: '+entry['url'])
+ abstract=next((clean(n.text()) or None for n in nodes if n.attrs.get('id')=='abstract'),None)
+ authors=[]
+ for author in meta['citation_author']:
+ if ',' in author:
+ family,given=author.split(',',1);author=given.strip()+' '+family.strip()
+ if clean(author):authors.append(clean(author))
+ if not authors:
+ raw=next((clean(n.text()) for n in nodes if n.attrs.get('id')=='authors'),'') or entry.get('index_authors') or ''
+ raw=raw.split(';',1)[0]
+ if ' and ' in raw and all(',' in name for name in raw.split(' and ')):
+ names=[' '.join(reversed([part.strip() for part in name.split(',',1)])) for name in raw.split(' and ')]
+ else:names=raw.split(',')
+ authors=[clean(name).removesuffix('*').strip() for name in names if clean(name).removesuffix('*').strip()]
+ dois={value for doi in meta['citation_doi'] if doi and (value:=doi_link(doi) or doi.strip().lower()) and re.fullmatch(r'10\.\d{4,9}/\S+',value)}
+ if entry.get('doi'):dois.add(entry['doi'])
+ if len(dois)>1:raise ValueError('Citation DOI differs from the official index')
+ identifiers={'doi':dois.pop()} if dois else {}
+ # Abstract/reference links are never interpreted as this paper's identifiers.
+ return {'source':source,'external_id':urlsplit(entry['url']).path,'url':entry['url'],
+ 'title':clean(title),'abstract':abstract,'authors':authors,'identifiers':identifiers,
+ 'venue':venue,'year':year,'track':track,'status':'accepted',
+ 'raw_decision':'listed in official proceedings','list_url':index_url,
+ 'metadata':{'citation':dict(meta),'index_title':entry['index_title'],'index_authors':entry.get('index_authors')}}
+
+
+def collect_index(source,index,cache,out):
+ url=index['index_url'];venue=index['venue'];year=index['year'];track=index['track']
+ entries=index.get('entries')
+ raw_count=len(entries) if entries is not None else None
+ if entries is None:
+ html=decode_html(fetch(url,cache,32*1024**2,delay=1))
+ # Some defaults contain only day one; prefer the explicitly linked all-papers page.
+ all_pages={urljoin(url,n.attrs.get('href','')) for n in Document(html).root.walk() if n.tag=='a' and parse_qs(urlsplit(urljoin(url,n.attrs.get('href',''))).query).get('day')==['all'] and urlsplit(urljoin(url,n.attrs.get('href',''))).netloc==urlsplit(url).netloc and urlsplit(urljoin(url,n.attrs.get('href',''))).path==urlsplit(url).path}
+ if len(all_pages)>1:raise ValueError('Ambiguous all-papers index')
+ if all_pages:
+ url=all_pages.pop();html=decode_html(fetch(url,cache,32*1024**2,delay=1))
+ entries,raw_count=index_entries(html,url)
+ records=[]
+ for number,entry in enumerate(entries,1):
+ if publication(entry['url'])!=(venue,year):raise ValueError('Publication path differs from requested edition')
+ record=parse_paper(decode_html(fetch(entry['url'],cache,2*1024**2,delay=1)),entry,source,venue,year,track,url)
+ records.append(record)
+ if number%25==0:print(json.dumps({'source':source,'venue':venue,'year':year,'track':track,'collected':number,'expected':len(entries)}),flush=True)
+ if not records:raise ValueError('Empty publication list')
+ path=Path(out)/f'{source}-{venue}{year}-{track}.jsonl';path.parent.mkdir(parents=True,exist_ok=True)
+ part=path.with_suffix('.part');part.write_text(''.join(json.dumps(r,ensure_ascii=False)+'\n' for r in records));part.replace(path)
+ missing=sum(not r['abstract'] for r in records);missing_authors=sum(not r['authors'] for r in records)
+ manifest={'source':source,'parser_version':1,'venue':venue,'year':year,'track':track,
+ 'count':len(records),'index_link_count':len(entries),'raw_index_entry_count':raw_count,
+ 'missing_abstract_count':missing,'missing_authors_count':missing_authors,'file':str(path),
+ 'list_url':url,'status':'partial' if missing or missing_authors else 'collected'}
+ path.with_suffix('.manifest.json').write_text(json.dumps(manifest,indent=2));print(json.dumps(manifest),flush=True)
+ return manifest
+
+
+def main():
+ parser=argparse.ArgumentParser();parser.add_argument('--source',choices=ROOTS,required=True)
+ parser.add_argument('--cache',required=True);parser.add_argument('--out',required=True)
+ parser.add_argument('--year',type=int,action='append');parser.add_argument('--venue',choices=['CVPR','ICCV','ECCV'],action='append')
+ args=parser.parse_args();out=Path(args.out);out.mkdir(parents=True,exist_ok=True)
+ indexes=discover(args.source,args.cache)
+ if args.year and set(args.year)-{r['year'] for r in indexes}:parser.error('Requested year is absent from the official source')
+ if args.venue and set(args.venue)-{r['venue'] for r in indexes}:parser.error('Requested venue is absent from this source')
+ if not any((not args.year or r['year'] in args.year) and (not args.venue or r['venue'] in args.venue) for r in indexes):parser.error('Requested venue/year combination is absent from the official source')
+ path=out/f'{args.source}-index-status.json';catalogue=json.loads(path.read_text()) if path.exists() else {};requested=[]
+ (out/f'{args.source}-discovery.json').write_text(json.dumps({'source':args.source,'root_url':ROOTS[args.source],
+ 'discovered_at':time.strftime('%Y-%m-%dT%H:%M:%SZ',time.gmtime()),'indexes':[
+ {**{k:index[k] for k in ('venue','year','track','index_url')},
+ 'index_link_count':len(index['entries']) if 'entries' in index else None} for index in indexes]},indent=2))
+ for index in indexes:
+ if args.year and index['year'] not in args.year or args.venue and index['venue'] not in args.venue:continue
+ key=f'{index["venue"]}:{index["year"]}:{index["track"]}';requested.append(key)
+ try:
+ manifest=collect_index(args.source,index,args.cache,args.out);catalogue[key]={'status':manifest['status'],'files':[manifest]}
+ except Exception as exc:
+ catalogue[key]={'status':'blocked','year':index['year'],'venue':index['venue'],'track':index['track'],'index_url':index['index_url'],'error':str(exc)}
+ print(json.dumps(catalogue[key]),flush=True)
+ part=path.with_suffix('.part');part.write_text(json.dumps(catalogue,indent=2));part.replace(path)
+ (out/f'{args.source}-last-run.json').write_text(json.dumps({'requested_indexes':requested,'year_filter':args.year,'venue_filter':args.venue},indent=2))
+ if any(catalogue[key]['status']=='blocked' for key in requested):raise SystemExit(1)
+
+
+if __name__=='__main__':main()
diff --git a/coverage_report.py b/coverage_report.py
new file mode 100644
index 0000000..11cdbac
--- /dev/null
+++ b/coverage_report.py
@@ -0,0 +1,41 @@
+"""Show every target venue, including those with no imported records."""
+import argparse
+import json
+from pathlib import Path
+import sqlite3
+
+from submission_evidence import timestamp
+
+
+def report(db,registry):
+ registry=json.loads(Path(registry).read_text());targets=registry['venues']
+ names=[row['acronym'].casefold() for row in targets]
+ if len(names)!=len(set(names)):raise ValueError('Duplicate target venue')
+ scope='(v.acronym=? COLLATE NOCASE OR EXISTS(SELECT 1 FROM venue_aliases a WHERE a.venue_id=v.id AND a.alias=? COLLATE NOCASE))'
+ c=sqlite3.connect(Path(db).resolve().as_uri()+'?mode=ro',uri=True);c.row_factory=sqlite3.Row
+ try:
+ c.execute('BEGIN');venues=[]
+ for target in targets:
+ acronym=target['acronym'];params=(acronym,acronym)
+ editions=[]
+ for row in c.execute('SELECT e.id,v.acronym AS canonical_venue,e.year,e.track,count(s.id) AS submissions,count(DISTINCT s.paper_id) AS papers FROM editions e JOIN venues v ON v.id=e.venue_id LEFT JOIN submissions s ON s.edition_id=e.id WHERE '+scope+' GROUP BY e.id ORDER BY e.year,e.track',params):
+ edition=dict(row)
+ edition['sources']=[dict(source) for source in c.execute('SELECT so.name,es.status,es.expected_count,es.imported_count,es.list_url,es.last_success_at,es.last_error FROM edition_sources es JOIN sources so ON so.id=es.source_id WHERE es.edition_id=? ORDER BY so.name',(row['id'],))]
+ editions.append(edition)
+ totals=c.execute('SELECT count(s.id),count(DISTINCT s.paper_id) FROM submissions s JOIN editions e ON e.id=s.edition_id JOIN venues v ON v.id=e.venue_id WHERE '+scope,params).fetchone()
+ venues.append({'acronym':acronym,'field':target['field'],'imported_submissions':totals[0],'distinct_papers':totals[1],
+ 'history_status':'partial_history_pending_independent_audit' if totals[0] else 'no_imported_records',
+ 'discovery_sources':target.get('discovery_sources',[]),'editions':editions})
+ return {'checked_at':timestamp(),'target_venue_count':len(targets),'venues_with_imported_records':sum(bool(v['imported_submissions']) for v in venues),
+ 'scope_note':registry['scope_note'],'interpretation':'All registry targets are listed. Edition source complete means that source file passed import checks; it does not prove full historical or abstract coverage. Papers can appear in multiple venues/years, so do not sum venue paper counts as a global total.',
+ 'venues':venues}
+ finally:c.close()
+
+
+if __name__=='__main__':
+ parser=argparse.ArgumentParser();parser.add_argument('--db',required=True)
+ parser.add_argument('--registry',default=str(Path(__file__).with_name('venues.json')));parser.add_argument('--out')
+ args=parser.parse_args();result=report(args.db,args.registry);text=json.dumps(result,ensure_ascii=False,indent=2)
+ if args.out:
+ path=Path(args.out);stage=path.with_suffix('.part');stage.write_text(text+'\n');stage.replace(path)
+ else:print(text)
diff --git a/download_snapshot.py b/download_snapshot.py
new file mode 100644
index 0000000..4f40848
--- /dev/null
+++ b/download_snapshot.py
@@ -0,0 +1,106 @@
+"""Checksum-verified streaming downloads; existing valid snapshots stay intact."""
+import argparse
+import fcntl
+import hashlib
+import json
+from pathlib import Path
+import re
+import shutil
+import time
+from urllib.parse import urlsplit
+import urllib.request
+
+
+def digest_file(path):
+ sha=hashlib.sha256();md5=hashlib.md5(usedforsecurity=False);size=0
+ with path.open('rb') as stream:
+ while block:=stream.read(1024**2):
+ sha.update(block);md5.update(block);size+=len(block)
+ return {'sha256':sha.hexdigest(),'md5':md5.hexdigest(),'bytes':size}
+
+
+def verify(actual,sha256,md5,expected_bytes):
+ if expected_bytes is not None and actual['bytes']!=expected_bytes:
+ raise ValueError('Snapshot size differs from trusted expected size')
+ if sha256 and actual['sha256']!=sha256:
+ raise ValueError('Snapshot SHA256 mismatch; partial file retained')
+ if md5 and actual['md5']!=md5:
+ raise ValueError('Snapshot MD5 mismatch; partial file retained')
+
+
+def write_manifest(output,url,actual):
+ manifest={'status':'verified','file':str(output),'url':url,**actual,
+ 'verified_at':time.strftime('%Y-%m-%dT%H:%M:%SZ',time.gmtime())}
+ metadata=Path(str(output)+'.manifest.json');stage=Path(str(metadata)+'.part')
+ stage.write_text(json.dumps(manifest,indent=2));stage.replace(metadata)
+ return manifest
+
+
+def download(url,output,sha256=None,md5=None,expected_bytes=None,resume=False,min_free=8*1024**3):
+ parsed=urlsplit(url)
+ if parsed.scheme!='https' or not parsed.netloc or parsed.username or parsed.password:
+ raise ValueError('A public HTTPS URL without embedded credentials is required')
+ if not (sha256 or md5):raise ValueError('A trusted published checksum is required')
+ if expected_bytes is not None and expected_bytes<=0:raise ValueError('Expected byte count must be positive')
+ if min_free<0:raise ValueError('Disk reserve must not be negative')
+ for name,value,length in [('SHA256',sha256,64),('MD5',md5,32)]:
+ if value and not re.fullmatch('[0-9a-f]{'+str(length)+'}',value):raise ValueError('Invalid '+name)
+ output=Path(output);output.parent.mkdir(parents=True,exist_ok=True)
+ if output.is_symlink():raise ValueError('Refusing a symlink output')
+ intent={'url':url,'expected_sha256':sha256,'expected_md5':md5,'expected_bytes':expected_bytes}
+ with Path(str(output)+'.download.lock').open('a') as lock:
+ fcntl.flock(lock,fcntl.LOCK_EX|fcntl.LOCK_NB)
+ if output.exists():
+ actual=digest_file(output);verify(actual,sha256,md5,expected_bytes)
+ if not Path(str(output)+'.manifest.json').exists():write_manifest(output,url,actual)
+ return {'status':'already_verified','file':str(output),**actual}
+ part=Path(str(output)+'.part');intent_path=Path(str(part)+'.json')
+ offset=0
+ if part.exists():
+ if not resume or not intent_path.is_file() or json.loads(intent_path.read_text())!=intent:
+ raise ValueError('Existing partial requires --resume and the same snapshot identity')
+ offset=part.stat().st_size
+ else:
+ intent_path.write_text(json.dumps(intent,indent=2))
+ remaining=max(0,(expected_bytes or 0)-offset)
+ if shutil.disk_usage(output.parent).freetotal:raise ValueError('HTTP response exceeds advertised size')
+ if written-reported>=64*1024**2:
+ print(json.dumps({'status':'downloading','file':str(output),'bytes':written,'expected_bytes':total}),flush=True);reported=written
+ if shutil.disk_usage(output.parent).free= 20:
+ print(json.dumps({'run':run,'rows':line_number}),flush=True)
+ last = time.monotonic()
+ conn.execute("UPDATE ingest_runs SET status='complete',finished_at=CURRENT_TIMESTAMP,records_seen=?,records_written=?,checkpoint_json=? WHERE id=?",(line_number,line_number,json.dumps({'archive':fingerprint,'line':line_number}),run))
+ conn.commit()
+ print(json.dumps({'status':'complete','rows':line_number}),flush=True)
+ except BaseException as exc:
+ conn.rollback()
+ conn.execute("UPDATE ingest_runs SET status='failed',error=? WHERE id=?",(str(exc),run))
+ conn.commit()
+ raise
+ finally:
+ conn.close()
+
+
+if __name__ == '__main__':
+ parser = argparse.ArgumentParser()
+ parser.add_argument('--db', required=True)
+ parser.add_argument('--archive', required=True)
+ args = parser.parse_args()
+ ingest(args.db,args.archive)
diff --git a/import_records.py b/import_records.py
new file mode 100644
index 0000000..fd5c61d
--- /dev/null
+++ b/import_records.py
@@ -0,0 +1,93 @@
+"""Import normalized conference JSONL files with coverage evidence."""
+import argparse
+import json
+import time
+from pathlib import Path
+
+from index_core import Store
+from submission_evidence import identities_for_row,timestamp
+
+
+def ingest_file(db,path):
+ path = Path(path)
+ manifest_path = path.with_suffix('.manifest.json')
+ manifest = json.loads(manifest_path.read_text()) if manifest_path.exists() else {}
+ if manifest.get('source') in ('colm','neurips') and manifest.get('parser_version',0)<2:
+ raise ValueError('Recollect proceedings with parser_version>=2 before identity matching')
+ if manifest.get('source')=='iclr_program' and manifest.get('parser_version',0)<2:
+ raise ValueError('Recollect ICLR with parser_version>=2 before submission identity matching')
+ store = Store(db)
+ c = store.conn
+ source_name = manifest.get('source')
+ if not source_name:
+ raise ValueError('A source manifest is required')
+ source_id = store.source(source_name,manifest['list_url'])
+ run = c.execute('INSERT INTO ingest_runs(source_id,checkpoint_json) VALUES(?,?)',(source_id,json.dumps({'file':str(path)}))).lastrowid
+ c.commit()
+ count = 0
+ missing_abstracts = 0
+ seen = set()
+ editions = set()
+ try:
+ with path.open() as stream:
+ for line in stream:
+ row = json.loads(line)
+ if row['source'] != source_name:
+ raise ValueError('Source differs from manifest')
+ if any(key in manifest and manifest[key]!=row[key] for key in ('venue','year','track')):
+ raise ValueError('Conference edition differs from manifest')
+ external = row['external_id']
+ if external in seen:
+ raise ValueError('Duplicate external ID in one collected file: '+external)
+ seen.add(external)
+ paper,record = store.record(source_name,external,row['url'],row['title'],row.get('abstract'),row.get('authors',[]),row.get('identifiers'),row.get('metadata'))
+ if 'tags' in row:
+ store.record_tags(paper,record,source_name,row['tags'])
+ if row.get('status') in ('accepted','rejected','desk_rejected','withdrawn','under_review','unknown'):
+ identities=identities_for_row(row)
+ events=row.get('decision_events')
+ if events is None:
+ events=[{'status':row['status'],'raw_value':row.get('raw_decision','listed in official proceedings' if source_name=='pmlr' else row['status']),'evidence_url':row.get('evidence_url',row['url'])}]
+ if not isinstance(events,list):raise ValueError('Decision events must be a list')
+ if not events and row['status']!='unknown':raise ValueError('Known status requires decision evidence')
+ if row['status']!='unknown' and not any(event.get('status')==row['status'] for event in events):raise ValueError('Summary status has no corresponding decision evidence')
+ row_observed=row.get('observed_at') or timestamp()
+ for event in events or [{'status':None}]:
+ status=event['status']
+ if events and status is None:raise ValueError('Explicit decision event requires a status')
+ if status is not None and status not in ('accepted','rejected','desk_rejected','withdrawn','under_review','unknown'):raise ValueError('Unsupported decision event')
+ edition=store.decision(paper,record,row['venue'],row['year'],row['track'],external,event.get('evidence_url',row['url']),status,event.get('raw_value',''),row.get('venue_aliases'),identities=identities,observed_at=event.get('observed_at') or row_observed,decision_date=event.get('decision_date'),source_decision_id=event.get('source_decision_id',''),submitted_at=row.get('submitted_at'))
+ editions.add(edition)
+ else:
+ raise ValueError('This importer requires an explicit supported decision status')
+ count += 1
+ missing_abstracts += not bool((row.get('abstract') or '').strip())
+ if not count:
+ raise ValueError('Empty metadata file')
+ if len(editions) != 1:
+ raise ValueError('A manifest must describe exactly one conference edition and track')
+ edition = next(iter(editions))
+ expected = manifest.get('count')
+ if expected is not None and expected != count:
+ raise ValueError(f'Manifest count {expected} differs from records {count}')
+ complete=expected==count and manifest.get('status') not in ('partial','blocked') and not manifest.get('missing_abstract_count',0) and not missing_abstracts
+ c.execute('INSERT INTO edition_sources(edition_id,source_id,list_url,status,expected_count,imported_count,last_success_at) VALUES(?,?,?,?,?,?,?) ON CONFLICT(edition_id,source_id,list_url) DO UPDATE SET status=excluded.status,expected_count=excluded.expected_count,imported_count=excluded.imported_count,last_success_at=excluded.last_success_at,last_error=NULL',(edition,source_id,manifest['list_url'],'complete' if complete else 'partial',expected,count,time.strftime('%Y-%m-%dT%H:%M:%SZ',time.gmtime())))
+ c.execute("UPDATE ingest_runs SET status='complete',finished_at=CURRENT_TIMESTAMP,records_seen=?,records_written=? WHERE id=?",(count,count,run))
+ c.commit()
+ print(json.dumps({'file':str(path),'status':'imported','records':count,'missing_abstract_count':missing_abstracts}),flush=True)
+ except BaseException as exc:
+ c.rollback()
+ c.execute("UPDATE ingest_runs SET status='failed',finished_at=CURRENT_TIMESTAMP,error=? WHERE id=?",(str(exc),run))
+ c.commit()
+ raise
+ finally:
+ c.close()
+
+
+if __name__=='__main__':
+ parser = argparse.ArgumentParser()
+ parser.add_argument('--db',required=True)
+ parser.add_argument('files',nargs='+')
+ args = parser.parse_args()
+ for filename in args.files:
+ ingest_file(args.db,filename)
diff --git a/index_core.py b/index_core.py
new file mode 100644
index 0000000..df7ce7f
--- /dev/null
+++ b/index_core.py
@@ -0,0 +1,155 @@
+"""Shared identity resolution and provenance-preserving metadata storage."""
+import json
+import re
+import sqlite3
+import unicodedata
+from pathlib import Path
+from submission_evidence import attach_submission,migrate_sources,write_decision
+
+
+def normalized(value):
+ return ''.join(c for c in unicodedata.normalize('NFKD',value).casefold() if c.isalnum())
+
+
+def author_keys(authors):
+ if not isinstance(authors,list):
+ return set()
+ result = set()
+ for author in authors:
+ if isinstance(author,list):
+ author = ' '.join(str(x) for x in author[1:2]+author[:1])
+ elif isinstance(author,dict):
+ author = ' '.join(str(author.get(k,'')) for k in ('given','family'))
+ key = normalized(str(author))
+ if key:
+ result.add(key)
+ return result
+
+
+class Store:
+ def __init__(self,path):
+ self.conn = sqlite3.connect(path,timeout=60)
+ self.conn.executescript(Path(__file__).with_name('schema.sql').read_text())
+ self.conn.execute('PRAGMA synchronous=NORMAL')
+ self.conn.execute('PRAGMA cache_size=-32768')
+ migrate_sources(self.conn)
+
+ def source(self,name,url):
+ self.conn.execute('INSERT OR IGNORE INTO sources(name,homepage) VALUES(?,?)',(name,url))
+ return self.conn.execute('SELECT id FROM sources WHERE name=?',(name,)).fetchone()[0]
+
+ def merge(self,target,other,reason):
+ c = self.conn
+ if target == other:
+ return
+ columns=('abstract','authors_json','first_published')
+ target_values=c.execute('SELECT abstract,authors_json,first_published FROM papers WHERE id=?',(target,)).fetchone()
+ other_values=c.execute('SELECT abstract,authors_json,first_published FROM papers WHERE id=?',(other,)).fetchone()
+ if not target_values or not other_values:raise ValueError('Missing paper entity during merge')
+ fill=[(column,value) for column,current,value in zip(columns,target_values,other_values) if current in (None,'','[]') and value not in (None,'','[]')]
+ if fill:c.execute('UPDATE papers SET '+','.join(column+'=?' for column,_ in fill)+' WHERE id=?',(*[value for _,value in fill],target))
+ c.execute('INSERT INTO paper_merges(former_paper_id,surviving_paper_id,reason) VALUES(?,?,?)',(other,target,reason))
+ c.execute('UPDATE identifiers SET paper_id=? WHERE paper_id=?',(target,other))
+ c.execute('UPDATE source_records SET paper_id=? WHERE paper_id=?',(target,other))
+ c.execute('UPDATE submissions SET paper_id=? WHERE paper_id=?',(target,other))
+ c.execute('INSERT OR IGNORE INTO paper_tags SELECT ?,tag_id,source_record_id,evidence_key FROM paper_tags WHERE paper_id=?',(target,other))
+ c.execute('DELETE FROM paper_tags WHERE paper_id=?',(other,))
+ c.execute('UPDATE paper_merges SET surviving_paper_id=? WHERE surviving_paper_id=?',(target,other))
+ # Reattach pending candidate evidence without losing colliding rows.
+ candidates = c.execute('SELECT left_paper_id,right_paper_id,method,score,evidence_json,status FROM match_candidates WHERE left_paper_id=? OR right_paper_id=?',(other,other)).fetchall()
+ c.execute('DELETE FROM match_candidates WHERE left_paper_id=? OR right_paper_id=?',(other,other))
+ for left,right,method,score,evidence,status in candidates:
+ left = target if left == other else left
+ right = target if right == other else right
+ if left != right:
+ c.execute('INSERT OR IGNORE INTO match_candidates VALUES(?,?,?,?,?,?)',(min(left,right),max(left,right),method,score,evidence,status))
+ c.execute('DELETE FROM papers WHERE id=?',(other,))
+
+ def record(self,source,external_id,url,title,abstract,authors,identifiers=None,metadata=None,published=None):
+ c = self.conn
+ identifiers = dict(identifiers or {})
+ # External IDs are scoped to platforms; an OpenReview ID is a forum ID.
+ identifiers[source] = external_id
+ ids = []
+ for scheme,value in identifiers.items():
+ if not value:
+ continue
+ value = str(value).strip()
+ if scheme == 'doi':
+ value = re.sub(r'^https?://(?:dx\.)?doi\.org/','',value,flags=re.I).lower()
+ if not re.fullmatch(r'10\.\d{4,9}/\S+',value):
+ continue
+ if scheme == 'arxiv':
+ value = re.sub(r'v\d+$','',value)
+ ids.append((scheme,value))
+ matches = set()
+ for scheme,value in ids:
+ found = c.execute('SELECT paper_id FROM identifiers WHERE scheme=? AND value=?',(scheme,value)).fetchone()
+ if found:
+ matches.add(found[0])
+ old = c.execute('SELECT paper_id,title,abstract FROM source_records WHERE source_id=? AND external_id=?',(source_id:=self.source(source,url),external_id)).fetchone()
+ if old:
+ matches.add(old[0])
+ key = normalized(title)
+ title_matches = c.execute('SELECT id,authors_json,abstract FROM papers WHERE title_key=?',(key,)).fetchall()
+ authors_set = author_keys(authors)
+ for paper,old_authors,old_abstract in title_matches:
+ overlap = authors_set & author_keys(json.loads(old_authors))
+ # Cross-platform title matching needs independent author corroboration.
+ same_source = c.execute('SELECT 1 FROM source_records WHERE paper_id=? AND source_id=? LIMIT 1',(paper,source_id)).fetchone()
+ if not same_source and len(key)>=25 and (len(overlap)>=2 or (overlap and abstract and abstract.strip()==(old_abstract or '').strip())):
+ matches.add(paper)
+ if matches:
+ paper = min(matches)
+ for other in sorted(matches-{paper}):
+ self.merge(paper,other,'explicit identifier or exact title corroborated by authors')
+ if old:
+ c.execute("UPDATE papers SET title=?,abstract=?,title_key=?,authors_json=? WHERE id=? AND title=? AND COALESCE(abstract,'')=COALESCE(?,'')",(title,abstract,key,json.dumps(authors,ensure_ascii=False),paper,old[1],old[2]))
+ c.execute("UPDATE papers SET abstract=CASE WHEN abstract IS NULL OR abstract='' THEN ? ELSE abstract END WHERE id=?",(abstract,paper))
+ else:
+ paper = c.execute('INSERT INTO papers(title,abstract,title_key,authors_json,first_published) VALUES(?,?,?,?,?)',(title,abstract,key,json.dumps(authors,ensure_ascii=False),published)).lastrowid
+ for scheme,value in ids:
+ c.execute('INSERT OR IGNORE INTO identifiers VALUES(?,?,?)',(scheme,value,paper))
+ metadata=dict(metadata or {});metadata['record_identifiers']=dict(ids)
+ c.execute('INSERT INTO source_records(source_id,external_id,paper_id,url,title,abstract,authors_json,metadata_json) VALUES(?,?,?,?,?,?,?,?) ON CONFLICT(source_id,external_id) DO UPDATE SET paper_id=excluded.paper_id,url=excluded.url,title=excluded.title,abstract=excluded.abstract,authors_json=excluded.authors_json,metadata_json=excluded.metadata_json,retrieved_at=CURRENT_TIMESTAMP',(source_id,external_id,paper,url,title,abstract,json.dumps(authors,ensure_ascii=False),json.dumps(metadata,ensure_ascii=False,default=str)))
+ record = c.execute('SELECT id FROM source_records WHERE source_id=? AND external_id=?',(source_id,external_id)).fetchone()[0]
+ for other,_,_ in title_matches:
+ if other != paper and c.execute('SELECT 1 FROM papers WHERE id=?',(other,)).fetchone():
+ c.execute('INSERT OR IGNORE INTO match_candidates VALUES(?,?,?,?,?,?)',(min(paper,other),max(paper,other),'exact_title_unconfirmed',None,json.dumps({'title':title}), 'pending'))
+ return paper,record
+
+ def record_tags(self,paper,record,source,tags):
+ """Refresh this record's platform tags without removing other evidence."""
+ values=set()
+ for tag in tags:
+ namespace,value=tag['namespace'],tag['value']
+ if not isinstance(namespace,str) or not namespace.startswith(source+':') or not isinstance(value,str) or not value.strip():
+ raise ValueError('Platform tag must have its own source namespace and a nonempty text value')
+ values.add((namespace,value.strip()))
+ evidence=f'{source}:record:{record}:tags'
+ c=self.conn
+ c.execute('DELETE FROM paper_tags WHERE source_record_id=? AND evidence_key=?',(record,evidence))
+ for namespace,value in sorted(values):
+ c.execute('INSERT OR IGNORE INTO tags(namespace,value) VALUES(?,?)',(namespace,value))
+ tag=c.execute('SELECT id FROM tags WHERE namespace=? AND value=?',(namespace,value)).fetchone()[0]
+ c.execute('INSERT OR IGNORE INTO paper_tags VALUES(?,?,?,?)',(paper,tag,record,evidence))
+
+ def decision(self,paper,record,venue,year,track,external_id,url,status,raw_value,aliases=None,identities=None,observed_at=None,decision_date=None,source_decision_id='',submitted_at=None):
+ c = self.conn
+ c.execute("INSERT OR IGNORE INTO venues(acronym,name,field,venue_type) VALUES(?,?,?,'conference')",(venue,venue,'unclassified'))
+ venue_id = c.execute('SELECT id FROM venues WHERE acronym=?',(venue,)).fetchone()[0]
+ for alias in aliases or []:
+ c.execute('INSERT OR IGNORE INTO venue_aliases VALUES(?,?)',(venue_id,alias))
+ c.execute('INSERT OR IGNORE INTO editions(venue_id,year,track) VALUES(?,?,?)',(venue_id,int(year),track))
+ edition = c.execute('SELECT id FROM editions WHERE venue_id=? AND year=? AND track=?',(venue_id,int(year),track)).fetchone()[0]
+ # A previous event in this row may already have merged the input paper.
+ paper=c.execute('SELECT paper_id FROM source_records WHERE id=?',(record,)).fetchone()[0]
+ submission=attach_submission(self,edition,paper,record,external_id,identities or [])
+ if submitted_at:
+ from submission_evidence import timestamp
+ c.execute('UPDATE submissions SET submitted_at=COALESCE(submitted_at,?) WHERE id=?',(timestamp(submitted_at),submission))
+ if status is not None:write_decision(self,submission,record,status,raw_value,url,observed_at,decision_date,source_decision_id)
+ return edition
+
+ def accepted(self,paper,record,venue,year,track,external_id,url):
+ return self.decision(paper,record,venue,year,track,external_id,url,'accepted','listed in official proceedings')
diff --git a/merge_conferences.py b/merge_conferences.py
new file mode 100644
index 0000000..0d07a41
--- /dev/null
+++ b/merge_conferences.py
@@ -0,0 +1,91 @@
+"""One-shot server operation: merge collected files at an arXiv checkpoint.
+
+Run as a transient systemd job so an SSH disconnect cannot strand the importer.
+Only the Paper Atlas arXiv unit is controlled; business services are untouched.
+"""
+import argparse
+from contextlib import closing
+import json
+import fcntl
+import sqlite3
+import subprocess
+from pathlib import Path
+
+from import_records import ingest_file
+from index_core import Store
+from backfill_submission_evidence import backfill,queue_files
+
+
+UNIT='paper-atlas-arxiv-import.service'
+
+
+def property_value(name):
+ return subprocess.check_output(['systemctl','show',UNIT,'-p',name,'--value'],text=True).strip()
+
+
+def merge_locked(db,archive,files,backfill_queue=None):
+ # Validate inputs before stopping the existing writer.
+ for filename in files:
+ path=Path(filename)
+ if not path.is_file() or not path.with_suffix('.manifest.json').is_file():
+ raise ValueError('Missing collected file or manifest: '+filename)
+ evidence_files=queue_files(backfill_queue) if backfill_queue else []
+ if not Path(archive).is_file():
+ raise ValueError('Missing arXiv archive')
+ if not Path(db).is_file():
+ raise ValueError('Missing existing database')
+ resume_arxiv=property_value('ActiveState')=='active'
+ if not resume_arxiv:
+ with closing(sqlite3.connect(Path(db).resolve().as_uri()+'?mode=ro',uri=True)) as c:
+ last=c.execute("SELECT status FROM ingest_runs WHERE source_id=(SELECT id FROM sources WHERE name='arxiv') ORDER BY id DESC LIMIT 1").fetchone()
+ if not last or last[0]!='complete':
+ raise RuntimeError('arXiv is neither active nor verified complete; inspect its state before maintenance')
+ try:
+ if resume_arxiv:subprocess.run(['systemctl','stop',UNIT],check=True)
+ with closing(sqlite3.connect(db)) as c, c:
+ checkpoint=c.execute("SELECT records_written,status FROM ingest_runs WHERE source_id=(SELECT id FROM sources WHERE name='arxiv') ORDER BY id DESC LIMIT 1").fetchone()
+ print('Saved arxiv checkpoint:',checkpoint,flush=True)
+ if checkpoint and checkpoint[1]=='complete':resume_arxiv=False
+ schema_store=Store(db)
+ schema_store.conn.close()
+ if evidence_files:backfill(db,evidence_files)
+ for filename in files:
+ ingest_file(db,filename)
+ with closing(sqlite3.connect(db)) as c, c:
+ c.execute('PRAGMA cache_size=-32768')
+ if files or evidence_files:
+ errors=c.execute('PRAGMA foreign_key_check').fetchall()
+ if errors:
+ raise RuntimeError('Foreign key violations after merge')
+ else:
+ print('Tag refresh plan:',c.execute('EXPLAIN QUERY PLAN DELETE FROM paper_tags WHERE source_record_id=? AND evidence_key=?',(1,'arxiv:categories')).fetchall(),flush=True)
+ print('Decision totals:',c.execute('SELECT status,count(*) FROM decisions GROUP BY status').fetchall(),flush=True)
+ finally:
+ if resume_arxiv:
+ if property_value('LoadState')=='loaded':
+ subprocess.run(['systemctl','start',UNIT],check=True)
+ else:
+ subprocess.run(['systemd-run','--unit='+UNIT,'--property=WorkingDirectory='+str(Path(__file__).parent),'--property=CPUQuota=50%','--property=MemoryMax=512M','--property=Nice=15','--property=IOWeight=10','--property=StandardOutput=append:/data/paper-index/arxiv-import.log','--property=StandardError=append:/data/paper-index/arxiv-import.log','/usr/bin/env','PYTHONDONTWRITEBYTECODE=1','python3','-B',str(Path(__file__).with_name('import_arxiv.py')),'--db',db,'--archive',archive],check=True)
+ if property_value('ActiveState')!='active':
+ raise RuntimeError('arXiv unit did not resume; inspect the logs')
+ print('ARXIV_RESUMED',flush=True)
+ else:print('ARXIV_ALREADY_COMPLETE',flush=True)
+
+
+def merge(db,archive,files,backfill_queue=None):
+ with Path(str(db)+'.maintenance.lock').open('a') as lock:
+ fcntl.flock(lock,fcntl.LOCK_EX|fcntl.LOCK_NB)
+ return merge_locked(db,archive,files,backfill_queue)
+
+
+if __name__=='__main__':
+ parser=argparse.ArgumentParser()
+ parser.add_argument('--db',required=True)
+ parser.add_argument('--archive',required=True)
+ parser.add_argument('--schema-only',action='store_true',help='Apply idempotent schema/index updates at a saved checkpoint')
+ parser.add_argument('--backfill-queue',help='Audited existing-source queue for canonical submission evidence')
+ parser.add_argument('files',nargs='*')
+ args=parser.parse_args()
+ if not args.files and not args.schema_only and not args.backfill_queue:
+ parser.error('Provide collected files or --schema-only')
+ merge(args.db,args.archive,args.files,args.backfill_queue)
diff --git a/metadata_checkout.py b/metadata_checkout.py
new file mode 100644
index 0000000..4d19294
--- /dev/null
+++ b/metadata_checkout.py
@@ -0,0 +1,29 @@
+"""Public metadata-only Git checkouts, without credentials or project hooks."""
+import os
+import subprocess
+import tempfile
+from pathlib import Path
+
+
+def checkout_metadata(url,destination,patterns,branch='master'):
+ destination=Path(destination)
+ destination.parent.mkdir(parents=True,exist_ok=True)
+ env=dict(os.environ,GIT_TERMINAL_PROMPT='0',GIT_LFS_SKIP_SMUDGE='1')
+ def git(*args):
+ result=subprocess.run(['git','-c','credential.helper=','-c','http.extraHeader=','-c','core.hooksPath=/dev/null','-c','core.deltaBaseCacheLimit=32m',*args],env=env,capture_output=True,text=True,timeout=900)
+ if result.returncode:
+ raise RuntimeError('Metadata checkout failed: '+result.stderr[-500:])
+ return result.stdout.strip()
+ if not destination.exists():
+ with tempfile.TemporaryDirectory(prefix='metadata-clone-',dir=destination.parent) as tmp:
+ stage=Path(tmp)/'repo'
+ git('clone','--filter=blob:none','--depth','1','--single-branch','--branch',branch,'--no-checkout',url,str(stage))
+ git('-C',str(stage),'sparse-checkout','set','--no-cone',*patterns)
+ git('-C',str(stage),'checkout',branch)
+ stage.rename(destination)
+ else:
+ if git('-C',str(destination),'status','--porcelain'):
+ raise RuntimeError('Metadata checkout has local changes; refusing to overwrite')
+ git('-C',str(destination),'fetch','--filter=blob:none','--depth','1','origin',branch)
+ git('-C',str(destination),'checkout','--detach','FETCH_HEAD')
+ return git('-C',str(destination),'rev-parse','HEAD')
diff --git a/query.py b/query.py
new file mode 100644
index 0000000..04354fb
--- /dev/null
+++ b/query.py
@@ -0,0 +1,90 @@
+"""Read-only command-line queries for the paper index."""
+import argparse
+import json
+import sqlite3
+from pathlib import Path
+
+
+def main():
+ parser = argparse.ArgumentParser()
+ parser.add_argument('--db', required=True)
+ parser.add_argument('--query', help='SQLite FTS5 query over title and abstract')
+ parser.add_argument('--paper',type=int,help='Paper ID; include sources, tags and submission history')
+ parser.add_argument('--tag', help='Filter as namespace=value, e.g. arxiv:category=cs.AI')
+ parser.add_argument('--venue', help='Conference acronym')
+ parser.add_argument('--year', type=int)
+ parser.add_argument('--track',help='Exact track, e.g. main, findings, workshop')
+ parser.add_argument('--status', choices=['accepted','rejected','desk_rejected','withdrawn','under_review','unknown'])
+ parser.add_argument('--limit', type=int, default=20)
+ parser.add_argument('--stats', action='store_true')
+ args = parser.parse_args()
+ conn = sqlite3.connect(Path(args.db).resolve().as_uri()+'?mode=ro', uri=True)
+ conn.row_factory = sqlite3.Row
+ conn.execute('BEGIN') # One consistent read snapshot while ingestion continues.
+ if args.stats:
+ result = {name:conn.execute('SELECT count(*) FROM '+name).fetchone()[0] for name in ('papers','source_records','submissions','decisions','tags','match_candidates')}
+ result['records_by_source'] = [dict(r) for r in conn.execute('SELECT s.name,count(r.id) AS imported_records FROM sources s LEFT JOIN source_records r ON r.source_id=s.id GROUP BY s.id ORDER BY s.name')]
+ result['decision_events_by_status'] = [dict(r) for r in conn.execute('SELECT status,count(*) AS events FROM decisions GROUP BY status')]
+ result['coverage'] = [dict(r) for r in conn.execute('SELECT status,count(*) AS sources FROM edition_sources GROUP BY status')]
+ result['runs'] = [dict(r) for r in conn.execute('SELECT * FROM ingest_runs ORDER BY id DESC LIMIT 10')]
+ else:
+ where = []; params = []
+ if args.paper is not None:
+ where.append('p.id=?'); params.append(args.paper)
+ if args.query:
+ where.append('p.id IN (SELECT rowid FROM papers_fts WHERE papers_fts MATCH ?)'); params.append(args.query)
+ if args.tag:
+ namespace,value = args.tag.split('=',1)
+ where.append('EXISTS(SELECT 1 FROM paper_tags pt JOIN tags t ON t.id=pt.tag_id WHERE pt.paper_id=p.id AND t.namespace=? AND t.value=?)'); params.extend([namespace,value])
+ if args.venue or args.year or args.status or args.track:
+ clause = ['1=1']
+ if args.venue:
+ if conn.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='venue_aliases'").fetchone():
+ clause.append('(v.acronym=? COLLATE NOCASE OR EXISTS(SELECT 1 FROM venue_aliases va WHERE va.venue_id=v.id AND va.alias=? COLLATE NOCASE))'); params.extend([args.venue,args.venue])
+ else:
+ clause.append('v.acronym=? COLLATE NOCASE');params.append(args.venue)
+ if args.track:
+ clause.append('e.track=?');params.append(args.track)
+ if args.year:
+ clause.append('e.year=?'); params.append(args.year)
+ if args.status:
+ latest='''EXISTS (
+ SELECT 1 FROM decisions d WHERE d.submission_id=s.id AND d.status=?
+ AND NOT EXISTS (
+ SELECT 1 FROM decisions newer WHERE newer.submission_id=d.submission_id
+ AND (newer.observed_at,COALESCE(newer.decision_date,newer.observed_at),newer.id)
+ > (d.observed_at,COALESCE(d.decision_date,d.observed_at),d.id)
+ )
+ )'''
+ clause.append('(NOT EXISTS(SELECT 1 FROM decisions d WHERE d.submission_id=s.id) OR '+latest+')' if args.status=='unknown' else latest); params.append(args.status)
+ where.append('p.id IN (SELECT s.paper_id FROM submissions s JOIN editions e ON e.id=s.edition_id JOIN venues v ON v.id=e.venue_id WHERE '+' AND '.join(clause)+')')
+ sql = 'SELECT p.* FROM papers p'+(' WHERE '+' AND '.join(where) if where else '')+' ORDER BY p.id LIMIT ?'
+ params.append(max(1,min(args.limit,200)))
+ result = []
+ for row in conn.execute(sql,params):
+ paper = dict(row)
+ paper['authors'] = json.loads(paper.pop('authors_json'))
+ paper['identifiers'] = [dict(r) for r in conn.execute('SELECT scheme,value FROM identifiers WHERE paper_id=?',(row['id'],))]
+ if args.paper is not None:
+ paper['tags'] = [dict(r) for r in conn.execute('SELECT t.namespace,t.value,pt.evidence_key FROM paper_tags pt JOIN tags t ON t.id=pt.tag_id WHERE pt.paper_id=?',(row['id'],))]
+ paper['sources'] = [dict(r) for r in conn.execute('SELECT s.name,r.external_id,r.url,r.title,r.abstract,r.retrieved_at FROM source_records r JOIN sources s ON s.id=r.source_id WHERE r.paper_id=?',(row['id'],))]
+ submissions = []
+ for sub in conn.execute('SELECT s.id,s.source_submission_id,s.submitted_at,v.acronym,e.year,e.track FROM submissions s JOIN editions e ON e.id=s.edition_id JOIN venues v ON v.id=e.venue_id WHERE s.paper_id=? ORDER BY e.year,s.id',(row['id'],)):
+ submission = dict(sub)
+ submission['decisions'] = []
+ for d in conn.execute('SELECT id,status,raw_value,evidence_url,observed_at,decision_date FROM decisions WHERE submission_id=? ORDER BY observed_at,COALESCE(decision_date,observed_at),id',(sub['id'],)):
+ decision=dict(d)
+ if conn.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='decision_sources'").fetchone():
+ decision['sources']=[dict(r) for r in conn.execute('SELECT s.name,r.external_id,ds.source_decision_id FROM decision_sources ds JOIN source_records r ON r.id=ds.source_record_id JOIN sources s ON s.id=r.source_id WHERE ds.decision_id=?',(d['id'],))]
+ submission['decisions'].append(decision)
+ if conn.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='submission_sources'").fetchone():
+ submission['sources']=[dict(r) for r in conn.execute('SELECT so.name,r.external_id,ss.source_submission_id FROM submission_sources ss JOIN source_records r ON r.id=ss.source_record_id JOIN sources so ON so.id=r.source_id WHERE ss.submission_id=?',(sub['id'],))]
+ submissions.append(submission)
+ paper['submissions'] = submissions
+ result.append(paper)
+ print(json.dumps(result,ensure_ascii=False,indent=2))
+ conn.close()
+
+
+if __name__ == '__main__':
+ main()
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..f62ce0c
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1 @@
+PyYAML==6.0.3
diff --git a/schema.sql b/schema.sql
new file mode 100644
index 0000000..5a0b268
--- /dev/null
+++ b/schema.sql
@@ -0,0 +1,125 @@
+PRAGMA foreign_keys=ON;
+PRAGMA journal_mode=WAL;
+CREATE TABLE IF NOT EXISTS sources (
+ id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE, homepage TEXT NOT NULL
+);
+CREATE TABLE IF NOT EXISTS papers (
+ id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, abstract TEXT,
+ title_key TEXT NOT NULL, authors_json TEXT NOT NULL DEFAULT '[]',
+ first_published TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
+);
+CREATE INDEX IF NOT EXISTS papers_title_key ON papers(title_key);
+CREATE TABLE IF NOT EXISTS identifiers (
+ scheme TEXT NOT NULL, value TEXT NOT NULL, paper_id INTEGER NOT NULL REFERENCES papers(id),
+ PRIMARY KEY(scheme,value)
+);
+CREATE INDEX IF NOT EXISTS identifiers_paper ON identifiers(paper_id);
+CREATE TABLE IF NOT EXISTS source_records (
+ id INTEGER PRIMARY KEY, source_id INTEGER NOT NULL REFERENCES sources(id),
+ external_id TEXT NOT NULL, paper_id INTEGER NOT NULL REFERENCES papers(id),
+ url TEXT NOT NULL, title TEXT NOT NULL, abstract TEXT, authors_json TEXT NOT NULL,
+ metadata_json TEXT NOT NULL, retrieved_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ UNIQUE(source_id,external_id)
+);
+CREATE INDEX IF NOT EXISTS source_records_paper ON source_records(paper_id);
+CREATE TABLE IF NOT EXISTS venues (
+ id INTEGER PRIMARY KEY, acronym TEXT NOT NULL UNIQUE, name TEXT NOT NULL,
+ field TEXT NOT NULL, venue_type TEXT NOT NULL CHECK(venue_type IN ('conference','journal','review_platform'))
+);
+CREATE TABLE IF NOT EXISTS editions (
+ id INTEGER PRIMARY KEY, venue_id INTEGER NOT NULL REFERENCES venues(id),
+ year INTEGER NOT NULL, track TEXT NOT NULL DEFAULT 'main',
+ UNIQUE(venue_id,year,track)
+);
+CREATE TABLE IF NOT EXISTS venue_aliases (
+ venue_id INTEGER NOT NULL REFERENCES venues(id), alias TEXT NOT NULL,
+ PRIMARY KEY(venue_id,alias)
+);
+CREATE TABLE IF NOT EXISTS edition_sources (
+ edition_id INTEGER NOT NULL REFERENCES editions(id), source_id INTEGER NOT NULL REFERENCES sources(id),
+ list_url TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'pending'
+ CHECK(status IN ('pending','running','complete','partial','blocked','unavailable','not_yet_published')),
+ expected_count INTEGER, imported_count INTEGER NOT NULL DEFAULT 0,
+ cursor TEXT, last_success_at TEXT, last_error TEXT,
+ PRIMARY KEY(edition_id,source_id,list_url)
+);
+CREATE TABLE IF NOT EXISTS submissions (
+ id INTEGER PRIMARY KEY, paper_id INTEGER NOT NULL REFERENCES papers(id),
+ edition_id INTEGER NOT NULL REFERENCES editions(id),
+ source_record_id INTEGER NOT NULL REFERENCES source_records(id),
+ source_submission_id TEXT NOT NULL, submitted_at TEXT,
+ UNIQUE(source_record_id,edition_id,source_submission_id)
+);
+CREATE INDEX IF NOT EXISTS submissions_paper ON submissions(paper_id);
+CREATE TABLE IF NOT EXISTS decisions (
+ id INTEGER PRIMARY KEY, submission_id INTEGER NOT NULL REFERENCES submissions(id),
+ status TEXT NOT NULL CHECK(status IN ('accepted','rejected','desk_rejected','withdrawn','under_review','unknown')),
+ raw_value TEXT NOT NULL, evidence_url TEXT NOT NULL, observed_at TEXT NOT NULL,
+ decision_date TEXT, UNIQUE(submission_id,status,raw_value,evidence_url,observed_at)
+);
+CREATE INDEX IF NOT EXISTS decisions_submission_order ON decisions(submission_id,observed_at DESC,id DESC);
+CREATE TABLE IF NOT EXISTS submission_sources (
+ submission_id INTEGER NOT NULL REFERENCES submissions(id),
+ source_record_id INTEGER NOT NULL REFERENCES source_records(id), source_submission_id TEXT NOT NULL,
+ PRIMARY KEY(submission_id,source_record_id,source_submission_id)
+);
+CREATE INDEX IF NOT EXISTS submission_sources_record ON submission_sources(source_record_id,source_submission_id);
+CREATE TABLE IF NOT EXISTS submission_identifiers (
+ edition_id INTEGER NOT NULL REFERENCES editions(id), scheme TEXT NOT NULL, value TEXT NOT NULL,
+ submission_id INTEGER NOT NULL REFERENCES submissions(id), PRIMARY KEY(edition_id,scheme,value)
+);
+CREATE INDEX IF NOT EXISTS submission_identifiers_submission ON submission_identifiers(submission_id);
+CREATE TABLE IF NOT EXISTS decision_sources (
+ decision_id INTEGER NOT NULL REFERENCES decisions(id),
+ source_record_id INTEGER NOT NULL REFERENCES source_records(id), source_decision_id TEXT NOT NULL DEFAULT '',
+ PRIMARY KEY(decision_id,source_record_id,source_decision_id)
+);
+CREATE TABLE IF NOT EXISTS submission_merges (
+ former_submission_id INTEGER PRIMARY KEY, surviving_submission_id INTEGER NOT NULL REFERENCES submissions(id),
+ reason TEXT NOT NULL, merged_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
+);
+CREATE TABLE IF NOT EXISTS decision_merges (
+ former_decision_id INTEGER PRIMARY KEY, surviving_decision_id INTEGER NOT NULL REFERENCES decisions(id),
+ reason TEXT NOT NULL, merged_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
+);
+CREATE TABLE IF NOT EXISTS schema_migrations (
+ name TEXT PRIMARY KEY, applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
+);
+CREATE TABLE IF NOT EXISTS tags (
+ id INTEGER PRIMARY KEY, namespace TEXT NOT NULL, value TEXT NOT NULL,
+ description TEXT, UNIQUE(namespace,value)
+);
+CREATE TABLE IF NOT EXISTS paper_tags (
+ paper_id INTEGER NOT NULL REFERENCES papers(id), tag_id INTEGER NOT NULL REFERENCES tags(id),
+ source_record_id INTEGER REFERENCES source_records(id),
+ evidence_key TEXT NOT NULL, PRIMARY KEY(paper_id,tag_id,evidence_key)
+);
+CREATE INDEX IF NOT EXISTS paper_tags_source_evidence ON paper_tags(source_record_id,evidence_key);
+CREATE TABLE IF NOT EXISTS match_candidates (
+ left_paper_id INTEGER NOT NULL REFERENCES papers(id), right_paper_id INTEGER NOT NULL REFERENCES papers(id),
+ method TEXT NOT NULL, score REAL, evidence_json TEXT NOT NULL,
+ status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','merged','distinct')),
+ PRIMARY KEY(left_paper_id,right_paper_id,method)
+);
+CREATE TABLE IF NOT EXISTS paper_merges (
+ id INTEGER PRIMARY KEY, former_paper_id INTEGER NOT NULL,
+ surviving_paper_id INTEGER NOT NULL REFERENCES papers(id),
+ reason TEXT NOT NULL, merged_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
+);
+CREATE TABLE IF NOT EXISTS ingest_runs (
+ id INTEGER PRIMARY KEY, source_id INTEGER NOT NULL REFERENCES sources(id),
+ started_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, finished_at TEXT,
+ status TEXT NOT NULL DEFAULT 'running', records_seen INTEGER NOT NULL DEFAULT 0,
+ records_written INTEGER NOT NULL DEFAULT 0, error TEXT, checkpoint_json TEXT
+);
+CREATE VIRTUAL TABLE IF NOT EXISTS papers_fts USING fts5(title,abstract,content='papers',content_rowid='id');
+CREATE TRIGGER IF NOT EXISTS papers_insert AFTER INSERT ON papers BEGIN
+ INSERT INTO papers_fts(rowid,title,abstract) VALUES(new.id,new.title,new.abstract);
+END;
+CREATE TRIGGER IF NOT EXISTS papers_delete AFTER DELETE ON papers BEGIN
+ INSERT INTO papers_fts(papers_fts,rowid,title,abstract) VALUES('delete',old.id,old.title,old.abstract);
+END;
+CREATE TRIGGER IF NOT EXISTS papers_update AFTER UPDATE OF title,abstract ON papers BEGIN
+ INSERT INTO papers_fts(papers_fts,rowid,title,abstract) VALUES('delete',old.id,old.title,old.abstract);
+ INSERT INTO papers_fts(rowid,title,abstract) VALUES(new.id,new.title,new.abstract);
+END;
diff --git a/submission_evidence.py b/submission_evidence.py
new file mode 100644
index 0000000..c1d350b
--- /dev/null
+++ b/submission_evidence.py
@@ -0,0 +1,115 @@
+"""Canonical submission events with independent source and decision evidence."""
+from datetime import datetime, timezone
+import re
+
+
+PUBLICATIONS={'pmlr','acl_anthology','colm','neurips','cvf','ecva','dblp'}
+
+
+def timestamp(value=None):
+ date=datetime.now(timezone.utc) if value is None else datetime.fromisoformat(value.replace('Z','+00:00'))
+ if date.tzinfo is None:raise ValueError('Decision timestamp requires a timezone')
+ return date.astimezone(timezone.utc).isoformat(timespec='milliseconds').replace('+00:00','Z')
+
+
+def identities_for_row(row):
+ identities=list(row.get('submission_identifiers') or [])
+ own=row.get('identifiers') or {}
+ if own.get('openreview'):
+ identities.append({'scheme':'openreview','value':own['openreview']})
+ if row['source'] in PUBLICATIONS and row.get('status')=='accepted' and own.get('doi'):
+ doi=re.sub(r'^https?://(?:dx\.)?doi\.org/','',str(own['doi']),flags=re.I).strip().lower()
+ if re.fullmatch(r'10\.\d{4,9}/\S+',doi):identities.append({'scheme':'publication_doi','value':doi})
+ meta=row.get('metadata') or {}
+ if row['source']=='iclr_program' and row['venue']=='ICLR' and meta.get('program_identity_type')=='submission' and meta.get('program_submission_number_verified') is True:
+ identities.append({'scheme':'iclr_number','value':str(meta['program_source_id'])})
+ result=set()
+ for identity in identities:
+ scheme,value=identity['scheme'],str(identity['value']).strip()
+ if scheme=='openreview':
+ valid=re.fullmatch(r'[A-Za-z0-9_-]+',value) and not re.match(r'20\d{2}-Oral-',value)
+ elif scheme=='iclr_number':valid=re.fullmatch(r'[1-9]\d*',value) and row['venue']=='ICLR'
+ elif scheme=='publication_doi':valid=re.fullmatch(r'10\.\d{4,9}/\S+',value)
+ else:valid=False
+ if not valid:raise ValueError('Invalid authoritative submission identity')
+ result.add((scheme,value))
+ return sorted(result)
+
+
+def migrate_sources(c):
+ if c.execute("SELECT 1 FROM schema_migrations WHERE name='submission_source_baseline_v1'").fetchone():return
+ with c:
+ c.execute('INSERT OR IGNORE INTO submission_sources SELECT id,source_record_id,source_submission_id FROM submissions')
+ c.execute("INSERT OR IGNORE INTO decision_sources SELECT d.id,s.source_record_id,'' FROM decisions d JOIN submissions s ON s.id=d.submission_id")
+ c.execute("INSERT INTO schema_migrations(name) VALUES('submission_source_baseline_v1')")
+
+
+def next_id(c,table,audit,former):
+ # Merged IDs are kept in audit; never allocate them to a later unrelated event.
+ return max(c.execute('SELECT COALESCE(max(id),0) FROM '+table).fetchone()[0],c.execute('SELECT COALESCE(max('+former+'),0) FROM '+audit).fetchone()[0])+1
+
+
+def merge_submissions(c,target,other):
+ if target==other:return
+ editions=c.execute('SELECT id,edition_id FROM submissions WHERE id IN (?,?)',(target,other)).fetchall()
+ if len(editions)!=2 or editions[0][1]!=editions[1][1]:raise ValueError('Cannot combine submissions from different editions')
+ c.execute('INSERT OR IGNORE INTO submission_sources SELECT ?,source_record_id,source_submission_id FROM submission_sources WHERE submission_id=?',(target,other))
+ c.execute('DELETE FROM submission_sources WHERE submission_id=?',(other,))
+ for decision,status,raw,url,observed,date in c.execute('SELECT id,status,raw_value,evidence_url,observed_at,decision_date FROM decisions WHERE submission_id=?',(other,)).fetchall():
+ duplicate=c.execute('SELECT id,decision_date FROM decisions WHERE submission_id=? AND status=? AND raw_value=? AND evidence_url=? AND observed_at=?',(target,status,raw,url,observed)).fetchone()
+ if duplicate:
+ surviving=duplicate[0]
+ if date and duplicate[1] and date!=duplicate[1]:raise ValueError('Conflicting dates for the same decision observation')
+ if date and not duplicate[1]:c.execute('UPDATE decisions SET decision_date=? WHERE id=?',(date,surviving))
+ c.execute('INSERT OR IGNORE INTO decision_sources SELECT ?,source_record_id,source_decision_id FROM decision_sources WHERE decision_id=?',(surviving,decision))
+ c.execute('DELETE FROM decision_sources WHERE decision_id=?',(decision,))
+ c.execute('UPDATE decision_merges SET surviving_decision_id=? WHERE surviving_decision_id=?',(surviving,decision))
+ c.execute("INSERT INTO decision_merges(former_decision_id,surviving_decision_id,reason) VALUES(?,?,'same decision observation on merged submission')",(decision,surviving))
+ c.execute('DELETE FROM decisions WHERE id=?',(decision,))
+ else:c.execute('UPDATE decisions SET submission_id=? WHERE id=?',(target,decision))
+ c.execute('UPDATE submission_identifiers SET submission_id=? WHERE submission_id=?',(target,other))
+ c.execute('UPDATE submission_merges SET surviving_submission_id=? WHERE surviving_submission_id=?',(target,other))
+ c.execute("INSERT INTO submission_merges(former_submission_id,surviving_submission_id,reason) VALUES(?,?,'shared authoritative submission identity')",(other,target))
+ c.execute('DELETE FROM submissions WHERE id=?',(other,))
+
+
+def attach_submission(store,edition,paper,record,external_id,identities):
+ c=store.conn
+ matches={r[0] for r in c.execute('SELECT s.id FROM submission_sources p JOIN submissions s ON s.id=p.submission_id WHERE p.source_record_id=? AND p.source_submission_id=? AND s.edition_id=?',(record,external_id,edition))}
+ matches.update(r[0] for r in c.execute('SELECT id FROM submissions WHERE source_record_id=? AND source_submission_id=? AND edition_id=?',(record,external_id,edition)))
+ for scheme,value in identities:
+ found=c.execute('SELECT submission_id FROM submission_identifiers WHERE edition_id=? AND scheme=? AND value=?',(edition,scheme,value)).fetchone()
+ if found:matches.add(found[0])
+ for scheme in ('openreview','iclr_number'):
+ values={value for kind,value in identities if kind==scheme}
+ for sid in matches:
+ values.update(value for value, in c.execute('SELECT value FROM submission_identifiers WHERE submission_id=? AND scheme=?',(sid,scheme)))
+ if len(values)>1:raise ValueError('Conflicting authoritative submission identities: '+scheme)
+ if matches:
+ papers={paper}|{c.execute('SELECT paper_id FROM submissions WHERE id=?',(sid,)).fetchone()[0] for sid in matches}
+ surviving=min(papers)
+ for other in sorted(papers-{surviving}):store.merge(surviving,other,'shared authoritative conference submission identity')
+ submission=min(matches)
+ for other in sorted(matches-{submission}):merge_submissions(c,submission,other)
+ c.execute('UPDATE submissions SET paper_id=? WHERE id=?',(surviving,submission))
+ else:
+ submission=next_id(c,'submissions','submission_merges','former_submission_id')
+ c.execute('INSERT INTO submissions(id,paper_id,edition_id,source_record_id,source_submission_id) VALUES(?,?,?,?,?)',(submission,paper,edition,record,external_id))
+ c.execute('INSERT OR IGNORE INTO submission_sources VALUES(?,?,?)',(submission,record,external_id))
+ for scheme,value in identities:
+ c.execute('INSERT OR IGNORE INTO submission_identifiers VALUES(?,?,?,?)',(edition,scheme,value,submission))
+ return submission
+
+
+def write_decision(store,submission,record,status,raw,url,observed_at=None,decision_date=None,source_decision_id=''):
+ c=store.conn
+ observed=timestamp(observed_at)
+ date=timestamp(decision_date) if decision_date else None
+ # Re-observing unchanged evidence adds provenance, not a new decision event.
+ old=c.execute('SELECT id FROM decisions WHERE submission_id=? AND status=? AND raw_value=? AND evidence_url=? AND COALESCE(decision_date,\'\')=COALESCE(?,\'\')',(submission,status,raw,url,date)).fetchone()
+ if old:decision=old[0]
+ else:
+ decision=next_id(c,'decisions','decision_merges','former_decision_id')
+ c.execute('INSERT INTO decisions(id,submission_id,status,raw_value,evidence_url,observed_at,decision_date) VALUES(?,?,?,?,?,?,?)',(decision,submission,status,raw,url,observed,date))
+ c.execute('INSERT OR IGNORE INTO decision_sources VALUES(?,?,?)',(decision,record,source_decision_id or ''))
+ return decision
diff --git a/test_acl.py b/test_acl.py
new file mode 100644
index 0000000..99775ba
--- /dev/null
+++ b/test_acl.py
@@ -0,0 +1,40 @@
+import json
+import tempfile
+import unittest
+import xml.etree.ElementTree as ET
+from pathlib import Path
+
+from collect_acl import paper_id,volume_scope,collect_xml
+
+
+class AnthologyTests(unittest.TestCase):
+ def test_documented_identifier_formats_and_exceptions(self):
+ for args,expected in [(('P17','1','1'),'P17-1001'),(('2025.acl','long','1'),'2025.acl-long.1'),(('C69','1','1'),'C69-0101'),(('D19','50','1'),'D19-5001'),(('W17','1','1'),'W17-0101')]:
+ self.assertEqual(paper_id(*args),expected)
+
+ def test_findings_uses_associated_conference_and_separate_track(self):
+ meta=ET.fromstring('findingsFindings of ACL: EMNLP 2025')
+ self.assertEqual(volume_scope(meta,'emnlp'),('EMNLP',['EMNLP'],'findings'))
+
+ def test_joint_event_has_one_identity_and_two_aliases(self):
+ meta=ET.fromstring('lreccolingLREC-COLING 2024')
+ self.assertEqual(volume_scope(meta,'main'),('COLING+LREC',['COLING','LREC'],'main'))
+
+ def test_xml_mixed_text_frontmatter_and_missing_abstract(self):
+ xml='2017aclACLFront matterA Mixed TitleJaneDoe10.18653/v1/P17-1001'
+ with tempfile.TemporaryDirectory() as tmp:
+ path=Path(tmp)/'P17.xml';path.write_text(xml)
+ result=collect_xml(path,tmp,'a'*40)[0]
+ row=json.loads(Path(result['file']).read_text())
+ self.assertEqual(result['count'],1)
+ self.assertEqual(result['excluded_frontmatter'],1)
+ self.assertEqual(result['status'],'partial')
+ self.assertEqual(row['external_id'],'P17-1001')
+ self.assertEqual(row['title'],'A Mixed Title')
+
+ def test_non_target_workshop_is_not_main_conference(self):
+ meta=ET.fromstring('some-workshopwsWorkshop at ACL')
+ self.assertIsNone(volume_scope(meta,'1'))
+
+
+if __name__=='__main__':unittest.main()
diff --git a/test_coverage_report.py b/test_coverage_report.py
new file mode 100644
index 0000000..c2c4e94
--- /dev/null
+++ b/test_coverage_report.py
@@ -0,0 +1,35 @@
+import json
+from pathlib import Path
+import tempfile
+import unittest
+
+from coverage_report import report
+from index_core import Store
+
+
+class CoverageTests(unittest.TestCase):
+ def setUp(self):
+ self.tmp=tempfile.TemporaryDirectory();self.addCleanup(self.tmp.cleanup)
+ self.root=Path(self.tmp.name);self.db=self.root/'papers.sqlite';self.store=Store(self.db);self.addCleanup(self.store.conn.close)
+ self.registry=self.root/'venues.json';self.registry.write_text(json.dumps({'scope_note':'Candidate scope','venues':[{'acronym':'COLING','field':'language'},{'acronym':'AAAI','field':'general_ai'}]}))
+
+ def test_zero_coverage_targets_are_listed_and_joint_alias_is_counted_once(self):
+ paper,record=self.store.record('acl_anthology','joint-paper','https://example.org','Joint paper','Abstract',['Jane Doe'])
+ edition=self.store.decision(paper,record,'COLING+LREC',2024,'main','joint-paper','https://example.org','accepted','Accepted',aliases=['COLING','LREC'])
+ self.store.decision(paper,record,'COLING+LREC',2024,'main','joint-paper','https://example.org/another-observation','accepted','Accepted',aliases=['COLING'])
+ self.store.conn.commit();result=report(self.db,self.registry)
+ self.assertEqual(result['target_venue_count'],2);self.assertEqual(result['venues_with_imported_records'],1)
+ coling,aaai=result['venues'];self.assertEqual(coling['imported_submissions'],1)
+ self.assertEqual(coling['editions'][0]['canonical_venue'],'COLING+LREC')
+ self.assertEqual(aaai['history_status'],'no_imported_records');self.assertEqual(aaai['editions'],[])
+ self.assertNotEqual(coling['history_status'],'complete')
+
+ def test_same_paper_across_years_is_not_two_distinct_papers(self):
+ paper,record=self.store.record('source','one','https://example.org','A paper','Abstract',[])
+ for year in (2024,2025):
+ self.store.decision(paper,record,'COLING',year,'main',str(year),'https://example.org','accepted','Accepted')
+ self.store.conn.commit();coling=report(self.db,self.registry)['venues'][0]
+ self.assertEqual(coling['imported_submissions'],2);self.assertEqual(coling['distinct_papers'],1)
+
+
+if __name__=='__main__':unittest.main()
diff --git a/test_dblp_catalogue.py b/test_dblp_catalogue.py
new file mode 100644
index 0000000..67bc23a
--- /dev/null
+++ b/test_dblp_catalogue.py
@@ -0,0 +1,68 @@
+import gzip
+import hashlib
+import json
+from pathlib import Path
+import sqlite3
+import tempfile
+import unittest
+
+from catalogue_dblp import catalogue,records
+
+
+class DblpCatalogueTests(unittest.TestCase):
+ def setUp(self):
+ self.tmp=tempfile.TemporaryDirectory();self.addCleanup(self.tmp.cleanup);self.root=Path(self.tmp.name)
+ self.dtd=self.root/'dblp.dtd';self.dtd.write_bytes(b'')
+
+ def archive(self,body,doctype=''):
+ path=self.root/'dblp.xml.gz'
+ with gzip.open(path,'wb') as stream:stream.write((''+doctype+''+body+'').encode())
+ return path
+
+ def test_streamed_entities_nested_titles_and_candidate_scope_remain_explicit(self):
+ path=self.archive('AAAI Workshop proceedings2025RenéLearn deep models2025AAAI Workshopconf/aaai/2025https://doi.org/10.1234/exampleOther2020')
+ manifest=catalogue(path,self.dtd,self.root/'out',hashlib.sha256(path.read_bytes()).hexdigest(),hashlib.md5(self.dtd.read_bytes()).hexdigest())
+ self.assertEqual(manifest['records_scanned'],3);self.assertEqual(manifest['candidate_records'],1)
+ row=json.loads(Path(manifest['file']).read_text())
+ self.assertEqual(row['fields']['author'],['René']);self.assertEqual(row['fields']['title'],['Learn deep models'])
+ self.assertNotIn('status',row);self.assertNotIn('venue',row)
+ with sqlite3.connect(manifest['catalogue']) as connection:
+ self.assertEqual(connection.execute('SELECT sum(count) FROM volume_counts').fetchone()[0],2)
+ self.assertEqual(connection.execute('SELECT count(*) FROM proceedings').fetchone()[0],1)
+
+ def test_remote_external_dtd_is_rejected_without_fetching(self):
+ path=self.archive('',doctype='')
+ with self.assertRaisesRegex(ValueError,'pinned external'):list(records(path,self.dtd.read_bytes()))
+
+ def test_real_monthly_dtd_name_and_latin1_encoding(self):
+ path=self.root/'monthly.xml.gz'
+ xml='RenéMetadata'
+ with gzip.open(path,'wb') as stream:stream.write(xml.encode('latin1'))
+ self.assertEqual(list(records(path,self.dtd.read_bytes()))[0]['fields']['author'],['René'])
+
+ def test_multiple_volume_fields_remain_reviewable_without_double_counting(self):
+ path=self.archive('Joint publication2024First meetingSecond meetingconf/aaai/joint2024')
+ manifest=catalogue(path,self.dtd,self.root/'out',hashlib.sha256(path.read_bytes()).hexdigest(),hashlib.md5(self.dtd.read_bytes()).hexdigest())
+ with sqlite3.connect(manifest['catalogue']) as connection:
+ raw=json.loads(connection.execute('SELECT metadata_json FROM volume_anomalies').fetchone()[0])
+ self.assertEqual(raw['fields']['booktitle'],['First meeting','Second meeting'])
+ self.assertEqual(connection.execute('SELECT sum(count) FROM volume_counts').fetchone()[0],1)
+
+ def test_internal_entities_are_rejected(self):
+ path=self.archive('',doctype=']>')
+ with self.assertRaisesRegex(ValueError,'pinned external'):list(records(path,self.dtd.read_bytes()))
+
+ def test_checksum_failure_preserves_existing_output(self):
+ path=self.archive('');out=self.root/'out';out.mkdir();target=out/'dblp-ai-candidates.jsonl';target.write_text('previous')
+ with self.assertRaisesRegex(ValueError,'snapshot checksum'):catalogue(path,self.dtd,out,'0'*64)
+ self.assertEqual(target.read_text(),'previous')
+
+ def test_truncated_gzip_cannot_publish_a_catalogue(self):
+ path=self.archive('One')
+ path.write_bytes(path.read_bytes()[:-8]);out=self.root/'out'
+ with self.assertRaises((EOFError,OSError)):
+ catalogue(path,self.dtd,out,hashlib.sha256(path.read_bytes()).hexdigest(),hashlib.md5(self.dtd.read_bytes()).hexdigest())
+ self.assertFalse((out/'dblp-ai-candidates.jsonl').exists())
+
+
+if __name__=='__main__':unittest.main()
diff --git a/test_download_snapshot.py b/test_download_snapshot.py
new file mode 100644
index 0000000..301c7aa
--- /dev/null
+++ b/test_download_snapshot.py
@@ -0,0 +1,71 @@
+import hashlib
+import io
+import json
+from pathlib import Path
+import tempfile
+import unittest
+from unittest.mock import patch
+
+from download_snapshot import download
+
+
+class Response(io.BytesIO):
+ def __init__(self,body,status=200,headers=None):
+ super().__init__(body);self.status=status;self.headers=headers or {'Content-Length':str(len(body))}
+
+
+class DownloadTests(unittest.TestCase):
+ def test_verified_download_is_reused_without_replacing_it(self):
+ body=b'public metadata';sha=hashlib.sha256(body).hexdigest()
+ with tempfile.TemporaryDirectory() as tmp:
+ path=Path(tmp)/'snapshot.gz'
+ with patch('download_snapshot.urllib.request.urlopen',return_value=Response(body)):
+ result=download('https://example.org/snapshot.gz',path,sha256=sha,expected_bytes=len(body),min_free=0)
+ self.assertEqual(result['status'],'verified')
+ with patch('download_snapshot.urllib.request.urlopen',side_effect=AssertionError('No network expected')):
+ reused=download('https://example.org/snapshot.gz',path,sha256=sha,min_free=0)
+ self.assertEqual(reused['status'],'already_verified')
+ self.assertEqual(json.loads(Path(str(path)+'.manifest.json').read_text())['sha256'],sha)
+
+ def test_bad_checksum_never_publishes_a_snapshot(self):
+ with tempfile.TemporaryDirectory() as tmp,patch('download_snapshot.urllib.request.urlopen',return_value=Response(b'wrong bytes')):
+ path=Path(tmp)/'snapshot.gz'
+ with self.assertRaisesRegex(ValueError,'SHA256 mismatch'):
+ download('https://example.org/snapshot.gz',path,sha256='0'*64,min_free=0)
+ self.assertFalse(path.exists());self.assertTrue(Path(str(path)+'.part').exists())
+
+ def test_resume_requires_the_same_identity_and_a_real_partial_response(self):
+ body=b'abcdef';sha=hashlib.sha256(body).hexdigest();url='https://example.org/snapshot.gz'
+ with tempfile.TemporaryDirectory() as tmp:
+ path=Path(tmp)/'snapshot.gz';part=Path(str(path)+'.part');part.write_bytes(body[:3])
+ Path(str(part)+'.json').write_text(json.dumps({'url':url,'expected_sha256':sha,'expected_md5':None,'expected_bytes':6}))
+ with self.assertRaisesRegex(ValueError,'same snapshot'):
+ download(url,path,sha256=sha,expected_bytes=6,min_free=0)
+ with patch('download_snapshot.urllib.request.urlopen',return_value=Response(body)):
+ with self.assertRaisesRegex(ValueError,'resume range'):
+ download(url,path,sha256=sha,expected_bytes=6,resume=True,min_free=0)
+ self.assertEqual(part.read_bytes(),b'abc')
+ with patch('download_snapshot.urllib.request.urlopen',return_value=Response(b'def',206,{'Content-Range':'bytes 3-5/6'})):
+ download(url,path,sha256=sha,expected_bytes=6,resume=True,min_free=0)
+ self.assertEqual(path.read_bytes(),body)
+
+ def test_wrong_http_length_is_rejected_before_writing(self):
+ with tempfile.TemporaryDirectory() as tmp,patch('download_snapshot.urllib.request.urlopen',return_value=Response(b'abc')):
+ path=Path(tmp)/'snapshot.gz'
+ with self.assertRaisesRegex(ValueError,'HTTP size'):
+ download('https://example.org/snapshot.gz',path,sha256='0'*64,expected_bytes=4,min_free=0)
+ self.assertFalse(path.exists());self.assertFalse(Path(str(path)+'.part').exists())
+
+ def test_empty_partial_and_missing_final_manifest_can_recover(self):
+ body=b'abc';sha=hashlib.sha256(body).hexdigest();url='https://example.org/snapshot.gz'
+ with tempfile.TemporaryDirectory() as tmp:
+ path=Path(tmp)/'snapshot.gz';part=Path(str(path)+'.part');part.write_bytes(b'')
+ Path(str(part)+'.json').write_text(json.dumps({'url':url,'expected_sha256':sha,'expected_md5':None,'expected_bytes':3}))
+ with patch('download_snapshot.urllib.request.urlopen',return_value=Response(body)):
+ download(url,path,sha256=sha,expected_bytes=3,resume=True,min_free=0)
+ metadata=Path(str(path)+'.manifest.json');metadata.unlink()
+ download(url,path,sha256=sha,expected_bytes=3,min_free=0)
+ self.assertEqual(json.loads(metadata.read_text())['sha256'],sha)
+
+
+if __name__=='__main__':unittest.main()
diff --git a/test_fetch.py b/test_fetch.py
new file mode 100644
index 0000000..720bf4b
--- /dev/null
+++ b/test_fetch.py
@@ -0,0 +1,49 @@
+import hashlib
+import io
+from pathlib import Path
+import tempfile
+import unittest
+from unittest.mock import patch
+from urllib.error import HTTPError,URLError
+
+from collect_pmlr import fetch
+
+
+class Response(io.BytesIO):
+ def __init__(self,body,expected=None):
+ super().__init__(body);self.status=200;self.headers={'Content-Length':str(expected if expected is not None else len(body))}
+
+
+class FetchTests(unittest.TestCase):
+ def test_transient_connection_failure_is_retried(self):
+ with tempfile.TemporaryDirectory() as tmp,patch('collect_pmlr.time.sleep'),patch('collect_pmlr.urllib.request.urlopen',side_effect=[URLError(ConnectionResetError('reset')),Response(b'complete')]) as network:
+ self.assertEqual(fetch('https://example.org/metadata',tmp),b'complete')
+ self.assertEqual(network.call_count,2)
+
+ def test_truncated_response_is_not_cached_as_complete(self):
+ with tempfile.TemporaryDirectory() as tmp,patch('collect_pmlr.time.sleep'),patch('collect_pmlr.urllib.request.urlopen',side_effect=[Response(b'bad',expected=8),Response(b'complete')]):
+ self.assertEqual(fetch('https://example.org/metadata',tmp),b'complete')
+ path=Path(tmp)/hashlib.sha256(b'https://example.org/metadata').hexdigest()
+ self.assertEqual(path.read_bytes(),b'complete')
+
+ def test_forbidden_response_is_not_retried_or_bypassed(self):
+ body=io.BytesIO(b'Forbidden');error=HTTPError('https://example.org/metadata',403,'Forbidden',{},body)
+ with tempfile.TemporaryDirectory() as tmp,patch('collect_pmlr.time.sleep'),patch('collect_pmlr.urllib.request.urlopen',side_effect=error) as network:
+ with self.assertRaises(HTTPError):fetch('https://example.org/metadata',tmp)
+ self.assertEqual(network.call_count,1)
+ self.assertTrue(body.closed)
+
+ def test_transport_retries_are_bounded(self):
+ with tempfile.TemporaryDirectory() as tmp,patch('collect_pmlr.time.sleep'),patch('collect_pmlr.urllib.request.urlopen',side_effect=ConnectionResetError('reset')) as network:
+ with self.assertRaises(ConnectionResetError):fetch('https://example.org/metadata',tmp)
+ self.assertEqual(network.call_count,3)
+
+ def test_cached_response_still_obeys_the_size_limit(self):
+ with tempfile.TemporaryDirectory() as tmp,patch('collect_pmlr.urllib.request.urlopen') as network:
+ path=Path(tmp)/hashlib.sha256(b'https://example.org/metadata').hexdigest();path.write_bytes(b'too large')
+ with self.assertRaisesRegex(RuntimeError,'exceeds byte limit'):
+ fetch('https://example.org/metadata',tmp,limit=2)
+ network.assert_not_called()
+
+
+if __name__=='__main__':unittest.main()
diff --git a/test_iclr.py b/test_iclr.py
new file mode 100644
index 0000000..fb9b983
--- /dev/null
+++ b/test_iclr.py
@@ -0,0 +1,98 @@
+import json
+from pathlib import Path
+import tempfile
+import unittest
+from unittest.mock import patch
+from urllib.error import HTTPError
+
+from collect_iclr import asset_urls, collect_year, discover, parse_program
+from import_records import ingest_file
+from index_core import Store
+
+
+class ICLRTests(unittest.TestCase):
+ def event(self,identifier=1,sourceid=10,**updates):
+ row={'id':identifier,'sourceid':sourceid,'sourceurl':'https://openreview.net/group?id=ICLR.cc/2026/Conference',
+ 'name':'A reliable paper on abstract metadata indexing','authors':[{'fullname':'Jane Doe'},{'fullname':'John Smith'}],
+ 'eventtype':'Poster','event_type':'Poster','decision':'Accept (Oral)',
+ 'paper_url':'https://openreview.net/forum?id=realForum','topic':'Learning->Theory','keywords':[]}
+ return dict(row,**updates)
+
+ def parse(self,events,abstracts=None,year=2026):
+ return parse_program({'count':len(events),'next':None,'previous':None,'results':events},abstracts or {},year,f'https://iclr.cc/virtual/{year}/papers.html')
+
+ def test_oral_placeholder_is_not_an_identifier_or_another_submission(self):
+ poster=self.event()
+ oral=self.event(2,-10,eventtype='Oral',event_type='Oral',paper_url='https://openreview.net/forum?id=2026-Oral--10-placeholder')
+ rows,audit=self.parse([oral,poster],{'1':'The real abstract.'})
+ self.assertEqual(len(rows),1)
+ self.assertEqual(rows[0]['identifiers'],{'openreview':'realForum'})
+ self.assertEqual(rows[0]['abstract'],'The real abstract.')
+ self.assertEqual(rows[0]['metadata']['presentation_types'],['Oral','Poster'])
+ self.assertEqual(audit['main_event_count'],2)
+
+ def test_blog_and_journal_presentations_are_not_main_acceptances(self):
+ rows,audit=self.parse([self.event(),self.event(2,20,sourceurl='https://openreview.net/group?id=ICLR.cc/2026/BlogPosts'),self.event(3,30,sourceurl='2026-TMLR-J2C-Spreadsheet')])
+ self.assertEqual(len(rows),1)
+ self.assertEqual(sum(audit['excluded_events_by_source'].values()),2)
+ self.assertEqual(audit['missing_abstract_count'],1)
+ self.assertIsNone(rows[0]['abstract'])
+
+ def test_title_revision_is_scoped_to_matching_signed_submission_and_authors(self):
+ poster=self.event(name='AlphaEdit: Null-Space Constrained Knowledge Editing for Language Models')
+ oral=self.event(2,-10,eventtype='Oral',name='AlphaEdit: Null-Space Constrained Model Editing for Language Models')
+ rows,_=self.parse([oral,poster])
+ self.assertEqual(len(rows),1)
+ self.assertEqual(rows[0]['title'],poster['name'])
+ self.assertEqual(len(rows[0]['metadata']['presentation_titles']),2)
+ with self.assertRaisesRegex(ValueError,'collides'):
+ self.parse([poster,dict(oral,authors=[{'fullname':'Unrelated Author'}])])
+
+ def test_truncation_and_identity_collision_fail(self):
+ with self.assertRaisesRegex(ValueError,'count differs'):
+ parse_program({'count':2,'results':[self.event()]},{},2026,'https://iclr.cc/')
+ with self.assertRaisesRegex(ValueError,'collides'):
+ self.parse([self.event(),self.event(2,-10,name='A different paper')])
+ with self.assertRaisesRegex(ValueError,'Conflicting identifiers'):
+ self.parse([self.event(),self.event(2,10,paper_url='https://openreview.net/forum?id=otherForum')])
+
+ def test_legacy_embedded_abstracts_and_paper_links(self):
+ row=self.event(sourceurl=None,abstract='Historical abstract',paper_url=None,paper_pdf_url='https://openreview.net/forum?id=historicalForum',decision='Poster')
+ rows,_=self.parse([row],year=2021)
+ self.assertEqual(rows[0]['abstract'],'Historical abstract')
+ self.assertEqual(rows[0]['identifiers'],{'openreview':'historicalForum'})
+ rows,_=self.parse([dict(row,paper_pdf_url=None)],year=2020)
+ self.assertEqual(rows[0]['metadata']['identity_state'],'program_only')
+ self.assertEqual(rows[0]['identifiers'],{})
+
+ def test_only_referenced_official_assets_and_real_navigation_years(self):
+ html='201320262025'
+ self.assertEqual([y for y,_ in discover(html)],[2013,2026])
+ html='start("/static/virtual/data/iclr-2026-orals-posters.json",true,"https://other.example/abstracts.json")'
+ self.assertEqual(asset_urls(html,'https://iclr.cc/virtual/2026/papers.html',2026),{'orals-posters':'https://iclr.cc/static/virtual/data/iclr-2026-orals-posters.json'})
+
+ def test_missing_split_abstract_asset_does_not_discard_embedded_abstract(self):
+ html=b'start("/static/virtual/data/iclr-2021-orals-posters.json",true,"/static/virtual/data/iclr-2021-abstracts.json")'
+ event=self.event(sourceurl=None,decision='Poster',abstract='Embedded historical abstract')
+ responses=[html,json.dumps({'count':1,'results':[event]}).encode(),HTTPError('https://iclr.cc/abstracts',404,'Not Found',None,None)]
+ with tempfile.TemporaryDirectory() as tmp,patch('collect_iclr.fetch',side_effect=responses):
+ manifest=collect_year(2021,tmp,tmp)
+ self.assertEqual(manifest['status'],'collected')
+ self.assertEqual(manifest['missing_abstract_count'],0)
+ self.assertEqual(manifest['abstract_asset_status'],'unavailable_404')
+
+ def test_import_repeat_preserves_one_submission_and_queryable_platform_tags(self):
+ rows,_=self.parse([self.event(),self.event(2,-10,eventtype='Oral')],{'1':'A searchable abstract.'})
+ with tempfile.TemporaryDirectory() as tmp:
+ path=Path(tmp)/'iclr.jsonl';db=Path(tmp)/'papers.sqlite'
+ path.write_text(''.join(json.dumps(r)+'\n' for r in rows))
+ path.with_suffix('.manifest.json').write_text(json.dumps({'source':'iclr_program','parser_version':2,'list_url':'https://iclr.cc/virtual/2026/papers.html','count':1,'status':'collected'}))
+ ingest_file(db,path);ingest_file(db,path)
+ store=Store(db);self.addCleanup(store.conn.close)
+ self.assertEqual(store.conn.execute('SELECT count(*) FROM submissions').fetchone()[0],1)
+ self.assertEqual(store.conn.execute('SELECT count(*) FROM decisions').fetchone()[0],1)
+ self.assertEqual(store.conn.execute('SELECT namespace,value FROM tags').fetchall(),[('iclr_program:topic','Learning->Theory')])
+ self.assertEqual(store.conn.execute('PRAGMA foreign_key_check').fetchall(),[])
+
+
+if __name__=='__main__':unittest.main()
diff --git a/test_iclr_snapshot_audit.py b/test_iclr_snapshot_audit.py
new file mode 100644
index 0000000..71d0a05
--- /dev/null
+++ b/test_iclr_snapshot_audit.py
@@ -0,0 +1,58 @@
+import json
+from pathlib import Path
+import tempfile
+import unittest
+
+from audit_iclr_snapshot import audit
+
+
+class SnapshotAuditTests(unittest.TestCase):
+ def setUp(self):
+ self.tmp=tempfile.TemporaryDirectory();self.addCleanup(self.tmp.cleanup);self.root=Path(self.tmp.name)
+
+ def write(self,source,rows):
+ prefix='iclr-program' if source=='iclr_program' else 'openreview-snapshot'
+ path=self.root/f'{prefix}-2026-main.jsonl'
+ path.write_text(''.join(json.dumps({'source':source,'venue':'ICLR','year':2026,'track':'main',**row})+'\n' for row in rows))
+ manifest={'source':source,'venue':'ICLR','year':2026,'track':'main','parser_version':2,'count':len(rows),'missing_abstract_count':0}
+ if source=='openreview_snapshot':
+ manifest['submission_status_counts']={status:sum(r['status']==status for r in rows) for status in set(r['status'] for r in rows)}
+ path.with_suffix('.manifest.json').write_text(json.dumps(manifest));return path
+
+ def row(self,forum,status='accepted',number=None):
+ ids=[{'scheme':'openreview','value':forum}]
+ if number:ids.append({'scheme':'iclr_number','value':str(number)})
+ return {'external_id':forum,'identifiers':{'openreview':forum},'submission_identifiers':ids,
+ 'title':'Title '+forum,'abstract':'Abstract','authors':['Jane Doe'],'status':status,
+ 'metadata':{'public_readers_verified':True},'decision_events':[] if status=='unknown' else [
+ {'status':status,'source_decision_id':'note-'+forum,'authority':{'public_readers_verified':True}}]}
+
+ def test_gaps_and_historical_status_differences_are_explicit_not_inferred(self):
+ self.write('iclr_program',[self.row('shared'),self.row('programOnly')])
+ self.write('openreview_snapshot',[self.row('shared','rejected',1),self.row('snapshotOnly','accepted',2),self.row('unknown','unknown',3)])
+ result=audit(self.root,[2026],self.root/'audit.json');report=result['years'][0]
+ self.assertEqual(report['program_only_forums'],['programOnly'])
+ self.assertEqual(report['snapshot_accepted_not_in_program'],['snapshotOnly'])
+ self.assertEqual(report['program_snapshot_status_differences'][0]['snapshot_status'],'rejected')
+ self.assertEqual(report['snapshot_status_counts']['rejected'],1)
+ self.assertTrue((self.root/'audit-backfill-queue.json').is_file())
+
+ def test_conflicting_numbers_fail_before_publishing_a_queue(self):
+ self.write('iclr_program',[self.row('one')])
+ self.write('openreview_snapshot',[self.row('one',number=1),self.row('two',number=1)])
+ with self.assertRaisesRegex(ValueError,'different forums'):audit(self.root,[2026],self.root/'audit.json')
+ self.assertFalse((self.root/'audit-backfill-queue.json').exists())
+
+ def test_missing_public_decision_evidence_is_rejected(self):
+ self.write('iclr_program',[self.row('one')])
+ row=self.row('one');row['decision_events'][0]['authority']={}
+ self.write('openreview_snapshot',[row])
+ with self.assertRaisesRegex(ValueError,'public decision authority'):audit(self.root,[2026],self.root/'audit.json')
+
+ def test_manifest_counts_cannot_silently_hide_missing_records(self):
+ path=self.write('iclr_program',[self.row('one')]);path.write_text('')
+ self.write('openreview_snapshot',[self.row('one')])
+ with self.assertRaisesRegex(ValueError,'count mismatch'):audit(self.root,[2026],self.root/'audit.json')
+
+
+if __name__=='__main__':unittest.main()
diff --git a/test_import_arxiv.py b/test_import_arxiv.py
new file mode 100644
index 0000000..d973cd4
--- /dev/null
+++ b/test_import_arxiv.py
@@ -0,0 +1,58 @@
+import json
+from contextlib import closing
+import sqlite3
+import tempfile
+import unittest
+import zipfile
+from pathlib import Path
+
+from import_arxiv import ingest
+
+
+class ImportTests(unittest.TestCase):
+ def setUp(self):
+ self.tmp = tempfile.TemporaryDirectory()
+ self.addCleanup(self.tmp.cleanup)
+ self.root = Path(self.tmp.name)
+ self.db = self.root/'papers.sqlite'
+ self.archive = self.root/'metadata.zip'
+
+ def write(self, rows):
+ with zipfile.ZipFile(self.archive,'w') as z:
+ z.writestr('arxiv-metadata-oai-snapshot.json','\n'.join(json.dumps(r) for r in rows))
+
+ def row(self, identifier, doi=None):
+ return dict(id=identifier,title='A shared title',abstract='Searchable abstract',authors_parsed=[['Doe','Jane','']],categories='cs.AI cs.LG',doi=doi,versions=[])
+
+ def test_identity_provenance_and_idempotency(self):
+ self.write([self.row('2601.00001','10.0000/example'),self.row('2601.00002','10.0000/example')])
+ ingest(self.db,self.archive)
+ ingest(self.db,self.archive)
+ with closing(sqlite3.connect(self.db)) as c, c:
+ self.assertEqual(c.execute('SELECT count(*) FROM papers').fetchone()[0],1)
+ self.assertEqual(c.execute('SELECT count(*) FROM source_records').fetchone()[0],2)
+ self.assertEqual(c.execute("SELECT count(*) FROM papers_fts WHERE papers_fts MATCH 'Searchable'").fetchone()[0],1)
+ self.assertEqual(c.execute('SELECT count(*) FROM decisions').fetchone()[0],0)
+ self.assertEqual(c.execute('PRAGMA foreign_key_check').fetchall(),[])
+ self.assertEqual(c.execute('PRAGMA integrity_check').fetchone()[0],'ok')
+
+ def test_title_alone_does_not_merge(self):
+ self.write([self.row('2601.00001'),self.row('2601.00002')])
+ ingest(self.db,self.archive)
+ with closing(sqlite3.connect(self.db)) as c, c:
+ self.assertEqual(c.execute('SELECT count(*) FROM papers').fetchone()[0],2)
+
+ def test_platform_and_custom_tags_remain_distinct(self):
+ self.write([self.row('2601.00001')])
+ ingest(self.db,self.archive)
+ with closing(sqlite3.connect(self.db)) as c, c:
+ paper = c.execute('SELECT id FROM papers').fetchone()[0]
+ tag = c.execute("INSERT INTO tags(namespace,value) VALUES('custom:topic','cs.AI')").lastrowid
+ c.execute('INSERT INTO paper_tags VALUES(?,?,NULL,?)',(paper,tag,'manual:1'))
+ ingest(self.db,self.archive)
+ with closing(sqlite3.connect(self.db)) as c, c:
+ self.assertEqual(c.execute("SELECT count(*) FROM paper_tags pt JOIN tags t ON pt.tag_id=t.id WHERE t.value='cs.AI'").fetchone()[0],2)
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/test_index_core.py b/test_index_core.py
new file mode 100644
index 0000000..c55c331
--- /dev/null
+++ b/test_index_core.py
@@ -0,0 +1,110 @@
+import json
+import sqlite3
+import tempfile
+import unittest
+from pathlib import Path
+
+from index_core import Store
+from import_records import ingest_file
+
+
+class StoreTests(unittest.TestCase):
+ def setUp(self):
+ self.tmp = tempfile.TemporaryDirectory()
+ self.addCleanup(self.tmp.cleanup)
+ self.db = Path(self.tmp.name)/'papers.sqlite'
+ self.store = Store(self.db)
+ self.addCleanup(self.store.conn.close)
+
+ def record(self,source,identifier,ids=None,authors=None):
+ return self.store.record(source,identifier,'https://example.org/'+identifier,'A sufficiently long example research paper title','Example abstract',authors or ['Jane Doe','John Smith'],ids)
+
+ def test_bridge_identifiers_preserves_submission_and_tags(self):
+ first,record = self.record('arxiv','2601.00001',{'arxiv':'2601.00001'},['First Author'])
+ second,other_record = self.record('pmlr','v1/other',{'doi':'10.0000/example'},['Other Author'])
+ self.store.accepted(second,other_record,'ICML',2025,'main','v1/other','https://example.org/other')
+ tag = self.store.conn.execute("INSERT INTO tags(namespace,value) VALUES('custom:topic','test')").lastrowid
+ self.store.conn.execute('INSERT INTO paper_tags VALUES(?,?,?,?)',(second,tag,other_record,'manual'))
+ resolved,_ = self.record('openreview','forum1',{'arxiv':'2601.00001','doi':'10.0000/example'},['Anonymous'])
+ self.assertEqual(resolved,first)
+ c=self.store.conn
+ self.assertEqual(c.execute('SELECT count(*) FROM papers').fetchone()[0],1)
+ self.assertEqual(c.execute('SELECT paper_id FROM submissions').fetchone()[0],first)
+ self.assertEqual(c.execute('SELECT paper_id FROM paper_tags').fetchone()[0],first)
+ self.assertEqual(c.execute('PRAGMA foreign_key_check').fetchall(),[])
+ self.assertEqual(c.execute('SELECT count(*) FROM paper_merges').fetchone()[0],1)
+
+ def test_cross_platform_exact_title_needs_author_corroboration(self):
+ paper,_=self.record('arxiv','2601.00001')
+ same,_=self.record('pmlr','v1/paper')
+ other,_=self.record('acl','2025.acl.1',authors=['Unrelated Person'])
+ self.assertEqual(same,paper)
+ self.assertNotEqual(other,paper)
+ self.assertEqual(self.store.conn.execute('SELECT count(*) FROM match_candidates').fetchone()[0],1)
+
+ def test_manifest_count_mismatch_rolls_back_records(self):
+ path=Path(self.tmp.name)/'source.jsonl'
+ path.write_text(json.dumps({'source':'pmlr','external_id':'v1/paper','url':'https://example.org/paper','title':'Paper','abstract':'Abstract','authors':[],'venue':'ICML','year':2025,'track':'main','status':'accepted'})+'\n')
+ path.with_suffix('.manifest.json').write_text(json.dumps({'source':'pmlr','list_url':'https://example.org/list','count':2}))
+ with self.assertRaisesRegex(ValueError,'differs'):
+ ingest_file(self.db,path)
+ self.assertEqual(self.store.conn.execute('SELECT count(*) FROM source_records').fetchone()[0],0)
+ self.assertEqual(self.store.conn.execute('SELECT status FROM ingest_runs').fetchone()[0],'failed')
+
+ def test_source_update_refreshes_canonical_text_and_fts(self):
+ paper,_=self.record('arxiv','2601.00001')
+ updated,_=self.store.record('arxiv','2601.00001','https://arxiv.org/abs/2601.00001','Updated research title','New searchable content',['Jane Doe'])
+ self.assertEqual(updated,paper)
+ self.assertEqual(self.store.conn.execute('SELECT title FROM papers').fetchone()[0],'Updated research title')
+ self.assertEqual(self.store.conn.execute('SELECT url FROM source_records').fetchone()[0],'https://arxiv.org/abs/2601.00001')
+ self.assertEqual(self.store.conn.execute("SELECT count(*) FROM papers_fts WHERE papers_fts MATCH 'searchable'").fetchone()[0],1)
+
+ def test_tag_refresh_keeps_other_record_evidence_and_custom_tags(self):
+ paper,first=self.record('iclr_program','2025/a',{'openreview':'sharedForum'})
+ same,second=self.record('iclr_program','2025/b',{'openreview':'sharedForum'})
+ self.store.record_tags(paper,first,'iclr_program',[{'namespace':'iclr_program:topic','value':'Shared'}])
+ self.store.record_tags(same,second,'iclr_program',[{'namespace':'iclr_program:topic','value':'Shared'}])
+ tag=self.store.conn.execute("INSERT INTO tags(namespace,value) VALUES('custom:topic','Manual')").lastrowid
+ self.store.conn.execute('INSERT INTO paper_tags VALUES(?,?,?,?)',(paper,tag,None,'manual'))
+ self.store.record_tags(paper,first,'iclr_program',[])
+ self.assertEqual(self.store.conn.execute('SELECT source_record_id FROM paper_tags ORDER BY source_record_id').fetchall(),[(None,),(second,)])
+ with self.assertRaisesRegex(ValueError,'namespace'):
+ self.store.record_tags(paper,second,'iclr_program',[{'namespace':'custom:topic','value':'No overwrite'}])
+ self.assertEqual(self.store.conn.execute('SELECT count(*) FROM paper_tags').fetchone()[0],2)
+
+ def test_missing_abstract_does_not_become_complete_coverage(self):
+ path=Path(self.tmp.name)/'partial.jsonl'
+ path.write_text(json.dumps({'source':'colm','external_id':'paper1','url':'https://example.org/paper1','title':'Paper','abstract':None,'authors':[],'venue':'COLM','year':2024,'track':'main','status':'accepted'})+'\n')
+ path.with_suffix('.manifest.json').write_text(json.dumps({'source':'colm','parser_version':2,'list_url':'https://example.org/list','count':1,'status':'partial','missing_abstract_count':1}))
+ ingest_file(self.db,path)
+ self.assertEqual(self.store.conn.execute('SELECT status FROM edition_sources').fetchone()[0],'partial')
+
+ def test_legacy_proceedings_manifest_is_rejected_before_import(self):
+ path=Path(self.tmp.name)/'old.jsonl'
+ path.write_text('')
+ path.with_suffix('.manifest.json').write_text(json.dumps({'source':'colm','count':1}))
+ with self.assertRaisesRegex(ValueError,'parser_version'):
+ ingest_file(self.db,path)
+ self.assertEqual(self.store.conn.execute('SELECT count(*) FROM source_records').fetchone()[0],0)
+
+ def test_missing_abstract_is_checked_even_when_manifest_claims_complete(self):
+ path=Path(self.tmp.name)/'incomplete.jsonl'
+ row={'source':'pmlr','external_id':'v1/paper','url':'https://example.org/paper','title':'Paper','abstract':None,'authors':[],'venue':'ICML','year':2025,'track':'main','status':'accepted'}
+ path.write_text(json.dumps(row)+'\n')
+ path.with_suffix('.manifest.json').write_text(json.dumps({'source':'pmlr','list_url':'https://example.org/list','count':1,'status':'collected','missing_abstract_count':0}))
+ ingest_file(self.db,path)
+ self.assertEqual(self.store.conn.execute('SELECT status FROM edition_sources').fetchone()[0],'partial')
+
+ def test_manifest_edition_mismatch_rolls_back_instead_of_marking_coverage(self):
+ path=Path(self.tmp.name)/'wrong-edition.jsonl'
+ row={'source':'pmlr','external_id':'v1/paper','url':'https://example.org/paper','title':'Paper','abstract':'Abstract','authors':[],'venue':'ICML','year':2025,'track':'main','status':'accepted'}
+ path.write_text(json.dumps(row)+'\n')
+ path.with_suffix('.manifest.json').write_text(json.dumps({'source':'pmlr','list_url':'https://example.org/list','count':1,'year':2024}))
+ with self.assertRaisesRegex(ValueError,'edition differs'):
+ ingest_file(self.db,path)
+ self.assertEqual(self.store.conn.execute('SELECT count(*) FROM source_records').fetchone()[0],0)
+ self.assertEqual(self.store.conn.execute('SELECT count(*) FROM edition_sources').fetchone()[0],0)
+
+
+if __name__=='__main__':
+ unittest.main()
diff --git a/test_maintenance.py b/test_maintenance.py
new file mode 100644
index 0000000..057f9d2
--- /dev/null
+++ b/test_maintenance.py
@@ -0,0 +1,41 @@
+from pathlib import Path
+import tempfile
+import unittest
+from unittest.mock import patch
+
+from index_core import Store
+from merge_conferences import merge
+
+
+class MaintenanceTests(unittest.TestCase):
+ def fixture(self,tmp,status):
+ db=Path(tmp)/'papers.sqlite';archive=Path(tmp)/'metadata.zip';archive.write_bytes(b'not read by schema-only maintenance')
+ store=Store(db);source=store.source('arxiv','https://arxiv.org')
+ store.conn.execute('INSERT INTO ingest_runs(source_id,status) VALUES(?,?)',(source,status));store.conn.commit();store.conn.close()
+ return db,archive
+
+ def test_completed_archive_is_not_started_again_after_maintenance(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ db,archive=self.fixture(tmp,'complete')
+ with patch('merge_conferences.property_value',return_value='inactive'),patch('merge_conferences.subprocess.run') as run:
+ merge(db,archive,[])
+ run.assert_not_called()
+
+ def test_inactive_incomplete_arxiv_requires_investigation(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ db,archive=self.fixture(tmp,'failed')
+ with patch('merge_conferences.property_value',return_value='inactive'),patch('merge_conferences.subprocess.run') as run:
+ with self.assertRaisesRegex(RuntimeError,'neither active nor verified complete'):
+ merge(db,archive,[])
+ run.assert_not_called()
+
+ def test_arxiv_finishing_at_the_stop_boundary_is_not_restarted(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ db,archive=self.fixture(tmp,'complete')
+ with patch('merge_conferences.property_value',return_value='active'),patch('merge_conferences.subprocess.run') as run:
+ merge(db,archive,[])
+ self.assertEqual(run.call_args_list[0].args[0],['systemctl','stop','paper-atlas-arxiv-import.service'])
+ self.assertEqual(run.call_count,1)
+
+
+if __name__=='__main__':unittest.main()
diff --git a/test_openreview_snapshot.py b/test_openreview_snapshot.py
new file mode 100644
index 0000000..46e1640
--- /dev/null
+++ b/test_openreview_snapshot.py
@@ -0,0 +1,108 @@
+import hashlib
+import io
+import json
+from pathlib import Path
+import sqlite3
+import tarfile
+import tempfile
+import unittest
+
+from collect_openreview_snapshot import collect,decision_record,paper_record
+from import_records import ingest_file
+
+
+class SnapshotTests(unittest.TestCase):
+ def setUp(self):
+ self.tmp=tempfile.TemporaryDirectory();self.addCleanup(self.tmp.cleanup)
+ self.root=Path(self.tmp.name)
+
+ def paper(self,forum='paper1',v2=True):
+ content={'title':'A public paper','abstract':'A public abstract','authors':['Jane Doe'],
+ 'authorids':['private@example.org'],'keywords':['learning']}
+ if v2:content={key:{'value':value} for key,value in content.items()}
+ number=int(forum[-1]) if forum[-1].isdigit() else 42
+ return {'id':forum,'forum':forum,'number':number,'readers':['everyone'],'cdate':1760000000000,
+ 'invitations':['ICLR.cc/2026/Conference/-/Submission'],'content':content}
+
+ def decision(self,forum='paper1',identifier='decision1',value='Reject'):
+ return {'id':identifier,'forum':forum,'_paper_forum':forum,'readers':['everyone'],
+ 'invitations':['ICLR.cc/2026/Conference/Submission42/-/Decision'],
+ 'signatures':['ICLR.cc/2026/Conference/Program_Chairs'],'cdate':1770000000000,
+ 'content':{'decision':{'value':value},'comment':{'value':'PRIVATE REVIEW BODY'}}}
+
+ def archive(self,papers,reviews):
+ path=self.root/'snapshot.tar.bz2'
+ with tarfile.open(path,'w:bz2') as archive:
+ # Real release order is not guaranteed; decisions precede submissions.
+ for kind,rows in [('reviews',reviews),('papers',papers)]:
+ data=(''.join(json.dumps(row)+'\n' for row in rows)).encode()
+ entry=tarfile.TarInfo('data/iclr_2026/'+kind+'.jsonl');entry.size=len(data)
+ archive.addfile(entry,io.BytesIO(data))
+ return path,hashlib.sha256(path.read_bytes()).hexdigest()
+
+ def test_public_v1_and_v2_metadata_omit_authorids_and_review_bodies(self):
+ for v2 in (False,True):
+ row,reason=paper_record(self.paper(v2=v2),2026)
+ self.assertIsNone(reason);self.assertEqual(row['authors'],['Jane Doe'])
+ self.assertNotIn('private@example.org',json.dumps(row))
+ self.assertEqual(row['identifiers']['openreview'],'paper1')
+ note=self.paper();note['readers']=['chairs']
+ self.assertEqual(paper_record(note,2026)[1],'not_public')
+
+ def test_only_public_signed_formal_decisions_count(self):
+ note=self.decision();row,reason=decision_record(note,2026)
+ self.assertIsNone(reason);self.assertEqual(row['status'],'rejected')
+ self.assertNotIn('PRIVATE REVIEW BODY',json.dumps(row))
+ note['invitations']=['ICLR.cc/2026/Conference/Submission42/-/Official_Review']
+ self.assertEqual(decision_record(note,2026)[1],'not_decision')
+ note=self.decision();note['signatures']=['ICLR.cc/2026/Conference/Submission42/Reviewer_1']
+ self.assertEqual(decision_record(note,2026)[1],'unverified_signature')
+ note=self.decision();note['readers']=['chairs']
+ self.assertEqual(decision_record(note,2026)[1],'not_public')
+ note=self.decision(value='Borderline Accept')
+ self.assertEqual(decision_record(note,2026)[0]['status'],'unknown')
+
+ def test_unordered_snapshot_import_is_idempotent_and_preserves_unknown_and_orphans(self):
+ path,sha=self.archive([self.paper(),self.paper('paper2'),self.paper('paper3')],
+ [self.decision(),self.decision('paper2','decision2','Accept (poster)'),self.decision('absent','orphan')])
+ out=self.root/'out';manifest=collect(path,out,[2026],sha)[2026]
+ self.assertEqual(manifest['submission_status_counts'],{'rejected':1,'accepted':1,'unknown':1})
+ self.assertEqual(manifest['unmatched_public_decisions'],1)
+ self.assertEqual(len(Path(manifest['unmatched_evidence_file']).read_text().splitlines()),1)
+ rows=[json.loads(line) for line in Path(manifest['file']).read_text().splitlines()]
+ self.assertEqual(rows[2]['decision_events'],[])
+ self.assertNotIn('PRIVATE REVIEW BODY',json.dumps(rows))
+ db=self.root/'db.sqlite'
+ ingest_file(db,manifest['file']);ingest_file(db,manifest['file'])
+ with sqlite3.connect(db) as connection:
+ self.assertEqual(connection.execute('SELECT count(*) FROM submissions').fetchone()[0],3)
+ self.assertEqual(connection.execute('SELECT count(*) FROM decisions').fetchone()[0],2)
+ self.assertEqual(connection.execute('PRAGMA foreign_key_check').fetchall(),[])
+
+ def test_identical_copies_coalesce_and_conflicting_copies_fail(self):
+ paper=self.paper();decision=self.decision()
+ path,sha=self.archive([paper,paper],[decision,decision])
+ manifest=collect(path,self.root/'out',[2026],sha)[2026]
+ self.assertEqual(manifest['count'],1)
+ self.assertEqual(manifest['archive_counts']['duplicate_decisions'],1)
+ different=self.paper();different['content']['title']={'value':'Conflicting title'}
+ path,sha=self.archive([paper,different],[decision])
+ with self.assertRaisesRegex(ValueError,'Conflicting copies'):
+ collect(path,self.root/'bad',[2026],sha)
+ self.assertFalse((self.root/'bad'/'openreview-snapshot-2026-main.jsonl').exists())
+
+ def test_checksum_failure_does_not_publish_output(self):
+ path,sha=self.archive([self.paper()],[self.decision()])
+ with self.assertRaisesRegex(ValueError,'checksum mismatch'):
+ collect(path,self.root/'out',[2026],'0'*64)
+ self.assertFalse((self.root/'out').exists())
+
+ def test_legacy_invitation_and_plain_decision_value(self):
+ note=self.decision();note['invitation']=note.pop('invitations')[0]
+ note['content']['decision']='Desk Reject'
+ self.assertEqual(decision_record(note,2026)[0]['status'],'desk_rejected')
+ note['_paper_forum']='wrong'
+ self.assertEqual(decision_record(note,2026)[1],'forum_mismatch')
+
+
+if __name__=='__main__':unittest.main()
diff --git a/test_peerread.py b/test_peerread.py
new file mode 100644
index 0000000..40ac185
--- /dev/null
+++ b/test_peerread.py
@@ -0,0 +1,33 @@
+import io
+import json
+import tempfile
+import unittest
+from pathlib import Path
+from unittest.mock import patch
+
+from collect_peerread import collect
+from import_records import ingest_file
+from index_core import Store
+
+
+class PeerReadTests(unittest.TestCase):
+ def test_explicit_rejection_and_no_review_body_storage(self):
+ tree={'sha':'a'*40,'tree':[{'path':'data/iclr_2017/test/reviews/123.json'}]}
+ paper={'conference':'ICLR 2017 conference submission','id':'123','title':'Public paper','abstract':'Abstract','authors':'Jane Doe, John Smith','accepted':False,'reviews':[{'text':'Do not persist this review body'}]}
+ with tempfile.TemporaryDirectory() as tmp:
+ root=Path(tmp)
+ with patch('collect_peerread.fetch',side_effect=[json.dumps({'sha':'a'*40}).encode(),json.dumps(tree).encode()]),patch('collect_peerread.urllib.request.urlopen',return_value=io.BytesIO(json.dumps(paper).encode())),patch('collect_peerread.time.sleep'):
+ collect(root/'cache',root/'out')
+ path=root/'out/peerread-iclr2017-main.jsonl'
+ record=json.loads(path.read_text())
+ self.assertEqual(record['status'],'rejected')
+ self.assertNotIn('Do not persist',path.read_text())
+ ingest_file(root/'db.sqlite',path)
+ store=Store(root/'db.sqlite')
+ self.addCleanup(store.conn.close)
+ self.assertEqual(store.conn.execute('SELECT status FROM decisions').fetchone()[0],'rejected')
+ self.assertEqual(store.conn.execute('SELECT status FROM edition_sources').fetchone()[0],'complete')
+
+
+if __name__=='__main__':
+ unittest.main()
diff --git a/test_pmlr.py b/test_pmlr.py
new file mode 100644
index 0000000..0af571d
--- /dev/null
+++ b/test_pmlr.py
@@ -0,0 +1,60 @@
+import unittest
+import tempfile
+import json
+from pathlib import Path
+from unittest.mock import patch
+
+from collect_pmlr import load_metadata,venue_key,collect_volume
+
+
+class MetadataTests(unittest.TestCase):
+ def test_legacy_binary_scalar_is_decoded_as_text(self):
+ self.assertEqual(load_metadata('title: !binary |\n SGVsbG8=\n')['title'],'Hello')
+
+ def test_trailing_empty_document_is_allowed(self):
+ self.assertEqual(load_metadata('---\nyear: 2024\n---\n'),{'year':2024})
+
+ def test_conflicting_documents_are_not_silently_combined(self):
+ with self.assertRaisesRegex(ValueError,'exactly one'):
+ load_metadata('year: 2024\n---\nyear: 2025\n')
+
+ def test_historical_conference_name_aliases(self):
+ self.assertEqual(venue_key('AISTATS 2010'),'AISTATS')
+ self.assertEqual(venue_key('AISTATS 2012'),'AISTATS')
+ self.assertEqual(venue_key('COLT 2012'),'COLT')
+ self.assertEqual(venue_key('ECML-PKDD'),'ECML-PKDD')
+
+ def test_missing_abstract_is_reported_as_partial_collection(self):
+ config=b'shortname: ICML\nyear: 2025\nbooktitle: ICML\n'
+ paper='---\nid: paper1\nlayout: inproceedings\ntitle: Paper\nabstract:\n---\n'
+ with tempfile.TemporaryDirectory() as tmp,patch('collect_pmlr.fetch',return_value=config),patch('collect_pmlr.metadata_texts',return_value=iter([('paper.md',paper)])):
+ result=collect_volume(999,tmp,tmp)
+ self.assertEqual(result['count'],1)
+ self.assertEqual(result['missing_abstract_count'],1)
+ self.assertEqual(result['status'],'partial')
+
+ def test_duplicate_publication_posts_preserve_date_evidence_once(self):
+ config=b'shortname: CoRL\nyear: 2023\nbooktitle: CoRL\n'
+ paper='---\nid: paper1\nlayout: inproceedings\ntitle: Paper\nabstract: Real abstract\ndate: {date}\n---\n'
+ posts=[('2023-11-13-paper1.md',paper.format(date='2023-11-13')),('2023-12-02-paper1.md',paper.format(date='2023-12-02'))]
+ with tempfile.TemporaryDirectory() as tmp,patch('collect_pmlr.fetch',return_value=config),patch('collect_pmlr.metadata_texts',return_value=iter(posts)):
+ result=collect_volume(229,tmp,tmp)
+ rows=[json.loads(line) for line in Path(result['file']).read_text().splitlines()]
+ self.assertEqual(result['count'],1)
+ self.assertEqual(result['metadata_post_count'],2)
+ self.assertEqual(result['duplicate_post_count'],1)
+ self.assertEqual(len(rows[0]['metadata']['source_versions']),2)
+ self.assertEqual(rows[0]['metadata']['date'],'2023-12-02')
+
+ def test_conflicting_duplicate_posts_do_not_replace_previous_collection(self):
+ config=b'shortname: CoRL\nyear: 2023\nbooktitle: CoRL\n'
+ paper='---\nid: paper1\nlayout: inproceedings\ntitle: {title}\nabstract: Abstract\n---\n'
+ with tempfile.TemporaryDirectory() as tmp,patch('collect_pmlr.fetch',return_value=config),patch('collect_pmlr.metadata_texts',return_value=iter([('a.md',paper.format(title='First paper')),('b.md',paper.format(title='Other paper'))])):
+ previous=Path(tmp)/'pmlr-v229.jsonl';previous.write_text('previous valid collection')
+ with self.assertRaisesRegex(ValueError,'Conflicting metadata posts'):
+ collect_volume(229,tmp,tmp)
+ self.assertEqual(previous.read_text(),'previous valid collection')
+
+
+if __name__=='__main__':
+ unittest.main()
diff --git a/test_proceedings.py b/test_proceedings.py
new file mode 100644
index 0000000..4c9f607
--- /dev/null
+++ b/test_proceedings.py
@@ -0,0 +1,66 @@
+import unittest
+import json
+import runpy
+import tempfile
+from pathlib import Path
+from unittest.mock import patch
+
+from collect_proceedings import paper_links,parse_paper
+
+
+class ProceedingsTests(unittest.TestCase):
+ def test_legacy_empty_class_attribute_does_not_break_a_paper(self):
+ html='Legacy formatting
Paper
Real abstract.
'
+ row=parse_paper(html,'https://proceedings.neurips.cc/paper/2000/hash/a-Abstract.html','neurips',2000,'https://proceedings.neurips.cc/paper/2000')
+ self.assertEqual(row['abstract'],'Real abstract.')
+
+ def test_colm_nested_abstract_and_citation_authors(self):
+ html='''Abstract
Real abstract text.
Name Change Policy
Do not ingest footer.
'''
+ row=parse_paper(html,'https://proceedings.colm.cc/paper_files/paper/2024/hash/a-Abstract-Conference.html','colm',2024,'https://proceedings.colm.cc/paper_files/paper/2024')
+ self.assertEqual(row['abstract'],'Real abstract text.')
+ self.assertEqual(row['authors'],['Jane Doe'])
+ self.assertEqual(row['track'],'main')
+ self.assertEqual(row['year'],2024)
+
+ def test_declared_index_count_detects_truncation(self):
+ html='2 papers
Paper'
+ with self.assertRaisesRegex(ValueError,'count differs'):
+ paper_links(html,'https://proceedings.colm.cc/paper/2024')
+
+ def test_missing_abstract_does_not_capture_footer(self):
+ row=parse_paper('Paper
Name Change Policy
Footer
','https://example.org/a','colm',2024,'https://example.org/')
+ self.assertIsNone(row['abstract'])
+
+ def test_paper_title_is_not_mistaken_for_index_paper_count(self):
+ html='Lessons from 1000 papers'
+ self.assertEqual(len(paper_links(html,'https://datasets-benchmarks-proceedings.neurips.cc/paper/2021')),1)
+
+ def test_neurips_track_and_legacy_abstract(self):
+ html='Paper
Datasets and Benchmarks TrackAbstract
Actual abstract.
Name Change Policy
Footer
'
+ row=parse_paper(html,'https://proceedings.neurips.cc/a','neurips',2025,'https://proceedings.neurips.cc/')
+ self.assertEqual(row['abstract'],'Actual abstract.')
+ self.assertEqual(row['track'],'datasets_benchmarks')
+
+ def test_focused_retry_preserves_other_index_status(self):
+ root='https://proceedings.colm.cc/'
+ url=root+'paper_files/paper/2024'
+ other=root+'paper_files/paper/2025'
+ paper=root+'paper_files/paper/2024/hash/a-Abstract-Conference.html'
+ documents={root:f'20242025',url:f'1 papers
Paper',paper:'Paper
Abstract
'}
+ with tempfile.TemporaryDirectory() as tmp:
+ status=Path(tmp)/'colm-index-status.json'
+ status.write_text(json.dumps({other:{'status':'collected','files':[]}}))
+ argv=['collect_proceedings.py','--source','colm','--cache',tmp,'--out',tmp,'--year','2024']
+ with patch('sys.argv',argv),patch('collect_pmlr.fetch',side_effect=lambda u,*a,**kw:documents[u].encode()):
+ runpy.run_path(str(Path(__file__).with_name('collect_proceedings.py')),run_name='__main__')
+ result=json.loads(status.read_text())
+ self.assertEqual(result[other]['status'],'collected')
+ self.assertEqual(result[url]['files'][0]['count'],1)
+
+ def test_abstract_citations_are_not_used_as_self_identifiers(self):
+ html='Related work and another paper.
'
+ row=parse_paper(html,'https://proceedings.colm.cc/paper','colm',2024,'https://proceedings.colm.cc/')
+ self.assertEqual(row['identifiers'],{'openreview':'selfForum'})
+
+
+if __name__=='__main__':unittest.main()
diff --git a/test_query.py b/test_query.py
new file mode 100644
index 0000000..068d4b9
--- /dev/null
+++ b/test_query.py
@@ -0,0 +1,51 @@
+import json
+import subprocess
+import sys
+import tempfile
+import unittest
+from pathlib import Path
+
+from index_core import Store
+
+
+class QueryTests(unittest.TestCase):
+ def test_joint_conference_alias_does_not_duplicate_submission(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ db=Path(tmp)/'papers.sqlite';store=Store(db);self.addCleanup(store.conn.close)
+ paper,record=store.record('acl_anthology','paper1','https://example.org/1','Joint event paper','Abstract',[])
+ store.decision(paper,record,'COLING+LREC',2024,'main','paper1','https://example.org/1','accepted','official XML',['COLING','LREC'])
+ store.conn.commit()
+ for venue in ('COLING','LREC'):
+ command=[sys.executable,'-B',str(Path(__file__).with_name('query.py')),'--db',str(db),'--venue',venue,'--track','main']
+ rows=json.loads(subprocess.run(command,check=True,capture_output=True,text=True).stdout)
+ self.assertEqual([r['id'] for r in rows],[paper])
+ self.assertEqual(store.conn.execute('SELECT count(*) FROM submissions').fetchone()[0],1)
+
+ def test_venue_filter_is_case_insensitive_and_uses_latest_decision(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ db=Path(tmp)/'papers.sqlite'
+ store=Store(db)
+ self.addCleanup(store.conn.close)
+ paper,record=store.record('peerread','paper1','https://example.org/paper1','A real submission','Abstract',['Jane Doe'])
+ store.decision(paper,record,'ICLR',2017,'main','paper1','https://example.org/paper1','rejected','explicit rejection')
+ store.record('arxiv','2601.00001','https://example.org/other','Another paper','Abstract',['John Smith'])
+ store.conn.commit()
+ command=[sys.executable,'-B',str(Path(__file__).with_name('query.py')),'--db',str(db),'--venue','iclr','--year','2017','--status','rejected']
+ result=json.loads(subprocess.run(command,check=True,capture_output=True,text=True).stdout)
+ self.assertEqual([r['id'] for r in result],[paper])
+ details_command=[sys.executable,'-B',str(Path(__file__).with_name('query.py')),'--db',str(db),'--paper',str(paper)]
+ details=json.loads(subprocess.run(details_command,check=True,capture_output=True,text=True).stdout)[0]
+ self.assertEqual(details['authors'],['Jane Doe'])
+ self.assertEqual(details['submissions'][0]['decisions'][0]['status'],'rejected')
+ self.assertEqual(details['sources'][0]['external_id'],'paper1')
+ stats_command=[sys.executable,'-B',str(Path(__file__).with_name('query.py')),'--db',str(db),'--stats']
+ stats=json.loads(subprocess.run(stats_command,check=True,capture_output=True,text=True).stdout)
+ self.assertEqual(stats['papers'],2)
+ self.assertEqual({r['name']:r['imported_records'] for r in stats['records_by_source']},{'arxiv':1,'peerread':1})
+ store.decision(paper,record,'ICLR',2017,'main','paper1','https://example.org/paper1','accepted','changed decision')
+ store.conn.commit()
+ self.assertEqual(json.loads(subprocess.run(command,check=True,capture_output=True,text=True).stdout),[])
+
+
+if __name__=='__main__':
+ unittest.main()
diff --git a/test_submission_evidence.py b/test_submission_evidence.py
new file mode 100644
index 0000000..71e195c
--- /dev/null
+++ b/test_submission_evidence.py
@@ -0,0 +1,173 @@
+import json
+from pathlib import Path
+import sqlite3
+import subprocess
+import sys
+import tempfile
+import unittest
+
+from import_records import ingest_file
+from index_core import Store
+from submission_evidence import identities_for_row
+from backfill_submission_evidence import backfill,queue_files
+
+
+class SubmissionTests(unittest.TestCase):
+ def setUp(self):
+ self.tmp=tempfile.TemporaryDirectory();self.addCleanup(self.tmp.cleanup)
+ self.db=Path(self.tmp.name)/'papers.sqlite';self.store=Store(self.db);self.addCleanup(self.store.conn.close)
+
+ def record(self,source,external,authors):
+ return self.store.record(source,external,'https://example.org/'+external,'A long paper about reliable metadata and historical events','Abstract',authors)
+
+ def decision(self,paper,record,external,identities,status='accepted',raw='Accept',url='https://example.org/decision',observed='2026-05-08T00:00:00Z',date=None):
+ return self.store.decision(paper,record,'ICLR',2026,'main',external,url,status,raw,identities=identities,observed_at=observed,decision_date=date)
+
+ def query(self,*args):
+ self.store.conn.commit()
+ command=[sys.executable,'-B',str(Path(__file__).with_name('query.py')),'--db',str(self.db),*args]
+ result=subprocess.run(command,capture_output=True,text=True)
+ self.assertEqual(result.returncode,0,result.stderr)
+ return json.loads(result.stdout)
+
+ def test_same_submission_across_sources_has_one_event_and_both_provenances(self):
+ first,one=self.store.record('iclr_program','2026/number-42','https://example.org/program','A long paper about reliable metadata and historical events',None,['Jane Doe','John Smith'])
+ second,two=self.record('openreview_snapshot','realForum',['Anonymous'])
+ self.assertNotEqual(first,second)
+ self.decision(first,one,'2026/number-42',[('iclr_number','42')])
+ self.decision(second,two,'realForum',[('iclr_number','42'),('openreview','realForum')],url='https://openreview.net/forum?id=realForum¬eId=note1')
+ c=self.store.conn
+ self.assertEqual(c.execute('SELECT count(*) FROM papers').fetchone()[0],1)
+ self.assertEqual(c.execute('SELECT abstract FROM papers').fetchone()[0],'Abstract')
+ self.assertEqual(c.execute('SELECT count(*) FROM submissions').fetchone()[0],1)
+ self.assertEqual(c.execute('SELECT count(*) FROM submission_sources').fetchone()[0],2)
+ details=self.query('--paper',str(first))[0]['submissions'][0]
+ self.assertEqual({r['name'] for r in details['sources']},{'iclr_program','openreview_snapshot'})
+ self.assertEqual(len(details['decisions']),2)
+ self.assertTrue(all(d['sources'] for d in details['decisions']))
+ self.assertEqual(c.execute('PRAGMA foreign_key_check').fetchall(),[])
+
+ def test_same_paper_can_have_two_distinct_submission_attempts(self):
+ paper,one=self.store.record('openreview_snapshot','forum1','https://example.org/1','First version','Abstract',[],{'arxiv':'2601.00001','openreview':'forum1'})
+ other,two=self.store.record('openreview_snapshot','forum2','https://example.org/2','Another version','Abstract',[],{'arxiv':'2601.00001','openreview':'forum2'})
+ self.assertEqual(paper,other)
+ self.decision(paper,one,'forum1',[('openreview','forum1'),('iclr_number','1')],status='rejected',raw='Reject')
+ self.decision(paper,two,'forum2',[('openreview','forum2'),('iclr_number','2')])
+ self.assertEqual(self.store.conn.execute('SELECT count(*) FROM submissions').fetchone()[0],2)
+
+ def test_conflicting_forums_cannot_be_bridged_by_one_submission_number(self):
+ paper,one=self.record('snapshot','realForum',['Jane Doe'])
+ other,two=self.record('snapshot','differentForum',['Other Author'])
+ self.decision(paper,one,'realForum',[('openreview','realForum'),('iclr_number','42')])
+ with self.assertRaisesRegex(ValueError,'Conflicting authoritative submission identities'):
+ self.decision(other,two,'differentForum',[('openreview','differentForum'),('iclr_number','42')])
+ self.assertEqual(self.store.conn.execute('SELECT count(*) FROM papers').fetchone()[0],2)
+ self.assertEqual(self.store.conn.execute('SELECT count(*) FROM submissions').fetchone()[0],1)
+ self.assertEqual(self.store.conn.execute('SELECT count(*) FROM submission_merges').fetchone()[0],0)
+
+ def test_late_identity_bridge_preserves_decisions_and_does_not_reuse_merged_ids(self):
+ first,one=self.record('iclr_program','number-42',['Jane Doe','John Smith'])
+ second,two=self.record('openreview_snapshot','realForum',['Anonymous'])
+ self.decision(first,one,'number-42',[('iclr_number','42')])
+ self.decision(second,two,'realForum',[('openreview','realForum')])
+ self.decision(second,two,'realForum',[('openreview','realForum'),('iclr_number','42')])
+ c=self.store.conn
+ self.assertEqual(c.execute('SELECT count(*) FROM submissions').fetchone()[0],1)
+ self.assertEqual(c.execute('SELECT count(*) FROM decisions').fetchone()[0],1)
+ self.assertEqual(c.execute('SELECT count(*) FROM decision_sources').fetchone()[0],2)
+ self.assertEqual(c.execute('SELECT count(*) FROM submission_merges').fetchone()[0],1)
+ self.assertEqual(c.execute('SELECT count(*) FROM decision_merges').fetchone()[0],1)
+ third,record=self.record('other','different',['Other Author'])
+ self.decision(third,record,'different',[('openreview','differentForum')],status='rejected',raw='Reject')
+ self.assertGreater(c.execute('SELECT max(id) FROM submissions').fetchone()[0],2)
+ self.assertGreater(c.execute('SELECT max(id) FROM decisions').fetchone()[0],2)
+ self.assertEqual(c.execute('PRAGMA foreign_key_check').fetchall(),[])
+
+ def test_old_snapshot_does_not_override_a_newer_official_observation(self):
+ paper,one=self.record('iclr_program','number-42',['Jane Doe','John Smith'])
+ _,two=self.record('openreview_snapshot','realForum',['Jane Doe','John Smith'])
+ self.decision(paper,one,'number-42',[('iclr_number','42')],observed='2026-11-06T00:00:00Z')
+ self.decision(paper,two,'realForum',[('iclr_number','42')],status='rejected',raw='Reject',observed='2026-05-08T00:00:00Z')
+ self.assertEqual(len(self.query('--venue','ICLR','--status','accepted')),1)
+ self.assertEqual(self.query('--venue','ICLR','--status','rejected'),[])
+
+ def test_decision_date_orders_events_with_the_same_snapshot_time(self):
+ paper,record=self.record('openreview_snapshot','realForum',['Jane Doe'])
+ self.decision(paper,record,'realForum',[('openreview','realForum')],date='2026-02-01T00:00:00Z')
+ self.decision(paper,record,'realForum',[('openreview','realForum')],status='rejected',raw='Reject',date='2026-01-01T00:00:00Z')
+ self.assertEqual(len(self.query('--venue','ICLR','--status','accepted')),1)
+ self.assertEqual(self.query('--venue','ICLR','--status','rejected'),[])
+
+ def test_unknown_without_a_decision_is_queryable_without_invented_verdict(self):
+ path=Path(self.tmp.name)/'unknown.jsonl'
+ row={'source':'openreview_snapshot','external_id':'realForum','url':'https://example.org/paper','title':'Unknown result','abstract':'Abstract','authors':[],'identifiers':{'openreview':'realForum'},'venue':'ICLR','year':2026,'track':'main','status':'unknown','decision_events':[]}
+ path.write_text(json.dumps(row)+'\n');path.with_suffix('.manifest.json').write_text(json.dumps({'source':row['source'],'list_url':'https://example.org/list','count':1}))
+ ingest_file(self.db,path)
+ self.assertEqual(self.store.conn.execute('SELECT count(*) FROM decisions').fetchone()[0],0)
+ self.assertEqual(len(self.query('--venue','ICLR','--status','unknown')),1)
+
+ def test_known_summary_without_corresponding_decision_rolls_back(self):
+ path=Path(self.tmp.name)/'missing-evidence.jsonl'
+ row={'source':'openreview_snapshot','external_id':'realForum','url':'https://example.org/paper','title':'Unsupported result','abstract':'Abstract','authors':[],'venue':'ICLR','year':2026,'track':'main','status':'accepted','decision_events':[{'status':None}]}
+ path.write_text(json.dumps(row)+'\n');path.with_suffix('.manifest.json').write_text(json.dumps({'source':row['source'],'list_url':'https://example.org/list','count':1}))
+ with self.assertRaisesRegex(ValueError,'no corresponding decision evidence'):
+ ingest_file(self.db,path)
+ self.assertEqual(self.store.conn.execute('SELECT count(*) FROM submissions').fetchone()[0],0)
+
+ def test_event_number_is_not_assumed_to_be_a_submission_number(self):
+ row={'source':'iclr_program','venue':'ICLR','metadata':{'program_source_id':42,'program_identity_type':'event'},'identifiers':{}}
+ self.assertEqual(identities_for_row(row),[])
+ row['metadata']['program_identity_type']='submission'
+ self.assertEqual(identities_for_row(row),[])
+ row['metadata']['program_submission_number_verified']=True
+ self.assertEqual(identities_for_row(row),[('iclr_number','42')])
+ with self.assertRaisesRegex(ValueError,'Invalid authoritative'):
+ identities_for_row({'source':'snapshot','venue':'ICLR','submission_identifiers':[{'scheme':'openreview','value':'2026-Oral--42-placeholder'}]})
+
+ def test_legacy_database_migration_preserves_source_and_decision_links(self):
+ legacy=Path(self.tmp.name)/'legacy.sqlite';sql=Path(__file__).with_name('schema.sql').read_text()
+ sql=sql[:sql.index('CREATE TABLE IF NOT EXISTS submission_sources')]+sql[sql.index('CREATE TABLE IF NOT EXISTS tags'):]
+ c=sqlite3.connect(legacy);c.executescript(sql)
+ c.execute("INSERT INTO sources VALUES(1,'legacy','https://example.org')")
+ c.execute("INSERT INTO papers(id,title,title_key) VALUES(1,'Paper','paper')")
+ c.execute("INSERT INTO source_records VALUES(1,1,'paper',1,'https://example.org/p','Paper','Abstract','[]','{}',CURRENT_TIMESTAMP)")
+ c.execute("INSERT INTO venues VALUES(1,'ICLR','ICLR','machine_learning','conference')")
+ c.execute("INSERT INTO editions VALUES(1,1,2026,'main')")
+ c.execute("INSERT INTO submissions VALUES(1,1,1,1,'paper',NULL)")
+ c.execute("INSERT INTO decisions VALUES(1,1,'accepted','Accept','https://example.org/decision','2026-01-01T00:00:00Z',NULL)")
+ c.commit();c.close()
+ store=Store(legacy);self.addCleanup(store.conn.close)
+ self.assertEqual(store.conn.execute('SELECT * FROM submission_sources').fetchall(),[(1,1,'paper')])
+ self.assertEqual(store.conn.execute('SELECT * FROM decision_sources').fetchall(),[(1,1,'')])
+ self.assertEqual(store.conn.execute('PRAGMA foreign_key_check').fetchall(),[])
+
+ def test_existing_program_identity_backfill_is_idempotent_and_preserves_text(self):
+ paper,record=self.record('iclr_program','2026/program-42',['Jane Doe','John Smith'])
+ self.decision(paper,record,'2026/program-42',[])
+ self.store.conn.commit()
+ original=self.store.conn.execute('SELECT title,abstract,retrieved_at FROM source_records WHERE id=?',(record,)).fetchone()
+ path=Path(self.tmp.name)/'program.jsonl'
+ row={'source':'iclr_program','external_id':'2026/program-42','venue':'ICLR','year':2026,'track':'main','status':'accepted','identifiers':{},'metadata':{'program_identity_type':'submission','program_source_id':42,'program_submission_number_verified':True}}
+ path.write_text(json.dumps(row)+'\n');path.with_suffix('.manifest.json').write_text(json.dumps({'source':'iclr_program','parser_version':2,'venue':'ICLR','year':2026,'track':'main','count':1}))
+ first=backfill(self.db,[path]);second=backfill(self.db,[path])
+ self.assertEqual(first,second)
+ self.assertEqual(self.store.conn.execute('SELECT scheme,value FROM submission_identifiers').fetchall(),[('iclr_number','42')])
+ self.assertEqual(self.store.conn.execute('SELECT title,abstract,retrieved_at FROM source_records WHERE id=?',(record,)).fetchone(),original)
+ other,snapshot=self.record('openreview_snapshot','realForum',['Anonymous'])
+ self.decision(other,snapshot,'realForum',[('iclr_number','42'),('openreview','realForum')])
+ self.assertEqual(self.store.conn.execute('SELECT count(*) FROM submissions').fetchone()[0],1)
+
+ def test_evidence_queue_rejects_changed_file_before_mutation(self):
+ path=Path(self.tmp.name)/'program.jsonl';path.write_text('changed bytes')
+ queue=Path(self.tmp.name)/'queue.json';queue.write_text(json.dumps({'files':[str(path)],'file_sha256':{str(path):'0'*64}}))
+ with self.assertRaisesRegex(ValueError,'differs from audited'):
+ queue_files(queue)
+
+ def test_old_untyped_program_files_require_recollection(self):
+ path=Path(self.tmp.name)/'old.jsonl';path.write_text('')
+ path.with_suffix('.manifest.json').write_text(json.dumps({'source':'iclr_program','parser_version':1}))
+ with self.assertRaisesRegex(ValueError,'Recollect ICLR'):
+ ingest_file(self.db,path)
+
+
+if __name__=='__main__':unittest.main()
diff --git a/test_vision.py b/test_vision.py
new file mode 100644
index 0000000..e236bc4
--- /dev/null
+++ b/test_vision.py
@@ -0,0 +1,72 @@
+import tempfile
+import unittest
+from unittest.mock import patch
+
+from collect_vision import collect_index, decode_html, discover, index_entries, parse_paper
+
+
+class VisionTests(unittest.TestCase):
+ def entry(self):
+ return {'url':'https://www.ecva.net/papers/eccv_2024/papers_ECCV/html/1_ECCV_2024_paper.php','index_title':'Paper','index_authors':'Jane Doe*, John Smith','doi':'10.1007/example_1'}
+
+ def test_ecva_doi_belongs_to_its_own_title_entry(self):
+ html='- First paper
- Jane Doe*, John Smith
- DOI
- Second paper
- Other Author
- DOI
'
+ entries,count=index_entries(html,'https://www.ecva.net/papers.php')
+ self.assertEqual(count,2)
+ self.assertEqual([e['doi'] for e in entries],['10.1007/example_1','10.1007/example_2'])
+ html='Paper
'
+ row=parse_paper(html,entries[0],'ecva','ECCV',2024,'main','https://www.ecva.net/papers.php')
+ self.assertEqual(row['identifiers'],{'doi':'10.1007/example_1'})
+ self.assertEqual(row['authors'],['Jane Doe','John Smith'])
+
+ def test_cvf_meta_authors_and_missing_abstract_never_capture_footer(self):
+ html='Do not include the proceedings citation
Copyright footer
'
+ entry=dict(self.entry(),doi=None)
+ row=parse_paper(html,entry,'cvf','CVPR',2013,'main','https://openaccess.thecvf.com/CVPR2013')
+ self.assertEqual(row['authors'],['Jane Doe'])
+ self.assertIsNone(row['abstract'])
+ self.assertEqual(row['title'],'A vision paper')
+
+ def test_conflicting_self_doi_is_rejected(self):
+ html=''
+ with self.assertRaisesRegex(ValueError,'DOI differs'):
+ parse_paper(html,self.entry(),'ecva','ECCV',2024,'main','https://www.ecva.net/papers.php')
+
+ def test_legacy_ecva_author_formats_do_not_become_extra_people(self):
+ entry=dict(self.entry(),index_authors='Doe, Jane and Smith, John')
+ page='Paper
Jane Doe, John Smith; The European Conference on Computer Vision (ECCV), 2018, pp. 1-12
'
+ row=parse_paper(page,entry,'ecva','ECCV',2018,'main','https://www.ecva.net/papers.php')
+ self.assertEqual(row['authors'],['Jane Doe','John Smith'])
+ row=parse_paper('Paper
',entry,'ecva','ECCV',2018,'main','https://www.ecva.net/papers.php')
+ self.assertEqual(row['authors'],['Jane Doe','John Smith'])
+
+ def test_cvpr_day_one_is_replaced_by_explicit_all_papers_index(self):
+ root='https://openaccess.thecvf.com/CVPR2026';all_url=root+'?day=all'
+ first='https://openaccess.thecvf.com/content/CVPR2026/html/First_CVPR_2026_paper.html'
+ second='https://openaccess.thecvf.com/content/CVPR2026/html/Second_CVPR_2026_paper.html'
+ def item(url):return f'PaperJane Doe, John Smith[pdf]'
+ pages={root:f'All papers{item(first)}
',all_url:f'{item(first)}{item(second)}
',first:'First paper
First abstract
',second:'Second paper
Footer without an abstract
'}
+ with tempfile.TemporaryDirectory() as tmp,patch('collect_vision.fetch',side_effect=lambda url,*a,**kw:pages[url].encode()) as fetch:
+ manifest=collect_index('cvf',{'venue':'CVPR','year':2026,'track':'main','index_url':root},tmp,tmp)
+ self.assertEqual(manifest['count'],2)
+ self.assertEqual(manifest['list_url'],all_url)
+ self.assertEqual(manifest['status'],'partial')
+ self.assertEqual(manifest['missing_abstract_count'],1)
+ self.assertEqual({call.args[0] for call in fetch.call_args_list},{root,all_url,first,second})
+
+ def test_discovery_keeps_findings_separate_and_excludes_workshops(self):
+ html='MainFindingsMainLegacy mainWorkshopsWACV'
+ with patch('collect_vision.fetch',return_value=html.encode()):indexes=discover('cvf','unused')
+ self.assertEqual({(x['venue'],x['year'],x['track']) for x in indexes},{('CVPR',2013,'main'),('CVPR',2026,'main'),('CVPR',2026,'findings'),('ICCV',2025,'main')})
+
+ def test_unparsed_publication_link_does_not_silently_reduce_coverage(self):
+ html='Other paper- Paper
'
+ with self.assertRaisesRegex(ValueError,'outside parsed'):
+ index_entries(html,'https://openaccess.thecvf.com/')
+
+ def test_legacy_declared_encoding_is_respected(self):
+ html='André
'
+ self.assertIn('André',decode_html(html.encode('iso-8859-1')))
+
+
+if __name__=='__main__':unittest.main()
diff --git a/venues.json b/venues.json
new file mode 100644
index 0000000..1784f8a
--- /dev/null
+++ b/venues.json
@@ -0,0 +1,658 @@
+{
+ "scope_note": "Broad field coverage, not a claim of objective ranking relative to COLM. Main tracks, Findings, workshops, journals remain distinct. Missing editions must be reported, not silently skipped.",
+ "sources": {
+ "arxiv": "https://info.arxiv.org/help/bulk_data/index.html",
+ "openreview": "https://docs.openreview.net/getting-started/using-the-api",
+ "acl_anthology": "https://github.com/acl-org/acl-anthology/tree/master/data/xml",
+ "pmlr": "https://proceedings.mlr.press/",
+ "neurips": "https://proceedings.neurips.cc/",
+ "cvf": "https://openaccess.thecvf.com/",
+ "ecva": "https://www.ecva.net/papers.php",
+ "aaai": "https://ojs.aaai.org/index.php/AAAI/issue/archive",
+ "ijcai": "https://www.ijcai.org/all_proceedings/",
+ "dblp": "https://dblp.org/db/conf/",
+ "colm": "https://proceedings.colm.cc/",
+ "iclr_program": "https://iclr.cc/virtual/2026/papers.html"
+ },
+ "venues": [
+ {
+ "acronym": "NeurIPS",
+ "field": "machine_learning",
+ "venue_type": "conference",
+ "discovery_sources": [
+ "openreview",
+ "neurips"
+ ],
+ "scope": "candidate_pending_scope_review",
+ "years": "all_publicly_available",
+ "tracks": [
+ "main"
+ ],
+ "coverage": "pending"
+ },
+ {
+ "acronym": "ICML",
+ "field": "machine_learning",
+ "venue_type": "conference",
+ "discovery_sources": [
+ "openreview",
+ "pmlr"
+ ],
+ "scope": "candidate_pending_scope_review",
+ "years": "all_publicly_available",
+ "tracks": [
+ "main"
+ ],
+ "coverage": "pending"
+ },
+ {
+ "acronym": "ICLR",
+ "field": "machine_learning",
+ "venue_type": "conference",
+ "discovery_sources": [
+ "openreview",
+ "iclr_program"
+ ],
+ "scope": "candidate_pending_scope_review",
+ "years": "all_publicly_available",
+ "tracks": [
+ "main"
+ ],
+ "coverage": "pending"
+ },
+ {
+ "acronym": "COLM",
+ "field": "machine_learning",
+ "venue_type": "conference",
+ "discovery_sources": [
+ "openreview",
+ "colm"
+ ],
+ "scope": "candidate_pending_scope_review",
+ "years": "all_publicly_available",
+ "tracks": [
+ "main"
+ ],
+ "coverage": "pending"
+ },
+ {
+ "acronym": "COLT",
+ "field": "machine_learning",
+ "venue_type": "conference",
+ "discovery_sources": [
+ "openreview",
+ "pmlr",
+ "neurips"
+ ],
+ "scope": "candidate_pending_scope_review",
+ "years": "all_publicly_available",
+ "tracks": [
+ "main"
+ ],
+ "coverage": "pending"
+ },
+ {
+ "acronym": "AISTATS",
+ "field": "machine_learning",
+ "venue_type": "conference",
+ "discovery_sources": [
+ "openreview",
+ "pmlr",
+ "neurips"
+ ],
+ "scope": "candidate_pending_scope_review",
+ "years": "all_publicly_available",
+ "tracks": [
+ "main"
+ ],
+ "coverage": "pending"
+ },
+ {
+ "acronym": "UAI",
+ "field": "machine_learning",
+ "venue_type": "conference",
+ "discovery_sources": [
+ "openreview",
+ "pmlr",
+ "neurips"
+ ],
+ "scope": "candidate_pending_scope_review",
+ "years": "all_publicly_available",
+ "tracks": [
+ "main"
+ ],
+ "coverage": "pending"
+ },
+ {
+ "acronym": "CLeaR",
+ "field": "machine_learning",
+ "venue_type": "conference",
+ "discovery_sources": [
+ "openreview",
+ "pmlr",
+ "neurips"
+ ],
+ "scope": "candidate_pending_scope_review",
+ "years": "all_publicly_available",
+ "tracks": [
+ "main"
+ ],
+ "coverage": "pending"
+ },
+ {
+ "acronym": "ACL",
+ "field": "language",
+ "venue_type": "conference",
+ "discovery_sources": [
+ "acl_anthology",
+ "openreview"
+ ],
+ "scope": "candidate_pending_scope_review",
+ "years": "all_publicly_available",
+ "tracks": [
+ "main"
+ ],
+ "coverage": "pending"
+ },
+ {
+ "acronym": "EMNLP",
+ "field": "language",
+ "venue_type": "conference",
+ "discovery_sources": [
+ "acl_anthology",
+ "openreview"
+ ],
+ "scope": "candidate_pending_scope_review",
+ "years": "all_publicly_available",
+ "tracks": [
+ "main"
+ ],
+ "coverage": "pending"
+ },
+ {
+ "acronym": "NAACL",
+ "field": "language",
+ "venue_type": "conference",
+ "discovery_sources": [
+ "acl_anthology",
+ "openreview"
+ ],
+ "scope": "candidate_pending_scope_review",
+ "years": "all_publicly_available",
+ "tracks": [
+ "main"
+ ],
+ "coverage": "pending"
+ },
+ {
+ "acronym": "EACL",
+ "field": "language",
+ "venue_type": "conference",
+ "discovery_sources": [
+ "acl_anthology",
+ "openreview"
+ ],
+ "scope": "candidate_pending_scope_review",
+ "years": "all_publicly_available",
+ "tracks": [
+ "main"
+ ],
+ "coverage": "pending"
+ },
+ {
+ "acronym": "COLING",
+ "field": "language",
+ "venue_type": "conference",
+ "discovery_sources": [
+ "acl_anthology",
+ "openreview"
+ ],
+ "scope": "candidate_pending_scope_review",
+ "years": "all_publicly_available",
+ "tracks": [
+ "main"
+ ],
+ "coverage": "pending"
+ },
+ {
+ "acronym": "CoNLL",
+ "field": "language",
+ "venue_type": "conference",
+ "discovery_sources": [
+ "acl_anthology",
+ "openreview"
+ ],
+ "scope": "candidate_pending_scope_review",
+ "years": "all_publicly_available",
+ "tracks": [
+ "main"
+ ],
+ "coverage": "pending"
+ },
+ {
+ "acronym": "CVPR",
+ "field": "vision",
+ "venue_type": "conference",
+ "discovery_sources": [
+ "cvf",
+ "dblp"
+ ],
+ "scope": "candidate_pending_scope_review",
+ "years": "all_publicly_available",
+ "tracks": [
+ "main"
+ ],
+ "coverage": "pending"
+ },
+ {
+ "acronym": "ICCV",
+ "field": "vision",
+ "venue_type": "conference",
+ "discovery_sources": [
+ "cvf",
+ "dblp"
+ ],
+ "scope": "candidate_pending_scope_review",
+ "years": "all_publicly_available",
+ "tracks": [
+ "main"
+ ],
+ "coverage": "pending"
+ },
+ {
+ "acronym": "ECCV",
+ "field": "vision",
+ "venue_type": "conference",
+ "discovery_sources": [
+ "ecva",
+ "dblp"
+ ],
+ "scope": "candidate_pending_scope_review",
+ "years": "all_publicly_available",
+ "tracks": [
+ "main"
+ ],
+ "coverage": "pending"
+ },
+ {
+ "acronym": "AAAI",
+ "field": "general_ai",
+ "venue_type": "conference",
+ "discovery_sources": [
+ "aaai",
+ "ijcai",
+ "dblp"
+ ],
+ "scope": "candidate_pending_scope_review",
+ "years": "all_publicly_available",
+ "tracks": [
+ "main"
+ ],
+ "coverage": "pending"
+ },
+ {
+ "acronym": "IJCAI",
+ "field": "general_ai",
+ "venue_type": "conference",
+ "discovery_sources": [
+ "aaai",
+ "ijcai",
+ "dblp"
+ ],
+ "scope": "candidate_pending_scope_review",
+ "years": "all_publicly_available",
+ "tracks": [
+ "main"
+ ],
+ "coverage": "pending"
+ },
+ {
+ "acronym": "ECAI",
+ "field": "general_ai",
+ "venue_type": "conference",
+ "discovery_sources": [
+ "aaai",
+ "ijcai",
+ "dblp"
+ ],
+ "scope": "candidate_pending_scope_review",
+ "years": "all_publicly_available",
+ "tracks": [
+ "main"
+ ],
+ "coverage": "pending"
+ },
+ {
+ "acronym": "KDD",
+ "field": "retrieval_data",
+ "venue_type": "conference",
+ "discovery_sources": [
+ "dblp",
+ "publisher"
+ ],
+ "scope": "candidate_pending_scope_review",
+ "years": "all_publicly_available",
+ "tracks": [
+ "main"
+ ],
+ "coverage": "pending"
+ },
+ {
+ "acronym": "SIGIR",
+ "field": "retrieval_data",
+ "venue_type": "conference",
+ "discovery_sources": [
+ "dblp",
+ "publisher"
+ ],
+ "scope": "candidate_pending_scope_review",
+ "years": "all_publicly_available",
+ "tracks": [
+ "main"
+ ],
+ "coverage": "pending"
+ },
+ {
+ "acronym": "WWW",
+ "field": "retrieval_data",
+ "venue_type": "conference",
+ "discovery_sources": [
+ "dblp",
+ "publisher"
+ ],
+ "scope": "candidate_pending_scope_review",
+ "years": "all_publicly_available",
+ "tracks": [
+ "main"
+ ],
+ "coverage": "pending"
+ },
+ {
+ "acronym": "WSDM",
+ "field": "retrieval_data",
+ "venue_type": "conference",
+ "discovery_sources": [
+ "dblp",
+ "publisher"
+ ],
+ "scope": "candidate_pending_scope_review",
+ "years": "all_publicly_available",
+ "tracks": [
+ "main"
+ ],
+ "coverage": "pending"
+ },
+ {
+ "acronym": "RecSys",
+ "field": "retrieval_data",
+ "venue_type": "conference",
+ "discovery_sources": [
+ "dblp",
+ "publisher"
+ ],
+ "scope": "candidate_pending_scope_review",
+ "years": "all_publicly_available",
+ "tracks": [
+ "main"
+ ],
+ "coverage": "pending"
+ },
+ {
+ "acronym": "CIKM",
+ "field": "retrieval_data",
+ "venue_type": "conference",
+ "discovery_sources": [
+ "dblp",
+ "publisher"
+ ],
+ "scope": "candidate_pending_scope_review",
+ "years": "all_publicly_available",
+ "tracks": [
+ "main"
+ ],
+ "coverage": "pending"
+ },
+ {
+ "acronym": "ICDM",
+ "field": "retrieval_data",
+ "venue_type": "conference",
+ "discovery_sources": [
+ "dblp",
+ "publisher"
+ ],
+ "scope": "candidate_pending_scope_review",
+ "years": "all_publicly_available",
+ "tracks": [
+ "main"
+ ],
+ "coverage": "pending"
+ },
+ {
+ "acronym": "SDM",
+ "field": "retrieval_data",
+ "venue_type": "conference",
+ "discovery_sources": [
+ "dblp",
+ "publisher"
+ ],
+ "scope": "candidate_pending_scope_review",
+ "years": "all_publicly_available",
+ "tracks": [
+ "main"
+ ],
+ "coverage": "pending"
+ },
+ {
+ "acronym": "ECML-PKDD",
+ "field": "retrieval_data",
+ "venue_type": "conference",
+ "discovery_sources": [
+ "dblp",
+ "publisher"
+ ],
+ "scope": "candidate_pending_scope_review",
+ "years": "all_publicly_available",
+ "tracks": [
+ "main"
+ ],
+ "coverage": "pending"
+ },
+ {
+ "acronym": "ICRA",
+ "field": "robotics",
+ "venue_type": "conference",
+ "discovery_sources": [
+ "dblp",
+ "pmlr",
+ "rss"
+ ],
+ "scope": "candidate_pending_scope_review",
+ "years": "all_publicly_available",
+ "tracks": [
+ "main"
+ ],
+ "coverage": "pending"
+ },
+ {
+ "acronym": "IROS",
+ "field": "robotics",
+ "venue_type": "conference",
+ "discovery_sources": [
+ "dblp",
+ "pmlr",
+ "rss"
+ ],
+ "scope": "candidate_pending_scope_review",
+ "years": "all_publicly_available",
+ "tracks": [
+ "main"
+ ],
+ "coverage": "pending"
+ },
+ {
+ "acronym": "RSS",
+ "field": "robotics",
+ "venue_type": "conference",
+ "discovery_sources": [
+ "dblp",
+ "pmlr",
+ "rss"
+ ],
+ "scope": "candidate_pending_scope_review",
+ "years": "all_publicly_available",
+ "tracks": [
+ "main"
+ ],
+ "coverage": "pending"
+ },
+ {
+ "acronym": "CoRL",
+ "field": "robotics",
+ "venue_type": "conference",
+ "discovery_sources": [
+ "dblp",
+ "pmlr",
+ "rss"
+ ],
+ "scope": "candidate_pending_scope_review",
+ "years": "all_publicly_available",
+ "tracks": [
+ "main"
+ ],
+ "coverage": "pending"
+ },
+ {
+ "acronym": "ICAPS",
+ "field": "reasoning_agents",
+ "venue_type": "conference",
+ "discovery_sources": [
+ "dblp",
+ "proceedings"
+ ],
+ "scope": "candidate_pending_scope_review",
+ "years": "all_publicly_available",
+ "tracks": [
+ "main"
+ ],
+ "coverage": "pending"
+ },
+ {
+ "acronym": "KR",
+ "field": "reasoning_agents",
+ "venue_type": "conference",
+ "discovery_sources": [
+ "dblp",
+ "proceedings"
+ ],
+ "scope": "candidate_pending_scope_review",
+ "years": "all_publicly_available",
+ "tracks": [
+ "main"
+ ],
+ "coverage": "pending"
+ },
+ {
+ "acronym": "AAMAS",
+ "field": "reasoning_agents",
+ "venue_type": "conference",
+ "discovery_sources": [
+ "dblp",
+ "proceedings"
+ ],
+ "scope": "candidate_pending_scope_review",
+ "years": "all_publicly_available",
+ "tracks": [
+ "main"
+ ],
+ "coverage": "pending"
+ },
+ {
+ "acronym": "ICASSP",
+ "field": "speech_audio",
+ "venue_type": "conference",
+ "discovery_sources": [
+ "dblp",
+ "isca",
+ "ismir"
+ ],
+ "scope": "candidate_pending_scope_review",
+ "years": "all_publicly_available",
+ "tracks": [
+ "main"
+ ],
+ "coverage": "pending"
+ },
+ {
+ "acronym": "INTERSPEECH",
+ "field": "speech_audio",
+ "venue_type": "conference",
+ "discovery_sources": [
+ "dblp",
+ "isca",
+ "ismir"
+ ],
+ "scope": "candidate_pending_scope_review",
+ "years": "all_publicly_available",
+ "tracks": [
+ "main"
+ ],
+ "coverage": "pending"
+ },
+ {
+ "acronym": "ISMIR",
+ "field": "speech_audio",
+ "venue_type": "conference",
+ "discovery_sources": [
+ "dblp",
+ "isca",
+ "ismir"
+ ],
+ "scope": "candidate_pending_scope_review",
+ "years": "all_publicly_available",
+ "tracks": [
+ "main"
+ ],
+ "coverage": "pending"
+ },
+ {
+ "acronym": "MICCAI",
+ "field": "specialist",
+ "venue_type": "conference",
+ "discovery_sources": [
+ "dblp",
+ "publisher"
+ ],
+ "scope": "candidate_pending_scope_review",
+ "years": "all_publicly_available",
+ "tracks": [
+ "main"
+ ],
+ "coverage": "pending"
+ },
+ {
+ "acronym": "FAccT",
+ "field": "specialist",
+ "venue_type": "conference",
+ "discovery_sources": [
+ "dblp",
+ "publisher"
+ ],
+ "scope": "candidate_pending_scope_review",
+ "years": "all_publicly_available",
+ "tracks": [
+ "main"
+ ],
+ "coverage": "pending"
+ }
+ ],
+ "additional_tracks": [
+ "Findings",
+ "workshop"
+ ],
+ "additional_nonconference_venues": [
+ "TMLR",
+ "JMLR",
+ "TACL",
+ "ACL ARR"
+ ]
+}