Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b4e249e675 |
@@ -48,15 +48,6 @@ 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)
|
||||
subprocess.run([python,'-I','-B','-m','paper_atlas','collect','rss2026-proceedings','--help'],cwd=outside,check=True)
|
||||
rss_fixture = json.loads(gzip.decompress((src/'tests/fixtures/rss2026-source.json.gz').read_bytes()))
|
||||
rss_snapshots = root/'rss2026-snapshots'
|
||||
rss_snapshots.mkdir()
|
||||
for name, html in rss_fixture['pages'].items():
|
||||
(rss_snapshots/name).write_bytes(html.encode('utf-8'))
|
||||
(rss_snapshots/'receipts.json').write_text(json.dumps(rss_fixture['receipts']))
|
||||
subprocess.run([python,'-I','-B','-m','paper_atlas','collect','rss2026-proceedings','--snapshots',str(rss_snapshots),'--out',str(root/'installed-rss2026')],cwd=outside,check=True)
|
||||
assert json.loads((root/'installed-rss2026/collection-report.json').read_text())['records'] == 210
|
||||
subprocess.run([python,'-I','-B','-m','paper_atlas','collect','www-archive','--help'],cwd=outside,check=True)
|
||||
subprocess.run([python,'-I','-B','-m','paper_atlas','collect','aamas-proceedings','--help'],cwd=outside,check=True)
|
||||
subprocess.run([python,'-I','-B','-m','paper_atlas','collect','neurips-program','--help'],cwd=outside,check=True)
|
||||
|
||||
@@ -118,5 +118,3 @@ CI 在已授权的远程 runner 上运行测试、构建 wheel,并从源码目
|
||||
已导入整卷的逐条轨道修正见 [edition-tracks 维护流程](docs/edition-tracks.md):按完整证据生成只读计划,保留原投稿和决定 ID,事务性修正 edition、标识与覆盖,并防止旧整卷刷新覆盖分类。
|
||||
|
||||
入库前可使用通用的[来源身份只读预检](docs/source-identity-audit.md),导出候选论文、稳定标识和既有投稿决定证据;候选结果需要审核,不会自动批准导入。
|
||||
|
||||
RSS 2026 已独立适配官方论文集与录用表;使用方法和与 2025 demo 分类的区别见 [RSS 2026 来源说明](docs/rss2026-proceedings.md)。
|
||||
|
||||
@@ -342,3 +342,9 @@ ICML 2024 起的一个 PMLR 卷可以同时包含研究主轨道与 Position Pap
|
||||
2024 年的官方节目单把两轨都放在 `Conference`,但提供 PMLR 论文自链接。该年仅采用组织方在 [2025 Position CFP](https://icml.cc/Conferences/2025/CallForPositionPapers) 明确链接的 [2024 Position 论文标题筛选](https://icml.cc/virtual/2024/papers.html?filter=titles&search=position%3A) 规则;同时保存规则页面的哈希。这个规则不用于其他会议或年份。官方节目单将 `guyard24a` 链接误写为 `theo24a`:只对这条已核查的映射额外要求标题精确一致、至少两名公开作者重合,不进行全库模糊匹配。
|
||||
|
||||
导入与身份回填拒绝 ICML 2024 起 parser_version<3 或没有官方轨道证据的旧文件。已有主库中的错误轨道需要在独占维护锁内迁移原投稿事件、保留决定与来源关联;仅重新导入拆分文件会留下旧的主轨道事件,不能作为修复方式。完成迁移前,不把新文件直接导入现有库。
|
||||
|
||||
### RSS 发表身份
|
||||
|
||||
已核验的 `rss_proceedings` 录用记录与其他出版社来源一样,将 DOI 作为届次范围内的 `publication_doi` 投稿身份。这样同一 RSS 出版物先由官方论文集、后由 DBLP 导入(或反向)时可以共享一次投稿,并保留各自来源和决定观察。此规则不把节目显示编号当原始 OpenReview 投稿号,不把拒稿/撤稿升级为录用,也不跨届次合并历史。
|
||||
|
||||
这不证明任意 DOI 的科学作品归属:导入前仍需核查原始标题、完整作者、版本和已有标识冲突。变更只作用于后续导入;不会自动回填、合并或改写旧 RSS 记录,旧记录需要独立审查计划。
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
# RSS 2026 论文集来源
|
||||
|
||||
`collect rss2026-proceedings` 是独立的年度来源适配器,不依赖尚未合入主线的 RSS 2025 PR #43,也不改变其 demo 分类。使用包内 HTML、HTTP 工具和统一 CLI;不连接数据库,不下载 PDF。
|
||||
|
||||
```sh
|
||||
paper-atlas collect rss2026-proceedings --cache /data/paper-index/cache/rss2026 \
|
||||
--out /data/paper-index/collected/rss2026-native-v1
|
||||
# 固定证据复现:包含 receipts.json 和对应 HTML 文件
|
||||
paper-atlas collect rss2026-proceedings --snapshots /data/paper-index/collected/rss2026-primary-v1 \
|
||||
--out /data/paper-index/collected/rss2026-native-replay-v1
|
||||
```
|
||||
|
||||
核对的来源为 [第22卷论文集](https://www.roboticsproceedings.org/rss22/)、[录用表](https://roboticsconference.org/2026/program/papers/)、[CFP](https://roboticsconference.org/2026/information/cfp/) 和 [Sponsor Demos](https://roboticsconference.org/2026/program/demos/)。当前审查目录为210篇。CFP明确2026没有单独 demo paper 类别,Science/Systems论文放在 main;赞助商展示不作为论文,也不推断逐篇是否现场演示。投稿录用后才公开、评审不公开;此来源不能提供拒稿或评审全集。
|
||||
|
||||
每篇保留出版社原始标题、完整作者顺序、摘要、日期、BibTeX、原始 DOI,以及独立节目标题、完整作者和缩略显示、session、显示链接。节目显示编号不是原始 OpenReview 投稿编号,不写入 submission_identifiers。六个缩略作者预览按完整隐藏副本恢复核验,不假设固定字符长度;六个姓名差异和一处 RLinf/RLux 标题差异按已审核原文指纹识别并保留,不推断人名别名或官方勘误。未知差异会停止。
|
||||
|
||||
快照收据必须逐文件匹配固定URL、HTTP200、字节数、哈希和带时区的真实采集时间;拒绝路径穿越、重复收据和符号链接。缓存模式只请求已审核的固定公开HTML URL,访问限制由HTTP工具正常报错,不绕过。每份原始HTML按哈希保存到输出 evidence/。
|
||||
|
||||
输出目录必须不存在;失败目录留存供检查,换新目录重试。`collection-report.json` 最后原子写入,缺少此文件不能视为完成。清单始终 partial;已采集不代表主库完整、身份已核验或科学作品已去重。DOI也是待核对的候选标识,入库前仍需实时 PostgreSQL 身份审计、版本/作者关联复核和保护基线;入库后独立验收,所有导入结束后再做全库去重。
|
||||
|
||||
2026原始来源审计已冻结214份HTML且210篇均有摘要。回归 fixture 同时包含原始来源和独立生成的逐篇预期值,测试比较210篇完整标题/作者/摘要/DOI/日期;构建和测试仅在已授权 ces-control:host 远程 runner 进行。
|
||||
@@ -23,7 +23,6 @@ COMMANDS = {
|
||||
('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','rss2026-proceedings'): ('paper_atlas.collectors.rss2026', {}, '核验 RSS 2026 官方论文集与独立录用表'),
|
||||
('collect','cvf'): ('paper_atlas.collectors.vision', {'source':'cvf'}, 'CVPR / ICCV 官方开放目录'),
|
||||
('collect','ecva'): ('paper_atlas.collectors.vision', {'source':'ecva'}, 'ECCV 官方开放目录'),
|
||||
('collect','aamas-proceedings'): ('paper_atlas.collectors.aamas', {}, '按官方目录区分 AAMAS 发表形式和摘要'),
|
||||
|
||||
@@ -1,222 +0,0 @@
|
||||
"""Reviewed RSS 2026 publisher metadata, with separate program evidence."""
|
||||
REVIEWED_AUTHOR_VARIANTS = {16: '04de7e813b7c68b5c1764e90fe41bcd2e45259d46749bb2cc54bac5150c8f25b', 27: '975f6adc4d15045f9c8829689a759c5e3a44eecb3410a18ff99b37226d9c8b97', 78: '8208d0950468295c2b73dae28658566b1c726f65a4167f490039cf3449aa2db4', 132: '31eee6289de404f3d859dd0d9de4dd40e968f86b89312de41df87ae01c529711', 199: 'ced0bc9e38b28b4a490799fd94315f104ece0df28be428b3cc417db8ea797b9d', 204: '7c6959e6668bf44af09959388f6b9e2d883a76106bf7f191dc9949368c969ee5'}
|
||||
import argparse
|
||||
from datetime import datetime, date
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import unicodedata
|
||||
|
||||
from paper_atlas.io.html import Document, Node, clean
|
||||
from paper_atlas.io.http import fetch
|
||||
|
||||
SOURCE = 'rss_proceedings'
|
||||
ROOT = 'https://www.roboticsproceedings.org/rss22/'
|
||||
HEADING = 'Robotics: Science and Systems XXII'
|
||||
PAGES = {'index.html': ROOT,
|
||||
'accepted.html': 'https://roboticsconference.org/2026/program/papers/',
|
||||
'cfp.html': 'https://roboticsconference.org/2026/information/cfp/',
|
||||
'sponsor-demos.html': 'https://roboticsconference.org/2026/program/demos/'}
|
||||
|
||||
|
||||
def digest(raw):
|
||||
return hashlib.sha256(raw).hexdigest()
|
||||
|
||||
|
||||
def one(values):
|
||||
if len(values) != 1:
|
||||
raise ValueError('Expected one reviewed HTML field')
|
||||
return values[0]
|
||||
|
||||
|
||||
def comparable(value):
|
||||
return unicodedata.normalize('NFC', clean(value))
|
||||
|
||||
|
||||
def parse_lists(index_html, program_html, policy_html, sponsors_html):
|
||||
policy = clean(Document(policy_html).root.text())
|
||||
for text in ('RSS 2026 will not have a separate category for demo papers.',
|
||||
'papers will only be public after acceptance', 'reviews will not be publicly visible'):
|
||||
if text not in policy:
|
||||
raise ValueError('Reviewed 2026 publication/demo/privacy policy changed')
|
||||
if [clean(n.text()) for n in Document(sponsors_html).root.walk() if n.tag == 'h1'] != ['Sponsor Demos']:
|
||||
raise ValueError('Sponsor activity page changed; never classify these as papers')
|
||||
nodes = list(Document(index_html).root.walk())
|
||||
if [clean(n.text()) for n in nodes if n.tag == 'h1'] != [HEADING]:
|
||||
raise ValueError('Unexpected publisher edition')
|
||||
index = {}
|
||||
for row in (n for n in nodes if n.tag == 'tr'):
|
||||
links = [n for n in row.walk() if n.tag == 'a' and re.fullmatch(r'p[0-9]{3}\.html', n.attrs.get('href', ''))]
|
||||
if not links:
|
||||
continue
|
||||
link = one(links); pid = int(link.attrs['href'][1:4])
|
||||
if pid in index:
|
||||
raise ValueError('Repeated publisher ID')
|
||||
index[pid] = {'title': clean(link.text()), 'authors': clean(one([n for n in row.walk() if n.tag == 'i']).text())}
|
||||
program = {}
|
||||
if [clean(n.text()) for n in Document(program_html).root.walk() if n.tag == 'h1'] != ['Accepted Papers']:
|
||||
raise ValueError('Unexpected accepted program')
|
||||
for row in (n for n in Document(program_html).root.walk() if n.tag == 'tr'):
|
||||
cells = [n for n in row.children if isinstance(n, Node) and n.tag == 'td']
|
||||
if not cells:
|
||||
continue
|
||||
if len(cells) != 4 or not re.fullmatch('[1-9][0-9]*', clean(cells[0].text())):
|
||||
raise ValueError('Unexpected program row')
|
||||
pid = int(clean(cells[0].text()))
|
||||
if pid in program:
|
||||
raise ValueError('Repeated program ID')
|
||||
preview = clean(' '.join(n for n in cells[3].children if isinstance(n, str)))
|
||||
full = one([clean(n.text()) for n in cells[3].walk() if n.tag == 'div'
|
||||
and 'display:none' in n.attrs.get('style', '').replace(' ', '')])
|
||||
if not preview or not full.startswith(preview):
|
||||
raise ValueError('Author preview is not a prefix of complete author evidence')
|
||||
links = [n.attrs.get('href') for n in cells[2].walk() if n.tag == 'a']
|
||||
if links != [f'/2026/program/papers/{pid}/']:
|
||||
raise ValueError('Program display link changed')
|
||||
program[pid] = {'display_id': pid, 'title': clean(cells[2].text()), 'authors_complete': full,
|
||||
'authors_preview': preview, 'session': clean(cells[1].text()),
|
||||
'program_url': 'https://roboticsconference.org'+links[0]}
|
||||
if set(index) != set(program) or set(index) != set(range(1, 211)):
|
||||
raise ValueError('Reviewed 210-entry source census changed')
|
||||
return index, program
|
||||
|
||||
|
||||
def parse_paper(html, pid, index, program):
|
||||
nodes = list(Document(html).root.walk()); meta = {}
|
||||
for n in nodes:
|
||||
if n.tag == 'meta' and n.attrs.get('name', '').startswith('citation_'):
|
||||
meta.setdefault(n.attrs['name'], []).append(n.attrs.get('content', ''))
|
||||
title = one(meta.get('citation_title', [])); authors = meta.get('citation_author', [])
|
||||
day = one(meta.get('citation_publication_date', []))
|
||||
if not re.fullmatch(r'2026/[0-9]{2}/[0-9]{2}', day):
|
||||
raise ValueError('Unexpected publication date')
|
||||
date.fromisoformat(day.replace('/', '-'))
|
||||
if (meta.get('citation_volume') != ['22'] or meta.get('citation_conference_title') != [HEADING]
|
||||
or [clean(n.text()) for n in nodes if n.tag == 'h1'] != [HEADING]
|
||||
or not authors or any(not clean(a) for a in authors)
|
||||
or comparable(title) != comparable(index['title'])
|
||||
or comparable(', '.join(authors)) != comparable(index['authors'])
|
||||
or [comparable(n.text()) for n in nodes if n.tag == 'h3'] != [comparable(title)]):
|
||||
raise ValueError('Publisher citation/index/visible metadata disagree')
|
||||
pdf = one(meta.get('citation_pdf_url', []))
|
||||
if pdf not in (ROOT+f'p{pid:03d}.pdf', ROOT.replace('https:', 'http:')+f'p{pid:03d}.pdf'):
|
||||
raise ValueError('Citation PDF ID mismatch; PDFs are never fetched')
|
||||
bib = one([n.text() for n in nodes if n.tag == 'pre'])
|
||||
doi = one(re.findall(r'^\s*DOI\s*=\s*\{([^{}]+)\}', bib, re.I | re.M))
|
||||
year = one(re.findall(r'^\s*YEAR\s*=\s*\{([^{}]+)\}', bib, re.I | re.M))
|
||||
if doi != f'10.15607/RSS.2026.XXII.{pid:03d}' or year != '2026':
|
||||
raise ValueError('BibTeX DOI/year mismatch')
|
||||
variants = []
|
||||
if comparable(', '.join(authors)) != comparable(program['authors_complete']):
|
||||
key = digest(json.dumps([authors, program['authors_complete']], ensure_ascii=False, separators=(',', ':')).encode())
|
||||
if REVIEWED_AUTHOR_VARIANTS.get(pid) != key:
|
||||
raise ValueError('Unreviewed complete author variant')
|
||||
variants.append('reviewed_program_author_name_variant_not_person_alias')
|
||||
if comparable(title) != comparable(program['title']):
|
||||
if (pid, title, program['title']) != (89,
|
||||
'RLinf-VLA: A Unified and Efficient Framework for Reinforcement Learning of Vision-Language-Action Models',
|
||||
'RLux-VLA: A Unified and Efficient Framework for Reinforcement Learning of Vision-Language-Action Models'):
|
||||
raise ValueError('Unreviewed program title variant')
|
||||
variants.append('reviewed_program_title_variant_not_erratum')
|
||||
inside = False; parts = []; markers = []
|
||||
for n in (n for n in nodes if n.tag == 'p'):
|
||||
labels = [clean(b.text()) for b in n.walk() if b.tag == 'b']
|
||||
if 'Abstract:' in labels:
|
||||
inside = True; markers.append('start'); value = clean(n.text()).removeprefix('Abstract:').strip()
|
||||
if value: parts.append(value)
|
||||
elif 'Download:' in labels:
|
||||
inside = False; markers.append('end')
|
||||
elif inside:
|
||||
parts.append(clean(n.text()))
|
||||
if markers != ['start', 'end'] or not any(parts):
|
||||
raise ValueError('Missing or changed publisher abstract layout')
|
||||
return {'title': title, 'authors': authors, 'abstract': '\n\n'.join(v for v in parts if v),
|
||||
'doi_raw': doi, 'publication_date': day, 'bibtex': bib, 'program_variants': variants}
|
||||
|
||||
|
||||
def collect(out, *, snapshots=None, cache=None):
|
||||
if (snapshots is None) == (cache is None):
|
||||
raise ValueError('Specify snapshots or cache')
|
||||
out = Path(out)
|
||||
if out.exists():
|
||||
raise ValueError('Output directory must not exist; inspect partial/completed runs first')
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
out.mkdir()
|
||||
evidence = {}; receipts = {}
|
||||
if snapshots is not None:
|
||||
root = Path(snapshots)
|
||||
try:
|
||||
for r in json.loads((root/'receipts.json').read_bytes()):
|
||||
name = r['file']
|
||||
if not isinstance(name, str) or Path(name).name != name or name in receipts:
|
||||
raise ValueError('Unsafe or repeated snapshot receipt')
|
||||
receipts[name] = r
|
||||
except Exception as error:
|
||||
(out/'failed.json').write_text(json.dumps({'status': 'failed_not_complete', 'error_type': type(error).__name__, 'database_written': False})+'\n')
|
||||
raise
|
||||
def page(name, url):
|
||||
if snapshots is not None:
|
||||
path = root/name
|
||||
if path.is_symlink(): raise ValueError('Snapshot symlink rejected')
|
||||
raw = path.read_bytes(); r = receipts[name]
|
||||
if (r['url'] != url or r.get('final_url', url) != url or r['http_status'] != 200
|
||||
or r['bytes'] != len(raw) or r['sha256'] != digest(raw)):
|
||||
raise ValueError('Snapshot hash/receipt mismatch')
|
||||
at = r['at']
|
||||
else:
|
||||
raw = fetch(url, cache, limit=8*1024*1024)
|
||||
sidecar = json.loads((Path(cache)/digest(url.encode())).with_suffix('.json').read_bytes())
|
||||
if sidecar['sha256'] != digest(raw): raise ValueError('Cache receipt mismatch')
|
||||
at = sidecar['retrieved_at']
|
||||
if len(raw) > 8*1024*1024 or datetime.fromisoformat(at.replace('Z', '+00:00')).utcoffset() is None:
|
||||
raise ValueError('Invalid source size/timezone')
|
||||
relative = 'evidence/'+digest(raw)+'.html'; target = out/relative
|
||||
target.parent.mkdir(exist_ok=True); target.write_bytes(raw)
|
||||
evidence[name] = {'file': relative, 'sha256': digest(raw), 'url': url, 'observed_at': at}
|
||||
return raw.decode('utf-8')
|
||||
try:
|
||||
pages = {name: page(name, url) for name, url in PAGES.items()}
|
||||
index, program = parse_lists(pages['index.html'], pages['accepted.html'], pages['cfp.html'], pages['sponsor-demos.html'])
|
||||
records = []
|
||||
for pid in sorted(index):
|
||||
name = f'p{pid:03d}.html'; fields = parse_paper(page(name, ROOT+name), pid, index[pid], program[pid])
|
||||
row = {'source': SOURCE, 'external_id': f'2026/p{pid:03d}', 'url': ROOT+name,
|
||||
'title': fields['title'], 'abstract': fields['abstract'], 'authors': fields['authors'],
|
||||
'identifiers': {'doi': fields['doi_raw'].lower()}, 'venue': 'RSS', 'venue_aliases': [],
|
||||
'year': 2026, 'track': 'main', 'status': 'accepted',
|
||||
'raw_decision': 'listed in official RSS 2026 proceedings and accepted papers',
|
||||
'observed_at': evidence[name]['observed_at'],
|
||||
'metadata': {'publisher_date': fields['publication_date'], 'publisher_doi_raw': fields['doi_raw'],
|
||||
'original_bibtex': fields['bibtex'], 'program': program[pid],
|
||||
'program_variants': fields['program_variants'], 'source_evidence': evidence[name],
|
||||
'index_evidence': evidence['index.html'], 'program_evidence': evidence['accepted.html'],
|
||||
'policy_evidence': evidence['cfp.html'],
|
||||
'scope': '2026 Science/Systems papers; no separate demo-paper category. Program display ID is not an original submission ID.'}}
|
||||
records.append(row)
|
||||
path = out/'rss-proceedings-2026-main.jsonl'
|
||||
raw = b''.join((json.dumps(r, ensure_ascii=False)+'\n').encode() for r in records)
|
||||
path.write_bytes(raw)
|
||||
manifest = {'source': SOURCE, 'parser_version': 1, 'venue': 'RSS', 'year': 2026, 'track': 'main',
|
||||
'count': 210, 'file': str(path), 'file_sha256': digest(raw), 'list_url': ROOT,
|
||||
'status': 'partial', 'missing_abstract_count': 0}
|
||||
path.with_suffix('.manifest.json').write_text(json.dumps(manifest, indent=2)+'\n')
|
||||
report = {'status': 'collected_pending_identity_review', 'source': SOURCE, 'records': 210,
|
||||
'files': [manifest], 'source_evidence': evidence, 'database_written': False, 'pdfs_downloaded': False,
|
||||
'limitations': 'Publisher metadata only; not database import, scientific-work deduplication, private reviews or a public rejection set.'}
|
||||
temporary = out/'collection-report.json.part'; temporary.write_text(json.dumps(report, ensure_ascii=False, indent=2)+'\n')
|
||||
temporary.replace(out/'collection-report.json')
|
||||
return report
|
||||
except Exception as error:
|
||||
(out/'failed.json').write_text(json.dumps({'status': 'failed_not_complete', 'error_type': type(error).__name__, 'database_written': False})+'\n')
|
||||
raise
|
||||
|
||||
|
||||
def main(argv=None, *, prog=None):
|
||||
p = argparse.ArgumentParser(prog=prog, description=__doc__)
|
||||
p.add_argument('--out', required=True, type=Path)
|
||||
g = p.add_mutually_exclusive_group(required=True)
|
||||
g.add_argument('--snapshots', type=Path); g.add_argument('--cache', type=Path)
|
||||
args = p.parse_args(argv)
|
||||
report = collect(args.out, snapshots=args.snapshots, cache=args.cache)
|
||||
print(json.dumps({'status': report['status'], 'records': report['records'], 'database_written': False}))
|
||||
@@ -4,7 +4,7 @@ import re
|
||||
from paper_atlas.io.publication_ids import mlsys_id
|
||||
|
||||
|
||||
PUBLICATIONS={'pmlr','acl_anthology','colm','neurips','mlsys','cvf','ecva','dblp','aamas_proceedings'}
|
||||
PUBLICATIONS={'pmlr','acl_anthology','colm','neurips','mlsys','cvf','ecva','dblp','aamas_proceedings','rss_proceedings'}
|
||||
|
||||
|
||||
def timestamp(value=None):
|
||||
|
||||
BIN
Binary file not shown.
@@ -1,100 +0,0 @@
|
||||
import gzip
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
from paper_atlas.collectors import rss2026 as rss
|
||||
|
||||
FIXTURE = Path(__file__).parent/'fixtures/rss2026-source.json.gz'
|
||||
|
||||
class Rss2026Tests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.fixture=json.loads(gzip.decompress(FIXTURE.read_bytes()))
|
||||
|
||||
def setUp(self):
|
||||
self.tmp=tempfile.TemporaryDirectory();self.addCleanup(self.tmp.cleanup)
|
||||
self.root=Path(self.tmp.name);self.snap=self.root/'snapshots';self.snap.mkdir()
|
||||
for name,text in self.fixture['pages'].items():(self.snap/name).write_bytes(text.encode())
|
||||
(self.snap/'receipts.json').write_text(json.dumps(self.fixture['receipts']))
|
||||
self.pages=self.fixture['pages'];self.index,self.program=self.lists()
|
||||
|
||||
def lists(self,**changes):
|
||||
pages={**self.pages,**changes}
|
||||
return rss.parse_lists(*(pages[n] for n in rss.PAGES))
|
||||
|
||||
def test_all210_records_match_independent_primary_inventory_and_evidence(self):
|
||||
report=rss.collect(self.root/'out',snapshots=self.snap)
|
||||
self.assertEqual(report['records'],210);self.assertFalse(report['database_written'])
|
||||
self.assertFalse(report['pdfs_downloaded']);self.assertEqual(len(report['source_evidence']),214)
|
||||
manifest,=report['files'];self.assertEqual(manifest['status'],'partial')
|
||||
rows=[json.loads(l) for l in Path(manifest['file']).read_text().splitlines()]
|
||||
for row,gold in zip(rows,self.fixture['independent_gold']):
|
||||
self.assertEqual(row['external_id'],f"2026/p{gold['paper_id']:03d}")
|
||||
for key in ('title','authors','abstract'):self.assertEqual(row[key],gold[key])
|
||||
self.assertEqual(row['identifiers'],{'doi':gold['doi_raw'].lower()})
|
||||
self.assertEqual(row['metadata']['publisher_date'],gold['publication_date'])
|
||||
self.assertNotIn('submission_identifiers',row);self.assertEqual(row['track'],'main')
|
||||
e=row['metadata']['source_evidence'];self.assertEqual(hashlib.sha256((self.root/'out'/e['file']).read_bytes()).hexdigest(),e['sha256'])
|
||||
self.assertEqual(manifest['file_sha256'],hashlib.sha256(Path(manifest['file']).read_bytes()).hexdigest())
|
||||
self.assertEqual(manifest['missing_abstract_count'],0)
|
||||
|
||||
def test_full_hidden_authors_are_separate_from_six_short_previews(self):
|
||||
shortened=[p for p in self.program.values() if p['authors_complete']!=p['authors_preview']]
|
||||
self.assertEqual(len(shortened),6)
|
||||
p=rss.parse_paper(self.pages['p087.html'],87,self.index[87],self.program[87])
|
||||
self.assertEqual(len(p['authors']),55)
|
||||
self.assertFalse(p['program_variants'])
|
||||
|
||||
def test_six_reviewed_name_variants_preserve_original_strings(self):
|
||||
for pid in rss.REVIEWED_AUTHOR_VARIANTS:
|
||||
p=rss.parse_paper(self.pages[f'p{pid:03d}.html'],pid,self.index[pid],self.program[pid])
|
||||
self.assertIn('reviewed_program_author_name_variant_not_person_alias',p['program_variants'])
|
||||
self.assertNotEqual(', '.join(p['authors']),self.program[pid]['authors_complete'])
|
||||
|
||||
def test_reviewed_title_variant_is_not_an_automatic_erratum(self):
|
||||
p=rss.parse_paper(self.pages['p089.html'],89,self.index[89],self.program[89])
|
||||
self.assertTrue(p['title'].startswith('RLinf-VLA'))
|
||||
self.assertTrue(self.program[89]['title'].startswith('RLux-VLA'))
|
||||
self.assertIn('reviewed_program_title_variant_not_erratum',p['program_variants'])
|
||||
|
||||
def test_unreviewed_author_or_title_variant_stops(self):
|
||||
for changed in [{'authors_complete':'An Unrelated Person'},{'title':'A different paper'}]:
|
||||
with self.assertRaisesRegex(ValueError,'Unreviewed'):
|
||||
rss.parse_paper(self.pages['p001.html'],1,self.index[1],{**self.program[1],**changed})
|
||||
|
||||
def test_demo_policy_cannot_be_reused_from2025(self):
|
||||
with self.assertRaisesRegex(ValueError,'policy'):
|
||||
self.lists(**{'cfp.html':self.pages['cfp.html'].replace('will not have a separate category','will have a separate category')})
|
||||
with self.assertRaises(ValueError):
|
||||
self.lists(**{'sponsor-demos.html':self.pages['sponsor-demos.html'].replace('Sponsor Demos','Demo Papers')})
|
||||
|
||||
def test_wrong_doi_year_pdf_or_missing_abstract_is_rejected(self):
|
||||
html=self.pages['p001.html']
|
||||
for old,new in [('10.15607/RSS.2026.XXII.001','10.15607/RSS.2025.XXI.001'),
|
||||
('2026/07/13','2025/07/13'),('rss22/p001.pdf','rss22/p002.pdf'),('Abstract:','Summary:')]:
|
||||
self.assertIn(old,html)
|
||||
with self.assertRaises(ValueError):rss.parse_paper(html.replace(old,new),1,self.index[1],self.program[1])
|
||||
|
||||
def test_index_census_and_preview_tampering_stop(self):
|
||||
with self.assertRaises(ValueError):self.lists(**{'index.html':self.pages['index.html'].replace('p001.html','p999.html')})
|
||||
bad=self.pages['accepted.html'].replace('Huayi Zhou, Kui Jia','Somebody Else',1)
|
||||
with self.assertRaisesRegex(ValueError,'prefix'):self.lists(**{'accepted.html':bad})
|
||||
|
||||
def test_snapshot_tamper_has_no_completion_marker_and_never_fetches(self):
|
||||
p=self.snap/'p001.html';p.write_bytes(p.read_bytes()+b'changed')
|
||||
with patch.object(rss,'fetch',side_effect=AssertionError('network must not be used')):
|
||||
with self.assertRaisesRegex(ValueError,'receipt'):rss.collect(self.root/'out',snapshots=self.snap)
|
||||
self.assertFalse((self.root/'out/collection-report.json').exists())
|
||||
self.assertTrue((self.root/'out/failed.json').exists())
|
||||
|
||||
def test_existing_output_and_unsafe_receipt_are_rejected(self):
|
||||
out=self.root/'existing';out.mkdir()
|
||||
with self.assertRaises(ValueError):rss.collect(out,snapshots=self.snap)
|
||||
receipts=[*self.fixture['receipts'],{**self.fixture['receipts'][0],'file':'../outside'}]
|
||||
(self.snap/'receipts.json').write_text(json.dumps(receipts))
|
||||
with self.assertRaisesRegex(ValueError,'Unsafe'):rss.collect(self.root/'unsafe',snapshots=self.snap)
|
||||
self.assertTrue((self.root/'unsafe/failed.json').exists())
|
||||
self.assertFalse((self.root/'unsafe/collection-report.json').exists())
|
||||
@@ -0,0 +1,59 @@
|
||||
"""RSS publisher/DBLP observations share one event without losing old decisions."""
|
||||
from pathlib import Path
|
||||
import json
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import unittest
|
||||
from paper_atlas.db.submissions import identities_for_row
|
||||
from tests.test_aamas_publication_identity import row, ingest
|
||||
|
||||
DOI='10.15607/rss.2026.xxii.041'
|
||||
|
||||
def rss(source,external,**kwargs):
|
||||
return row(source,external,venue='RSS',year=2026,track='main',
|
||||
identifiers=kwargs.pop('identifiers',{'doi':DOI}),**kwargs)
|
||||
|
||||
class RssPublicationIdentityTests(unittest.TestCase):
|
||||
def test_only_verified_accepted_source_adds_publication_identity(self):
|
||||
r=rss('rss_proceedings','2026/p041',identifiers={'doi':'https://doi.org/'+DOI.upper()})
|
||||
self.assertEqual(identities_for_row(r),[('publication_doi',DOI)])
|
||||
for status in ('rejected','desk_rejected','withdrawn','under_review','unknown'):
|
||||
self.assertEqual(identities_for_row({**r,'status':status}),[])
|
||||
self.assertEqual(identities_for_row({**r,'source':'unverified_rss_copy'}),[])
|
||||
self.assertEqual(identities_for_row({**r,'identifiers':{'doi':'not-a-doi'}}),[])
|
||||
|
||||
def test_publisher_and_bibliography_both_orders_preserve_two_observations(self):
|
||||
a=rss('rss_proceedings','2026/p041');b=rss('dblp','conf/rss/Example26')
|
||||
for pair in ((a,b),(b,a)):
|
||||
with self.subTest(first=pair[0]['source']),tempfile.TemporaryDirectory() as tmp:
|
||||
root=Path(tmp);db=root/'papers.sqlite'
|
||||
for r in pair:ingest(db,root,r)
|
||||
ingest(db,root,a)
|
||||
with 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 source_records').fetchone()[0],2)
|
||||
self.assertEqual(c.execute('SELECT count(*) FROM submission_sources').fetchone()[0],2)
|
||||
self.assertEqual(c.execute('SELECT count(*) FROM decisions').fetchone()[0],2)
|
||||
self.assertEqual(c.execute('SELECT count(*) FROM decision_sources').fetchone()[0],2)
|
||||
self.assertEqual(c.execute('SELECT scheme,value FROM submission_identifiers').fetchall(),[('publication_doi',DOI)])
|
||||
metadata=[json.loads(r[0]) for r in c.execute('SELECT metadata_json FROM source_records')]
|
||||
self.assertEqual({x['original_source'] for x in metadata},{'rss_proceedings','dblp'})
|
||||
self.assertEqual(c.execute('PRAGMA foreign_key_check').fetchall(),[])
|
||||
|
||||
def test_prior_rejection_remains_independent_and_raw_evidence_unchanged(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root=Path(tmp);db=root/'papers.sqlite'
|
||||
old=row('openreview_snapshot','old-forum',venue='ICLR',year=2025,track='main',
|
||||
status='rejected',identifiers={'arxiv':'2501.00001','openreview':'old-forum'})
|
||||
ingest(db,root,old)
|
||||
with sqlite3.connect(db) as c:
|
||||
before=c.execute('SELECT * FROM decisions').fetchall()
|
||||
new=rss('rss_proceedings','2026/p041',identifiers={'arxiv':'2501.00001','doi':DOI})
|
||||
ingest(db,root,new)
|
||||
with sqlite3.connect(db) as c:
|
||||
self.assertEqual(c.execute('SELECT * FROM decisions WHERE status=\'rejected\'').fetchall(),before)
|
||||
self.assertEqual(c.execute('SELECT count(*) FROM submissions').fetchone()[0],2)
|
||||
self.assertEqual(c.execute('SELECT count(*) FROM papers').fetchone()[0],1)
|
||||
self.assertEqual(c.execute('SELECT count(*) FROM decisions WHERE status=\'accepted\'').fetchone()[0],1)
|
||||
self.assertEqual(c.execute('PRAGMA foreign_key_check').fetchall(),[])
|
||||
Reference in New Issue
Block a user