Compare commits

..

3 Commits

Author SHA1 Message Date
codex-bot 93f00d1a64 Preserve failed-run checkpoint when snapshot receipts are invalid
Metadata integrity checks / metadata (push) Successful in 2m44s
2026-11-15 22:47:27 +08:00
codex-bot 8b7bc6d871 Include frozen RSS 2026 source fixture and independent 210-record inventory
Metadata integrity checks / metadata (push) Successful in 2m54s
2026-11-15 22:43:21 +08:00
codex-bot 7588a4e904 Collect reviewed RSS 2026 publisher metadata with separate program variants 2026-11-15 22:43:05 +08:00
7 changed files with 355 additions and 0 deletions
+9
View File
@@ -48,6 +48,15 @@ jobs:
outside.mkdir() outside.mkdir()
subprocess.run([str(venv/'bin/paper-atlas'),'--help'],cwd=outside,check=True) 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','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','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','aamas-proceedings','--help'],cwd=outside,check=True)
subprocess.run([python,'-I','-B','-m','paper_atlas','collect','neurips-program','--help'],cwd=outside,check=True) subprocess.run([python,'-I','-B','-m','paper_atlas','collect','neurips-program','--help'],cwd=outside,check=True)
+2
View File
@@ -118,3 +118,5 @@ CI 在已授权的远程 runner 上运行测试、构建 wheel,并从源码目
已导入整卷的逐条轨道修正见 [edition-tracks 维护流程](docs/edition-tracks.md):按完整证据生成只读计划,保留原投稿和决定 ID,事务性修正 edition、标识与覆盖,并防止旧整卷刷新覆盖分类。 已导入整卷的逐条轨道修正见 [edition-tracks 维护流程](docs/edition-tracks.md):按完整证据生成只读计划,保留原投稿和决定 ID,事务性修正 edition、标识与覆盖,并防止旧整卷刷新覆盖分类。
入库前可使用通用的[来源身份只读预检](docs/source-identity-audit.md),导出候选论文、稳定标识和既有投稿决定证据;候选结果需要审核,不会自动批准导入。 入库前可使用通用的[来源身份只读预检](docs/source-identity-audit.md),导出候选论文、稳定标识和既有投稿决定证据;候选结果需要审核,不会自动批准导入。
RSS 2026 已独立适配官方论文集与录用表;使用方法和与 2025 demo 分类的区别见 [RSS 2026 来源说明](docs/rss2026-proceedings.md)。
+21
View File
@@ -0,0 +1,21 @@
# 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 进行。
+1
View File
@@ -23,6 +23,7 @@ COMMANDS = {
('collect','colm'): ('paper_atlas.collectors.proceedings', {'source':'colm'}, 'COLM 官方论文集'), ('collect','colm'): ('paper_atlas.collectors.proceedings', {'source':'colm'}, 'COLM 官方论文集'),
('collect','neurips'): ('paper_atlas.collectors.proceedings', {'source':'neurips'}, 'NeurIPS 官方论文集'), ('collect','neurips'): ('paper_atlas.collectors.proceedings', {'source':'neurips'}, 'NeurIPS 官方论文集'),
('collect','mlsys'): ('paper_atlas.collectors.proceedings', {'source':'mlsys'}, 'MLSys 官方历年标题与摘要'), ('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','cvf'): ('paper_atlas.collectors.vision', {'source':'cvf'}, 'CVPR / ICCV 官方开放目录'),
('collect','ecva'): ('paper_atlas.collectors.vision', {'source':'ecva'}, 'ECCV 官方开放目录'), ('collect','ecva'): ('paper_atlas.collectors.vision', {'source':'ecva'}, 'ECCV 官方开放目录'),
('collect','aamas-proceedings'): ('paper_atlas.collectors.aamas', {}, '按官方目录区分 AAMAS 发表形式和摘要'), ('collect','aamas-proceedings'): ('paper_atlas.collectors.aamas', {}, '按官方目录区分 AAMAS 发表形式和摘要'),
+222
View File
@@ -0,0 +1,222 @@
"""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}))
Binary file not shown.
+100
View File
@@ -0,0 +1,100 @@
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())