Compare commits

...

14 Commits

Author SHA1 Message Date
codex-bot ac08edf572 Add ICML 2026 public rejected-submission archive with platform evidence
Metadata integrity checks / metadata (push) Successful in 35s
2026-11-06 20:55:49 +08:00
codex-bot 249713a342 Collect and audit explicit public rejection metadata across conferences
Metadata integrity checks / metadata (push) Successful in 35s
2026-11-06 20:52:42 +08:00
Zayden-Jung 3f35c7e42f Merge pull request '核验 arXiv 原始编号并恢复逐来源类别证据' (#7) from codex/arxiv-integrity into main
Metadata integrity checks / metadata (push) Successful in 29s
Reviewed-on: #7
2026-11-06 11:15:33 +00:00
Zayden-Jung 22d8f78870 Merge pull request '排除 DBLP 论文目录中的会务与前后页材料' (#6) from codex/dblp-frontmatter into main
Metadata integrity checks / metadata (push) Successful in 27s
Reviewed-on: #6
2026-11-06 11:12:47 +00:00
codex-bot 503908198e Audit raw arXiv IDs and preserve per-record category evidence
Metadata integrity checks / metadata (push) Successful in 26s
2026-11-06 19:11:02 +08:00
codex-bot 5f7dc3b6c0 Exclude proceedings matter from DBLP paper publications
Metadata integrity checks / metadata (push) Successful in 26s
2026-11-06 19:04:08 +08:00
Zayden-Jung 6c44e50a24 Merge pull request '封装 Paper Atlas:模块分层、统一 CLI 与安装验证' (#2) from codex/package-layout into main
Metadata integrity checks / metadata (push) Successful in 25s
Reviewed-on: #2
2026-11-06 10:56:59 +00:00
Zayden-Jung 57996189ac Merge pull request '整理 DBLP 历史出版记录并核对会议归属' (#5) from codex/dblp-import into main
Metadata integrity checks / metadata (push) Successful in 30s
Reviewed-on: #5
2026-11-06 10:56:45 +00:00
Zayden-Jung 29e5708959 Merge pull request '补齐 CVF 逐日目录并保留缺失摘要页的索引证据' (#4) from codex/cvf-coverage into main
Metadata integrity checks / metadata (push) Successful in 27s
Reviewed-on: #4
2026-11-06 10:56:37 +00:00
Zayden-Jung 5c3de59b58 Merge pull request '保留拒稿历史并汇总跨会议的已知录用去向' (#3) from codex/submission-outcomes into main
Metadata integrity checks / metadata (push) Successful in 37s
Reviewed-on: #3
2026-11-06 10:56:29 +00:00
codex-bot e5381d0e70 Verify short historical years and preserve mixed publication tracks
Metadata integrity checks / metadata (push) Successful in 24s
2026-11-06 18:38:53 +08:00
codex-bot b644de428b Normalize verified DBLP conference editions and publication metadata
Metadata integrity checks / metadata (push) Successful in 32s
2026-11-06 18:34:13 +08:00
codex-bot d4d02ee814 Collect every CVF day and retain index evidence for missing abstract pages
Metadata integrity checks / metadata (push) Successful in 55s
2026-11-06 18:07:48 +08:00
codex-bot b2a6efc5a6 Separate paper acceptance outcomes from individual rejected submissions
Metadata integrity checks / metadata (push) Successful in 43s
2026-11-06 17:39:09 +08:00
25 changed files with 1789 additions and 37 deletions
+62 -1
View File
@@ -58,6 +58,36 @@ PMLR 的同一出版 ID 可能对应多份 Git 元数据,例如 CoRL 2023 的
PeerRead 采集仅选择作者公开发布的 ICLR 2017 投稿元数据,固定到源仓库 commit。只依据明确的布尔 `accepted` 标签形成 accepted/rejected;空标签为 unknown,不使用其他数据分区中的 probably-rejected 推断。不会保存 reviews 或 parsed_pdfs 正文。
### 其他会议的公开拒稿
范围是逐会议、逐年份寻找已公开的拒稿投稿,不能只补录已发表名单。ICML、NeurIPS 部分年份由作者选择是否公开拒稿,因此公开子集不能用于推算完整拒稿数或录用率。其他会议没有在某个镜像中出现 Reject,也不代表该会议没有公开拒稿;需要继续核对官方公开政策、原平台与其他有明确标签的公开快照。
`collect public-openreview` 读取 [PaperCopilot 的公开元数据镜像](https://github.com/papercopilot/openreview/tree/41ee277825d685bd78e88e48b69da6aead9a69bb),固定版本并核验每个文件的 Git blob 哈希。默认盘点该版本截至 2026 年的非 ICLR 文件;ICLR 默认沿用独立的公开原始投稿/决定快照,需要补查时可显式指定 `--family iclr`。只保留明确的 Reject、Desk rejected、Withdrawn 标签,不根据 Submitted、Active、Pending、TBD、分数或录用名单缺席推断拒稿。
```sh
paper-atlas collect public-openreview --out /data/paper-index/collected/public-openreview-v1
paper-atlas audit public-openreview --db /data/paper-index/papers.sqlite \
--inventory /data/paper-index/collected/public-openreview-v1/public-openreview-inventory.json \
--out /data/paper-index/collected/public-openreview-preimport.json
```
输出按会议、届次及 main、position、datasets_benchmarks 分文件。workshop 默认进入单独的 held 清单;有明确 workshop 名称时可通过 `--include-workshops` 单列采集,不合并到主会。Retraction、未映射的轨道或负面标签也保留在 `held-records.jsonl`,不强改为 Reject 或 Withdrawn。当前决定表没有独立 retracted 状态,因此撤回发表记录仍需后续专门建模。
该来源明确标记为 `curated_public_status_assertion`:保留源 commit、文件哈希、行号、原标签和 forum ID,不声称已核验原始 PC 签名。只保存标题、摘要、公开作者名、关键词、领域、索引与决定来源,不缓存镜像中的个人资料、邮箱、性别、评审内容或 PDF。所有输出清单均为 partial;盘点中的零条记录仅描述这个固定镜像。
入库前审计核验文件哈希、数量、轨道和来源声明,并用真实 forum ID 对比同届同轨道已有投稿。存在不同决定时阻止整批导入,交由来源和时序核查;另一会议的后续录用属于独立投稿历史,不构成该次拒稿的冲突。同一 forum 的镜像不新增投稿事件,但论文实体的跨平台去重仍须在全量结束后核验。审计通过的报告包含带 SHA-256 的导入队列;应在单写入维护锁下及时串行导入,主库变化后重新审计。
ICML 2026 另有[公开原始投稿快照](https://github.com/HemantKumar01/ICML-2026-data/blob/1a1b9d973a8dfbf254f740fb8100a2fcf36757ea/data/rejected.json),包含 214 条投稿根记录。`collect icml-public-archive` 固定该 commit、文件长度及 Git blob 哈希,逐条要求 `readers` 包含 everyone、forum 等于自身 ID、域及投稿邀请属于主会、平台 `venueid` 明确为 `ICML.cc/2026/Conference/Rejected_Submission`。不能仅根据显示文案 Submitted to ICML 2026 推断拒稿。
```sh
paper-atlas collect icml-public-archive --out /data/paper-index/collected/icml-2026-public-archive
paper-atlas audit public-openreview --db /data/paper-index/papers.sqlite \
--inventory /data/paper-index/collected/icml-2026-public-archive/public-openreview-inventory.json \
--out /data/paper-index/collected/icml-2026-public-preimport.json
```
这批证据标记为 `public_submission_venueid`,保留逐投稿许可及快照出处;它与整理者的 Reject 标签、原始签名决定分别表述。记录修改时间不当作决定日期,观察时间采用快照上界,不采用今天的导入时间。214 是这个公开 opt-in 子集的投稿数,不能当作会议全部拒稿数,也不能直接当作去重论文实体数。该采集器同样只提取元数据与标题摘要,丢弃 authorids、个人资料、评审和 PDF 字段。
```sh
python3 -B -m paper_atlas collect peerread --cache /data/paper-index/raw/http-cache \
--out /data/paper-index/collected
@@ -113,6 +143,25 @@ python3 -B -m paper_atlas catalogue dblp \
`dblp-catalogue.sqlite` 保存论文集目录、按 crossref/年份/booktitle 的记录数量、各目录族数量及少量样本;`dblp-ai-candidates.jsonl` 保存候选会议族的原始索引元数据。存在多个出版卷字段的记录另存 `volume_anomalies`,保留全部值供归属核验;统计中仍只计一条,不任取第一个字段或展开成多篇。候选范围包含历史名称与 workshop,尚未赋予主会归属或录用状态。后续须依据具体论文集和届次核对目标范围、track 与出版标识,才能生成入库文件;不会把同一目录下的所有记录都视为主会。该盘点不下载 PDF,也不更改论文主库。
目录盘点完成后,可按已核对的书目名称整理指定会议的分届文件。例如先补齐尚无主库记录的会议:
```sh
paper-atlas collect dblp \
--catalogue /data/paper-index/collected/dblp/dblp-catalogue.sqlite \
--archive /data/paper-index/raw/dblp-2026-11-01.xml.gz \
--dtd /data/paper-index/raw/dblp-2023-06-28.dtd \
--venue AAAI --venue IJCAI --venue KDD \
--out /data/paper-index/collected/dblp-normalized
```
也可用 `--candidates` 指定已通过清单 SHA-256 核验的候选文件;完整 XML 模式则可包含初次候选族之外的实际目录,例如 CLeaR 的 `clear2`。规则按书目名、crossref 论文集和会议年核对:不把另一个 `CLEAR` 或 Adaptive Agents 会议误标为 CLeaR/AAMAS;联合会议保留名称别名,出版年与会议年分别保存。workshop 默认单独留待审核,可显式使用 `--include-workshops`;其他已识别的官方附属 track 单列。
输出按会议/年份/track 分文件,附 manifest、`dblp-normalization.json` 范围审查统计和带哈希的 `dblp-import-queue.json`。不能识别的书目、年份或归属会逐类登记,不默认算作主会。DBLP 不含摘要,因此输出摘要为空、清单为 partial;需要通过有证据的跨来源关联补齐论文实体摘要。书目发表证据与原始审稿决定分别保留,不依据书目缺席推断拒稿。已存在其他出版来源的届次,还须先回填其权威身份并核验镜像记录,不能直接累加投稿数。
部分早期 SIGIR、CIKM、IJCAI 出版卷使用两位年份键;只有论文集标题明确给出唯一完整年份且与短键相符时才采用,不推测世纪。WWW 特殊兴趣、Alternate track 单列;同卷混合 Applied Data Science、Demo、Industrial 且不能逐篇区分时使用 `proceedings_unspecified`,不把整卷归为某一个具体 track。
书目中的前后页、目录组合和明确会务标题不产生论文录用记录;缺作者的赞助、委员会、活动汇总等标题单独排除。WWW 2019 companion 的匿名合成活动条目归入待核查材料,原始快照仍保留。范围报告分别统计这些原因;单纯缺作者并不排除真实研究论文,也不补造作者。
XML 只允许校验过的本地 DTD;官方通用名 `dblp.dtd` 与月度快照使用的 `dblp-2023-06-28.dtd` 均解析到同一份已校验文件。拒绝其他外部实体和内部 DTD 子集,不访问 XML 声明的任意网络地址,并遵循源 XML 的字符编码。临时目录库或 gzip 检查失败时不发布正式候选文件,需先调查残留的 `.part` 状态,再决定是否重新运行。
公开 ICLR 投稿快照的固定 Release 供后续筛选投稿和明确决定;它是第三方保存的 OpenReview 历史副本,不等于最新官方全量数据:
@@ -215,8 +264,20 @@ python3 -B -m paper_atlas collect 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` 或二者组合会报错,不补造双年会议届次,也不能因此推断旧届次未举办。
CVF 从官方根目录发现 CVPR、ICCV 主会与 Findings,分别保存 track;不把 workshop 或 WACV 算入这些会议。优先跟随页面明确提供的 All Papers 链接;只有逐日目录时遍历全部明确链接的日期,再按论文 URL 合并重复条目并检查标题、作者、DOI 一致性。`.py` 与无后缀的同届入口可关联,不跟随其他会议、年份或站点的日期链接。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` 限定重试;其他届次状态会保留。官方开放目录之外的早期届次、尚未刊出的新届次和独立出版方历史数据仍需后续补齐,采集器完成不代表全历史覆盖已完成。
单个摘要页面 404 时,仍保存官方目录提供的题目、作者和发表证据,摘要留空并记录失败 URL;整届文件标为 partial。403 等访问拒绝会终止该届采集。刷新缺失摘要时,存储层保留原来已知的论文摘要及其来源观察信息,后续页面恢复时可以更新。
针对旧 catalogue 的 blocked 届次可单独补采到新目录,避免与现有采集任务同时改写状态文件:
```sh
paper-atlas collect cvf --cache /data/paper-index/raw/http-cache \
--retry-catalogue /data/paper-index/collected/cvf-index-status.json \
--out /data/paper-index/collected/cvf-repair
```
parser_version 2 的清单还保存实际遍历的 index_urls、入口错误及缺失摘要页面。补采结果经过清单与身份核验后才导入,不能因重试任务启动就标记缺口已解决。
+18
View File
@@ -2,6 +2,24 @@
代码以固定 commit 部署,已经启动的任务继续使用其原版本。目录重构不要求迁移数据库,也不更改已有 JSONL 和 manifest 格式。
## arXiv 原始编号与类别证据
原始行数、唯一 arXiv 编号数和合并后的论文实体数分别核验。以下只读原始压缩包,流式处理到 ZIP EOF/CRC 检查完成;输出审计 JSON 与编号 SQLite 索引,不修改论文主库:
```sh
paper-atlas audit arxiv --archive /data/paper-index/raw/arxiv-metadata.zip \
--out /data/paper-index/collected/arxiv-source-audit.json
```
同编号的重复行会记录出现位置、次数和内容哈希版本,不直接视作不同论文;同篇论文的多个 arXiv 编号则保留各自来源及类别证据。旧导入器使用共享类别证据键,可能在论文实体合并时丢失某个编号的相同类别出处。新导入使用逐记录证据键;已有数据须在没有其他主库写入任务时按保留的来源 metadata 修复:
```sh
paper-atlas maintain arxiv-tags --db /data/paper-index/papers.sqlite \
--out /data/paper-index/collected/arxiv-tag-repair.json
```
修复使用同一维护锁,分批提交并保存断点,保留 `custom:*` 和其他来源证据;失败后确认原因已解决,再增加 `--resume` 从上次提交位置继续。完成报告不能作为重新扫描的断点,重新核验应指定新报告。源审计和标签修复均保留 8 GiB 空闲空间,不能代替所有来源入库后的实体、投稿去重与完整性检查。
## 长期维护与更新
1. 每次采集先核对来源、许可、可访问性、空闲空间和现有运行批次。保持低并发,遵守来源限流、重试与人机验证要求。
+14
View File
@@ -14,10 +14,15 @@ paper-atlas collect cvf --cache /data/paper-index/raw/http-cache --out /data/pap
paper-atlas collect ecva --cache /data/paper-index/raw/http-cache --out /data/paper-index/collected
paper-atlas catalogue dblp --help
paper-atlas audit iclr --help
paper-atlas collect public-openreview --help
paper-atlas collect icml-public-archive --help
paper-atlas audit public-openreview --help
```
采集输出 JSONL、manifest 和逐卷或逐年 catalogue。逐项检查 blocked、partial、缺失摘要及目标会议归属。DBLP 候选目录还需核验届次和 track,不能直接当作主会录用名单。
其他会议的公开拒稿使用 `collect public-openreview`,再以 `audit public-openreview` 核对既有投稿身份和决定冲突。来源版本、逐文件状态盘点和未解决记录分别保存在 inventory 与 held 清单中,具体命令见[数据源说明](data-sources.md#其他会议的公开拒稿)。这类公开子集不能当作会议完整投稿名单。
## 3. 串行导入
首次 arXiv 导入使用 `paper-atlas import arxiv --db ... --archive ...`。现有数据库合并已核验的会议文件使用:
@@ -42,4 +47,13 @@ paper-atlas coverage --db /data/paper-index/papers.sqlite --out /data/paper-inde
`--status rejected` 筛选某次投稿的最新已知决定。它并不表示论文在所有会议均未录用;分析转投情况时查看该论文的完整投稿历史。年份表示会议届次,观察时间和决定日期分别保留。
例如,查找在 ICLR 有拒稿记录、但在其他已知投稿中已有录用的论文:
```sh
paper-atlas query --db /data/paper-index/papers.sqlite \\
--venue ICLR --status rejected --outcome accepted
```
`known_acceptances` 列出目前仍为录用状态的各次投稿及会议、年份、track 和决定证据,`publication_outcome` 汇总为 accepted 或 no_known_acceptance。汇总会检查所有会议和 track,不把某次拒稿覆盖到整篇论文;同一次投稿的后续撤稿也会更新该次状态并保留原决定。no_known_acceptance 只表示现有来源中没有查到录用,不能称为“从未录用”。
`--stats` 中 papers 是当前规则下的论文实体数;source_records 是来源记录数;decisions 是决定观察数。一个投稿可能有多个来源和多条观察,不能相加当作论文数。
+90
View File
@@ -0,0 +1,90 @@
"""Independently count raw arXiv IDs and their payload variants to ZIP EOF."""
import argparse
from contextlib import closing
import fcntl
import hashlib
import json
from pathlib import Path
import shutil
import sqlite3
import tempfile
import zipfile
from paper_atlas.db.submissions import timestamp
from paper_atlas.io.snapshots import digest_file
MEMBER='arxiv-metadata-oai-snapshot.json'
def fingerprint(path):
info=path.stat()
return {'bytes':info.st_size,'mtime_ns':info.st_mtime_ns}
def audit(archive,out,expected_sha256=None):
out=Path(out).resolve();out.parent.mkdir(parents=True,exist_ok=True)
with out.with_suffix('.lock').open('a') as lock:
fcntl.flock(lock,fcntl.LOCK_EX|fcntl.LOCK_NB)
return _audit(archive,out,expected_sha256)
def _audit(archive,out,expected_sha256=None):
archive=Path(archive).resolve();out=Path(out).resolve()
ids=out.with_suffix('.ids.sqlite')
if out.exists() or ids.exists():raise ValueError('Audit output exists; inspect it before another run')
out.parent.mkdir(parents=True,exist_ok=True)
before=fingerprint(archive);identity=digest_file(archive)
if expected_sha256 and identity['sha256']!=expected_sha256:raise ValueError('Archive checksum mismatch')
if shutil.disk_usage(out.parent).free<8*1024**3:raise RuntimeError('Less than 8 GiB free before source audit')
count=0;missing_title=0;missing_abstract=0
with tempfile.TemporaryDirectory(prefix='.arxiv-audit-',dir=out.parent) as temporary:
stage=Path(temporary)/ids.name
with closing(sqlite3.connect(stage)) as c:
c.executescript('PRAGMA cache_size=-16384; CREATE TABLE raw_ids(external_id TEXT PRIMARY KEY,first_line INTEGER,last_line INTEGER,occurrences INTEGER,first_sha256 BLOB,has_payload_variants INTEGER) WITHOUT ROWID; CREATE TABLE duplicate_variants(external_id TEXT,payload_sha256 BLOB,first_line INTEGER,last_line INTEGER,occurrences INTEGER,PRIMARY KEY(external_id,payload_sha256)) WITHOUT ROWID;')
with zipfile.ZipFile(archive) as zipped:
info=zipped.getinfo(MEMBER)
with zipped.open(info) as stream:
for count,line in enumerate(stream,1):
row=json.loads(line);external=row.get('id')
if not isinstance(external,str) or not external or external!=external.strip():raise ValueError('Missing or malformed arXiv ID')
payload=hashlib.sha256(json.dumps(row,ensure_ascii=False,sort_keys=True,separators=(',',':')).encode()).digest()
inserted=c.execute('INSERT OR IGNORE INTO raw_ids VALUES(?,?,?,1,?,0)',(external,count,count,payload)).rowcount
if not inserted:
first_line,first=c.execute('SELECT first_line,first_sha256 FROM raw_ids WHERE external_id=?',(external,)).fetchone()
c.execute('INSERT OR IGNORE INTO duplicate_variants VALUES(?,?,?,?,1)',(external,first,first_line,first_line))
c.execute('INSERT INTO duplicate_variants VALUES(?,?,?,?,1) ON CONFLICT(external_id,payload_sha256) DO UPDATE SET last_line=excluded.last_line,occurrences=duplicate_variants.occurrences+1',(external,payload,count,count))
c.execute('UPDATE raw_ids SET last_line=?,occurrences=occurrences+1,has_payload_variants=has_payload_variants OR first_sha256!=? WHERE external_id=?',(count,payload,external))
missing_title+=not bool((row.get('title') or '').strip())
missing_abstract+=not bool((row.get('abstract') or '').strip())
if count%10000==0:
c.commit()
if shutil.disk_usage(out.parent).free<8*1024**3:raise RuntimeError('Less than 8 GiB free during source audit')
if count%100000==0:print(json.dumps({'source':'arxiv_audit','raw_rows':count}),flush=True)
# zipfile verifies the member CRC only when its stream reaches EOF.
if not count:raise ValueError('Empty arXiv metadata member')
if fingerprint(archive)!=before:raise ValueError('Archive changed during audit')
c.commit()
if c.execute('PRAGMA quick_check').fetchone()[0]!='ok':raise ValueError('Audit index failed SQLite quick_check')
unique=c.execute('SELECT count(*) FROM raw_ids').fetchone()[0]
duplicate_ids=[]
for key,first,last,occurrences,variants in c.execute('SELECT external_id,first_line,last_line,occurrences,has_payload_variants FROM raw_ids WHERE occurrences>1 ORDER BY external_id'):
duplicate_ids.append({'external_id':key,'first_line':first,'last_line':last,'occurrences':occurrences,'has_payload_variants':bool(variants),
'variants':[{'sha256':digest.hex(),'first_line':a,'last_line':b,'occurrences':n} for digest,a,b,n in c.execute('SELECT payload_sha256,first_line,last_line,occurrences FROM duplicate_variants WHERE external_id=? ORDER BY first_line',(key,))]})
result={'checked_at':timestamp(),'archive':str(archive),'archive_identity':identity,'member':MEMBER,'member_crc32':f'{info.CRC:08x}',
'uncompressed_bytes':info.file_size,'zip_eof_crc_verified':True,'raw_rows':count,'unique_external_ids':unique,'extra_rows_for_repeated_ids':count-unique,
'repeated_id_count':len(duplicate_ids),'repeated_ids_with_payload_variants':sum(item['has_payload_variants'] for item in duplicate_ids),
'missing_title_rows':missing_title,'missing_abstract_rows':missing_abstract,'duplicate_ids':duplicate_ids,'id_index':str(ids),
'interpretation':'Raw ID/payload audit, not a count of unique research works. Payload variants are retained as evidence; latest source import uses the last row for each ID. Cross-source entity/submission dedup requires a separate database audit.'}
stage.replace(ids)
part=out.with_suffix('.part');part.write_text(json.dumps(result,ensure_ascii=False,indent=2)+'\n');part.replace(out)
print(json.dumps({key:value for key,value in result.items() if key!='duplicate_ids'}),flush=True)
return result
def main(argv=None,*,prog=None):
parser=argparse.ArgumentParser(prog=prog);parser.add_argument('--archive',required=True);parser.add_argument('--out',required=True)
parser.add_argument('--expected-sha256');args=parser.parse_args(argv)
audit(args.archive,args.out,args.expected_sha256)
if __name__=='__main__':main()
+107
View File
@@ -0,0 +1,107 @@
"""Check curated negative assertions against existing submission decisions."""
import argparse
from contextlib import closing
import hashlib
import json
from pathlib import Path
import sqlite3
from paper_atlas.collectors.public_openreview import SOURCE, REVISION
from paper_atlas.collectors.icml_public_archive import SOURCE as ARCHIVE_SOURCE, REVISION as ARCHIVE_REVISION
from paper_atlas.db.submissions import timestamp
def audit(db, inventory, out):
inventory = Path(inventory).resolve()
result = json.loads(inventory.read_text())
specs = {SOURCE: (REVISION, 'curated_public_status_assertion'),
ARCHIVE_SOURCE: (ARCHIVE_REVISION, 'public_submission_venueid')}
source = result.get('source')
if source not in specs or result.get('revision') != specs[source][0]:
raise ValueError('Unsupported public source inventory')
revision, evidence_kind = specs[source]
db = Path(db).resolve()
if not db.is_file():
raise ValueError('Existing database required for identity and status audit')
conflicts = []
seen = set()
checked = 0
existing_attempts = 0
files = []
checksums = {}
with closing(sqlite3.connect(db.as_uri() + '?mode=ro', uri=True)) as c:
c.execute('BEGIN')
for manifest in result['files']:
path = Path(manifest['file'])
if hashlib.sha256(path.read_bytes()).hexdigest() != manifest['file_sha256']:
raise ValueError('Collected file differs from pinned inventory')
if json.loads(path.with_suffix('.manifest.json').read_text()) != manifest:
raise ValueError('Collected manifest differs from inventory')
count = 0
for line in path.read_text().splitlines():
row = json.loads(line)
if any(row[key] != manifest[key] for key in ('source', 'venue', 'year', 'track')):
raise ValueError('Record edition differs from inventory')
meta = row['metadata']
if meta.get('evidence_kind') != evidence_kind or meta.get('source_revision') != revision:
raise ValueError('Missing explicit public source provenance')
if meta.get('original_decision_signature_verified') is not False:
raise ValueError('Curated metadata must not claim original signatures')
if row['status'] not in ('rejected', 'desk_rejected', 'withdrawn'):
raise ValueError('Unsupported negative assertion')
forum = row['identifiers']['openreview']
if forum in seen:
raise ValueError('Repeated forum in normalized inventory')
seen.add(forum)
existing = c.execute('''SELECT s.id FROM submission_identifiers i
JOIN submissions s ON s.id=i.submission_id JOIN editions e ON e.id=i.edition_id
JOIN venues v ON v.id=e.venue_id WHERE v.acronym=? AND e.year=? AND e.track=?
AND i.scheme='openreview' AND i.value=?''',
(row['venue'], row['year'], row['track'], forum)).fetchall()
if len(existing) > 1:
raise ValueError('Multiple canonical attempts for one forum')
if existing:
existing_attempts += 1
latest = c.execute('''SELECT status FROM decisions WHERE submission_id=?
ORDER BY observed_at DESC,COALESCE(decision_date,'') DESC,id DESC LIMIT 1''', existing[0]).fetchone()
if latest and latest[0] not in ('unknown', 'under_review', row['status']):
conflicts.append({'forum': forum, 'venue': row['venue'], 'year': row['year'],
'track': row['track'], 'submission_id': existing[0][0],
'existing_status': latest[0], 'curated_status': row['status']})
count += 1
if count != manifest['count']:
raise ValueError('Collected record count differs from inventory')
checked += count
files.append(str(path))
checksums[str(path)] = manifest['file_sha256']
if checked != result['normalized_records']:
raise ValueError('Normalized inventory total differs from checked records')
report = {'source': source, 'revision': revision, 'database': str(db),
'inventory': str(inventory), 'checked_at': timestamp(), 'records_checked': checked,
'existing_same_edition_attempts': existing_attempts, 'conflicts': conflicts,
'status': 'blocked' if conflicts else 'ready_for_serial_import',
'files': [] if conflicts else files, 'file_sha256': {} if conflicts else checksums,
'note': 'Read-only preflight. Other-conference acceptances remain separate history; '
'this is not an entity deduplication or complete rejection-coverage proof.'}
out = Path(out)
out.parent.mkdir(parents=True, exist_ok=True)
part = out.with_suffix('.part')
part.write_text(json.dumps(report, ensure_ascii=False, indent=2) + '\n')
part.replace(out)
print(json.dumps(report), flush=True)
return report
def main(argv=None, *, prog=None):
parser = argparse.ArgumentParser(prog=prog)
parser.add_argument('--db', required=True)
parser.add_argument('--inventory', required=True)
parser.add_argument('--out', required=True)
args = parser.parse_args(argv)
report = audit(args.db, args.inventory, args.out)
if report['status'] == 'blocked':
raise SystemExit('Conflicting existing decisions require review before import')
if __name__ == '__main__':
main()
+6
View File
@@ -13,17 +13,23 @@ COMMANDS = {
('collect','acl'): ('paper_atlas.collectors.acl', {}, 'ACL Anthology XML'),
('collect','iclr-program'): ('paper_atlas.collectors.iclr', {}, 'ICLR 官方公开程序'),
('collect','iclr-snapshot'): ('paper_atlas.collectors.openreview', {}, 'ICLR 公开投稿与决定快照'),
('collect','public-openreview'): ('paper_atlas.collectors.public_openreview', {}, '核验其他会议的公开拒稿及撤稿标签'),
('collect','icml-public-archive'): ('paper_atlas.collectors.icml_public_archive', {}, 'ICML 2026 公开拒稿原始投稿快照'),
('collect','peerread'): ('paper_atlas.collectors.peerread', {}, 'PeerRead 历史决定补充'),
('collect','colm'): ('paper_atlas.collectors.proceedings', {'source':'colm'}, 'COLM 官方论文集'),
('collect','neurips'): ('paper_atlas.collectors.proceedings', {'source':'neurips'}, 'NeurIPS 官方论文集'),
('collect','cvf'): ('paper_atlas.collectors.vision', {'source':'cvf'}, 'CVPR / ICCV 官方开放目录'),
('collect','ecva'): ('paper_atlas.collectors.vision', {'source':'ecva'}, 'ECCV 官方开放目录'),
('collect','dblp'): ('paper_atlas.collectors.dblp_records', {}, '核对并整理 DBLP 历史发表记录'),
('catalogue','dblp'): ('paper_atlas.collectors.dblp', {}, '盘点 DBLP 历史会议目录'),
('import','arxiv'): ('paper_atlas.ingestion.arxiv', {}, '导入 arXiv 元数据 ZIP'),
('import','records'): ('paper_atlas.ingestion.records', {}, '导入核验过的会议文件'),
('maintain','merge'): ('paper_atlas.ingestion.maintenance', {}, '协调单写入合并与恢复'),
('maintain','backfill'): ('paper_atlas.ingestion.backfill', {}, '回填既有投稿的来源身份'),
('maintain','arxiv-tags'): ('paper_atlas.ingestion.arxiv_tags', {}, '恢复逐来源记录的 arXiv 类别证据'),
('audit','iclr'): ('paper_atlas.audits.iclr', {}, '核对 ICLR 快照与同届官方程序'),
('audit','arxiv'): ('paper_atlas.audits.arxiv', {}, '核验 arXiv 原始编号、重复行与 ZIP 完整性'),
('audit','public-openreview'): ('paper_atlas.audits.public_openreview', {}, '核对公开负面决定与已有投稿历史'),
}
+186
View File
@@ -0,0 +1,186 @@
"""Normalize pinned DBLP publication metadata into audited edition files."""
import argparse
from collections import Counter,OrderedDict
from contextlib import closing
import fcntl
import hashlib
import json
from pathlib import Path
import re
import shutil
import sqlite3
import tempfile
from urllib.parse import parse_qs,unquote,urlsplit
from paper_atlas.config import DEFAULT_REGISTRY
from paper_atlas.collectors.dblp import XML_SHA,DTD_MD5,RELEASE,records
from paper_atlas.collectors.dblp_scope import scope
from paper_atlas.db.submissions import timestamp
from paper_atlas.io.snapshots import digest_file
AS_OF='2026-11-01T00:00:00.000Z'
MATERIAL_PART=r'(?:title(?: page)?|preface|foreword|editorial|front\s?matter|back\s?matter|(?:table of )?contents|(?:author |subject )?index|contributors|(?:conference |program |organizing |steering )?committee|conference organization|reviewers)'
FRONTMATTER=re.compile(MATERIAL_PART+r'(?:\s*[,;&]\s*'+MATERIAL_PART+r')*[.!:]?',re.I)
HEADINGS=re.compile(r'(?:(?:IJCAI|AAAI)(?:[- ]\d{2,4})? |(?:19|20)\d{2} )?(?:conference )?(?:organization|organizers and sponsors|sponsorship|sponsors|(?:non-PC )?reviewers|awards and distinguished papers|keynote and invited speakers|area chairs and program committee|tutorials|panel session|invited talks|workshops|workshop summaries|special track on continuous planning)[.!:]?',re.I)
def material_reason(title,authors,key,year,track):
if FRONTMATTER.fullmatch(title):return 'frontmatter'
if re.search(r"\bchairs['’]? welcome(?:\s*&\s*organization listing)?[.!:]?$",title,re.I):return 'frontmatter'
if not authors:
if HEADINGS.fullmatch(title) or re.fullmatch(r'(?:past IJCAI conferences|events colocated with IJCAI-\d{2}|welcome message from the conference chairs|welcome to (?:ICDM|IJCAI|AAAI) \d{4})[.!:]?',title,re.I):return 'proceedings_heading'
# These anonymous synthetic entries name whole workshop events, rather
# than identifiable papers. Hold them without discarding the raw source.
if year==2019 and track=='companion' and re.fullmatch(r'conf/www/X19[a-z]?',key):return 'unverified_proceedings_material'
return None
def identifiers(fields):
dois=set();arxiv=set();forums=set()
for link in fields.get('ee',[]):
parsed=urlsplit(link);path=unquote(parsed.path)
doi=None
if parsed.netloc in ('doi.org','dx.doi.org','doi.ieeecomputersociety.org'):doi=path.lstrip('/')
elif parsed.netloc=='link.springer.com' and path.startswith('/chapter/'):doi=path.removeprefix('/chapter/')
elif parsed.netloc=='dl.acm.org':
match=re.match(r'/doi/(?:abs/|full/|pdf/)?(10\..+)',path)
if match:doi=match[1]
if doi and re.fullmatch(r'10\.\d{4,9}/\S+',doi):dois.add(doi.lower())
if parsed.netloc in ('arxiv.org','www.arxiv.org'):
match=re.fullmatch(r'/(?:abs|pdf)/((?:\d{4}\.\d{4,5}|[a-zA-Z.-]+/\d{7})(?:v\d+)?)(?:\.pdf)?',path)
if match:arxiv.add(re.sub(r'v\d+$','',match[1]))
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'[A-Za-z0-9_-]+',values[0]) and not re.match(r'20\d{2}-Oral-',values[0]):forums.add(values[0])
for doi in dois:
match=re.fullmatch(r'10\.48550/arxiv\.((?:\d{4}\.\d{4,5}|[a-z.-]+/\d{7})(?:v\d+)?)',doi)
if match:arxiv.add(re.sub(r'v\d+$','',match[1]))
publications={doi for doi in dois if not doi.startswith('10.48550/arxiv.')}
own={}
for scheme,values in (('doi',publications),('arxiv',arxiv),('openreview',forums)):
if len(values)==1:own[scheme]=next(iter(values))
ambiguous={scheme:sorted(values) for scheme,values in (('doi',publications),('arxiv',arxiv),('openreview',forums)) if len(values)>1}
return own,ambiguous
def normalize_row(row,volumes,targets,include_workshops=False,through_year=2026):
if row['record_type']!='inproceedings':return None,'not_a_paper'
fields=row['fields'];refs=fields.get('crossref',[])
if len(refs)!=1:return None,'ambiguous_or_missing_crossref'
edition,reason=scope(row,volumes.get(refs[0]),through_year)
if not edition:return None,reason
if not ({edition['venue']}|set(edition['venue_aliases']))&targets:return None,'outside_requested_venues'
if edition['track']=='workshop' and not include_workshops:return None,'separate_workshop'
titles=fields.get('title',[])
if len(titles)!=1 or not titles[0].strip():return None,'missing_or_multiple_titles'
title=' '.join(titles[0].split())
authors=[re.sub(r'\s+\d{4}$','',name).strip() for name in fields.get('author',[]) if name.strip()]
reason=material_reason(title,authors,row['key'],edition['year'],edition['track'])
if reason:return None,reason
own,ambiguous=identifiers(fields)
metadata={'snapshot_sha256':XML_SHA,'snapshot_release':RELEASE,'observation_date_precision':'day',
'proceedings_key':refs[0],'proceedings_title':edition['proceedings_title'],'bibliographic_year':edition['bibliographic_year'],
'edition_year_basis':edition['edition_year_basis'],
'scope_rule':edition['rule'],'author_names_raw':fields.get('author',[]),'publtype':row.get('publtype'),
'bibliography':{key:value for key,value in fields.items() if key in ('booktitle','year','ee','url','pages','volume','number','publisher','series','isbn')},
'ambiguous_identifiers':ambiguous,'publication_evidence':'inproceedings bibliography; not an original decision note'}
flagged=bool(re.search(r'\b(?:withdrawn|retracted)\b',row.get('publtype') or '',re.I))
result={'source':'dblp','external_id':row['key'],'url':'https://dblp.org/rec/'+row['key']+'.html',
'title':title,'abstract':None,'authors':authors,'identifiers':own,
**{key:edition[key] for key in ('venue','venue_aliases','year','track')},
'status':'unknown' if flagged else 'accepted','observed_at':AS_OF,
'raw_decision':'listed in pinned DBLP inproceedings bibliography','metadata':metadata,
'tags':[{'namespace':'dblp:booktitle','value':fields['booktitle'][0]}]}
if flagged:result['decision_events']=[]
return result,None
def collect(catalogue,out,venues=None,candidates=None,archive=None,dtd=None,include_workshops=False,through_year=2026):
if bool(candidates)==bool(archive):raise ValueError('Choose exactly one candidates file or original archive')
catalogue=Path(catalogue);manifest=json.loads(catalogue.with_name('dblp-ai-candidates.manifest.json').read_text())
if manifest['snapshot_sha256']!=XML_SHA:raise ValueError('Unsupported snapshot identity')
if candidates:
if digest_file(Path(candidates))['sha256']!=manifest['candidate_sha256']:raise ValueError('Candidate checksum mismatch')
else:
if not dtd or digest_file(Path(dtd))['md5']!=DTD_MD5:raise ValueError('DTD checksum mismatch')
if digest_file(Path(archive))['sha256']!=XML_SHA:raise ValueError('Archive checksum mismatch')
with closing(sqlite3.connect(catalogue.resolve().as_uri()+'?mode=ro',uri=True)) as c:
volumes={key:json.loads(payload) for key,payload in c.execute('SELECT key,metadata_json FROM proceedings')}
if len(volumes)!=manifest['record_types']['proceedings']:raise ValueError('Catalogue inventory mismatch')
allowed={row['acronym'].casefold():row['acronym'] for row in json.loads(DEFAULT_REGISTRY.read_text())['venues']}
if venues and set(name.casefold() for name in venues)-allowed.keys():raise ValueError('Unknown registry venue')
targets={allowed[name.casefold()] for name in venues} if venues else set(allowed.values())
out=Path(out);out.mkdir(parents=True,exist_ok=True)
with (out/'dblp.normalize.lock').open('a') as lock:
fcntl.flock(lock,fcntl.LOCK_EX|fcntl.LOCK_NB)
return _collect_rows(volumes,out,targets,candidates,archive,dtd,include_workshops,through_year,manifest)
def _collect_rows(volumes,out,targets,candidates,archive,dtd,include_workshops,through_year,source_manifest):
counts=Counter();excluded=Counter();examples={};groups={};seen=set();handles=OrderedDict();scanned=0
def rows():
if candidates:
with Path(candidates).open() as stream:
for line in stream:yield json.loads(line)
else:yield from records(archive,Path(dtd).read_bytes())
if shutil.disk_usage(out).free<8*1024**3:raise RuntimeError('Less than 8 GiB free before normalization')
with tempfile.TemporaryDirectory(prefix='.dblp-normalize-',dir=out) as temporary:
stage=Path(temporary)
try:
for row in rows():
scanned+=1
record,reason=normalize_row(row,volumes,targets,include_workshops,through_year)
if record is None:
excluded[reason]+=1
label=reason+'|'+'/'.join(row.get('key','').split('/')[:2])+'|'+';'.join(row['fields'].get('booktitle',[]))
if label not in examples:examples[label]={'count':0,'example_key':row.get('key')}
examples[label]['count']+=1
else:
key=record['external_id']
if key in seen:raise ValueError('Repeated DBLP publication key')
seen.add(key);group=(record['venue'],record['year'],record['track'])
filename='dblp-'+re.sub(r'[^a-z0-9]+','-',group[0].casefold())+'-'+str(group[1])+'-'+group[2]+'.jsonl'
if group not in groups:groups[group]={'filename':filename,'count':0,'unknown':0,'ambiguous_identifiers':0}
info=groups[group];info['count']+=1;info['unknown']+=record['status']=='unknown';info['ambiguous_identifiers']+=bool(record['metadata']['ambiguous_identifiers'])
if group not in handles:
if len(handles)>=32:handles.popitem(last=False)[1].close()
handles[group]=(stage/filename).open('a')
handles.move_to_end(group);handles[group].write(json.dumps(record,ensure_ascii=False)+'\n');counts[record['venue']]+=1
if scanned%50000==0:
if shutil.disk_usage(out).free<8*1024**3:raise RuntimeError('Less than 8 GiB free during normalization')
print(json.dumps({'source':'dblp','scanned':scanned,'normalized':sum(counts.values())}),flush=True)
finally:
for handle in handles.values():handle.close()
expected=source_manifest['candidate_records'] if candidates else sum(source_manifest['record_types'].values())
if scanned!=expected:raise ValueError('Input count differs from verified catalogue')
if not seen:raise ValueError('No target publications were verified')
files=[]
for (venue,year,track),info in sorted(groups.items()):
path=out/info['filename'];checksum=digest_file(stage/info['filename'])['sha256']
item={'source':'dblp','parser_version':1,'venue':venue,'year':year,'track':track,'count':info['count'],
'missing_abstract_count':info['count'],'unknown_status_count':info['unknown'],'ambiguous_identifier_count':info['ambiguous_identifiers'],
'list_url':RELEASE,'status':'partial','file':str(path),'file_sha256':checksum,'snapshot_sha256':XML_SHA,
'abstract_note':'DBLP provides no abstract; canonical abstracts must come from independently linked sources.'}
(stage/info['filename']).replace(path)
meta=path.with_suffix('.manifest.json');part=meta.with_suffix('.part');part.write_text(json.dumps(item,indent=2));part.replace(meta);files.append(item)
result={'source':'dblp','finished_at':timestamp(),'snapshot_sha256':XML_SHA,'input_mode':'candidates' if candidates else 'original_archive',
'requested_venues':sorted(targets),'include_workshops':include_workshops,'through_year':through_year,'scanned_records':scanned,
'normalized_records':len(seen),'records_by_venue':dict(counts),'excluded_by_reason':dict(excluded),'scope_review':examples,'files':files,
'input_candidate_sha256':source_manifest['candidate_sha256'] if candidates else None,
'note':'Classification covers requested recognizable bibliography entries, not an independent proof of complete venue histories. Unknown scopes remain in scope_review.'}
target=out/'dblp-normalization.json';part=target.with_suffix('.part');part.write_text(json.dumps(result,ensure_ascii=False,indent=2));part.replace(target)
queue={'files':[item['file'] for item in files],'file_sha256':{item['file']:item['file_sha256'] for item in files}}
target=out/'dblp-import-queue.json';part=target.with_suffix('.part');part.write_text(json.dumps(queue,indent=2));part.replace(target)
print(json.dumps({key:result[key] for key in ('scanned_records','normalized_records','records_by_venue','excluded_by_reason')}),flush=True)
return result
def main(argv=None,*,prog=None):
parser=argparse.ArgumentParser(prog=prog);parser.add_argument('--catalogue',required=True);parser.add_argument('--out',required=True)
inputs=parser.add_mutually_exclusive_group(required=True);inputs.add_argument('--candidates');inputs.add_argument('--archive')
parser.add_argument('--dtd');parser.add_argument('--venue',action='append');parser.add_argument('--include-workshops',action='store_true')
parser.add_argument('--through-year',type=int,default=2026);args=parser.parse_args(argv)
collect(args.catalogue,args.out,args.venue,args.candidates,args.archive,args.dtd,args.include_workshops,args.through_year)
if __name__=='__main__':main()
+96
View File
@@ -0,0 +1,96 @@
"""Explicit bibliographic aliases; a directory family alone is not venue evidence."""
import re
# Ordered long/joint names precede their shorter forms.
BOOKS = {
'aaai': [('AAAI/IAAI','AAAI+IAAI',['AAAI','IAAI']),('AAAI','AAAI',[])],
'ijcai': [('IJCAI/ECAI','ECAI+IJCAI',['ECAI','IJCAI']),('IJCAI','IJCAI',[])],
'ecai': [('ECAI','ECAI',[]),('AISB/GI (ECAI)','ECAI',[]),('AISB (ECAI)','ECAI',[])],
'nips': [('NeurIPS Datasets and Benchmarks','NeurIPS',[]),('NeurIPS','NeurIPS',[]),('NIPS','NeurIPS',[])],
'icml': [('ICML','ICML',[]),('ML','ICML',[])], 'iclr':[('ICLR','ICLR',[])],
'colm':[('COLM','COLM',[])], 'colt':[('COLT/EuroCOLT','COLT',[]),('COLT','COLT',[])],
'aistats':[('AISTATS','AISTATS',[])], 'uai':[('UAI','UAI',[])],
'clear2':[('CLeaR','CLeaR',[])],
'acl':[('COLING-ACL','ACL+COLING',['ACL','COLING']),('ACL/IJCNLP','ACL',[]),('ACL','ACL',[])],
'emnlp':[('EMNLP-CoNLL','CoNLL+EMNLP',['CoNLL','EMNLP']),('EMNLP/IJCNLP','EMNLP',[]),('EMNLP','EMNLP',[])],
'naacl':[('HLT/EMNLP','EMNLP',[]),('NAACL-HLT','NAACL',[]),('HLT-NAACL','NAACL',[]),('NAACL','NAACL',[]),('HLT','NAACL',[])],
'eacl':[('EACL','EACL',[])],
'coling':[('LREC/COLING','COLING+LREC',['COLING','LREC']),('COLING-ACL','ACL+COLING',['ACL','COLING']),('COLING','COLING',[])],
'lrec':[('LREC/COLING','COLING+LREC',['COLING','LREC'])],
'conll':[('CoNLL/LLL','CoNLL',[]),('CoNLL','CoNLL',[])],
'cvpr':[('CVPR','CVPR',[])], 'iccv':[('ICCVW','ICCV',[]),('ICCV','ICCV',[])],
'eccv':[('ECCV','ECCV',[])], 'kdd':[('KDD','KDD',[])], 'sigir':[('SIGIR','SIGIR',[])],
'www':[('WWW','WWW',[])], 'wsdm':[('WSDM','WSDM',[])], 'recsys':[('RecSys','RecSys',[])],
'cikm':[('CIKM','CIKM',[])], 'icdm':[('ICDM','ICDM',[])], 'sdm':[('SDM','SDM',[])],
'ecml':[('ECML','ECML',['ECML-PKDD']),('EWSL','EWSL',['ECML-PKDD'])],
'pkdd':[('ECML/PKDD','ECML-PKDD',[]),('ECML PKDD','ECML-PKDD',[]),('PKDD','PKDD',['ECML-PKDD'])],
'icra':[('ICRA','ICRA',[])], 'iros':[('IROS','IROS',[])],
'rss':[('Robotics: Science and Systems','RSS',[])], 'corl':[('CoRL','CoRL',[])],
'aips':[('ICAPS','ICAPS',[]),('AIPS','AIPS',['ICAPS'])], 'icaps':[('ICAPS','ICAPS',[])],
'kr':[('KR','KR',[])], 'atal':[('AAMAS','AAMAS',[])],
'icassp':[('ICASSP','ICASSP',[])],
'interspeech':[('INTERSPEECH','INTERSPEECH',[]),('EUROSPEECH','EUROSPEECH',['INTERSPEECH']),('ICSLP','ICSLP',['INTERSPEECH']),('ECST','ECST',['INTERSPEECH'])],
'ismir':[('ISMIR','ISMIR',[])], 'miccai':[('MICCAI','MICCAI',[])],
'fat':[('FAccT','FAccT',[]),('FAT*','FAccT',[]),('FAT','FAccT',[])],
}
def scope(row,volume,through_year=2026):
fields=row['fields'];books=fields.get('booktitle',[])
if len(books)!=1:return None,'ambiguous_booktitle'
book=books[0];key=row.get('key','');parts=key.split('/')
family=parts[1] if len(parts)>=3 and parts[0]=='conf' else ''
if family not in BOOKS:return None,'outside_target_family'
if not volume:return None,'missing_proceedings'
title=' '.join(volume['fields'].get('title',[]))
if family=='clear2' and 'causal learning and reasoning' not in title.casefold():return None,'ambiguous_venue_name'
matched=None
for base,venue,aliases in BOOKS[family]:
match=re.fullmatch(re.escape(base)+r'(?:\s+\((.*)\)|\s+(.*)|,\s*(Vol\.\s*\d+))?',book,re.I)
if match:
matched=(base,venue,aliases,' '.join(value for value in match.groups() if value));break
if not matched:return None,'unclassified_booktitle'
base,venue,aliases,suffix=matched
volume_family=volume['key'].split('/')[1] if volume['key'].startswith('conf/') else ''
if volume_family!=family and not any(item[1]==venue for item in BOOKS.get(volume_family,[])):
return None,'crossref_venue_conflict'
year_match=re.match(r'conf/[^/]+/((?:19|20)\d{2})',volume['key'])
year_basis='proceedings key, checked against bibliographic year or explicit event year in proceedings title'
if year_match:year=int(year_match[1])
else:
# Old SIGIR/CIKM/IJCAI keys use two digits. Never guess their century.
short=re.fullmatch(r'conf/[^/]+/(\d{2})',volume['key'])
years=set(re.findall(r'\b(?:19|20)\d{2}\b',title))
if not short or len(years)!=1:return None,'unverified_edition_year'
year=int(next(iter(years)))
if year%100!=int(short[1]):return None,'unverified_edition_year'
year_basis='unique explicit event year in proceedings title, corroborated by two-digit proceedings key'
if year>through_year:return None,'future_edition'
bibliographic=fields.get('year',[])
if len(bibliographic)!=1 or not re.fullmatch(r'\d{4}',bibliographic[0]):return None,'ambiguous_bibliographic_year'
if int(bibliographic[0])!=year and not re.search(r'\b'+str(year)+r'\b',title):return None,'unverified_year_difference'
text=(suffix+' '+title).casefold();track='main'
if 'findings' in text:track='findings'
elif 'shared task' in text:track='shared_task'
elif 'student research' in text or 'workshop' in text or 'wokshop' in text or base in ('ICCVW','EWSL'):track='workshop'
elif ('demo track' in text and ('industrial track' in text or 'applied data science track' in text)):track='proceedings_unspecified'
elif 'demonstration' in text or re.search(r'\bdemos?\b',text):track='demo'
elif 'tutorial' in text:track='tutorial'
elif 'industry' in text or 'industrial track' in text or 'applied data science' in text:track='industry'
elif 'position paper' in text:track='position'
elif 'companion' in text:track='companion'
elif 'late-breaking' in text or 'late breaking' in text:track='late_breaking'
elif 'doctoral' in text:track='doctoral'
elif 'challenge' in text or 'competition' in text:track='competition'
elif 'bridge program' in text:track='bridge'
elif 'special interest tracks' in text:track='special_interest'
elif 'alternate track' in text:track='alternate'
elif base=='NeurIPS Datasets and Benchmarks':track='datasets_benchmarks'
elif suffix and not (re.fullmatch(r'(?:\d+|Vol\.\s*\d+|Volume \d+: (?:Long|Short) Papers|Long Papers|Short Papers|Selected Papers|Fall)',suffix,re.I)
or suffix.casefold() in ('poster','posters','oral','spotlight')):return None,'unclassified_track'
elif suffix.casefold() in ('poster','posters') and venue not in ('ICLR','ACL','EMNLP','NAACL','EACL','COLING','CoNLL'):track='poster'
if venue=='AAAI+IAAI':track='proceedings_unspecified'
if venue=='AAMAS' and year<2002:return None,'predecessor_not_aamas'
return {'venue':venue,'venue_aliases':aliases,'year':year,'track':track,'bibliographic_year':int(bibliographic[0]),
'rule':family+':'+base,'proceedings_title':title,'edition_year_basis':year_basis},None
@@ -0,0 +1,125 @@
"""ICML 2026 public submission roots with explicit Rejected_Submission IDs."""
import argparse
import fcntl
import hashlib
import json
from pathlib import Path
import re
import shutil
import tempfile
from paper_atlas.collectors.openreview import date_ms, invitations, public, value
from paper_atlas.collectors.public_openreview import download
from paper_atlas.db.submissions import timestamp
SOURCE = 'openreview_public_archive'
REVISION = '1a1b9d973a8dfbf254f740fb8100a2fcf36757ea'
AS_OF = '2026-08-08T12:03:26.000Z'
URL = 'https://raw.githubusercontent.com/HemantKumar01/ICML-2026-data/' + REVISION + '/data/rejected.json'
BLOB_SHA1 = 'cebdcddffe3eb2145d32d328db89a1626e3f85eb'
BYTES = 1600856
EXPECTED = 214
DOMAIN = 'ICML.cc/2026/Conference'
VENUEID = DOMAIN + '/Rejected_Submission'
def normalize(note):
forum = note.get('id')
if not isinstance(forum, str) or not re.fullmatch(r'[A-Za-z0-9_-]{8,32}', forum) or note.get('forum') != forum:
raise ValueError('Not a public submission root with a real forum identity')
if not public(note) or note.get('ddate'):
raise ValueError('Submission is not explicitly public and live in the archive')
if note.get('domain') != DOMAIN or DOMAIN + '/-/Submission' not in invitations(note):
raise ValueError('Submission is outside the ICML 2026 conference track')
content = note.get('content') or {}
if value(content, 'venueid') != VENUEID:
raise ValueError('Missing explicit platform Rejected_Submission classification')
title, abstract = value(content, 'title'), value(content, 'abstract')
if not isinstance(title, str) or not title.strip():
raise ValueError('Submission lacks a title')
if abstract is not None and not isinstance(abstract, str):
raise ValueError('Submission abstract must be text')
authors = value(content, 'authors', []) or []
if not isinstance(authors, list):
raise ValueError('Unexpected public author format')
authors = [a.strip() for a in authors if isinstance(a, str) and a.strip() and '@' not in a
and not re.fullmatch(r'anonymous(?: authors?)?', a.strip(), re.I)]
tags = []
for field in ('keywords', 'primary_area'):
raw = value(content, field)
terms = raw if isinstance(raw, list) else re.split(r'[,;]', raw) if field == 'keywords' and isinstance(raw, str) else [raw]
tags.extend({'namespace': SOURCE + ':' + field, 'value': term.strip()}
for term in terms if isinstance(term, str) and term.strip())
url = 'https://openreview.net/forum?id=' + forum
return {'source': SOURCE, 'external_id': 'icml/2026/' + forum, 'url': url,
'title': ' '.join(title.split()), 'abstract': abstract.strip() if abstract else None,
'authors': authors, 'identifiers': {'openreview': forum},
'submission_identifiers': [{'scheme': 'openreview', 'value': forum}],
'venue': 'ICML', 'year': 2026, 'track': 'main', 'status': 'rejected',
'raw_decision': VENUEID, 'evidence_url': url, 'observed_at': AS_OF,
'submitted_at': date_ms(note.get('cdate') or note.get('tcdate')), 'tags': tags,
'metadata': {'source_revision': REVISION, 'source_blob_sha1': BLOB_SHA1,
'source_file': URL, 'original_forum': forum, 'license': note.get('license'),
'venue': value(content, 'venue'), 'venueid': VENUEID,
'public_readers_verified': True, 'original_invitations': invitations(note),
'source_note_modified_at': date_ms(note.get('mdate')),
'evidence_kind': 'public_submission_venueid',
'original_decision_signature_verified': False,
'source_snapshot_upper_bound': AS_OF}}
def collect(out, fetcher=None):
fetcher = fetcher or download
out = Path(out)
out.mkdir(parents=True, exist_ok=True)
with (out / 'collect.lock').open('a') as lock:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
if (out / 'public-openreview-inventory.json').exists():
raise ValueError('Completed inventory exists; select a new output directory')
if shutil.disk_usage(out).free < 8 * 1024**3:
raise RuntimeError('Less than 8 GiB free before collection')
data = fetcher(URL, BYTES)
blob = hashlib.sha1(b'blob ' + str(len(data)).encode() + b'\0' + data).hexdigest()
if len(data) != BYTES or blob != BLOB_SHA1:
raise ValueError('Public archive checksum mismatch')
archive = json.loads(data)
notes = archive['notes']
if archive.get('count') != EXPECTED or not isinstance(notes, list) or len(notes) != EXPECTED:
raise ValueError('Public archive inventory count mismatch')
records = [normalize(note) for note in notes]
if len({row['identifiers']['openreview'] for row in records}) != EXPECTED:
raise ValueError('Repeated submission forum in public archive')
records.sort(key=lambda row: row['external_id'])
with tempfile.TemporaryDirectory(prefix='.icml-public-', dir=out) as temporary:
stage = Path(temporary)
path = stage / 'public-openreview-icml-2026-main.jsonl'
path.write_text(''.join(json.dumps(row, ensure_ascii=False) + '\n' for row in records))
manifest = {'source': SOURCE, 'parser_version': 1, 'venue': 'ICML', 'year': 2026,
'track': 'main', 'count': EXPECTED, 'status': 'partial', 'list_url': URL,
'file': str(out / path.name), 'file_sha256': hashlib.sha256(path.read_bytes()).hexdigest(),
'source_revision': REVISION, 'missing_abstract_count': sum(not row['abstract'] for row in records),
'evidence_kind': 'public_submission_venueid',
'note': 'Public opt-in subset; explicit platform venueid, not original signed PC notes.'}
path.with_suffix('.manifest.json').write_text(json.dumps(manifest, indent=2) + '\n')
result = {'source': SOURCE, 'revision': REVISION, 'finished_at': timestamp(),
'input_rows': EXPECTED, 'normalized_records': EXPECTED, 'files': [manifest],
'source_files': [{'url': URL, 'git_blob_sha1': blob, 'input_bytes': len(data)}],
'note': 'Snapshot preserves per-note public visibility and licenses; no reviews, authorids or PDFs retained.'}
inventory = stage / 'public-openreview-inventory.json'
inventory.write_text(json.dumps(result, indent=2) + '\n')
for child in sorted(stage.iterdir(), key=lambda child: child == inventory):
child.replace(out / child.name)
print(json.dumps(result), flush=True)
return result
def main(argv=None, *, prog=None):
parser = argparse.ArgumentParser(prog=prog)
parser.add_argument('--out', required=True)
args = parser.parse_args(argv)
collect(args.out)
if __name__ == '__main__':
main()
+252
View File
@@ -0,0 +1,252 @@
"""Collect explicit public negative labels without inferring unobserved decisions.
PaperCopilot is a curated mirror, not the conference's signed decision feed.
Only metadata is retained; source profiles, reviews and PDFs are never cached.
"""
import argparse
from collections import Counter, defaultdict
import fcntl
import hashlib
import io
import json
from pathlib import Path
import re
import shutil
import tempfile
import urllib.request
from paper_atlas.config import DEFAULT_REGISTRY
from paper_atlas.db.submissions import timestamp
SOURCE = 'papercopilot_openreview'
REVISION = '41ee277825d685bd78e88e48b69da6aead9a69bb'
AS_OF = '2026-10-31T01:25:52.000Z'
REPOSITORY = 'https://github.com/papercopilot/openreview'
RAW_ROOT = 'https://raw.githubusercontent.com/papercopilot/openreview/' + REVISION + '/'
TREE_URL = 'https://api.github.com/repos/papercopilot/openreview/git/trees/' + REVISION + '?recursive=1'
NEGATIVE = {'reject': 'rejected', 'rejected': 'rejected',
'desk reject': 'desk_rejected', 'desk rejected': 'desk_rejected',
'withdrawn': 'withdrawn', 'withdrawal': 'withdrawn'}
TRACKS = {'main': 'main', 'Position': 'position',
'Datasets & Benchmarks': 'datasets_benchmarks'}
WORKSHOP_REJECT_LABELS = {"aim-fm workshop @ neurips'24 reject",
'coginterp @ neurips 2025 reject'}
def download(url, limit):
request = urllib.request.Request(url, headers={'User-Agent': 'PaperAtlas/0.1 (public scholarly metadata)'})
with urllib.request.urlopen(request, timeout=90) as response:
if response.status != 200:
raise ValueError('Expected a complete HTTP 200 response')
result = response.read(limit + 1)
if len(result) > limit:
raise ValueError('Public metadata exceeds byte limit')
return result
def negative_status(raw):
"""A closed mapping: pending, absent, low-score and retracted are not Reject."""
if not isinstance(raw, str):
raise ValueError('Status must be text')
key = ' '.join(raw.casefold().split())
if key in NEGATIVE:
return NEGATIVE[key]
if re.fullmatch(r'(?:icml|neurips) 20\d{2} (?:conference|workshop [a-z0-9 -]+) withdrawn submission', key):
return 'withdrawn'
if key in WORKSHOP_REJECT_LABELS:
return 'rejected'
return None
def normalize(row, family, year, provenance, include_workshops=False):
"""Return a normalized assertion or a reason requiring separate review."""
raw_status = row.get('status', '')
status = negative_status(raw_status)
if status is None:
if re.search(r'\bretract\w*\b', raw_status, re.I):
return None, 'retracted'
if re.search(r'\b(?:reject\w*|withdraw\w*|desk)\b', raw_status, re.I):
return None, 'unmapped_negative_label'
return None, 'not_explicit_negative'
forum = row.get('id')
if not isinstance(forum, str) or not re.fullmatch(r'[A-Za-z0-9_-]{8,32}', forum) or re.match(r'20\d{2}-Oral-', forum):
raise ValueError('Invalid public submission forum ID')
track = row.get('track')
if not isinstance(track, str):
raise ValueError('Track must be explicit text')
normalized_track = TRACKS.get(track)
if normalized_track is None:
if re.fullmatch(r'workshop_[a-z0-9_]+', track) and include_workshops:
normalized_track = track
else:
return None, 'separate_workshop' if track.startswith('workshop') else 'unmapped_track'
prefixed = re.match(r'^(ICML|NeurIPS) (20\d{2}) ', raw_status)
if prefixed and (prefixed[1].casefold() != ('neurips' if family == 'nips' else family) or int(prefixed[2]) != year):
raise ValueError('Status label conflicts with source conference year')
allowed = {v['acronym'].casefold(): v['acronym'] for v in json.loads(DEFAULT_REGISTRY.read_text())['venues']}
venue = allowed.get('neurips' if family == 'nips' else family)
if venue is None:
return None, 'unmapped_venue'
title = row.get('title')
abstract = row.get('abstract')
if not isinstance(title, str) or not title.strip():
raise ValueError('Public submission lacks a title')
if abstract is not None and not isinstance(abstract, str):
raise ValueError('Abstract must be text or null')
author_text = row.get('author') or ''
if not isinstance(author_text, str):
raise ValueError('Unexpected public author format')
authors = [a.strip() for a in author_text.split(';') if a.strip() and '@' not in a
and not re.fullmatch(r'anonymous(?: authors?)?', a.strip(), re.I)]
tags = []
for field in ('keywords', 'primary_area'):
value = row.get(field)
if value is None:
continue
terms = value if isinstance(value, list) else re.split(r'[,;]', value) if field == 'keywords' and isinstance(value, str) else [value]
tags.extend({'namespace': SOURCE + ':' + field, 'value': term.strip()}
for term in terms if isinstance(term, str) and term.strip())
evidence = provenance['source_file'] + '#L' + str(provenance['source_line'])
return {'source': SOURCE, 'external_id': f'{family}/{year}/{forum}',
'url': 'https://openreview.net/forum?id=' + forum,
'title': ' '.join(title.split()), 'abstract': abstract.strip() if abstract else None,
'authors': authors, 'identifiers': {'openreview': forum},
'submission_identifiers': [{'scheme': 'openreview', 'value': forum}],
'venue': venue, 'year': year, 'track': normalized_track,
'status': status, 'raw_decision': raw_status, 'evidence_url': evidence,
'observed_at': AS_OF, 'tags': tags,
'metadata': {**provenance, 'raw_track': track, 'raw_status': raw_status,
'evidence_kind': 'curated_public_status_assertion',
'original_decision_signature_verified': False,
'source_snapshot_upper_bound': AS_OF}}, None
def held_metadata(row, reason, provenance):
# Retractions and unresolved track scopes remain visible without becoming
# invented Reject/Withdrawn events in a schema that cannot represent them.
return {key: row.get(key) for key in ('id', 'title', 'abstract', 'track', 'status')} | {
'reason': reason, 'provenance': provenance}
def collect(out, families=None, years=None, include_workshops=False, fetcher=None):
fetcher = fetcher or download
out = Path(out)
out.mkdir(parents=True, exist_ok=True)
with (out / 'collect.lock').open('a') as lock:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
if (out / 'public-openreview-inventory.json').exists():
raise ValueError('Completed inventory exists; select a new output directory')
if shutil.disk_usage(out).free < 8 * 1024**3:
raise RuntimeError('Less than 8 GiB free before collection')
tree = json.loads(fetcher(TREE_URL, 8 * 1024**2))
if tree.get('truncated') is not False or tree.get('sha') != REVISION:
raise ValueError('Incomplete or unpinned public source tree')
selected = []
for item in tree['tree']:
match = re.fullmatch(r'venues/([a-z0-9]+)/\1((?:19|20)\d{2})\.jsonl', item['path'])
if item['type'] != 'blob' or not match:
continue
family, year = match[1], int(match[2])
if year > 2026 or (families is None and family == 'iclr'):
continue
if families is not None and family not in families or years is not None and year not in years:
continue
selected.append((item, family, year))
if not selected:
raise ValueError('No pinned source files match the request')
if families and set(families) - {family for _, family, _ in selected}:
raise ValueError('Requested venue has no selected source file')
if years and set(years) - {year for _, _, year in selected}:
raise ValueError('Requested year has no selected source file')
return _collect_files(out, selected, include_workshops, fetcher)
def _collect_files(out, selected, include_workshops, fetcher):
summaries = []
groups = defaultdict(list)
global_forums = {}
with tempfile.TemporaryDirectory(prefix='.public-openreview-', dir=out) as temporary:
stage = Path(temporary)
held_path = stage / 'held-records.jsonl'
with held_path.open('w') as held:
for item, family, year in sorted(selected, key=lambda entry: entry[0]['path']):
size = item['size']
if not isinstance(size, int) or not 0 < size <= 96 * 1024**2:
raise ValueError('Unexpected metadata file size')
data = fetcher(RAW_ROOT + item['path'], size)
blob = hashlib.sha1(b'blob ' + str(len(data)).encode() + b'\0' + data).hexdigest()
if len(data) != size or blob != item['sha']:
raise ValueError('Pinned source blob checksum mismatch')
matrix = Counter()
reasons = Counter()
seen = set()
rows = 0
for number, line in enumerate(io.BytesIO(data), 1):
if len(line) > 4 * 1024**2:
raise ValueError('Oversized metadata row')
row = json.loads(line)
forum = row.get('id')
if not isinstance(forum, str) or forum in seen:
raise ValueError('Missing or duplicate source forum ID')
seen.add(forum)
edition = (family, year, row.get('track'))
if forum in global_forums and global_forums[forum] != edition:
raise ValueError('One forum appears in multiple source editions or tracks')
global_forums[forum] = edition
rows += 1
matrix[(row.get('track'), row.get('status'))] += 1
provenance = {'source_revision': REVISION, 'source_blob_sha1': blob,
'source_file': RAW_ROOT + item['path'], 'source_line': number}
record, reason = normalize(row, family, year, provenance, include_workshops)
if record is None:
reasons[reason] += 1
if reason != 'not_explicit_negative':
held.write(json.dumps(held_metadata(row, reason, provenance), ensure_ascii=False) + '\n')
else:
groups[(family, year, record['track'])].append(record)
summary = {'path': item['path'], 'input_bytes': size, 'git_blob_sha1': blob,
'rows': rows, 'excluded_or_held': dict(reasons),
'status_matrix': [{'track': t, 'status': s, 'count': n} for (t, s), n in matrix.items()]}
summaries.append(summary)
print(json.dumps({key: summary[key] for key in ('path', 'rows', 'excluded_or_held')}), flush=True)
files = []
for (family, year, track), records in sorted(groups.items()):
name = f'public-openreview-{family}-{year}-{track}.jsonl'
path = stage / name
path.write_text(''.join(json.dumps(row, ensure_ascii=False) + '\n' for row in records))
manifest = {'source': SOURCE, 'parser_version': 1, 'venue': records[0]['venue'],
'year': year, 'track': track, 'count': len(records), 'status': 'partial',
'file': str(out / name), 'file_sha256': hashlib.sha256(path.read_bytes()).hexdigest(),
'list_url': REPOSITORY + '/tree/' + REVISION, 'source_revision': REVISION,
'missing_abstract_count': sum(not row['abstract'] for row in records),
'evidence_kind': 'curated_public_status_assertion',
'note': 'Public subset only; no population coverage or original decision-signature claim.'}
path.with_suffix('.manifest.json').write_text(json.dumps(manifest, indent=2) + '\n')
files.append(manifest)
result = {'source': SOURCE, 'revision': REVISION, 'finished_at': timestamp(),
'input_rows': sum(item['rows'] for item in summaries), 'source_files': summaries,
'normalized_records': sum(item['count'] for item in files), 'files': files,
'include_workshops': include_workshops, 'held_file': str(out / held_path.name),
'held_file_sha256': hashlib.sha256(held_path.read_bytes()).hexdigest(),
'note': 'Zero explicit negative records in this mirror does not prove no public rejections exist.'}
inventory = stage / 'public-openreview-inventory.json'
inventory.write_text(json.dumps(result, ensure_ascii=False, indent=2) + '\n')
# Publish only after every selected source has passed its pinned hash.
for path in sorted(stage.iterdir(), key=lambda path: path == inventory):
path.replace(out / path.name)
return result
def main(argv=None, *, prog=None):
parser = argparse.ArgumentParser(prog=prog)
parser.add_argument('--out', required=True)
parser.add_argument('--family', action='append', help='Source family, e.g. icml, nips, emnlp')
parser.add_argument('--year', type=int, action='append')
parser.add_argument('--include-workshops', action='store_true')
args = parser.parse_args(argv)
collect(args.out, args.family, args.year, args.include_workshops)
if __name__ == '__main__':
main()
+88 -17
View File
@@ -5,6 +5,8 @@ import json
from pathlib import Path
import re
import time
from datetime import date
from urllib.error import HTTPError
from urllib.parse import parse_qs, unquote, urljoin, urlsplit
from paper_atlas.io.http import fetch
@@ -98,6 +100,14 @@ def discover(source,cache):
return sorted(indexes.values(),key=lambda row:(row['year'],row['venue'],row['track']!='main',row['track']))
def author_names(raw):
raw=(raw or '').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(',')
return [clean(name).removesuffix('*').strip() for name in names if clean(name).removesuffix('*').strip()]
def parse_paper(html,entry,source,venue,year,track,index_url):
nodes=list(Document(html).root.walk());meta=defaultdict(list)
for node in nodes:
@@ -112,11 +122,7 @@ def parse_paper(html,entry,source,venue,year,track,index_url):
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()]
authors=author_names(raw)
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')
@@ -126,35 +132,95 @@ def parse_paper(html,entry,source,venue,year,track,index_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')}}
'metadata':{'citation':dict(meta),'index_title':entry['index_title'],'index_authors':entry.get('index_authors'),
'index_urls':entry.get('index_urls',[index_url])}}
def index_identity(url):
parsed=urlsplit(url)
match=re.fullmatch(r'/(CVPR|ICCV)(20\d{2})(_findings)?(?:\.py)?/?',parsed.path)
return match.groups() if parsed.netloc=='openaccess.thecvf.com' and match else None
def read_index(index,cache):
url=index['index_url'];html=decode_html(fetch(url,cache,32*1024**2,delay=1))
links=set()
for node in Document(html).root.walk():
if node.tag!='a':continue
link=urljoin(url,node.attrs.get('href',''));day=parse_qs(urlsplit(link).query).get('day',[])
if index_identity(link)!=index_identity(url) or index_identity(url) is None or len(day)!=1:continue
if day[0]!='all':
try:valid=date.fromisoformat(day[0]).year==index['year']
except ValueError:valid=False
if not valid:continue
links.add(link)
all_pages=sorted(link for link in links if parse_qs(urlsplit(link).query)['day']==['all'])
day_pages=sorted(links-set(all_pages));index_errors=[]
if len(all_pages)>1:raise ValueError('Ambiguous all-papers index')
pages=[]
if all_pages:
try:pages=[(all_pages[0],decode_html(fetch(all_pages[0],cache,32*1024**2,delay=1)))]
except HTTPError as error:
if error.code!=404 or not day_pages:
error.close();raise
index_errors.append({'url':all_pages[0],'status':404});error.close()
if not pages and day_pages:
pages=[(page,decode_html(fetch(page,cache,32*1024**2,delay=1))) for page in day_pages]
if not pages:pages=[(url,html)]
entries={};raw_count=0
for page,document in pages:
parsed,count=index_entries(document,page);raw_count+=count
for entry in parsed:
entry['index_urls']=[page];old=entries.get(entry['url'])
if old:
for key in ('index_title','index_authors','doi'):
if old.get(key) and entry.get(key) and old[key]!=entry[key]:raise ValueError('Conflicting metadata across official day indexes')
if not old.get(key):old[key]=entry.get(key)
old['index_urls'].append(page)
else:entries[entry['url']]=entry
return list(entries.values()),raw_count,[page for page,_ in pages],index_errors
def index_only_record(entry,source,venue,year,track,index_url,error):
if not entry['index_title'].strip():raise ValueError('Missing title in official index')
return {'source':source,'external_id':urlsplit(entry['url']).path,'url':entry['url'],
'title':entry['index_title'],'abstract':None,'authors':author_names(entry.get('index_authors')),
'identifiers':{'doi':entry['doi']} if entry.get('doi') else {},'venue':venue,'year':year,'track':track,
'status':'accepted','raw_decision':'listed in official proceedings index',
'evidence_url':entry['index_urls'][0],'list_url':index_url,
'metadata':{'index_title':entry['index_title'],'index_authors':entry.get('index_authors'),
'index_urls':entry['index_urls'],'abstract_fetch_error':{'status':error.code,'url':error.url,'message':str(error)}}}
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
index_urls=[url];index_errors=[];missing_pages=[]
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)
entries,raw_count,index_urls,index_errors=read_index(index,cache)
if len(index_urls)==1:url=index_urls[0]
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)
try:record=parse_paper(decode_html(fetch(entry['url'],cache,2*1024**2,delay=1)),entry,source,venue,year,track,url)
except HTTPError as error:
if error.code!=404:
error.close();raise
entry.setdefault('index_urls',[url])
record=index_only_record(entry,source,venue,year,track,url,error)
missing_pages.append(entry['url']);error.close()
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,
manifest={'source':source,'parser_version':2,'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'}
'index_urls':index_urls,'index_errors':index_errors,'missing_abstract_pages':missing_pages,
'list_url':url,'status':'partial' if missing or missing_authors or index_errors else 'collected'}
path.with_suffix('.manifest.json').write_text(json.dumps(manifest,indent=2));print(json.dumps(manifest),flush=True)
return manifest
@@ -165,8 +231,13 @@ def main(argv=None, *, prog=None, source=None):
else:parser.set_defaults(source=source)
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')
parser.add_argument('--retry-catalogue',help='Select only blocked editions from an existing catalogue; write results to --out')
args=parser.parse_args(argv);out=Path(args.out);out.mkdir(parents=True,exist_ok=True)
indexes=discover(args.source,args.cache)
if args.retry_catalogue:
retry=json.loads(Path(args.retry_catalogue).read_text())
indexes=[index for index in indexes if retry.get(f'{index["venue"]}:{index["year"]}:{index["track"]}',{}).get('status')=='blocked']
if not indexes:parser.error('Retry catalogue has no blocked editions for this source')
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')
@@ -181,7 +252,7 @@ def main(argv=None, *, prog=None, source=None):
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)}
catalogue[key]={'status':'blocked','year':index['year'],'venue':index['venue'],'track':index['track'],'index_url':index['index_url'],'error':str(exc),'error_url':getattr(exc,'url',None)}
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))
+9 -4
View File
@@ -88,7 +88,7 @@ class Store:
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()
old = c.execute('SELECT paper_id,title,abstract,retrieved_at,metadata_json 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)
@@ -105,13 +105,17 @@ class Store:
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]))
old_abstract=old[2] or json.loads(old[4]).get('last_known_abstract',{}).get('abstract')
c.execute("UPDATE papers SET title=?,abstract=COALESCE(NULLIF(?,''),abstract),title_key=?,authors_json=CASE WHEN ?='[]' THEN authors_json ELSE ? END WHERE id=? AND title=? AND COALESCE(abstract,'')=COALESCE(?,'')",(title,abstract,key,json.dumps(authors,ensure_ascii=False),json.dumps(authors,ensure_ascii=False),paper,old[1],old_abstract))
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)
if old and not abstract:
previous={'abstract':old[2],'observed_at':old[3]} if old[2] else json.loads(old[4]).get('last_known_abstract')
if previous:metadata['last_known_abstract']=previous
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:
@@ -137,8 +141,9 @@ class Store:
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]
existing=c.execute('SELECT id FROM venues WHERE acronym=? COLLATE NOCASE',(venue,)).fetchone()
if existing:venue_id=existing[0]
else:venue_id=c.execute("INSERT INTO venues(acronym,name,field,venue_type) VALUES(?,?,?,'conference')",(venue,venue,'unclassified')).lastrowid
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))
+8 -5
View File
@@ -10,6 +10,13 @@ from pathlib import Path
from paper_atlas.db.store import Store
def refresh_categories(store,paper,record,categories):
"""Keep category evidence for each arXiv record, including merged papers."""
if not isinstance(categories,str):raise ValueError('arXiv categories must be text')
store.record_tags(paper,record,'arxiv',[{'namespace':'arxiv:category','value':value} for value in categories.split()])
store.conn.execute("DELETE FROM paper_tags WHERE source_record_id=? AND evidence_key='arxiv:categories' AND tag_id IN (SELECT id FROM tags WHERE namespace='arxiv:category')",(record,))
def ingest(db, archive):
store = Store(db)
conn = store.conn
@@ -45,11 +52,7 @@ def ingest(db, archive):
authors = row.get('authors_parsed') or row.get('authors') or []
metadata = {k:v for k,v in row.items() if k not in ('title','abstract','authors','authors_parsed')}
paper,record = store.record('arxiv',external_id,'https://arxiv.org/abs/'+external_id,title,abstract,authors,{'arxiv':external_id,'doi':row.get('doi')},metadata,(row.get('versions') or [{}])[0].get('created'))
conn.execute("DELETE FROM paper_tags WHERE source_record_id=? AND evidence_key='arxiv:categories'", (record,))
for category in (row.get('categories') or '').split():
conn.execute("INSERT OR IGNORE INTO tags(namespace,value) VALUES('arxiv:category',?)",(category,))
tag = conn.execute("SELECT id FROM tags WHERE namespace='arxiv:category' AND value=?",(category,)).fetchone()[0]
conn.execute('INSERT OR IGNORE INTO paper_tags VALUES(?,?,?,?)',(paper,tag,record,'arxiv:categories'))
refresh_categories(store,paper,record,row.get('categories') or '')
if line_number % 500 == 0:
if shutil.disk_usage(Path(db).parent).free < 4*1024**3:
raise RuntimeError('Stopped before exhausting disk: less than 4 GiB free')
+74
View File
@@ -0,0 +1,74 @@
"""Rebuild per-record arXiv category evidence from retained source metadata."""
import argparse
from contextlib import closing
import fcntl
import json
from pathlib import Path
import shutil
import sqlite3
from paper_atlas.db.store import Store
from paper_atlas.db.submissions import timestamp
from paper_atlas.ingestion.arxiv import refresh_categories
def save(path,state):
part=path.with_suffix('.part');part.write_text(json.dumps(state,indent=2)+'\n');part.replace(path)
def repair(db,out,resume=False,batch_size=1000):
db=Path(db).resolve();out=Path(out).resolve()
if not db.is_file():raise ValueError('Existing database required')
if batch_size<1 or batch_size>10000:raise ValueError('Batch size must be between 1 and 10000')
out.parent.mkdir(parents=True,exist_ok=True)
with Path(str(db)+'.maintenance.lock').open('a') as lock:
fcntl.flock(lock,fcntl.LOCK_EX|fcntl.LOCK_NB)
with closing(sqlite3.connect(db.as_uri()+'?mode=ro',uri=True)) as c:
source=c.execute("SELECT id FROM sources WHERE name='arxiv'").fetchone()
if not source:raise ValueError('No imported arXiv source')
total,maximum=c.execute('SELECT count(*),COALESCE(max(id),0) FROM source_records WHERE source_id=?',source).fetchone()
scope={'db':str(db),'source_id':source[0],'source_record_count':total,'maximum_source_record_id':maximum}
if resume:
state=json.loads(out.read_text())
if any(state.get(key)!=value for key,value in scope.items()):raise ValueError('Repair scope changed since checkpoint')
if state['status']=='complete':raise ValueError('Completed report exists; use a new report for a fresh repair')
else:
if out.exists():raise ValueError('Repair report exists; inspect it or explicitly resume')
state={**scope,'started_at':timestamp(),'last_record_id':0,'records_refreshed':0}
if shutil.disk_usage(db.parent).free<8*1024**3:raise RuntimeError('Less than 8 GiB free before repair')
state.update(status='running',error=None);save(out,state)
c=None
try:
store=Store(db);c=store.conn
while True:
rows=c.execute('SELECT id,paper_id,metadata_json FROM source_records WHERE source_id=? AND id>? AND id<=? ORDER BY id LIMIT ?',(source[0],state['last_record_id'],maximum,batch_size)).fetchall()
if not rows:break
for record,paper,payload in rows:
categories=json.loads(payload).get('categories','')
if categories is None:categories=''
refresh_categories(store,paper,record,categories)
c.commit()
state.update(last_record_id=rows[-1][0],records_refreshed=state['records_refreshed']+len(rows));save(out,state)
if state['records_refreshed']%10000<len(rows):print(json.dumps(state),flush=True)
if shutil.disk_usage(db.parent).free<8*1024**3:raise RuntimeError('Less than 8 GiB free during repair')
if state['records_refreshed']!=total:raise ValueError('Repaired count differs from fixed source inventory')
current=c.execute('SELECT count(*),COALESCE(max(id),0) FROM source_records WHERE source_id=?',source).fetchone()
if current!=(total,maximum):raise ValueError('arXiv source inventory changed during repair')
legacy=c.execute("SELECT count(*) FROM paper_tags pt JOIN source_records r ON r.id=pt.source_record_id JOIN tags t ON t.id=pt.tag_id WHERE r.source_id=? AND t.namespace='arxiv:category' AND pt.evidence_key='arxiv:categories'",source).fetchone()[0]
if legacy:raise ValueError('Legacy category evidence remains after repair')
state.update(status='complete',finished_at=timestamp(),legacy_category_evidence_remaining=legacy);save(out,state)
except BaseException as error:
if c is not None:c.rollback()
state.update(status='failed',error=str(error));save(out,state);raise
finally:
if c is not None:c.close()
print(json.dumps(state),flush=True);return state
def main(argv=None,*,prog=None):
parser=argparse.ArgumentParser(prog=prog);parser.add_argument('--db',required=True);parser.add_argument('--out',required=True)
parser.add_argument('--resume',action='store_true');parser.add_argument('--batch-size',type=int,default=1000);args=parser.parse_args(argv)
repair(args.db,args.out,args.resume,args.batch_size)
if __name__=='__main__':main()
+1 -1
View File
@@ -36,7 +36,7 @@ def backfill(db,files):
if any(row.get(key)!=manifest.get(key) for key in ('source','venue','year','track')):
raise ValueError('Evidence row differs from edition manifest')
record=c.execute('SELECT r.id,r.paper_id,r.metadata_json FROM source_records r JOIN sources s ON s.id=r.source_id WHERE s.name=? AND r.external_id=?',(row['source'],row['external_id'])).fetchone()
edition=c.execute('SELECT e.id FROM editions e JOIN venues v ON v.id=e.venue_id WHERE v.acronym=? AND e.year=? AND e.track=?',(row['venue'],row['year'],row['track'])).fetchone()
edition=c.execute('SELECT e.id FROM editions e JOIN venues v ON v.id=e.venue_id WHERE v.acronym=? COLLATE NOCASE AND e.year=? AND e.track=?',(row['venue'],row['year'],row['track'])).fetchone()
if not record or not edition:raise ValueError('Evidence backfill requires an already imported record and edition')
if not c.execute('SELECT 1 FROM submission_sources ss JOIN submissions s ON s.id=ss.submission_id WHERE ss.source_record_id=? AND ss.source_submission_id=? AND s.edition_id=?',(record[0],row['external_id'],edition[0])).fetchone():
raise ValueError('No existing submission to backfill')
+27 -8
View File
@@ -5,6 +5,22 @@ import sqlite3
from pathlib import Path
LATEST_DECISION = '''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)
)'''
ACCEPTED_PAPERS = "SELECT s.paper_id FROM submissions s JOIN decisions d ON d.submission_id=s.id WHERE d.status='accepted' AND "+LATEST_DECISION
def known_acceptances(conn,paper):
return [dict(row) for row in conn.execute('''SELECT s.id AS submission_id,v.acronym AS venue,e.year,e.track,
d.id AS decision_id,d.decision_date,d.observed_at,d.evidence_url
FROM submissions s JOIN editions e ON e.id=s.edition_id JOIN venues v ON v.id=e.venue_id
JOIN decisions d ON d.submission_id=s.id WHERE s.paper_id=? AND d.status='accepted' AND '''+LATEST_DECISION+
' ORDER BY e.year,v.acronym,e.track,s.id',(paper,))]
def main(argv=None, *, prog=None):
parser = argparse.ArgumentParser(prog=prog)
parser.add_argument('--db', required=True)
@@ -15,6 +31,7 @@ def main(argv=None, *, prog=None):
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('--outcome',choices=['accepted','no_known_acceptance'],help='跨会议的已知录用情况;未查到录用不等于从未录用')
parser.add_argument('--limit', type=int, default=20)
parser.add_argument('--stats', action='store_true')
args = parser.parse_args(argv)
@@ -25,6 +42,10 @@ def main(argv=None, *, prog=None):
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')]
accepted=conn.execute('SELECT count(DISTINCT paper_id) FROM ('+ACCEPTED_PAPERS+')').fetchone()[0]
result['papers_with_known_acceptance']=accepted
result['papers_without_known_acceptance']=result['papers']-accepted
result['outcome_scope']='Only imported evidence is known. No known acceptance is not proof that a paper was never accepted.'
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:
@@ -36,6 +57,8 @@ def main(argv=None, *, prog=None):
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.outcome:
where.append('p.id '+('IN' if args.outcome=='accepted' else 'NOT IN')+' ('+ACCEPTED_PAPERS+')')
if args.venue or args.year or args.status or args.track:
clause = ['1=1']
if args.venue:
@@ -48,14 +71,7 @@ def main(argv=None, *, prog=None):
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)
)
)'''
latest='EXISTS (SELECT 1 FROM decisions d WHERE d.submission_id=s.id AND d.status=? AND '+LATEST_DECISION+')'
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 ?'
@@ -65,6 +81,8 @@ def main(argv=None, *, prog=None):
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'],))]
paper['known_acceptances']=known_acceptances(conn,row['id'])
paper['publication_outcome']='accepted' if paper['known_acceptances'] else 'no_known_acceptance'
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'],))]
@@ -77,6 +95,7 @@ def main(argv=None, *, prog=None):
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)
submission['latest_status']=submission['decisions'][-1]['status'] if submission['decisions'] else 'unknown'
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)
+44
View File
@@ -0,0 +1,44 @@
from types import SimpleNamespace
from unittest.mock import patch
import json
from pathlib import Path
import sqlite3
from contextlib import closing
import tempfile
import unittest
import zipfile
from paper_atlas.audits.arxiv import audit,MEMBER
class ArxivSourceAuditTests(unittest.TestCase):
def setUp(self):
self.temp=tempfile.TemporaryDirectory();self.addCleanup(self.temp.cleanup);self.root=Path(self.temp.name)
self.archive=self.root/'metadata.zip';self.out=self.root/'audit.json'
disk=patch('paper_atlas.audits.arxiv.shutil.disk_usage',return_value=SimpleNamespace(free=16*1024**3));disk.start();self.addCleanup(disk.stop)
def write(self,body):
with zipfile.ZipFile(self.archive,'w',compression=zipfile.ZIP_DEFLATED) as z:z.writestr(MEMBER,body)
def test_repeated_ids_and_payload_changes_are_distinct_from_unique_papers(self):
one={'id':'2601.00001','title':'A paper','abstract':'First text'};updated={**one,'abstract':'Revised text'}
two={'id':'2601.00002','title':'Another paper','abstract':''}
self.write('\n'.join(json.dumps(row) for row in (one,updated,updated,two)))
result=audit(self.archive,self.out)
self.assertEqual((result['raw_rows'],result['unique_external_ids'],result['extra_rows_for_repeated_ids']),(4,2,2))
self.assertTrue(result['zip_eof_crc_verified']);self.assertEqual(result['missing_abstract_rows'],1)
duplicate=result['duplicate_ids'][0];self.assertEqual(duplicate['occurrences'],3)
self.assertTrue(duplicate['has_payload_variants']);self.assertEqual([v['occurrences'] for v in duplicate['variants']],[1,2])
with closing(sqlite3.connect(result['id_index'])) as c:self.assertEqual(c.execute('SELECT count(*) FROM raw_ids').fetchone()[0],2)
with self.assertRaisesRegex(ValueError,'output exists'):audit(self.archive,self.out)
def test_invalid_or_unverified_input_never_publishes_audit_outputs(self):
self.write('{"id":"2601.00001","title":"Valid"}\nnot JSON')
with self.assertRaises(json.JSONDecodeError):audit(self.archive,self.out)
self.assertFalse(self.out.exists());self.assertFalse(self.out.with_suffix('.ids.sqlite').exists())
self.write('{"id":"2601.00001","title":"Valid"}')
with self.assertRaisesRegex(ValueError,'checksum mismatch'):audit(self.archive,self.out,'0'*64)
self.assertFalse(self.out.exists())
if __name__=='__main__':unittest.main()
+49
View File
@@ -0,0 +1,49 @@
from contextlib import closing
import json
from pathlib import Path
import sqlite3
import tempfile
from types import SimpleNamespace
import unittest
from unittest.mock import patch
from paper_atlas.db.store import Store
from paper_atlas.ingestion.arxiv_tags import repair
class ArxivTagRepairTests(unittest.TestCase):
def setUp(self):
self.temp=tempfile.TemporaryDirectory();self.addCleanup(self.temp.cleanup);self.root=Path(self.temp.name)
self.db=self.root/'papers.sqlite';self.out=self.root/'repair.json'
disk=patch('paper_atlas.ingestion.arxiv_tags.shutil.disk_usage',return_value=SimpleNamespace(free=16*1024**3));disk.start();self.addCleanup(disk.stop)
store=Store(self.db)
for key in ('one','two'):
paper,record=store.record('arxiv',key,'https://arxiv.org/abs/'+key,'Same title','AB',[],{'doi':'10.1234/same'},{'categories':'cs.AI cs.LG'})
for category in ('cs.AI','cs.LG'):
store.conn.execute("INSERT OR IGNORE INTO tags(namespace,value) VALUES('arxiv:category',?)",(category,))
tag=store.conn.execute("SELECT id FROM tags WHERE namespace='arxiv:category' AND value=?",(category,)).fetchone()[0]
store.conn.execute("INSERT OR IGNORE INTO paper_tags VALUES(?,?,?,'arxiv:categories')",(paper,tag,record))
tag=store.conn.execute("INSERT INTO tags(namespace,value) VALUES('custom:topic','cs.AI')").lastrowid
store.conn.execute("INSERT INTO paper_tags VALUES(?,?,NULL,'manual:1')",(paper,tag));store.conn.commit();store.conn.close()
def test_restore_collided_evidence_and_preserve_custom_tags_idempotently(self):
result=repair(self.db,self.out,batch_size=1)
self.assertEqual(result['records_refreshed'],2);self.assertEqual(result['status'],'complete')
with closing(sqlite3.connect(self.db)) as c:
self.assertEqual(c.execute('SELECT count(*) FROM paper_tags').fetchone()[0],5)
self.assertEqual(c.execute('SELECT count(DISTINCT source_record_id) FROM paper_tags').fetchone()[0],2)
self.assertEqual(c.execute("SELECT count(*) FROM paper_tags WHERE evidence_key='manual:1'").fetchone()[0],1)
repair(self.db,self.root/'again.json')
with closing(sqlite3.connect(self.db)) as c:self.assertEqual(c.execute('SELECT count(*) FROM paper_tags').fetchone()[0],5)
def test_failed_batch_resumes_only_after_last_committed_record(self):
with closing(sqlite3.connect(self.db)) as c,c:c.execute("UPDATE source_records SET metadata_json=? WHERE external_id='two'",(json.dumps({'categories':['invalid']}),))
with self.assertRaisesRegex(ValueError,'categories must be text'):repair(self.db,self.out,batch_size=1)
state=json.loads(self.out.read_text());self.assertEqual(state['records_refreshed'],1);self.assertEqual(state['status'],'failed')
with closing(sqlite3.connect(self.db)) as c,c:c.execute("UPDATE source_records SET metadata_json=? WHERE external_id='two'",(json.dumps({'categories':'cs.AI cs.LG'}),))
result=repair(self.db,self.out,resume=True,batch_size=1)
self.assertEqual(result['records_refreshed'],2);self.assertEqual(result['status'],'complete')
with closing(sqlite3.connect(self.db)) as c:self.assertEqual(c.execute('SELECT count(*) FROM paper_tags').fetchone()[0],5)
if __name__=='__main__':unittest.main()
+145
View File
@@ -0,0 +1,145 @@
import hashlib
import json
from contextlib import closing
from pathlib import Path
import sqlite3
import tempfile
from types import SimpleNamespace
import unittest
from unittest.mock import patch
from paper_atlas.collectors.dblp import XML_SHA
from paper_atlas.collectors.dblp_records import collect,identifiers,normalize_row,material_reason
from paper_atlas.collectors.dblp_scope import scope
from paper_atlas.db.store import Store
from paper_atlas.ingestion.records import ingest_file
class DblpRecordTests(unittest.TestCase):
def setUp(self):
# Tiny fixtures may live on a small tmpfs; production reserve stays 8 GiB.
budget=patch('paper_atlas.collectors.dblp_records.shutil.disk_usage',return_value=SimpleNamespace(free=16*1024**3))
budget.start();self.addCleanup(budget.stop)
def row(self,family='aaai',book='AAAI',year=2024,key='one'):
return {'record_type':'inproceedings','key':f'conf/{family}/{key}','publtype':None,
'fields':{'title':['A research publication'],'author':['Jane Doe 0001','John Smith'],
'booktitle':[book],'year':[str(year)],'crossref':[f'conf/{family}/{year}'],'ee':['https://doi.org/10.1234/'+key]}}
def volume(self,family,year,title=None):
return {'record_type':'proceedings','key':f'conf/{family}/{year}','fields':{'title':[title or f'{family.upper()} {year}'],'year':[str(year)]}}
def test_real_directory_aliases_do_not_confuse_other_conferences(self):
row=self.row('clear2','CLeaR');volume=self.volume('clear2',2024,'Causal Learning and Reasoning, 2024')
self.assertEqual(scope(row,volume)[0]['venue'],'CLeaR')
self.assertIsNone(scope(self.row('clear','CLEAR'),self.volume('clear',2024))[0])
self.assertIsNone(scope(self.row('aamas','Adaptive Agents and Multi-Agent Systems'),self.volume('aamas',2024))[0])
self.assertEqual(scope(self.row('atal','AAMAS'),self.volume('atal',2024))[0]['venue'],'AAMAS')
self.assertEqual(scope(self.row('aips','ICAPS'),self.volume('aips',2024))[0]['venue'],'ICAPS')
def test_conference_year_is_distinct_from_publication_year(self):
row=self.row('nips','NIPS',1988);row['fields']['crossref']=['conf/nips/1987']
volume=self.volume('nips',1987,'NIPS Conference, Denver, 1987; published 1988')
result,_=scope(row,volume);self.assertEqual(result['year'],1987);self.assertEqual(result['bibliographic_year'],1988)
volume['fields']['title']=['NIPS collected papers']
self.assertEqual(scope(row,volume)[1],'unverified_year_difference')
def test_joint_events_and_tracks_are_explicit(self):
result,_=scope(self.row('ijcai','IJCAI/ECAI',2022),self.volume('ijcai',2022))
self.assertEqual(result['venue'],'ECAI+IJCAI');self.assertEqual(result['venue_aliases'],['ECAI','IJCAI'])
result,_=scope(self.row('coling','LREC/COLING'),self.volume('coling',2024))
self.assertEqual(result['venue'],'COLING+LREC')
for book,track in [('ACL (Findings)','findings'),('ACL (Student Research Workshop)','workshop'),('ACL (System Demonstrations)','demo')]:
self.assertEqual(scope(self.row('acl',book),self.volume('acl',2024))[0]['track'],track)
for suffix,track in [('Special interest tracks and posters','special_interest'),('Alternate Track Papers & Posters','alternate')]:
self.assertEqual(scope(self.row('www','WWW ('+suffix+')'),self.volume('www',2024))[0]['track'],track)
volume=self.volume('pkdd',2026,'ECML PKDD 2026, Applied Data Science Track, Demo Track and Industrial Track')
self.assertEqual(scope(self.row('pkdd','ECML PKDD (10)',2026),volume)[0]['track'],'proceedings_unspecified')
def test_short_year_keys_require_a_unique_explicit_year_not_a_guessed_century(self):
row=self.row('sigir','SIGIR',1980);volume=self.volume('sigir',1980,'Information Retrieval Research, Cambridge, June 1980')
volume['key']='conf/sigir/80';volume['fields']['year']=['1981'];row['fields']['crossref']=[volume['key']]
edition,_=scope(row,volume);self.assertEqual(edition['year'],1980)
self.assertIn('two-digit',edition['edition_year_basis'])
volume['fields']['title']=['SIGIR 1980, published 1981']
self.assertEqual(scope(row,volume)[1],'unverified_edition_year')
volume['fields']['title']=['SIGIR 1989']
self.assertEqual(scope(row,volume)[1],'unverified_edition_year')
def test_crossref_mismatch_and_future_editions_are_held_for_review(self):
self.assertEqual(scope(self.row(),self.volume('kdd',2024))[1],'crossref_venue_conflict')
self.assertEqual(scope(self.row(year=2027),self.volume('aaai',2027))[1],'future_edition')
def test_only_own_identifier_links_are_used_and_ambiguity_is_preserved(self):
own,ambiguous=identifiers({'ee':['https://doi.org/10.1234/published','https://doi.org/10.48550/arXiv.2401.00001','https://openreview.net/forum?id=realForum'],
'cite':['https://doi.org/10.9999/reference']})
self.assertEqual(own,{'doi':'10.1234/published','arxiv':'2401.00001','openreview':'realForum'});self.assertFalse(ambiguous)
own,ambiguous=identifiers({'ee':['https://doi.org/10.1234/a','https://doi.org/10.1234/b','https://openreview.net/forum?id=2026-Oral--99-placeholder']})
self.assertEqual(own,{});self.assertEqual(len(ambiguous['doi']),2)
def test_frontmatter_and_anonymous_event_headers_are_not_paper_acceptances(self):
for title in ('Title, Preface.','Title, Conference Committee, Table of Contents, Author Index.','Front Matter.','Backmatter.','Foreword.'):
self.assertEqual(material_reason(title,['An Editor'],'conf/sigir/C78',1978,'main'),'frontmatter')
for title in ('Sponsors.','IJCAI-09 Awards and Distinguished Papers.','2013 Conference Organization.','Area Chairs and Program Committee.','Welcome to ICDM 2005.','Workshop Summaries.'):
self.assertIsNotNone(material_reason(title,[],'conf/ijcai/X09',2009,'main'))
self.assertEqual(material_reason("Wiki Workshop Chairs' Welcome.",['An Organizer'],'conf/www/X19t',2019,'companion'),'frontmatter')
self.assertEqual(material_reason('Attention - from Neuroscience to the Web and Wellbeing.',[],'conf/www/X19b',2019,'companion'),'unverified_proceedings_material')
row=self.row();row['fields']['title']=['Sponsors.'];row['fields']['author']=[]
self.assertEqual(normalize_row(row,{'conf/aaai/2024':self.volume('aaai',2024)},{'AAAI'})[1],'proceedings_heading')
def test_missing_authors_alone_do_not_remove_real_research_records(self):
self.assertIsNone(material_reason('The Onomastica interlanguage pronunciation lexicon.',[],'conf/interspeech/X95',1995,'main'))
self.assertIsNone(material_reason('Learning Models of Relational Stochastic Processes.',[],'conf/ecml/SanghaiDW05',2005,'main'))
self.assertIsNone(material_reason('Sponsors.', ['A Researcher'],'conf/aaai/research',2024,'main'))
def fixture(self,root,rows):
catalogue=root/'dblp-catalogue.sqlite';volume=self.volume('aaai',2024)
with closing(sqlite3.connect(catalogue)) as c, c:
c.execute('CREATE TABLE proceedings(key TEXT PRIMARY KEY,metadata_json TEXT)')
c.execute('INSERT INTO proceedings VALUES(?,?)',(volume['key'],json.dumps(volume)))
candidates=root/'candidates.jsonl';candidates.write_text(''.join(json.dumps(row)+'\n' for row in rows))
manifest={'snapshot_sha256':XML_SHA,'candidate_sha256':hashlib.sha256(candidates.read_bytes()).hexdigest(),
'candidate_records':len(rows),'record_types':{'proceedings':1,'inproceedings':len(rows)}}
catalogue.with_name('dblp-ai-candidates.manifest.json').write_text(json.dumps(manifest))
return catalogue,candidates
def test_normalized_files_import_with_unknown_abstracts_and_no_invented_retracted_acceptance(self):
with tempfile.TemporaryDirectory() as tmp:
root=Path(tmp);withdrawn=self.row(key='two');withdrawn['publtype']='withdrawn'
catalogue,candidates=self.fixture(root,[self.row(),withdrawn,self.row(book='AAAI Workshops',key='workshop')])
result=collect(catalogue,root/'out',['AAAI'],candidates=candidates)
self.assertEqual(result['normalized_records'],2);self.assertEqual(result['excluded_by_reason']['separate_workshop'],1)
manifest=result['files'][0];self.assertEqual(manifest['missing_abstract_count'],2)
rows=[json.loads(line) for line in Path(manifest['file']).read_text().splitlines()]
self.assertEqual(rows[0]['authors'],['Jane Doe','John Smith']);self.assertIsNone(rows[0]['abstract'])
self.assertEqual(rows[1]['status'],'unknown');self.assertEqual(rows[1]['decision_events'],[])
db=root/'papers.sqlite';ingest_file(db,manifest['file'])
with closing(sqlite3.connect(db)) as c:self.assertEqual(c.execute('SELECT count(*) FROM decisions').fetchone()[0],1)
def test_checksum_failure_and_duplicate_keys_do_not_publish_files(self):
with tempfile.TemporaryDirectory() as tmp:
root=Path(tmp);catalogue,candidates=self.fixture(root,[self.row(),self.row()])
with self.assertRaisesRegex(ValueError,'Repeated DBLP'):collect(catalogue,root/'out',['AAAI'],candidates=candidates)
self.assertFalse(list((root/'out').glob('*.jsonl')))
candidates.write_text('tampered')
with self.assertRaisesRegex(ValueError,'checksum mismatch'):collect(catalogue,root/'out',['AAAI'],candidates=candidates)
def test_case_variants_use_one_conference_edition_and_submission(self):
with tempfile.TemporaryDirectory() as tmp:
store=Store(Path(tmp)/'papers.sqlite');self.addCleanup(store.conn.close)
paper,one=store.record('pmlr','one','https://example.org/one','A paper','Abstract',[],{'doi':'10.1234/one'})
store.decision(paper,one,'CORL',2024,'main','one','https://example.org/one','accepted','Accept',identities=[('publication_doi','10.1234/one')])
same,two=store.record('dblp','two','https://example.org/two','A paper',None,[],{'doi':'10.1234/one'})
store.decision(same,two,'CoRL',2024,'main','two','https://example.org/two','accepted','Published',identities=[('publication_doi','10.1234/one')])
self.assertEqual(store.conn.execute('SELECT count(*) FROM venues').fetchone()[0],1)
self.assertEqual(store.conn.execute('SELECT count(*) FROM submissions').fetchone()[0],1)
def test_production_disk_reserve_stops_before_publishing(self):
with tempfile.TemporaryDirectory() as tmp:
root=Path(tmp);catalogue,candidates=self.fixture(root,[self.row()])
with patch('paper_atlas.collectors.dblp_records.shutil.disk_usage',return_value=SimpleNamespace(free=1024)):
with self.assertRaisesRegex(RuntimeError,'8 GiB'):collect(catalogue,root/'out',['AAAI'],candidates=candidates)
self.assertFalse(list((root/'out').glob('*.jsonl')))
if __name__=='__main__':unittest.main()
+86
View File
@@ -0,0 +1,86 @@
import hashlib
import json
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
from paper_atlas.collectors import icml_public_archive as archive
from paper_atlas.audits.public_openreview import audit
from paper_atlas.db.store import Store
from paper_atlas.ingestion.records import ingest_file
class ICMLPublicArchiveTests(unittest.TestCase):
def note(self, **changes):
return {'id': 'PublicRoot1', 'forum': 'PublicRoot1', 'readers': ['everyone'],
'domain': archive.DOMAIN, 'invitations': [archive.DOMAIN + '/-/Submission'],
'license': 'CC BY 4.0', 'cdate': 1769251630136,
'content': {'title': {'value': 'A public rejected submission'},
'abstract': {'value': 'Public abstract'},
'authors': {'value': ['Anonymous', 'Jane Doe']},
'authorids': {'value': ['private@example.org']},
'venue': {'value': 'Submitted to ICML 2026'},
'venueid': {'value': archive.VENUEID},
'primary_area': {'value': 'learning'},
'pdf': {'value': '/private.pdf'}},
'details': {'reviews': ['not retained']}, **changes}
def test_explicit_platform_classification_requires_public_root_and_correct_scope(self):
original = self.note()
normalized = archive.normalize(original)
self.assertEqual(normalized['status'], 'rejected')
self.assertEqual(normalized['raw_decision'], archive.VENUEID)
self.assertEqual(normalized['metadata']['license'], 'CC BY 4.0')
self.assertEqual(normalized['metadata']['evidence_kind'], 'public_submission_venueid')
self.assertFalse(normalized['metadata']['original_decision_signature_verified'])
self.assertNotIn('private@example.org', json.dumps(normalized))
self.assertNotIn('not retained', json.dumps(normalized))
for changes in ({'readers': ['authors']}, {'forum': 'AnotherRoot'}, {'ddate': 1000},
{'domain': 'ICML.cc/2026/Position_Papers'}, {'invitations': []}):
with self.subTest(changes=changes), self.assertRaises(ValueError):
archive.normalize(self.note(**changes))
for venueid in (None, 'ICML.cc/2026/Conference/Submission', 'ICML.cc/2026/Conference/Accepted_Submission'):
candidate = self.note()
candidate['content']['venueid'] = {'value': venueid}
with self.assertRaisesRegex(ValueError, 'classification'):
archive.normalize(candidate)
def collect_fixture(self, out, notes, count=None, corrupt=False):
data = json.dumps({'count': len(notes) if count is None else count, 'notes': notes}).encode()
blob = hashlib.sha1(b'blob ' + str(len(data)).encode() + b'\0' + data).hexdigest()
with patch.multiple(archive, BYTES=len(data), BLOB_SHA1=blob, EXPECTED=len(notes)), \
patch.object(archive.shutil, 'disk_usage', return_value=type('Space', (), {'free': 20 * 1024**3})()):
return archive.collect(out, fetcher=lambda url, limit: data + b'x' if corrupt else data)
def test_verified_archive_roundtrips_through_preflight_and_idempotent_import(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
result = self.collect_fixture(root / 'source', [self.note()])
db = root / 'db.sqlite'
Store(db).conn.close()
report = audit(db, root / 'source/public-openreview-inventory.json', root / 'audit.json')
self.assertEqual(report['status'], 'ready_for_serial_import')
self.assertEqual(report['source'], archive.SOURCE)
self.assertEqual(result['files'][0]['status'], 'partial')
for _ in range(2):
ingest_file(db, report['files'][0])
store = Store(db)
try:
self.assertEqual(store.conn.execute('SELECT count(*) FROM submissions').fetchone()[0], 1)
self.assertEqual(store.conn.execute('SELECT status FROM decisions').fetchall(), [('rejected',)])
finally:
store.conn.close()
def test_bad_hash_count_or_duplicate_identity_publishes_nothing(self):
for kwargs in ({'corrupt': True}, {'count': 99}, {}):
notes = [self.note(), self.note()] if not kwargs else [self.note()]
with self.subTest(kwargs=kwargs), tempfile.TemporaryDirectory() as tmp:
with self.assertRaises(ValueError):
self.collect_fixture(tmp, notes, **kwargs)
self.assertEqual(list(Path(tmp).glob('*.jsonl')), [])
self.assertFalse((Path(tmp) / 'public-openreview-inventory.json').exists())
if __name__ == '__main__':
unittest.main()
+24
View File
@@ -7,6 +7,8 @@ import zipfile
from pathlib import Path
from paper_atlas.ingestion.arxiv import ingest
from paper_atlas.db.store import Store
from paper_atlas.ingestion.arxiv import refresh_categories
class ImportTests(unittest.TestCase):
@@ -53,6 +55,28 @@ class ImportTests(unittest.TestCase):
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)
def test_merged_records_retain_separate_category_evidence_across_refresh(self):
one=self.row('2601.00001','10.0000/example');two=self.row('2601.00002','10.0000/example')
self.write([one,two]);ingest(self.db,self.archive)
with closing(sqlite3.connect(self.db)) as c:
self.assertEqual(c.execute('SELECT count(*) FROM paper_tags').fetchone()[0],4)
self.assertEqual(c.execute('SELECT count(DISTINCT source_record_id) FROM paper_tags').fetchone()[0],2)
one['categories']='cs.AI';two['categories']='cs.LG';self.write([one,two]);ingest(self.db,self.archive)
with closing(sqlite3.connect(self.db)) as c:
actual=c.execute('SELECT r.external_id,t.value FROM paper_tags pt JOIN source_records r ON r.id=pt.source_record_id JOIN tags t ON t.id=pt.tag_id ORDER BY r.external_id').fetchall()
self.assertEqual(actual,[('2601.00001','cs.AI'),('2601.00002','cs.LG')])
def test_migrate_legacy_categories_without_removing_custom_evidence(self):
store=Store(self.db);self.addCleanup(store.conn.close)
paper,record=store.record('arxiv','2601.00001','https://arxiv.org/abs/2601.00001','A title','AB',[])
for namespace,key in [('arxiv:category','arxiv:categories'),('custom:topic','manual:1')]:
tag=store.conn.execute('INSERT INTO tags(namespace,value) VALUES(?,?)',(namespace,'cs.AI')).lastrowid
store.conn.execute('INSERT INTO paper_tags VALUES(?,?,?,?)',(paper,tag,record,key))
refresh_categories(store,paper,record,'cs.AI cs.LG');refresh_categories(store,paper,record,'cs.AI cs.LG')
self.assertEqual(store.conn.execute('SELECT count(*) FROM paper_tags').fetchone()[0],3)
self.assertEqual(store.conn.execute("SELECT count(*) FROM paper_tags WHERE evidence_key='arxiv:categories'").fetchone()[0],0)
self.assertEqual(store.conn.execute("SELECT count(*) FROM paper_tags WHERE evidence_key='manual:1'").fetchone()[0],1)
if __name__ == '__main__':
unittest.main()
+11
View File
@@ -9,6 +9,17 @@ from paper_atlas.ingestion.records import ingest_file
class StoreTests(unittest.TestCase):
def test_partial_source_refresh_preserves_known_abstract_and_can_later_restore_it(self):
paper,record=self.store.record('cvf','paper','https://example.org/paper','Known title','Known abstract',['Jane Doe'])
self.store.record('cvf','paper','https://example.org/paper','Known title',None,[],metadata={'abstract_fetch_error':{'status':404}})
self.store.record('cvf','paper','https://example.org/paper','Known title',None,[],metadata={'abstract_fetch_error':{'status':404}})
c=self.store.conn
self.assertEqual(c.execute('SELECT abstract,authors_json FROM papers').fetchone(),('Known abstract','["Jane Doe"]'))
source=c.execute('SELECT abstract,metadata_json FROM source_records WHERE id=?',(record,)).fetchone()
self.assertIsNone(source[0]);self.assertEqual(json.loads(source[1])['last_known_abstract']['abstract'],'Known abstract')
self.store.record('cvf','paper','https://example.org/paper','Updated title','Restored revised abstract',['Jane Doe'])
self.assertEqual(c.execute('SELECT title,abstract FROM papers').fetchone(),('Updated title','Restored revised abstract'))
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
+157
View File
@@ -0,0 +1,157 @@
import hashlib
import json
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
from paper_atlas.collectors.public_openreview import (REVISION, SOURCE, collect,
negative_status, normalize)
from paper_atlas.db.store import Store
from paper_atlas.ingestion.records import ingest_file
from paper_atlas.audits.public_openreview import audit
class PublicOpenReviewTests(unittest.TestCase):
def row(self, **overrides):
return {'id': 'RealForum01', 'title': 'A public paper', 'abstract': 'An abstract',
'author': 'Anonymous;Jane Doe;private@example.org', 'track': 'main',
'status': 'Reject', 'keywords': 'planning;reasoning',
'profiles': {'private': 'secret'}, 'reviews': ['private review'],
'gender': 'irrelevant', **overrides}
def provenance(self):
return {'source_file': 'https://example.org/pinned.jsonl', 'source_line': 7,
'source_revision': REVISION, 'source_blob_sha1': 'a' * 40}
def fetcher(self, files):
tree = []
payloads = {}
for name, rows in files.items():
data = ''.join(json.dumps(row) + '\n' for row in rows).encode()
sha = hashlib.sha1(b'blob ' + str(len(data)).encode() + b'\0' + data).hexdigest()
tree.append({'path': name, 'type': 'blob', 'size': len(data), 'sha': sha})
payloads[name] = data
def fetch(url, limit):
if '?recursive=1' in url:
return json.dumps({'sha': REVISION, 'truncated': False, 'tree': tree}).encode()
return payloads[url.split('/' + REVISION + '/')[1]]
return fetch
def collect(self, out, files, **kwargs):
with patch('paper_atlas.collectors.public_openreview.shutil.disk_usage', return_value=type('Space', (), {'free': 20 * 1024**3})()):
return collect(out, fetcher=self.fetcher(files), **kwargs)
def test_only_explicit_decisions_and_verified_scope_become_events(self):
for value in ('Pending', 'Active', 'Submitted to NeurIPS 2025', 'TBD', '',
'Low score', 'ICML 2025 Retracted by Authors', 'Do not reject'):
self.assertIsNone(negative_status(value), value)
self.assertEqual(negative_status('Reject'), 'rejected')
self.assertEqual(negative_status('Desk rejected'), 'desk_rejected')
self.assertEqual(negative_status('NeurIPS 2023 Conference Withdrawn Submission'), 'withdrawn')
for raw, expected in [('main', 'main'), ('Position', 'position'),
('Datasets & Benchmarks', 'datasets_benchmarks')]:
row, reason = normalize(self.row(track=raw), 'nips', 2025, self.provenance())
self.assertIsNone(reason)
self.assertEqual(row['track'], expected)
for raw in ('workshop', 'workshop_mlsys', ''):
self.assertIsNone(normalize(self.row(track=raw), 'icml', 2025, self.provenance())[0])
with self.assertRaisesRegex(ValueError, 'conference year'):
normalize(self.row(status='NeurIPS 2023 Conference Withdrawn Submission'), 'icml', 2025, self.provenance())
def test_metadata_whitelist_retains_curated_provenance_without_profiles_or_reviews(self):
row, _ = normalize(self.row(), 'icml', 2025, self.provenance())
self.assertEqual(row['authors'], ['Jane Doe'])
self.assertEqual(row['identifiers'], {'openreview': 'RealForum01'})
self.assertEqual(row['metadata']['evidence_kind'], 'curated_public_status_assertion')
self.assertFalse(row['metadata']['original_decision_signature_verified'])
self.assertEqual(row['evidence_url'], 'https://example.org/pinned.jsonl#L7')
for private in ('secret', 'private review', 'gender', 'private@example.org'):
self.assertNotIn(private, json.dumps(row))
def test_pinned_collection_separates_tracks_and_holds_retraction(self):
with tempfile.TemporaryDirectory() as tmp:
files = {'venues/icml/icml2025.jsonl': [self.row(), self.row(id='Position01', track='Position'),
self.row(id='Retracted1', status='ICML 2025 Retracted by Authors'),
self.row(id='Pending001', status='Submitted to ICML 2025')]}
result = self.collect(tmp, files)
self.assertEqual(result['input_rows'], 4)
self.assertEqual(result['normalized_records'], 2)
self.assertEqual({f['track'] for f in result['files']}, {'main', 'position'})
held = [json.loads(line) for line in Path(result['held_file']).read_text().splitlines()]
self.assertEqual([(r['id'], r['reason']) for r in held], [('Retracted1', 'retracted')])
self.assertTrue(all(f['status'] == 'partial' for f in result['files']))
with self.assertRaisesRegex(ValueError, 'Completed inventory'):
self.collect(tmp, files)
def test_hash_failure_or_duplicate_forum_publishes_no_ingestible_files(self):
files = {'venues/icml/icml2025.jsonl': [self.row()]}
fetch = self.fetcher(files)
def corrupted(url, limit):
result = fetch(url, limit)
return result if '?recursive' in url else result.replace(b'Reject', b'Accept')
for fetcher in (corrupted, self.fetcher({'venues/icml/icml2025.jsonl': [self.row(), self.row()]})):
with tempfile.TemporaryDirectory() as tmp, patch('paper_atlas.collectors.public_openreview.shutil.disk_usage', return_value=type('Space', (), {'free': 20 * 1024**3})()):
with self.assertRaises(ValueError):
collect(tmp, fetcher=fetcher)
self.assertEqual(list(Path(tmp).glob('*.jsonl')), [])
self.assertFalse((Path(tmp) / 'public-openreview-inventory.json').exists())
def test_mirror_reimport_preserves_one_attempt_and_later_acceptance_elsewhere(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
result = self.collect(root / 'source', {'venues/icml/icml2025.jsonl': [self.row(author='Jane Doe')]})
db = root / 'db.sqlite'
path = result['files'][0]['file']
ingest_file(db, path)
ingest_file(db, path)
store = Store(db)
try:
paper = store.conn.execute('SELECT paper_id FROM source_records').fetchone()[0]
store.conn.execute('INSERT INTO identifiers VALUES(?,?,?)', ('arxiv', '2501.00001', paper))
same, record = store.record('official_program', 'neurips-later', 'https://example.org/accepted',
'A revised public paper', 'An abstract', ['Jane Doe'],
{'openreview': 'LaterForum1', 'arxiv': '2501.00001'})
self.assertEqual(same, paper)
store.decision(paper, record, 'NeurIPS', 2026, 'main', 'neurips-later',
'https://example.org/accepted', 'accepted', 'Accept',
identities=[('openreview', 'LaterForum1')])
store.conn.commit()
self.assertEqual(store.conn.execute('SELECT count(*) FROM submissions').fetchone()[0], 2)
self.assertEqual(store.conn.execute('SELECT count(*) FROM decisions').fetchone()[0], 2)
self.assertEqual(set(store.conn.execute('SELECT status FROM decisions')), {('accepted',), ('rejected',)})
self.assertEqual(store.conn.execute('SELECT count(*) FROM papers').fetchone()[0], 1)
self.assertEqual(store.conn.execute('PRAGMA foreign_key_check').fetchall(), [])
finally:
store.conn.close()
def test_preimport_audit_blocks_same_attempt_conflict_but_allows_later_other_venue_acceptance(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
self.collect(root / 'source', {'venues/icml/icml2025.jsonl': [self.row(author='Jane Doe')]})
db = root / 'db.sqlite'
store = Store(db)
paper, record = store.record('program', 'later', 'https://example.org/later',
'A public paper', 'An abstract', ['Jane Doe'], {'openreview': 'LaterForum1'})
store.decision(paper, record, 'NeurIPS', 2026, 'main', 'later',
'https://example.org/later', 'accepted', 'Accept', identities=[('openreview', 'LaterForum1')])
store.conn.commit()
inventory = root / 'source/public-openreview-inventory.json'
first = audit(db, inventory, root / 'audit.json')
self.assertEqual(first['status'], 'ready_for_serial_import')
self.assertEqual(len(first['files']), 1)
store.decision(paper, record, 'ICML', 2025, 'main', 'same-attempt',
'https://example.org/same', 'accepted', 'Accept', identities=[('openreview', 'RealForum01')])
store.conn.commit()
try:
second = audit(db, inventory, root / 'audit.json')
self.assertEqual(second['status'], 'blocked')
self.assertEqual(second['files'], [])
self.assertEqual(second['conflicts'][0]['existing_status'], 'accepted')
finally:
store.conn.close()
if __name__ == '__main__':
unittest.main()
+35
View File
@@ -9,6 +9,41 @@ from paper_atlas.db.store import Store
class QueryTests(unittest.TestCase):
def run_query(self,db,*args):
return json.loads(subprocess.run([sys.executable,'-B','-m','paper_atlas','query','--db',str(db),*args],check=True,capture_output=True,text=True).stdout)
def test_rejection_then_acceptance_at_another_venue_preserves_both_attempts(self):
with tempfile.TemporaryDirectory() as tmp:
db=Path(tmp)/'papers.sqlite';store=Store(db);self.addCleanup(store.conn.close)
paper,first=store.record('openreview_snapshot','forum1','https://example.org/1','Original title','Abstract',['Jane Doe'],{'arxiv':'2401.00001','openreview':'forum1'})
store.decision(paper,first,'ICLR',2024,'main','forum1','https://example.org/reject','rejected','Reject',identities=[('openreview','forum1')],observed_at='2024-02-01T00:00:00Z')
same,second=store.record('colm','forum2','https://example.org/2','Revised title','Revised abstract',['Jane Doe'],{'arxiv':'2401.00001','openreview':'forum2'})
store.decision(same,second,'COLM',2025,'main','forum2','https://example.org/accept','accepted','Accept',identities=[('openreview','forum2')],observed_at='2025-08-01T00:00:00Z')
store.conn.commit();self.assertEqual(same,paper)
rows=self.run_query(db,'--venue','ICLR','--status','rejected','--outcome','accepted')
self.assertEqual([row['id'] for row in rows],[paper])
self.assertEqual(rows[0]['publication_outcome'],'accepted')
self.assertEqual([a['venue'] for a in rows[0]['known_acceptances']],['COLM'])
detail=self.run_query(db,'--paper',str(paper))[0]
self.assertEqual([(s['acronym'],s['latest_status']) for s in detail['submissions']],[('ICLR','rejected'),('COLM','accepted')])
self.assertEqual(self.run_query(db,'--venue','ICLR','--status','rejected','--outcome','no_known_acceptance'),[])
self.assertEqual(self.run_query(db,'--stats')['papers_with_known_acceptance'],1)
def test_later_rejection_elsewhere_does_not_erase_acceptance_and_unknown_is_not_never(self):
with tempfile.TemporaryDirectory() as tmp:
db=Path(tmp)/'papers.sqlite';store=Store(db);self.addCleanup(store.conn.close)
paper,one=store.record('source','one','https://example.org/1','One paper','Abstract',[])
store.decision(paper,one,'COLM',2024,'main','one','https://example.org/accept','accepted','Accept',observed_at='2024-01-01T00:00:00Z')
store.decision(paper,one,'ICLR',2025,'main','two','https://example.org/reject','rejected','Reject',observed_at='2025-01-01T00:00:00Z')
unknown,_=store.record('arxiv','other','https://example.org/other','No observed submissions','Abstract',[])
store.conn.commit()
self.assertEqual([r['id'] for r in self.run_query(db,'--outcome','accepted')],[paper])
self.assertEqual([r['id'] for r in self.run_query(db,'--outcome','no_known_acceptance')],[unknown])
store.decision(paper,one,'COLM',2024,'main','one','https://example.org/withdraw','withdrawn','Withdrawn',observed_at='2026-01-01T00:00:00Z')
store.conn.commit();self.assertEqual(self.run_query(db,'--outcome','accepted'),[])
history=self.run_query(db,'--paper',str(paper))[0]
self.assertEqual([d['status'] for d in history['submissions'][0]['decisions']],['accepted','withdrawn'])
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)
+75 -1
View File
@@ -1,11 +1,85 @@
import tempfile
import io
import json
from pathlib import Path
import unittest
from unittest.mock import patch
from urllib.error import HTTPError
from paper_atlas.collectors.vision import collect_index, decode_html, discover, index_entries, parse_paper
from paper_atlas.collectors.vision import collect_index, decode_html, discover, index_entries, parse_paper,main
class VisionTests(unittest.TestCase):
def cvf_fixture(self,year=2018):
root=f'https://openaccess.thecvf.com/CVPR{year}.py'
papers=[f'https://openaccess.thecvf.com/content_cvpr_{year}/html/Paper{i}_CVPR_{year}_paper.html' for i in (1,2,3)]
def entry(url,title='Paper'):
return f'<dt><a href="{url}">{title}</a></dt><dd>Jane Doe, John Smith</dd>'
return root,papers,entry
def test_all_explicit_days_are_combined_and_overlapping_papers_are_not_duplicated(self):
root,papers,entry=self.cvf_fixture();day1=root+'?day=2018-06-19';day2=root+'?day=2018-06-20'
pages={root:f'<a href="{day1}">Day1</a><a href="{day2}">Day2</a><a href="https://evil.example/CVPR2018.py?day=all">Other</a><a href="/CVPR2019.py?day=all">Wrong year</a>',day1:'<dl>'+entry(papers[0])+entry(papers[1])+'</dl>',day2:'<dl>'+entry(papers[1])+entry(papers[2])+'</dl>'}
pages.update({paper:'<div id="papertitle">Paper</div><div id="abstract">Abstract</div>' for paper in papers})
with tempfile.TemporaryDirectory() as tmp,patch('paper_atlas.collectors.vision.fetch',side_effect=lambda url,*a,**kw:pages[url].encode()) as fetch:
manifest=collect_index('cvf',{'venue':'CVPR','year':2018,'track':'main','index_url':root},tmp,tmp)
rows=[json.loads(line) for line in Path(manifest['file']).read_text().splitlines()]
self.assertEqual(manifest['count'],3);self.assertEqual(manifest['raw_index_entry_count'],4)
self.assertEqual(manifest['index_urls'],[day1,day2]);self.assertEqual(manifest['status'],'collected')
self.assertEqual(rows[1]['metadata']['index_urls'],[day1,day2])
self.assertEqual({call.args[0] for call in fetch.call_args_list},set(pages))
def test_conflicting_copies_across_days_fail_before_publishing(self):
root,papers,entry=self.cvf_fixture();days=[root+'?day=2018-06-19',root+'?day=2018-06-20']
pages={root:''.join(f'<a href="{day}">Day</a>' for day in days),days[0]:'<dl>'+entry(papers[0],'First title')+'</dl>',days[1]:'<dl>'+entry(papers[0],'Different title')+'</dl>'}
with tempfile.TemporaryDirectory() as tmp,patch('paper_atlas.collectors.vision.fetch',side_effect=lambda url,*a,**kw:pages[url].encode()):
with self.assertRaisesRegex(ValueError,'Conflicting metadata'):
collect_index('cvf',{'venue':'CVPR','year':2018,'track':'main','index_url':root},tmp,tmp)
self.assertFalse(list(Path(tmp).glob('*.jsonl')))
def test_missing_abstract_page_keeps_official_index_metadata_and_marks_partial(self):
root,papers,entry=self.cvf_fixture();error=HTTPError(papers[0],404,'Not Found',{},io.BytesIO())
def fetch(url,*args,**kwargs):
if url==root:return ('<dl>'+entry(papers[0],'Known publication')+'</dl>').encode()
raise error
with tempfile.TemporaryDirectory() as tmp,patch('paper_atlas.collectors.vision.fetch',side_effect=fetch):
manifest=collect_index('cvf',{'venue':'CVPR','year':2018,'track':'main','index_url':root},tmp,tmp)
row=json.loads(Path(manifest['file']).read_text())
self.assertEqual(row['title'],'Known publication');self.assertIsNone(row['abstract'])
self.assertEqual(row['authors'],['Jane Doe','John Smith']);self.assertEqual(row['evidence_url'],root)
self.assertEqual(manifest['status'],'partial');self.assertEqual(manifest['missing_abstract_pages'],[papers[0]])
self.assertEqual(row['metadata']['abstract_fetch_error']['status'],404)
def test_forbidden_paper_is_not_replaced_with_a_fallback(self):
root,papers,entry=self.cvf_fixture()
def fetch(url,*args,**kwargs):
if url==root:return ('<dl>'+entry(papers[0])+'</dl>').encode()
raise HTTPError(url,403,'Forbidden',{},io.BytesIO())
with tempfile.TemporaryDirectory() as tmp,patch('paper_atlas.collectors.vision.fetch',side_effect=fetch):
with self.assertRaises(HTTPError):collect_index('cvf',{'venue':'CVPR','year':2018,'track':'main','index_url':root},tmp,tmp)
self.assertFalse(list(Path(tmp).glob('*.jsonl')))
def test_broken_all_papers_link_can_use_all_advertised_days(self):
root,papers,entry=self.cvf_fixture();day=root+'?day=2018-06-19';all_url=root+'?day=all'
pages={root:f'<a href="{day}">Day</a><a href="{all_url}">All</a>',day:'<dl>'+entry(papers[0])+'</dl>',papers[0]:'<div id="papertitle">Paper</div><div id="abstract">Abstract</div>'}
def fetch(url,*a,**kw):
if url==all_url:raise HTTPError(url,404,'Not Found',{},io.BytesIO())
return pages[url].encode()
with tempfile.TemporaryDirectory() as tmp,patch('paper_atlas.collectors.vision.fetch',side_effect=fetch):
manifest=collect_index('cvf',{'venue':'CVPR','year':2018,'track':'main','index_url':root},tmp,tmp)
self.assertEqual(manifest['index_urls'],[day]);self.assertEqual(manifest['index_errors'],[{'url':all_url,'status':404}])
def test_selective_retry_uses_separate_output_and_leaves_original_catalogue_intact(self):
with tempfile.TemporaryDirectory() as tmp:
old=Path(tmp)/'old.json';payload={'CVPR:2018:main':{'status':'blocked'},'CVPR:2019:main':{'status':'collected'}};old.write_text(json.dumps(payload))
indexes=[{'venue':'CVPR','year':year,'track':'main','index_url':f'https://openaccess.thecvf.com/CVPR{year}'} for year in (2018,2019)]
output=Path(tmp)/'repaired'
with patch('paper_atlas.collectors.vision.discover',return_value=indexes),patch('paper_atlas.collectors.vision.collect_index',return_value={'status':'collected','count':1}) as collect:
main(['--cache',tmp,'--out',str(output),'--retry-catalogue',str(old)],source='cvf')
self.assertEqual(collect.call_count,1);self.assertEqual(collect.call_args.args[1]['year'],2018)
self.assertEqual(json.loads(old.read_text()),payload)
self.assertEqual(set(json.loads((output/'cvf-index-status.json').read_text())),{'CVPR:2018:main'})
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'}