Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9a279f2eae | |||
| 25cc5b865b | |||
| a65edc6b9d | |||
| 0fbcaf4ec7 | |||
| c199ed3c1d | |||
| 4d17231f1f |
@@ -43,7 +43,7 @@ jobs:
|
||||
outside.mkdir()
|
||||
subprocess.run([str(venv/'bin/paper-atlas'),'--help'],cwd=outside,check=True)
|
||||
subprocess.run([python,'-I','-B','-m','paper_atlas','collect','cvf','--help'],cwd=outside,check=True)
|
||||
smoke = "from paper_atlas.db.store import Store; from paper_atlas.config import DEFAULT_REGISTRY; import json; assert len(json.loads(DEFAULT_REGISTRY.read_text())['venues'])==41; s=Store('installed.sqlite'); s.conn.close()"
|
||||
smoke = "from paper_atlas.db.store import Store; from paper_atlas.config import DEFAULT_REGISTRY; import json; assert {'COLM','MM','MLSys'} <= {v['acronym'] for v in json.loads(DEFAULT_REGISTRY.read_text())['venues']}; s=Store('installed.sqlite'); s.conn.close()"
|
||||
subprocess.run([python,'-I','-B','-c',smoke],cwd=outside,check=True)
|
||||
subprocess.run([python,'-I','-B','-m','paper_atlas','query','--db','installed.sqlite','--stats'],cwd=outside,check=True)
|
||||
PY
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
Paper Atlas 将 arXiv、AI 会议论文集及公开投稿决定整理到可追溯的 SQLite 数据库中,用于查询和后续研究分析。它按批次运行,不需要常驻 Web 服务;采集标题、摘要、标识、标签及决定证据,不下载论文全文。
|
||||
|
||||
项目仍在建设中。候选范围包含 41 个会议,尚未完成全部历史数据和最终去重复核。实际进度以数据库、来源清单和覆盖报告为准,而不是仓库文件数量。
|
||||
项目仍在建设中。候选范围包含 43 个会议(含 ACM MM、MLSys),尚未完成全部历史数据和最终去重复核。实际进度以数据库、来源清单和覆盖报告为准,而不是仓库文件数量。
|
||||
|
||||
## 项目结构
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
- [ACL Anthology 权威 XML 与 API](https://aclanthology.org/info/development/)
|
||||
- [NeurIPS](https://proceedings.neurips.cc/)
|
||||
- [COLM](https://proceedings.colm.cc/)
|
||||
- [MLSys](https://proceedings.mlsys.org/)
|
||||
- [ACM Multimedia 历史目录](https://dblp.org/db/conf/mm/index.html)
|
||||
- [CVF](https://openaccess.thecvf.com/)
|
||||
- [ECVA](https://www.ecva.net/papers.php)
|
||||
- [IJCAI](https://www.ijcai.org/all_proceedings/)
|
||||
@@ -29,6 +31,7 @@
|
||||
| 机器学习、理论 | NeurIPS、ICML、ICLR、COLM、COLT、AISTATS、UAI、CLeaR | OpenReview、PMLR、NeurIPS、COLM 官方论文集 |
|
||||
| 自然语言 | ACL、EMNLP、NAACL、EACL、COLING、CoNLL | ACL Anthology |
|
||||
| 视觉 | CVPR、ICCV、ECCV | CVF、ECVA |
|
||||
| 多模态与机器学习系统 | ACM MM、MLSys | ACM / DBLP、MLSys 官方论文集 |
|
||||
| 综合 AI | AAAI、IJCAI、ECAI | 官方论文集、DBLP |
|
||||
| 检索与数据 | KDD、SIGIR、WWW、WSDM、RecSys、CIKM、ICDM、SDM、ECML-PKDD | 官方出版方、DBLP,具体年份入口待逐项登记 |
|
||||
| 机器人 | ICRA、IROS、RSS、CoRL | 官方论文集、PMLR、DBLP |
|
||||
@@ -38,6 +41,23 @@
|
||||
|
||||
这些是领域覆盖候选,不构成统一排名,也不能据此声称已全部入库。逐年目录以真实举办届次和正式出版清单为准:双年会议不补造不存在的年份;主会、Findings、workshop 分开。`edition_sources` 保存实际采集状态,不能用候选名单代替覆盖率报告。
|
||||
|
||||
## ACM MM 与 MLSys
|
||||
|
||||
会议注册表包含 `MM`(别名 `ACMMM`、`ACM MM`)和 `MLSys`。MLSys 官方根目录目前列出 2019–2026 年的 8 卷,直接采集每篇标题、摘要、公开作者和引用元数据:
|
||||
|
||||
```sh
|
||||
paper-atlas collect mlsys --cache /data/paper-index/raw/http-cache \
|
||||
--out /data/paper-index/collected/mlsys
|
||||
```
|
||||
|
||||
每年清单核对官方声明数量,缺摘要标为 partial;未知现代轨道、空目录或读取失败不能记为成功。`SysML 2019` 归于同一 MLSys 系列并保留别名。2018 年官方说明接受的投稿为 non-archival,论文集从 2019 年开始;不拼造 2018 年归档卷,也不把未出现在论文集中的投稿判为拒稿。参见 [2018 年官方说明](https://mlsys.org/Conferences/2018/)。
|
||||
|
||||
MLSys 老式 `/paper/<year>/hash/…` 与新式 `/paper_files/paper/<year>/hash/…` 链接规范为同一个 `publication_mlsys` 标识,也支持 2023 年实际使用的 `-Abstract-mlsys2023.html` 及对应年份标签;标签年份必须与目录年份一致。DBLP 和官方摘要源共用它,避免同一发表记录成为两个论文实体或两次投稿。只从自身出版社链接提取,不从摘要参考文献提取,也不把发表标识用于合并某次拒稿尝试。共享投稿身份入口要求 MLSys 源具有有效的同年出版社身份,缺失或错年的文件不能生成投稿事件;旧版 DBLP 整理文件可利用已保留的唯一自身出版社链接补得此身份,不需要丢弃来源或依靠标题猜测。
|
||||
|
||||
ACM MM 使用固定 DBLP 原始快照和已核验的 proceedings crossref 盘点历史,主会 `ACM Multimedia`、历史编号分卷与同系列 workshop 分开。主会卷也包含 Doctoral Symposium、Reproducibility Companion Papers、Tutorials 等章节(参见 [2025 年目录](https://dblp.org/db/conf/mm/mm2025.html)),而原始 XML 不含逐篇章节,所以这类记录暂标 `proceedings_unspecified`,不能整卷当作 regular 主轨道;后续需逐篇目录证据补齐。旧版候选 JSONL 没有 `mm` / `mlsys` 家族,不能用它假装覆盖新增会议;应从已经下载并校验的原始 XML 重新筛选这两个会议,保留完整扫描数量、卷证据与遗漏原因。DBLP 没有摘要,后续须通过 DOI、真实 forum 等独立标识关联官方或公开元数据,不能将书目导入成功表述为摘要已齐全。
|
||||
|
||||
公开拒稿镜像的 `acmmm` 家族映射到 `MM`,只接受明确的负面状态,继续保留镜像与原始签名决定的区别。现有固定 OpenReview 镜像只含 ACM MM 2024 的录用记录,没有据此证明其他年份没有公开拒稿;[2025 年官方 CFP](https://acmmm2025.org/call-for-papers/) 仅说明审稿期间不公开,不能外推为会后永不公开。
|
||||
|
||||
## 官方会议元数据采集
|
||||
|
||||
```sh
|
||||
@@ -253,6 +273,25 @@ python3 -B -m paper_atlas query --db /data/paper-index/papers.sqlite \
|
||||
|
||||
只将 Conference 来源的论文视为 ICLR 主会录用,排除 Blog 和期刊转会展示,并在审计清单保存排除计数。2020/2021 旧数据允许依据官方主会 paper program 形成录用证据;无 forum ID 时保留 program 身份和匹配候选,不伪造标识。历史年度返回 404 会登记 unavailable,未来未发表届次登记 not_yet_published,其他访问或解析失败登记 blocked;这些状态都不能宣称该年已覆盖。
|
||||
|
||||
ICLR 2020 的现代程序 JSON 没有真实 forum 链接,数字 event ID 也不能拼接为旧版海报地址。该年未核验身份的记录先使用官方 Papers 索引地址。旧版官方页面实际引用的 [papers.json](https://iclr.cc/virtual_2020/papers.json) 提供 687 个 forum;可用固定大小及 SHA-256 校验的副本进行独立身份核对:
|
||||
|
||||
```sh
|
||||
paper-atlas download --url https://iclr.cc/virtual_2020/papers.json \
|
||||
--output /data/paper-index/raw/iclr-2020-legacy-papers.json \
|
||||
--expected-bytes 391687 \
|
||||
--sha256 2405f2da6e2641fa600762f7d7cdf7c9d27f4d0ae65799168f3aee46036d30d2
|
||||
|
||||
paper-atlas audit iclr-legacy \
|
||||
--program /data/paper-index/collected/iclr-program-2020-main.jsonl \
|
||||
--snapshot /data/paper-index/collected/openreview-snapshot-2020-main.jsonl \
|
||||
--legacy /data/paper-index/raw/iclr-2020-legacy-papers.json \
|
||||
--out /data/paper-index/collected/iclr-2020-verified
|
||||
```
|
||||
|
||||
核对同时要求同届公开录用、唯一投稿编号和官方 forum 清单一致;编号只是候选关联,还必须有独立的标题/摘要/公开作者证据。旧版作者缩写或姓名格式差异不会放宽全库作者匹配规则。一篇 Explain Your Move 有三个来源中的三种标题,使用逐个核对的精确标题组合及至少两名共同公开作者作例外,不启用通用模糊标题合并。
|
||||
|
||||
输出保留原 `2020/program-编号` 来源 ID、标题、摘要和作者,补充真实 forum、核验证据和旧版官方海报 URL。它不写数据库,也不修改输入文件。先在维护锁下使用输出的 `iclr-2020-backfill-queue.json` 给已有程序投稿回填身份,再导入核验后的程序文件与原始投稿快照;这样补充拒稿不会把已有录用事件再建一次。`iclr-2020-snapshot-queue.json` 和审计报告均记录待用文件的哈希,合并前仍须复核。
|
||||
|
||||
主题、关键词使用 `iclr_program:topic`、`iclr_program:keyword` 命名空间。通用导入器按源记录刷新标签证据,保留同一论文其他源记录及 `custom:*` 标签。每年的 JSONL、manifest、`iclr-program-year-status.json` 和 `iclr-program-last-run.json` 保存实际范围、独立展示数、主会展示数、论文数、缺失摘要与缺失 forum 数。录用程序不能提供拒稿全集,公开投稿及拒稿补充仍需独立来源。
|
||||
|
||||
### CVF / ECVA 视觉会议元数据
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
"""Reconcile the 2020 program with independently identified official papers."""
|
||||
import argparse
|
||||
from collections import Counter, defaultdict
|
||||
import copy
|
||||
import fcntl
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import tempfile
|
||||
|
||||
from paper_atlas.audits.iclr import load
|
||||
from paper_atlas.db.store import author_keys, normalized
|
||||
from paper_atlas.db.submissions import timestamp
|
||||
|
||||
|
||||
LEGACY_URL = 'https://iclr.cc/virtual_2020/papers.json'
|
||||
LEGACY_SHA256 = '2405f2da6e2641fa600762f7d7cdf7c9d27f4d0ae65799168f3aee46036d30d2'
|
||||
LEGACY_BYTES = 391687
|
||||
EXPECTED = 687
|
||||
# This one paper has three documented titles across the pinned official legacy
|
||||
# list, current official program, and public submission snapshot. The exception
|
||||
# also requires independent public-author agreement; it is not fuzzy matching.
|
||||
REVIEWED_TITLE_VARIANT = {
|
||||
'forum': 'SJgzLkBKPB',
|
||||
'program': 'Explain Your Move: Understanding Agent Actions Using Salient and Relevant Feature Attribution',
|
||||
'legacy': 'Explain Your Move: Understanding Agent Actions Using Focused Feature Saliency',
|
||||
'snapshot': 'Explain Your Move: Understanding Agent Actions Using Specific and Relevant Feature Attribution',
|
||||
}
|
||||
|
||||
|
||||
def reconcile(program, snapshot, legacy):
|
||||
by_number = defaultdict(list)
|
||||
for row in snapshot.values():
|
||||
by_number[row['metadata'].get('source_submission_number')].append(row)
|
||||
legacy_by_forum = {}
|
||||
for note in legacy:
|
||||
forum = note.get('forum')
|
||||
content = note.get('content') or {}
|
||||
if not isinstance(forum, str) or not re.fullmatch(r'[A-Za-z0-9_-]+', forum):
|
||||
raise ValueError('Invalid legacy forum identity')
|
||||
if note.get('id') != forum or content.get('iclr_id') != forum or forum in legacy_by_forum:
|
||||
raise ValueError('Conflicting or repeated official legacy forum')
|
||||
if not isinstance(content.get('title'), str) or not content['title'].strip() or not isinstance(content.get('authors'), list):
|
||||
raise ValueError('Legacy paper lacks title or public authors')
|
||||
legacy_by_forum[forum] = content
|
||||
if len(program) != len(legacy_by_forum):
|
||||
raise ValueError('Official program and legacy inventories differ')
|
||||
title_counts = Counter(normalized(row['title']) for row in legacy_by_forum.values())
|
||||
enriched = []
|
||||
assigned = set()
|
||||
numbers = set()
|
||||
evidence_counts = Counter()
|
||||
for original in program:
|
||||
if any(original.get(k) != v for k, v in {'source': 'iclr_program', 'venue': 'ICLR', 'year': 2020, 'track': 'main', 'status': 'accepted'}.items()):
|
||||
raise ValueError('Program record is outside the accepted 2020 main track')
|
||||
metadata = original.get('metadata') or {}
|
||||
number = metadata.get('program_source_id')
|
||||
if metadata.get('program_identity_type') != 'submission' or type(number) is not int or number <= 0 or number in numbers:
|
||||
raise ValueError('Missing or repeated program submission number')
|
||||
numbers.add(number)
|
||||
candidates = by_number[number]
|
||||
if len(candidates) != 1:
|
||||
raise ValueError('Submission number is absent or ambiguous in the public snapshot')
|
||||
submission = candidates[0]
|
||||
forum = submission['identifiers']['openreview']
|
||||
if submission['status'] != 'accepted' or forum not in legacy_by_forum or forum in assigned:
|
||||
raise ValueError('Snapshot acceptance and official forum inventory do not agree')
|
||||
content = legacy_by_forum[forum]
|
||||
shared_legacy = len(author_keys(original['authors']) & author_keys(content['authors']))
|
||||
shared_snapshot = len(author_keys(original['authors']) & author_keys(submission['authors']))
|
||||
checks = {'legacy_title': normalized(original['title']) == normalized(content['title']),
|
||||
'snapshot_title': normalized(original['title']) == normalized(submission['title']),
|
||||
'snapshot_abstract': bool(original.get('abstract')) and original['abstract'].strip() == (submission.get('abstract') or '').strip(),
|
||||
'two_snapshot_authors': shared_snapshot >= 2}
|
||||
independent_snapshot = sum(checks[k] for k in ('snapshot_title', 'snapshot_abstract', 'two_snapshot_authors')) >= 2
|
||||
checks['legacy_title_unique'] = title_counts[normalized(content['title'])] == 1
|
||||
independent_legacy = checks['legacy_title'] and checks['legacy_title_unique'] and shared_legacy >= 1
|
||||
reviewed_variant = (forum == REVIEWED_TITLE_VARIANT['forum']
|
||||
and normalized(original['title']) == normalized(REVIEWED_TITLE_VARIANT['program'])
|
||||
and normalized(content['title']) == normalized(REVIEWED_TITLE_VARIANT['legacy'])
|
||||
and normalized(submission['title']) == normalized(REVIEWED_TITLE_VARIANT['snapshot'])
|
||||
and shared_legacy >= 2 and shared_snapshot >= 2)
|
||||
if not (independent_snapshot or independent_legacy or reviewed_variant):
|
||||
raise ValueError('Submission number alone cannot establish forum identity: ' + original['external_id'])
|
||||
own = original.get('identifiers') or {}
|
||||
if own.get('openreview') not in (None, forum):
|
||||
raise ValueError('Existing program forum conflicts with independent evidence')
|
||||
row = copy.deepcopy(original)
|
||||
row['identifiers'] = {**own, 'openreview': forum}
|
||||
row['url'] = 'https://iclr.cc/virtual_2020/poster_' + forum + '.html'
|
||||
row['metadata'].update(identity_state='forum', program_submission_number_verified=True,
|
||||
program_identity_verification={'method': 'official_legacy_and_public_submission_crosscheck',
|
||||
'legacy_url': LEGACY_URL, 'legacy_sha256': LEGACY_SHA256,
|
||||
'original_program_url': original['url'], 'legacy_forum': forum,
|
||||
'checks': checks, 'shared_legacy_authors': shared_legacy,
|
||||
'shared_snapshot_authors': shared_snapshot, 'reviewed_title_variant': reviewed_variant})
|
||||
enriched.append(row)
|
||||
assigned.add(forum)
|
||||
evidence_counts.update({'independent_snapshot': int(independent_snapshot),
|
||||
'independent_legacy': int(independent_legacy),
|
||||
'reviewed_title_variant': int(reviewed_variant)})
|
||||
if assigned != legacy_by_forum.keys():
|
||||
raise ValueError('Unmatched official legacy forum identities remain')
|
||||
if assigned != {forum for forum, row in snapshot.items() if row['status'] == 'accepted'}:
|
||||
raise ValueError('Accepted snapshot forum inventory differs from the official program')
|
||||
return enriched, dict(evidence_counts)
|
||||
|
||||
|
||||
def audit(program_path, snapshot_path, legacy_path, out):
|
||||
program_path, snapshot_path, legacy_path = map(lambda p: Path(p).resolve(), (program_path, snapshot_path, legacy_path))
|
||||
raw = legacy_path.read_bytes()
|
||||
if len(raw) != LEGACY_BYTES or hashlib.sha256(raw).hexdigest() != LEGACY_SHA256:
|
||||
raise ValueError('Official legacy snapshot checksum mismatch')
|
||||
legacy = json.loads(raw)
|
||||
if not isinstance(legacy, list) or len(legacy) != EXPECTED:
|
||||
raise ValueError('Official legacy count mismatch')
|
||||
manifest = json.loads(program_path.with_suffix('.manifest.json').read_text())
|
||||
if manifest.get('parser_version', 0) < 2 or any(manifest.get(k) != v for k, v in {'source': 'iclr_program', 'venue': 'ICLR', 'year': 2020, 'track': 'main', 'count': EXPECTED}.items()):
|
||||
raise ValueError('Invalid collected program inventory')
|
||||
program_bytes = program_path.read_bytes()
|
||||
program = [json.loads(line) for line in program_bytes.splitlines()]
|
||||
if len(program) != EXPECTED or len({r['external_id'] for r in program}) != EXPECTED:
|
||||
raise ValueError('Program count or source identity mismatch')
|
||||
snapshot, snapshot_manifest, snapshot_sha = load(snapshot_path, 'openreview_snapshot', 2020)
|
||||
rows, checks = reconcile(program, snapshot, legacy)
|
||||
out = Path(out).resolve()
|
||||
if out / 'iclr-program-2020-main.jsonl' in (program_path, snapshot_path, legacy_path):
|
||||
raise ValueError('Output must preserve the original input files')
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
with (out / 'iclr-legacy.lock').open('a') as lock:
|
||||
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
if (out / 'iclr-2020-identity-audit.json').exists():
|
||||
raise ValueError('Completed identity audit exists; choose a new output directory')
|
||||
with tempfile.TemporaryDirectory(prefix='.iclr-legacy-', dir=out) as temporary:
|
||||
stage = Path(temporary)
|
||||
path = stage / 'iclr-program-2020-main.jsonl'
|
||||
path.write_text(''.join(json.dumps(row, ensure_ascii=False) + '\n' for row in rows))
|
||||
digest = hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
manifest.update(parser_version=3, file=str(out / path.name), file_sha256=digest,
|
||||
missing_forum_count=0, legacy_identity_sha256=LEGACY_SHA256)
|
||||
path.with_suffix('.manifest.json').write_text(json.dumps(manifest, indent=2) + '\n')
|
||||
backfill = {'files': [str(out / path.name)], 'file_sha256': {str(out / path.name): digest}}
|
||||
snapshots = {'files': [str(snapshot_path)], 'file_sha256': {str(snapshot_path): snapshot_sha}}
|
||||
result = {'year': 2020, 'checked_at': timestamp(), 'program_records': len(rows),
|
||||
'snapshot_records': len(snapshot), 'snapshot_status_counts': snapshot_manifest['submission_status_counts'],
|
||||
'legacy_url': LEGACY_URL, 'legacy_sha256': LEGACY_SHA256,
|
||||
'program_input_sha256': hashlib.sha256(program_bytes).hexdigest(),
|
||||
'corroboration_counts': checks, 'backfill_queue': backfill, 'snapshot_files': snapshots,
|
||||
'note': 'No database writes. Backfill existing program identities before importing the snapshot; source IDs, titles and abstracts are preserved.'}
|
||||
(stage / 'iclr-2020-backfill-queue.json').write_text(json.dumps(backfill, indent=2) + '\n')
|
||||
(stage / 'iclr-2020-snapshot-queue.json').write_text(json.dumps(snapshots, indent=2) + '\n')
|
||||
report = stage / 'iclr-2020-identity-audit.json'
|
||||
report.write_text(json.dumps(result, ensure_ascii=False, indent=2) + '\n')
|
||||
for child in sorted(stage.iterdir(), key=lambda child: child == report):
|
||||
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('--program', required=True)
|
||||
parser.add_argument('--snapshot', required=True)
|
||||
parser.add_argument('--legacy', required=True)
|
||||
parser.add_argument('--out', required=True)
|
||||
args = parser.parse_args(argv)
|
||||
audit(args.program, args.snapshot, args.legacy, args.out)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -18,6 +18,7 @@ COMMANDS = {
|
||||
('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','mlsys'): ('paper_atlas.collectors.proceedings', {'source':'mlsys'}, 'MLSys 官方历年标题与摘要'),
|
||||
('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 历史发表记录'),
|
||||
@@ -28,6 +29,7 @@ COMMANDS = {
|
||||
('maintain','backfill'): ('paper_atlas.ingestion.backfill', {}, '回填既有投稿的来源身份'),
|
||||
('maintain','arxiv-tags'): ('paper_atlas.ingestion.arxiv_tags', {}, '恢复逐来源记录的 arXiv 类别证据'),
|
||||
('audit','iclr'): ('paper_atlas.audits.iclr', {}, '核对 ICLR 快照与同届官方程序'),
|
||||
('audit','iclr-legacy'): ('paper_atlas.audits.iclr_legacy', {}, '独立核验 ICLR 2020 旧目录投稿身份'),
|
||||
('audit','arxiv'): ('paper_atlas.audits.arxiv', {}, '核验 arXiv 原始编号、重复行与 ZIP 完整性'),
|
||||
('audit','public-openreview'): ('paper_atlas.audits.public_openreview', {}, '核对公开负面决定与已有投稿历史'),
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ DTD_MD5='96e4283e71886dec2173718957293648'
|
||||
DTD_NAMES={'dblp.dtd','dblp-2023-06-28.dtd'}
|
||||
RELEASE='https://doi.org/10.4230/dblp.xml.2026-11-01'
|
||||
# Broad discovery candidates, including historical names; not a venue mapping.
|
||||
FAMILIES=set('nips icml iclr colm colt aistats uai clear acl emnlp naacl eacl coling conll cvpr iccv eccv aaai ijcai ecai kdd sigir www wsdm recsys cikm icdm sdm ecml pkdd ecmlpkdd icra iros rss corl aips icaps kr atal aamas icassp interspeech eurospeech icslp ismir miccai fat facct lrec'.split())
|
||||
FAMILIES=set('nips icml iclr colm colt aistats uai clear acl emnlp naacl eacl coling conll cvpr iccv eccv aaai ijcai ecai kdd sigir www wsdm recsys cikm icdm sdm ecml pkdd ecmlpkdd icra iros rss corl aips icaps kr atal aamas icassp interspeech eurospeech icslp ismir miccai fat facct lrec mm mlsys'.split())
|
||||
|
||||
|
||||
def records(archive,dtd_bytes):
|
||||
|
||||
@@ -17,6 +17,7 @@ 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
|
||||
from paper_atlas.io.publication_ids import mlsys_id
|
||||
|
||||
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)'
|
||||
@@ -36,8 +37,10 @@ def material_reason(title,authors,key,year,track):
|
||||
|
||||
|
||||
def identifiers(fields):
|
||||
dois=set();arxiv=set();forums=set()
|
||||
dois=set();arxiv=set();forums=set();mlsys=set()
|
||||
for link in fields.get('ee',[]):
|
||||
if own_mlsys := mlsys_id(link):
|
||||
mlsys.add(own_mlsys)
|
||||
parsed=urlsplit(link);path=unquote(parsed.path)
|
||||
doi=None
|
||||
if parsed.netloc in ('doi.org','dx.doi.org','doi.ieeecomputersociety.org'):doi=path.lstrip('/')
|
||||
@@ -57,9 +60,10 @@ def identifiers(fields):
|
||||
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)):
|
||||
groups=(('doi',publications),('arxiv',arxiv),('openreview',forums),('publication_mlsys',mlsys))
|
||||
for scheme,values in groups:
|
||||
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}
|
||||
ambiguous={scheme:sorted(values) for scheme,values in groups if len(values)>1}
|
||||
return own,ambiguous
|
||||
|
||||
|
||||
@@ -84,6 +88,8 @@ def normalize_row(row,volumes,targets,include_workshops=False,through_year=2026)
|
||||
'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'}
|
||||
if edition.get('track_scope_note'):
|
||||
metadata['track_scope_note']=edition['track_scope_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,
|
||||
@@ -110,6 +116,12 @@ def collect(catalogue,out,venues=None,candidates=None,archive=None,dtd=None,incl
|
||||
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())
|
||||
if candidates:
|
||||
families = manifest.get('candidate_family_counts', {})
|
||||
missing = [venue for venue, family in [('MM', 'mm'), ('MLSys', 'mlsys')]
|
||||
if venue in targets and not families.get(family)]
|
||||
if missing:
|
||||
raise ValueError('Candidate file predates requested families ' + ', '.join(missing) + '; use the original --archive')
|
||||
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)
|
||||
|
||||
@@ -33,6 +33,8 @@ BOOKS = {
|
||||
'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',[])],
|
||||
'mm':[('ACM Multimedia','MM',['ACMMM','ACM MM'])],
|
||||
'mlsys':[('MLSys','MLSys',[]),('SysML','MLSys',['SysML'])],
|
||||
}
|
||||
|
||||
|
||||
@@ -45,6 +47,7 @@ def scope(row,volume,through_year=2026):
|
||||
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'
|
||||
if family=='mlsys' and 'machine learning and systems' 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)
|
||||
@@ -67,6 +70,7 @@ def scope(row,volume,through_year=2026):
|
||||
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'
|
||||
if venue=='MLSys' and year<2019:return None,'nonarchival_predecessor'
|
||||
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'
|
||||
@@ -92,5 +96,11 @@ def scope(row,volume,through_year=2026):
|
||||
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
|
||||
result = {'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}
|
||||
if venue=='MM' and track=='main':
|
||||
# A main MM volume also includes doctoral, tutorials, companion papers
|
||||
# and other sections. DBLP XML omits the per-paper TOC section.
|
||||
result['track']='proceedings_unspecified'
|
||||
result['track_scope_note']='Main proceedings volume; per-paper section is absent from the bibliography. Do not infer a regular research track.'
|
||||
return result,None
|
||||
|
||||
@@ -141,6 +141,10 @@ def parse_program(data, abstracts, year, index_url):
|
||||
external = f'{year}/{forum}' if forum else f'{year}/program-{identity[1]}'
|
||||
missing_identity += not bool(forum)
|
||||
page = urljoin(ROOT, primary.get('virtualsite_url') or f'/virtual/{year}/{str(primary.get("eventtype", "Poster")).lower()}/{primary["id"]}')
|
||||
if year == 2020 and not primary.get('virtualsite_url'):
|
||||
# Numeric modern event IDs do not identify legacy poster pages.
|
||||
# The independently verified forum bridge supplies the paper URL.
|
||||
page = index_url
|
||||
topics = sorted({str(row['topic']).strip() for row in group if row.get('topic')})
|
||||
keywords = sorted({str(keyword).strip() for row in group for keyword in row.get('keywords', []) if keyword})
|
||||
records.append({'source': SOURCE, 'external_id': external, 'url': page,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Collect COLM/NeurIPS official abstract pages without downloading PDFs."""
|
||||
"""Collect COLM, NeurIPS and MLSys official metadata without downloading PDFs."""
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
@@ -8,8 +8,11 @@ from pathlib import Path
|
||||
from urllib.parse import urljoin,urlsplit
|
||||
|
||||
from paper_atlas.io.http import fetch
|
||||
from paper_atlas.io.publication_ids import mlsys_id
|
||||
|
||||
ROOTS={'colm':'https://proceedings.colm.cc/','neurips':'https://proceedings.neurips.cc/'}
|
||||
ROOTS={'colm':'https://proceedings.colm.cc/','neurips':'https://proceedings.neurips.cc/',
|
||||
'mlsys':'https://proceedings.mlsys.org/'}
|
||||
VENUES={'colm':'COLM','neurips':'NeurIPS','mlsys':'MLSys'}
|
||||
|
||||
|
||||
from paper_atlas.io.html import Document,Node,clean
|
||||
@@ -72,6 +75,22 @@ def parse_paper(html,url,source,year,index_url):
|
||||
track_text=' '.join(clean(n.text()) for n in nodes if 'paper-track' in n.attrs.get('class','').split())
|
||||
track=track_key(track_text or url)
|
||||
identifiers={}
|
||||
if source == 'mlsys':
|
||||
if year < 2019:
|
||||
raise ValueError('SysML 2018 was non-archival; official proceedings begin in 2019')
|
||||
own = mlsys_id(url)
|
||||
if not own or int(own.split('/')[0]) != year:
|
||||
raise ValueError('MLSys paper URL does not identify this publication year')
|
||||
identifiers['publication_mlsys'] = own
|
||||
if track_text:
|
||||
explicit = {'conference': 'main', 'main track': 'main', 'research track': 'main',
|
||||
'industry track': 'industry', 'industrial track': 'industry',
|
||||
'mlsys' + str(year): 'main'}
|
||||
if track_text.casefold() not in explicit:
|
||||
raise ValueError('Unclassified official MLSys track: ' + track_text)
|
||||
track = explicit[track_text.casefold()]
|
||||
elif year >= 2025 and not url.endswith('-Abstract-Conference.html'):
|
||||
raise ValueError('Modern MLSys paper lacks an explicit conference track')
|
||||
if meta['citation_doi']:
|
||||
identifiers['doi']=meta['citation_doi'][0]
|
||||
action_nodes=[]
|
||||
@@ -90,8 +109,11 @@ def parse_paper(html,url,source,year,index_url):
|
||||
from urllib.parse import parse_qs
|
||||
forum=parse_qs(parsed.query).get('id')
|
||||
if forum: identifiers['openreview']=forum[0]
|
||||
venue='COLM' if source=='colm' else 'NeurIPS'
|
||||
return {'source':source,'external_id':urlsplit(url).path,'url':url,'title':clean(title),'abstract':abstract,'authors':authors,'identifiers':identifiers,'venue':venue,'year':year,'track':track,'status':'accepted','raw_decision':'listed in official proceedings','list_url':index_url,'metadata':{'citation':dict(meta),'publication_state':'pre-proceedings' if 'pre-proceedings' in html.lower() else 'proceedings'}}
|
||||
venue=VENUES[source]
|
||||
result = {'source':source,'external_id':urlsplit(url).path,'url':url,'title':clean(title),'abstract':abstract,'authors':authors,'identifiers':identifiers,'venue':venue,'year':year,'track':track,'status':'accepted','raw_decision':'listed in official proceedings','list_url':index_url,'metadata':{'citation':dict(meta),'publication_state':'pre-proceedings' if 'pre-proceedings' in html.lower() else 'proceedings'}}
|
||||
if source == 'mlsys' and year == 2019:
|
||||
result['venue_aliases'] = ['SysML']
|
||||
return result
|
||||
|
||||
|
||||
def discover(source,cache):
|
||||
|
||||
@@ -85,7 +85,7 @@ def normalize(row, family, year, provenance, include_workshops=False):
|
||||
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)
|
||||
venue = allowed.get({'nips': 'neurips', 'acmmm': 'mm'}.get(family, family))
|
||||
if venue is None:
|
||||
return None, 'unmapped_venue'
|
||||
title = row.get('title')
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"""Canonical submission events with independent source and decision evidence."""
|
||||
from datetime import datetime, timezone
|
||||
import re
|
||||
from paper_atlas.io.publication_ids import mlsys_id
|
||||
|
||||
|
||||
PUBLICATIONS={'pmlr','acl_anthology','colm','neurips','cvf','ecva','dblp'}
|
||||
PUBLICATIONS={'pmlr','acl_anthology','colm','neurips','mlsys','cvf','ecva','dblp'}
|
||||
|
||||
|
||||
def timestamp(value=None):
|
||||
@@ -15,11 +16,26 @@ def timestamp(value=None):
|
||||
def identities_for_row(row):
|
||||
identities=list(row.get('submission_identifiers') or [])
|
||||
own=row.get('identifiers') or {}
|
||||
if row['source']=='mlsys':
|
||||
value=own.get('publication_mlsys')
|
||||
if (not isinstance(value,str) or not re.fullmatch(str(row['year'])+r'/[a-f0-9]{32}',value)
|
||||
or row['venue']!='MLSys'):
|
||||
raise ValueError('Invalid authoritative MLSys publisher identity')
|
||||
if own.get('openreview'):
|
||||
identities.append({'scheme':'openreview','value':own['openreview']})
|
||||
if row['source'] in PUBLICATIONS and row.get('status')=='accepted' and own.get('doi'):
|
||||
doi=re.sub(r'^https?://(?:dx\.)?doi\.org/','',str(own['doi']),flags=re.I).strip().lower()
|
||||
if re.fullmatch(r'10\.\d{4,9}/\S+',doi):identities.append({'scheme':'publication_doi','value':doi})
|
||||
publication_mlsys = own.get('publication_mlsys')
|
||||
if not publication_mlsys and row['source']=='dblp' and row['venue']=='MLSys':
|
||||
# Older normalized DBLP files retain their own publisher links even
|
||||
# when a newly supported historical URL label was not yet parsed.
|
||||
links = (row.get('metadata') or {}).get('bibliography', {}).get('ee', [])
|
||||
candidates = {identity for link in links if (identity := mlsys_id(link))}
|
||||
if len(candidates)==1:
|
||||
publication_mlsys = candidates.pop()
|
||||
if row['source'] in PUBLICATIONS and row.get('status')=='accepted' and publication_mlsys:
|
||||
identities.append({'scheme':'publication_mlsys','value':publication_mlsys})
|
||||
meta=row.get('metadata') or {}
|
||||
if row['source']=='iclr_program' and row['venue']=='ICLR' and meta.get('program_identity_type')=='submission' and meta.get('program_submission_number_verified') is True:
|
||||
identities.append({'scheme':'iclr_number','value':str(meta['program_source_id'])})
|
||||
@@ -30,6 +46,9 @@ def identities_for_row(row):
|
||||
valid=re.fullmatch(r'[A-Za-z0-9_-]+',value) and not re.match(r'20\d{2}-Oral-',value)
|
||||
elif scheme=='iclr_number':valid=re.fullmatch(r'[1-9]\d*',value) and row['venue']=='ICLR'
|
||||
elif scheme=='publication_doi':valid=re.fullmatch(r'10\.\d{4,9}/\S+',value)
|
||||
elif scheme=='publication_mlsys':
|
||||
valid=(re.fullmatch(str(row['year'])+r'/[a-f0-9]{32}',value)
|
||||
and row['venue']=='MLSys' and row['source'] in PUBLICATIONS and row.get('status')=='accepted')
|
||||
else:valid=False
|
||||
if not valid:raise ValueError('Invalid authoritative submission identity')
|
||||
result.add((scheme,value))
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
"""Stable identities from publisher-owned metadata self-links."""
|
||||
import re
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
|
||||
def mlsys_id(url):
|
||||
parsed = urlsplit(url)
|
||||
if parsed.scheme not in ('https', 'http') or parsed.netloc != 'proceedings.mlsys.org':
|
||||
return None
|
||||
match = re.fullmatch(r'/paper(?:_files/paper)?/((?:19|20)\d{2})/hash/([a-f0-9]{32})-Abstract(?:-(?:Conference|mlsys(20\d{2})))?\.html', parsed.path)
|
||||
if not match or match[3] and match[3] != match[1]:
|
||||
return None
|
||||
return match[1] + '/' + match[2]
|
||||
@@ -12,7 +12,9 @@
|
||||
"ijcai": "https://www.ijcai.org/all_proceedings/",
|
||||
"dblp": "https://dblp.org/db/conf/",
|
||||
"colm": "https://proceedings.colm.cc/",
|
||||
"iclr_program": "https://iclr.cc/virtual/2026/papers.html"
|
||||
"iclr_program": "https://iclr.cc/virtual/2026/papers.html",
|
||||
"mlsys": "https://proceedings.mlsys.org/",
|
||||
"acmmm": "https://www.acmmm.org/"
|
||||
},
|
||||
"venues": [
|
||||
{
|
||||
@@ -643,6 +645,37 @@
|
||||
"main"
|
||||
],
|
||||
"coverage": "pending"
|
||||
},
|
||||
{
|
||||
"acronym": "MLSys",
|
||||
"field": "ml_systems",
|
||||
"venue_type": "conference",
|
||||
"discovery_sources": [
|
||||
"mlsys",
|
||||
"dblp"
|
||||
],
|
||||
"scope": "user_confirmed_related",
|
||||
"years": "all_publicly_available",
|
||||
"tracks": [
|
||||
"main"
|
||||
],
|
||||
"coverage": "pending"
|
||||
},
|
||||
{
|
||||
"acronym": "MM",
|
||||
"field": "multimodal",
|
||||
"venue_type": "conference",
|
||||
"discovery_sources": [
|
||||
"dblp",
|
||||
"acmmm",
|
||||
"openreview"
|
||||
],
|
||||
"scope": "user_confirmed_related",
|
||||
"years": "all_publicly_available",
|
||||
"tracks": [
|
||||
"main"
|
||||
],
|
||||
"coverage": "pending"
|
||||
}
|
||||
],
|
||||
"additional_tracks": [
|
||||
|
||||
+4
-1
@@ -18,7 +18,10 @@ class CliTests(unittest.TestCase):
|
||||
|
||||
def test_unified_import_and_query_use_shipped_schema_and_registry(self):
|
||||
from paper_atlas.config import DEFAULT_REGISTRY
|
||||
self.assertEqual(len(json.loads(DEFAULT_REGISTRY.read_text())['venues']),41)
|
||||
venues = json.loads(DEFAULT_REGISTRY.read_text())['venues']
|
||||
names = [venue['acronym'] for venue in venues]
|
||||
self.assertTrue({'COLM', 'MM', 'MLSys'} <= set(names))
|
||||
self.assertEqual(len(names), len(set(names)))
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root=Path(tmp);db=root/'db.sqlite';source=root/'records.jsonl'
|
||||
source.write_text(json.dumps({'source':'example','external_id':'one','url':'https://example.org','title':'CLI paper','abstract':'Abstract','authors':['Jane Doe'],'venue':'Example','year':2026,'track':'main','status':'accepted'})+'\n')
|
||||
|
||||
@@ -64,6 +64,7 @@ class ICLRTests(unittest.TestCase):
|
||||
rows,_=self.parse([dict(row,paper_pdf_url=None)],year=2020)
|
||||
self.assertEqual(rows[0]['metadata']['identity_state'],'program_only')
|
||||
self.assertEqual(rows[0]['identifiers'],{})
|
||||
self.assertEqual(rows[0]['url'],'https://iclr.cc/virtual/2020/papers.html')
|
||||
|
||||
def test_only_referenced_official_assets_and_real_navigation_years(self):
|
||||
html='<a href="/archive/2013/">2013</a><a href="/Conferences/2026">2026</a><a href="https://other.example/2025">2025</a>'
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from paper_atlas.audits import iclr_legacy
|
||||
from paper_atlas.ingestion.backfill import backfill, queue_files
|
||||
from paper_atlas.ingestion.records import ingest_file
|
||||
from paper_atlas.db.store import Store
|
||||
|
||||
|
||||
class ICLRLegacyTests(unittest.TestCase):
|
||||
def program(self):
|
||||
return {'source': 'iclr_program', 'external_id': '2020/program-42', 'venue': 'ICLR',
|
||||
'year': 2020, 'track': 'main', 'status': 'accepted',
|
||||
'url': 'https://iclr.cc/virtual/2020/papers.html', 'title': 'Historical paper title',
|
||||
'abstract': 'Historical abstract', 'authors': ['Jane Doe', 'John Smith'],
|
||||
'identifiers': {}, 'raw_decision': 'listed in official program',
|
||||
'evidence_url': 'https://iclr.cc/virtual/2020/papers.html',
|
||||
'metadata': {'program_identity_type': 'submission', 'program_source_id': 42}}
|
||||
|
||||
def snapshot(self):
|
||||
return {'source': 'openreview_snapshot', 'external_id': '2020/realForum', 'venue': 'ICLR',
|
||||
'year': 2020, 'track': 'main', 'status': 'accepted',
|
||||
'url': 'https://openreview.net/forum?id=realForum', 'title': 'Historical paper title',
|
||||
'abstract': 'Historical abstract', 'authors': ['Jane Doe', 'John Smith'],
|
||||
'identifiers': {'openreview': 'realForum'},
|
||||
'submission_identifiers': [{'scheme': 'openreview', 'value': 'realForum'},
|
||||
{'scheme': 'iclr_number', 'value': '42'}],
|
||||
'decision_events': [{'status': 'accepted', 'raw_value': 'Accept',
|
||||
'source_decision_id': 'decision1', 'evidence_url': 'https://openreview.net/forum?id=realForum¬eId=decision1',
|
||||
'authority': {'public_readers_verified': True}}],
|
||||
'metadata': {'source_submission_number': 42, 'public_readers_verified': True}}
|
||||
|
||||
def legacy(self):
|
||||
return {'id': 'realForum', 'forum': 'realForum', 'content': {
|
||||
'iclr_id': 'realForum', 'title': 'Historical paper title', 'authors': ['Jane Doe', 'John Smith']}}
|
||||
|
||||
def test_independent_legacy_identity_preserves_existing_source_id_and_text(self):
|
||||
program, submission = self.program(), self.snapshot()
|
||||
submission.update(title='A later title', abstract='A later abstract', authors=['Jane Doe'])
|
||||
rows, checks = iclr_legacy.reconcile([program], {'realForum': submission}, [self.legacy()])
|
||||
self.assertEqual(rows[0]['external_id'], '2020/program-42')
|
||||
self.assertEqual(rows[0]['url'], 'https://iclr.cc/virtual_2020/poster_realForum.html')
|
||||
for field in ('title', 'abstract', 'authors'):
|
||||
self.assertEqual(rows[0][field], program[field])
|
||||
self.assertEqual(rows[0]['identifiers']['openreview'], 'realForum')
|
||||
self.assertTrue(rows[0]['metadata']['program_submission_number_verified'])
|
||||
self.assertEqual(checks['independent_legacy'], 1)
|
||||
self.assertEqual(program['identifiers'], {})
|
||||
|
||||
def test_program_number_and_shared_authors_alone_do_not_assign_a_forum(self):
|
||||
program = self.program()
|
||||
program.update(title='A different paper', abstract='An unrelated abstract')
|
||||
with self.assertRaisesRegex(ValueError, 'number alone'):
|
||||
iclr_legacy.reconcile([program], {'realForum': self.snapshot()}, [self.legacy()])
|
||||
|
||||
def test_independent_snapshot_title_and_abstract_handle_legacy_name_variants(self):
|
||||
legacy = self.legacy()
|
||||
legacy['content']['authors'] = ['J. Doe', 'J. Smith']
|
||||
rows, checks = iclr_legacy.reconcile([self.program()], {'realForum': self.snapshot()}, [legacy])
|
||||
self.assertEqual(checks['independent_snapshot'], 1)
|
||||
self.assertEqual(rows[0]['identifiers']['openreview'], 'realForum')
|
||||
|
||||
def test_reviewed_title_variant_is_exact_and_requires_public_author_corroboration(self):
|
||||
variant = iclr_legacy.REVIEWED_TITLE_VARIANT
|
||||
program, submission, legacy = self.program(), self.snapshot(), self.legacy()
|
||||
program['title'] = variant['program']
|
||||
submission.update(title=variant['snapshot'], abstract='A different revision')
|
||||
submission['identifiers']['openreview'] = variant['forum']
|
||||
legacy.update(id=variant['forum'], forum=variant['forum'])
|
||||
legacy['content'].update(iclr_id=variant['forum'], title=variant['legacy'])
|
||||
rows, checks = iclr_legacy.reconcile([program], {variant['forum']: submission}, [legacy])
|
||||
self.assertEqual(checks['reviewed_title_variant'], 1)
|
||||
self.assertTrue(rows[0]['metadata']['program_identity_verification']['reviewed_title_variant'])
|
||||
program['authors'] = ['Jane Doe']
|
||||
with self.assertRaisesRegex(ValueError, 'number alone'):
|
||||
iclr_legacy.reconcile([program], {variant['forum']: submission}, [legacy])
|
||||
|
||||
def test_conflicting_forum_ambiguous_number_and_repeated_legacy_identity_fail(self):
|
||||
p = self.program()
|
||||
p['identifiers']['openreview'] = 'anotherForum'
|
||||
with self.assertRaisesRegex(ValueError, 'conflicts'):
|
||||
iclr_legacy.reconcile([p], {'realForum': self.snapshot()}, [self.legacy()])
|
||||
duplicate = copy.deepcopy(self.snapshot())
|
||||
duplicate['identifiers']['openreview'] = 'otherForum'
|
||||
with self.assertRaisesRegex(ValueError, 'ambiguous'):
|
||||
iclr_legacy.reconcile([self.program()], {'realForum': self.snapshot(), 'otherForum': duplicate}, [self.legacy()])
|
||||
with self.assertRaisesRegex(ValueError, 'repeated'):
|
||||
iclr_legacy.reconcile([self.program()], {'realForum': self.snapshot()}, [self.legacy(), self.legacy()])
|
||||
|
||||
def write_file(self, path, rows, source):
|
||||
path.write_text(''.join(json.dumps(row) + '\n' for row in rows))
|
||||
manifest = {'source': source, 'venue': 'ICLR', 'year': 2020, 'track': 'main',
|
||||
'parser_version': 2, 'count': len(rows), 'missing_abstract_count': 0,
|
||||
'list_url': 'https://iclr.cc/virtual/2020/papers.html', 'status': 'collected',
|
||||
'submission_status_counts': {status: sum(r['status'] == status for r in rows)
|
||||
for status in {r['status'] for r in rows}}}
|
||||
path.with_suffix('.manifest.json').write_text(json.dumps(manifest))
|
||||
|
||||
def test_audited_bridge_then_snapshot_import_keeps_one_accepted_attempt(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
program_path, snapshot_path, legacy_path = (root / name for name in ('program.jsonl', 'snapshot.jsonl', 'legacy.json'))
|
||||
self.write_file(program_path, [self.program()], 'iclr_program')
|
||||
self.write_file(snapshot_path, [self.snapshot()], 'openreview_snapshot')
|
||||
raw = json.dumps([self.legacy()]).encode()
|
||||
legacy_path.write_bytes(raw)
|
||||
with patch.multiple(iclr_legacy, LEGACY_BYTES=len(raw), LEGACY_SHA256=hashlib.sha256(raw).hexdigest(), EXPECTED=1):
|
||||
report = iclr_legacy.audit(program_path, snapshot_path, legacy_path, root / 'out')
|
||||
db = root / 'papers.sqlite'
|
||||
ingest_file(db, program_path)
|
||||
queue = root / 'out/iclr-2020-backfill-queue.json'
|
||||
result = backfill(db, queue_files(queue))
|
||||
self.assertEqual(result['submissions_before'], result['submissions_after'])
|
||||
ingest_file(db, report['backfill_queue']['files'][0])
|
||||
ingest_file(db, snapshot_path)
|
||||
store = Store(db)
|
||||
try:
|
||||
self.assertEqual(store.conn.execute('SELECT count(*) FROM papers').fetchone()[0], 1)
|
||||
self.assertEqual(store.conn.execute('SELECT count(*) FROM submissions').fetchone()[0], 1)
|
||||
self.assertEqual(store.conn.execute('SELECT count(*) FROM submission_sources').fetchone()[0], 2)
|
||||
self.assertEqual(store.conn.execute('SELECT url FROM source_records WHERE external_id=?', ('2020/program-42',)).fetchone()[0],
|
||||
'https://iclr.cc/virtual_2020/poster_realForum.html')
|
||||
self.assertEqual(store.conn.execute('PRAGMA foreign_key_check').fetchall(), [])
|
||||
finally:
|
||||
store.conn.close()
|
||||
|
||||
def test_unverified_legacy_bytes_publish_no_output(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
legacy = root / 'legacy.json'
|
||||
legacy.write_text('[]')
|
||||
with self.assertRaisesRegex(ValueError, 'checksum'):
|
||||
iclr_legacy.audit(root / 'program', root / 'snapshot', legacy, root / 'out')
|
||||
self.assertFalse((root / 'out').exists())
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,166 @@
|
||||
import hashlib
|
||||
import json
|
||||
from contextlib import closing
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from paper_atlas.collectors.proceedings import discover, parse_paper
|
||||
from paper_atlas.collectors.dblp import XML_SHA
|
||||
from paper_atlas.collectors.dblp_records import identifiers, normalize_row, collect
|
||||
from paper_atlas.collectors.dblp_scope import scope
|
||||
from paper_atlas.collectors.public_openreview import normalize
|
||||
from paper_atlas.db.submissions import identities_for_row
|
||||
from paper_atlas.ingestion.records import ingest_file
|
||||
from paper_atlas.io.publication_ids import mlsys_id
|
||||
|
||||
|
||||
HASH = '0123456789abcdef' * 2
|
||||
ROOT = 'https://proceedings.mlsys.org/'
|
||||
|
||||
|
||||
def page(year=2026, modern=True):
|
||||
return ROOT + ('paper_files/paper/' if modern else 'paper/') + str(year) + '/hash/' + HASH + ('-Abstract-Conference.html' if modern else '-Abstract.html')
|
||||
|
||||
|
||||
def raw_row(family='mm', book='ACM Multimedia', year=2024):
|
||||
return {'record_type': 'inproceedings', 'key': f'conf/{family}/Paper24', 'publtype': None,
|
||||
'fields': {'title': ['A published paper'], 'author': ['Jane Doe', 'John Smith'],
|
||||
'booktitle': [book], 'year': [str(year)], 'crossref': [f'conf/{family}/{year}']}}
|
||||
|
||||
|
||||
def volume(family='mm', year=2024, title=None):
|
||||
return {'record_type': 'proceedings', 'key': f'conf/{family}/{year}',
|
||||
'fields': {'title': [title or f'ACM Multimedia {year}'], 'year': [str(year)]}}
|
||||
|
||||
|
||||
class MlsysMultimediaTests(unittest.TestCase):
|
||||
def test_mlsys_discovery_retains_all_official_years_and_ignores_pdf_or_other_host(self):
|
||||
html = ''.join(f'<a href="/paper_files/paper/{year}">MLSys {year}</a>' for year in range(2019, 2027))
|
||||
html += '<a href="https://example.org/paper_files/paper/2018">Other</a><a href="/paper/2026/file/paper.pdf">PDF</a>'
|
||||
with patch('paper_atlas.collectors.proceedings.fetch', return_value=html.encode()):
|
||||
self.assertEqual([year for _, year in discover('mlsys', '/unused')], list(range(2019, 2027)))
|
||||
|
||||
def test_mlsys_metadata_has_stable_publisher_identity_and_explicit_track(self):
|
||||
html = '<meta name="citation_title" content="A title"><meta name="citation_author" content="Doe, Jane"><span class="paper-track">Conference</span><h2>Abstract</h2><p>Real abstract.</p><h4>Name Change Policy</h4><p>Footer.</p>'
|
||||
row = parse_paper(html, page(), 'mlsys', 2026, ROOT + 'paper_files/paper/2026')
|
||||
self.assertEqual((row['venue'], row['track'], row['abstract'], row['authors']), ('MLSys', 'main', 'Real abstract.', ['Jane Doe']))
|
||||
self.assertEqual(row['identifiers']['publication_mlsys'], '2026/' + HASH)
|
||||
with self.assertRaisesRegex(ValueError, 'Unclassified'):
|
||||
parse_paper(html.replace('Conference', 'New Unknown Track'), page(), 'mlsys', 2026, ROOT)
|
||||
with self.assertRaisesRegex(ValueError, 'publication year'):
|
||||
parse_paper(html, page(), 'mlsys', 2025, ROOT)
|
||||
with self.assertRaisesRegex(ValueError, 'non-archival'):
|
||||
parse_paper(html, page(2018), 'mlsys', 2018, ROOT)
|
||||
|
||||
def test_year_label_and_retained_bibliography_links_bridge_the_2023_format(self):
|
||||
url = page(2023).replace('-Conference.html', '-mlsys2023.html')
|
||||
self.assertEqual(mlsys_id(url), '2023/' + HASH)
|
||||
self.assertIsNone(mlsys_id(url.replace('mlsys2023.html', 'mlsys2022.html')))
|
||||
official = parse_paper('<h1>Updated title</h1><span class="paper-track">mlsys2023</span><p class="paper-abstract">Official abstract</p>', url, 'mlsys', 2023, ROOT)
|
||||
self.assertEqual(official['track'], 'main')
|
||||
legacy = {'source': 'dblp', 'external_id': 'conf/mlsys/paper23', 'url': 'https://dblp.org/rec/conf/mlsys/paper23',
|
||||
'venue': 'MLSys', 'year': 2023, 'track': 'main', 'status': 'accepted', 'title': 'Bibliographic title',
|
||||
'abstract': None, 'authors': [], 'identifiers': {}, 'metadata': {'bibliography': {'ee': [url]}}}
|
||||
self.assertEqual(identities_for_row(legacy), [('publication_mlsys', '2023/' + HASH)])
|
||||
self.assertEqual(identities_for_row({**legacy, 'status': 'unknown'}), [])
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp); db = root / 'papers.sqlite'
|
||||
for row in [legacy, official]:
|
||||
path = root / (row['source'] + '.jsonl'); path.write_text(json.dumps(row) + '\n')
|
||||
path.with_suffix('.manifest.json').write_text(json.dumps({'source': row['source'], 'parser_version': 2, 'list_url': ROOT, 'count': 1}))
|
||||
ingest_file(db, path)
|
||||
with closing(sqlite3.connect(db)) as c:
|
||||
self.assertEqual(c.execute('SELECT count(*) FROM papers').fetchone()[0], 1)
|
||||
self.assertEqual(c.execute('SELECT count(*) FROM submissions').fetchone()[0], 1)
|
||||
self.assertEqual(c.execute('SELECT count(*) FROM submission_sources').fetchone()[0], 2)
|
||||
|
||||
def test_legacy_mlsys_and_sysml_alias_preserve_same_publication_identity(self):
|
||||
self.assertEqual(mlsys_id(page(2019, False)), mlsys_id(page(2019)))
|
||||
self.assertIsNone(mlsys_id(page().replace('proceedings.mlsys.org', 'example.org')))
|
||||
own, ambiguous = identifiers({'ee': [page(2019, False), page(2019)], 'cite': [page(2025)]})
|
||||
self.assertEqual(own, {'publication_mlsys': '2019/' + HASH}); self.assertFalse(ambiguous)
|
||||
_, ambiguous = identifiers({'ee': [page(2019), page(2019).replace(HASH, 'a' * 32)]})
|
||||
self.assertEqual(len(ambiguous['publication_mlsys']), 2)
|
||||
row = parse_paper('<h1>Legacy</h1><p class="paper-abstract">Abstract</p>', page(2019, False), 'mlsys', 2019, ROOT)
|
||||
self.assertEqual(row['venue_aliases'], ['SysML'])
|
||||
self.assertEqual(row['track'], 'main')
|
||||
|
||||
def test_mm_main_numbered_parts_and_workshops_remain_separate(self):
|
||||
for book in ['ACM Multimedia', 'ACM Multimedia (1)', 'ACM Multimedia (2)']:
|
||||
result, reason = scope(raw_row(book=book), volume())
|
||||
self.assertIsNone(reason); self.assertEqual(result['venue'], 'MM'); self.assertEqual(result['track'], 'proceedings_unspecified')
|
||||
self.assertIn('per-paper section', result['track_scope_note'])
|
||||
self.assertIn('ACMMM', result['venue_aliases'])
|
||||
self.assertEqual(scope(raw_row(book='ACM Multimedia Workshops'), volume())[0]['track'], 'workshop')
|
||||
self.assertEqual(scope(raw_row(book='ACM Multimedia (Thematic Workshops)'), volume())[0]['track'], 'workshop')
|
||||
self.assertIsNone(scope(raw_row(book='MMSports@MM'), volume())[0])
|
||||
self.assertEqual(normalize_row(raw_row(book='ACM Multimedia Workshops'), {'conf/mm/2024': volume()}, {'MM'})[1], 'separate_workshop')
|
||||
|
||||
def test_sysml_alias_requires_machine_learning_proceedings(self):
|
||||
row = raw_row('mlsys', 'SysML', 2019)
|
||||
result, reason = scope(row, volume('mlsys', 2019, 'Conference on Machine Learning and Systems, SysML 2019'))
|
||||
self.assertIsNone(reason); self.assertEqual(result['venue'], 'MLSys'); self.assertEqual(result['venue_aliases'], ['SysML'])
|
||||
self.assertIsNone(scope(row, volume('mlsys', 2019, 'Systems Modeling Language 2019'))[0])
|
||||
|
||||
def test_bibliography_and_official_abstract_import_as_one_paper_and_submission(self):
|
||||
raw = raw_row('mlsys', 'SysML', 2019); raw['fields']['ee'] = [page(2019, False)]
|
||||
bib, reason = normalize_row(raw, {'conf/mlsys/2019': volume('mlsys', 2019, 'Machine Learning and Systems 2019')}, {'MLSys'})
|
||||
self.assertIsNone(reason)
|
||||
official = parse_paper('<meta name="citation_title" content="An updated title"><p class="paper-abstract">The official abstract.</p>', page(2019), 'mlsys', 2019, ROOT)
|
||||
# Different title and absent official authors deliberately prevent a title-based bridge.
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp); db = root / 'papers.sqlite'
|
||||
for row in [bib, official]:
|
||||
path = root / (row['source'] + '.jsonl'); path.write_text(json.dumps(row) + '\n')
|
||||
path.with_suffix('.manifest.json').write_text(json.dumps({'source': row['source'], 'parser_version': 2,
|
||||
'list_url': ROOT, 'count': 1, 'venue': 'MLSys', 'year': 2019, 'track': 'main'}))
|
||||
ingest_file(db, path)
|
||||
with closing(sqlite3.connect(db)) as c:
|
||||
self.assertEqual(c.execute('SELECT count(*) FROM papers').fetchone()[0], 1)
|
||||
self.assertEqual(c.execute('SELECT count(*) FROM submissions').fetchone()[0], 1)
|
||||
self.assertEqual(c.execute('SELECT count(*) FROM submission_sources').fetchone()[0], 2)
|
||||
self.assertEqual(c.execute('SELECT abstract FROM papers').fetchone()[0], 'The official abstract.')
|
||||
self.assertEqual(c.execute('PRAGMA foreign_key_check').fetchall(), [])
|
||||
|
||||
def test_publisher_identity_cannot_bridge_rejection_other_year_or_venue(self):
|
||||
row = {'source': 'mlsys', 'venue': 'MLSys', 'year': 2026, 'status': 'accepted',
|
||||
'identifiers': {'publication_mlsys': '2026/' + HASH}}
|
||||
self.assertEqual(identities_for_row(row), [('publication_mlsys', '2026/' + HASH)])
|
||||
self.assertEqual(identities_for_row({**row, 'status': 'rejected'}), [])
|
||||
with self.assertRaisesRegex(ValueError, 'Invalid authoritative'):
|
||||
identities_for_row({**row, 'identifiers': {}})
|
||||
for changed in [dict(year=2025), dict(venue='MM'), dict(source='curated')]:
|
||||
bad = {**row, **changed, 'submission_identifiers': [{'scheme': 'publication_mlsys', 'value': '2026/' + HASH}]}
|
||||
with self.assertRaisesRegex(ValueError, 'Invalid authoritative'):
|
||||
identities_for_row(bad)
|
||||
|
||||
def test_explicit_mm_negative_assertion_maps_to_mm_without_inventing_labels(self):
|
||||
provenance = {'source_file': 'https://example.org/pinned', 'source_line': 1}
|
||||
row = {'id': 'realForum1', 'title': 'A title', 'abstract': 'An abstract', 'track': 'main', 'status': 'Reject'}
|
||||
normalized, reason = normalize(row, 'acmmm', 2025, provenance)
|
||||
self.assertIsNone(reason); self.assertEqual(normalized['venue'], 'MM')
|
||||
self.assertEqual(normalized['status'], 'rejected')
|
||||
self.assertIsNone(normalize({**row, 'status': 'Submitted'}, 'acmmm', 2025, provenance)[0])
|
||||
|
||||
def test_old_candidate_subset_cannot_silently_omit_new_requested_venues(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp); catalogue = root / 'dblp-catalogue.sqlite'
|
||||
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(?,?)', ('conf/mm/2024', json.dumps(volume())))
|
||||
candidates = root / 'candidates.jsonl'; candidates.write_text(json.dumps(raw_row()) + '\n')
|
||||
original = candidates.read_bytes()
|
||||
catalogue.with_name('dblp-ai-candidates.manifest.json').write_text(json.dumps({
|
||||
'snapshot_sha256': XML_SHA, 'candidate_sha256': hashlib.sha256(original).hexdigest(),
|
||||
'candidate_family_counts': {'aaai': 1}, 'record_types': {'proceedings': 1}}))
|
||||
with self.assertRaisesRegex(ValueError, 'original --archive'):
|
||||
collect(catalogue, root / 'out', ['MM'], candidates=candidates)
|
||||
self.assertEqual(candidates.read_bytes(), original)
|
||||
self.assertFalse((root / 'out').exists())
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user