Compare commits

...

7 Commits

Author SHA1 Message Date
codex-bot 5d858ab8e2 Keep publisher namespaces separate in RSS identity regression
Metadata integrity checks / metadata (push) Successful in 2m6s
2026-11-09 18:03:16 +08:00
codex-bot bf4d0cd38a Collect RSS 2025 proceedings with explicit program evidence
Metadata integrity checks / metadata (push) Failing after 1m58s
2026-11-09 18:00:40 +08:00
Zayden-Jung ec9edd88d4 Merge pull request '保留ICLR历史录用档案的轨道与完整来源证据' (#41) from codex/iclr-archive into main
Metadata integrity checks / metadata (push) Successful in 2m54s
Reviewed-on: #41
2026-11-09 09:02:49 +00:00
codex-bot b5c0618be5 Handle standalone italic author blocks in the historical archive
Metadata integrity checks / metadata (push) Successful in 2m6s
2026-11-09 16:24:42 +08:00
codex-bot 81864e48ae Preserve complete fragmented ICLR 2014 author paragraphs
Metadata integrity checks / metadata (push) Successful in 2m9s
2026-11-09 16:19:19 +08:00
Zayden-Jung 8ad052f577 Merge pull request '核验学习与规划会议前身卷并补齐发现范围' (#40) from codex/historical-predecessors into main
Metadata integrity checks / metadata (push) Successful in 2m12s
Reviewed-on: #40
2026-11-09 08:13:52 +00:00
codex-bot 348ebe0695 Collect archived ICLR accepted lists with track and presentation evidence
Metadata integrity checks / metadata (push) Successful in 2m15s
2026-11-09 16:09:13 +08:00
16 changed files with 10384 additions and 0 deletions
+2
View File
@@ -50,6 +50,8 @@ jobs:
subprocess.run([python,'-I','-B','-m','paper_atlas','collect','cvf','--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','iclr-archive','--help'],cwd=outside,check=True)
subprocess.run([python,'-I','-B','-m','paper_atlas','collect','rss-proceedings','--help'],cwd=outside,check=True)
subprocess.run([python,'-I','-B','-m','paper_atlas','maintain','postgres','--help'],cwd=outside,check=True)
subprocess.run([python,'-I','-B','-m','paper_atlas','maintain','edition-tracks','--help'],cwd=outside,check=True)
subprocess.run([python,'-I','-B','-m','paper_atlas','maintain','joint-editions','--help'],cwd=outside,check=True)
+4
View File
@@ -6,6 +6,10 @@ Paper Atlas 将 arXiv、AI 会议论文集及公开投稿决定整理到可追
项目仍在建设中。候选范围包含 43 个会议(含 ACM MM、MLSys),尚未完成全部历史数据和最终去重复核。实际进度以数据库、来源清单和覆盖报告为准,而不是仓库文件数量。
历史 ICLR 2014–2016 官方录用页由 `collect iclr-archive` 适配,保留主会/workshop及重复展示证据;运行和身份核验边界见 [历史档案说明](docs/coverage/iclr-archive-2014-2016.md)。
RSS 2025 的 `collect rss-proceedings` 读取官方逐篇标题、完整作者、摘要和 DOI,并核对录用与演示节目单;不下载 PDF。15 个演示 ID 有明确来源,其余逐篇投稿类型保持未明确,不用标题或会话主题代替 track。运行、证据恢复和限制见 [RSS 元数据说明](docs/rss-proceedings.md)。
## 项目结构
```text
+44
View File
@@ -0,0 +1,44 @@
# ICLR 2014–2016 官方历史录用档案
此适配器是长期维护工具的一部分,按需运行。只识别已经独立核对的三种页面布局;后续页面发生变化必须重新核查,不能放松计数或轨道边界以使采集成功。
| 官方页面 | 页面链接次数 | 整理后的不同记录 |
|---|---:|---:|
| [2014 Conference Proceedings](https://iclr.cc/archive/2014/conference-proceedings/) | 35 | main 35 |
| [2015 accepted-main](https://iclr.cc/archive/www/doku.php%3Fid=iclr2015:accepted-main.html) | 116 | main 31、workshop 74 |
| [2016 accepted-main](https://iclr.cc/archive/www/doku.php%3Fid=iclr2016:accepted-main.html) | 80 | main 80 |
2015 的 11 篇 oral 全部再次出现在 poster 段落中。相同年份、轨道及 arXiv 标识的重复展示只生成一条来源记录,但每次展示的标题、作者显示字符串、链接、版本和段落均保留在 `source_variants`。两处标题或解析作者冲突时停止采集、等待人工核查。不同年份或不同轨道不合并投稿。
2014 的作者以分号列出,一条有末尾分号,一条标题和作者斜体块不在普通段落中,部分作者段落拆成多个格式片段;必须读取完整作者段落或独立斜体块,不能只读首个格式片段。2016 的代码链接不属于作者。保留原文,解析结果不替代来源证据。oral、poster 作为 `iclr_archive:presentation` 标签,不改成独立投稿轨道。arXiv 版本留在来源变体中,稳定标识使用去版本号的编号。
## 来源快照与运行
2026-11-09 已冻结的官方响应 SHA256:
- 2014:`a33858176fbffae0066ed3c10310f7dd98a66d12a36334b6339ef1f915ef07af`
- 2015:`131491663742cb084662a4804009b8318e0fa7bc184d4a9044d16dd28f05b888`
- 2016:`7320b8652431793b6ea0743f871ee446bf08b6361ccd4e421b6b53449e71f4f6`
在服务器运行,输出新目录,不下载论文 PDF:
```sh
paper-atlas collect iclr-archive --year 2015 \
--snapshots /data/paper-index/collected/historical-next-source-evidence-v1 \
--out /data/paper-index/collected/iclr-archive-source-v1/2015
# 或重新下载官方 HTML,并保留 HTTP 缓存与收据
paper-atlas collect iclr-archive --year 2016 \
--cache /data/paper-index/cache/iclr-archive \
--out /data/paper-index/collected/iclr-archive-source-v1/2016
```
冻结输入目录必须包含 `receipts.json`,每项有精确 `url`、HTTP `status: 200`、相对 `file`、`bytes`、`sha256` 和带时区的 `finished_at`。读取时重新核对尺寸、哈希和路径;拒绝越界及指向目录外的软链接。已完成输出不可覆盖,应检查报告后另建明确版本目录。解析或计数失败时不生成完成报告,也不导入数据库。
输出分轨道 JSONL、manifest、原始 HTML 证据和年度采集报告。仅支持这三年已审查的页面。2014 workshop、其他未列轨道、逐篇摘要及公开拒稿范围尚未通过这些页面核验,不能据此声称完整。缺席不代表 rejected、withdrawn 或从未被录用。
## 导入前后的边界
这三页合计 220 条不同来源记录,不等于新增 220 篇论文;同一工作可能已存在于 arXiv 或其他会议。先核验来源字段与原始页面,再执行 PostgreSQL 只读预检,比较稳定标识、标题、作者和原始来源。标题/作者差异、版本关联及冲突必须保留证据并明确评估;不能仅靠标题猜测身份,也不能覆盖原有摘要。
采集器不连接数据库。正式导入使用既有 `import records`,必须有新的生产保护基线和独立验收;全部导入结束后仍需全库去重和历史覆盖审计。来自官方录用清单的 accepted 是清单观察,不是原始审稿决定签名或决定日期。
+38
View File
@@ -0,0 +1,38 @@
# RSS 官方论文元数据
这是长期维护、按需运行的 package collector,不是定时服务或一次性导入脚本。目前仅接受独立核对过的 2025 年版式;不把 2026 年或其他年份套用同一分类规则。
## 数据范围和证据
- [RSS XXI 官方论文集](https://www.roboticsproceedings.org/rss21/)列出 163 个不同的逐篇 HTML 链接。每篇有 citation 元数据、BibTeX 和摘要区;PDF 链接作为原始元数据保存,不请求全文。
- [官方 Accepted Papers](https://roboticsconference.org/2025/program/papers/)按论文 ID、标题和 session 对账。网页作者列包含隐藏的重复展示,逐篇有序 citation_author 与论文集目录作者完整对比后才采用;不截断作者、不将重复展示当新论文。
- [官方 Demos](https://roboticsconference.org/2025/program/demos/)明确列出 15 个论文 ID,包括标题不以 Demonstrating 开头的 ID 93。这些条目标为 `demo`,保留整行节目证据。其他 148 条使用 `proceedings_unspecified`:没有逐篇类型依据时,不因缺席演示表就自动推断 main。
- [作者说明](https://roboticsconference.org/2025/information/authorinfo/)描述 Science/Systems 与 Demo 两类投稿,两类都进入论文集。分类属于该来源的投稿/发表语义,不自动生成通用 research_type 标签。
`rss_proceedings:session` 保留官方 session,`rss_proceedings:program_role` 保留明确的 demonstration。二者与其他平台标签隔离;session 主题不等于投稿 track。单篇来源 ID 是 `2025/pNNN`,DOI 同时保留原始 BibTeX 大小写和标准化身份键,显式的 `publication_doi` 供原生导入器在同一会议年度/track 中关联其他正式发表来源;不能据此合并不同 track 或不同会议的投稿经历。
只有已发表、被官方录用表列出的条目才产生 accepted 记录。采集观察时间来自响应回执;出版日期单独留在 metadata,不伪造原始决定日期。来源未列出的论文不能推断为 rejected、desk rejected 或 withdrawn;公开负面决定是另一个待核范围。
## 运行
构建、安装和项目测试仅使用已确认的 `ces-control:host` 远程 runner。本机不能执行隐式构建的验证。服务器已安装环境运行示例:
```sh
paper-atlas collect rss-proceedings --year 2025 \
--snapshots /data/paper-index/collected/rss2025-full-evidence-v1 \
--out /data/paper-index/collected/rss2025-source-v1
```
也可使用 `--cache /data/paper-index/raw/rss-http` 请求限定官方 HTML 元数据。两种输入互斥。冻结快照的 `receipts.json` 每项必须包含 `file`、准确 `url/final_url`、`http_status: 200`、`content_type: text/html`、`bytes`、`sha256` 和含时区的观察时间 `at`。路径必须位于快照内,不能通过符号链接逃逸。所有输入逐个核验字节数与哈希,UTF-8 解码;重定向、未知版式和异常 ID 需要先核查。
采集只输出 JSONL、每个 track 的 manifest、完整 HTML 证据和 `rss-2025.json` 来源报告,不连接生产数据库。两份清单状态保持 `partial`;即使该卷 163 个链接全部解析,也不表示独立作品去重、逐篇类型或公开负面决定全集已完成。明确为空的摘要保存 `null` 和原因;缺失或重复摘要边界属于版式异常,停止输出。
## 冻结与恢复
2026-11-09 在服务器冻结 167 个官方 HTML 响应:目录、录用表、演示表、作者说明和 163 个逐篇页面。回执 SHA256:`f427d93a113702030864e789efe1176d69f71d2c567e81536d155e53d9513a93`。六个未改写样例位于 `tests/fixtures/rss2025/`,附其真实响应回执;完整原件保存在服务器来源目录与独立审阅备份,不作为运行时下载依赖。
整个采集持有输出目录的排他锁。全部解析和交叉检查通过后才发布 JSONL、manifest 和完成报告;完成报告最后发布。已有完成报告或 JSONL 时拒绝覆盖。若发布中断,先核对目录、回执和所有文件哈希,保留现场,再换新输出目录重采;不能看到日志安静就重启或重复导入。新的官方页面内容或计数与冻结样本不同,必须另留快照、解释变化并验证后再更新采集器。
生产导入前必须另外做来源独立核验和 PostgreSQL 只读身份预检。DOI、同标题候选和既有 arXiv 实体需要逐项核查,保留跨会的所有投稿和决定关系;发现冲突时隔离,不能只按标题强合并。已导入队列不得重放。生产独立验收通过后才重新统计年度缺口;导入成功和 CI 不能替代最终全库去重、摘要/标签及约束和实际查询审计。
本说明中的 163/15 是冻结官方页面的来源清单,不是当前生产库导入结果。历史缺口在本批生产验收前仍为 47。
+2
View File
@@ -13,6 +13,8 @@ COMMANDS = {
('download',): ('paper_atlas.io.snapshots', {}, '下载并校验固定版本快照'),
('collect','pmlr'): ('paper_atlas.collectors.pmlr', {}, 'PMLR 官方元数据'),
('collect','acl'): ('paper_atlas.collectors.acl', {}, 'ACL Anthology XML'),
('collect','iclr-archive'): ('paper_atlas.collectors.iclr_archive', {}, '核对ICLR 2014–2016官方历史录用档案'),
('collect','rss-proceedings'): ('paper_atlas.collectors.rss', {}, '核对RSS 2025官方论文摘要与演示证据'),
('collect','iclr-program'): ('paper_atlas.collectors.iclr', {}, 'ICLR 官方公开程序'),
('collect','iclr-snapshot'): ('paper_atlas.collectors.openreview', {}, 'ICLR 公开投稿与决定快照'),
('collect','public-openreview'): ('paper_atlas.collectors.public_openreview', {}, '核验其他会议的公开拒稿及撤稿标签'),
+225
View File
@@ -0,0 +1,225 @@
"""Frozen official ICLR 2014–2016 lists; presentation evidence is not a track."""
import argparse
from collections import Counter, defaultdict
import fcntl
import hashlib
import json
from pathlib import Path
import re
import tempfile
from urllib.parse import urlsplit
from paper_atlas.db.submissions import timestamp
from paper_atlas.io.html import Document, Node, clean
from paper_atlas.io.http import fetch
SOURCE = 'iclr_archive'
PAGES = {
2014: ('https://iclr.cc/archive/2014/conference-proceedings/', {'main': 35}),
2015: ('https://iclr.cc/archive/www/doku.php%3Fid=iclr2015:accepted-main.html', {'main': 31, 'workshop': 74}),
2016: ('https://iclr.cc/archive/www/doku.php%3Fid=iclr2016:accepted-main.html', {'main': 80}),
}
SECTIONS = {
2015: {'Main Conference - Oral Presentations': ('main', 'oral'),
'Main Conference - Poster Presentations': ('main', 'poster'),
'Workshop Papers': ('workshop', 'workshop')},
2016: {'Oral Presentations': ('main', 'oral'), 'Poster Presentations': ('main', 'poster')},
}
def arxiv_link(url):
parsed = urlsplit(url)
if parsed.hostname != 'arxiv.org':
return None
match = re.fullmatch(r'/(?:abs|pdf)/(\d{4}\.\d{4,5})(v[1-9]\d*)?(?:\.pdf)?', parsed.path)
if (parsed.scheme not in ('http', 'https') or parsed.netloc != 'arxiv.org'
or parsed.query or parsed.fragment or not match):
raise ValueError('Unexpected official arXiv link')
return match[1], match[2] or None
def without_links(node):
if node.tag in ('a', 'script', 'style'):
return ''
value = ''.join(without_links(n) if isinstance(n, Node) else n for n in node.children)
return ' '+value+' ' if node.tag in ('div', 'li', 'p', 'br') else value
def authors(display, year):
parts = display.rstrip(';').split(';') if year == 2014 else re.split(r',\s*(?:and\s+)?|\s+and\s+', display)
names = [clean(p) for p in parts]
if not names or any(not name for name in names):
raise ValueError('Missing author or unsupported author delimiter')
return names
def parse_page(html, year, evidence):
if year not in PAGES:
raise ValueError('Unreviewed ICLR archive year')
document = Document(html)
nodes = list(document.root.walk())
headings = [clean(n.text()) for n in nodes if n.tag in ('h1', 'h2', 'h3')]
if year == 2014:
if 'Conference Proceedings' not in headings or 'conference papers accepted' not in clean(document.root.text()):
raise ValueError('Unexpected ICLR 2014 accepted list')
elif (set(SECTIONS[year]) - set(headings)
or (year == 2016 and 'Accepted Papers (Conference Track)' not in headings)):
raise ValueError('Unexpected ICLR accepted sections')
entries = []
section = None
for index, node in enumerate(nodes):
if node.tag in ('h1', 'h2', 'h3'):
section = clean(node.text())
if year == 2014:
if node.tag != 'a' or not arxiv_link(node.attrs.get('href', '')):
continue
link = node
# This archived Google Sites page has an entry outside a paragraph.
# Authors can span several differently formatted italic spans.
# Read their whole paragraph, never only the first styled fragment.
display = None
for following in nodes[index+sum(1 for _ in link.walk()):]:
if following.tag == 'a' and arxiv_link(following.attrs.get('href', '')):
break
if following.tag in ('i', 'em') or (following.tag == 'p' and any(
n.tag in ('i', 'em') or 'font-style:italic' in n.attrs.get('style', '').replace(' ', '')
for n in following.walk())):
text = clean(following.text())
if text:
display = text
break
if not display:
raise ValueError('Missing explicit ICLR 2014 author span')
track, presentation = 'main', 'conference'
else:
if node.tag != 'li':
continue
links = [n for n in node.walk() if n.tag == 'a' and arxiv_link(n.attrs.get('href', ''))]
if not links:
continue
if len(links) != 1 or section not in SECTIONS[year]:
raise ValueError('Ambiguous paper link or unknown accepted section')
link, = links
display = clean(without_links(node)).lstrip(',').strip()
track, presentation = SECTIONS[year][section]
arxiv, version = arxiv_link(link.attrs['href'])
title = clean(link.text())
if not title:
raise ValueError('Empty official title')
entries.append({'arxiv': arxiv, 'version': version, 'title': title,
'authors': authors(display, year), 'authors_display': display,
'paper_url': link.attrs['href'], 'track': track,
'section': section, 'presentation': presentation})
# Check every arXiv occurrence, including links outside the expected layout.
occurrences = sum(n.tag == 'a' and bool(arxiv_link(n.attrs.get('href', ''))) for n in nodes)
if len(entries) != occurrences or not entries:
raise ValueError('Some official paper links were not parsed')
grouped = defaultdict(list)
for entry in entries:
grouped[(entry['track'], entry['arxiv'])].append(entry)
result = []
for (track, arxiv), variants in grouped.items():
first = variants[0]
if any((v['title'], v['authors']) != (first['title'], first['authors']) for v in variants):
raise ValueError('Repeated presentation has conflicting title or authors; review required')
presentations = sorted({v['presentation'] for v in variants})
result.append({'source': SOURCE, 'external_id': f'{year}/{track}/{arxiv}',
'url': PAGES[year][0], 'title': first['title'], 'abstract': None,
'authors': first['authors'], 'identifiers': {'arxiv': arxiv},
'venue': 'ICLR', 'venue_aliases': [], 'year': year, 'track': track,
'status': 'accepted', 'observed_at': evidence['observed_at'],
'raw_decision': 'listed in official ICLR accepted '+track+' list',
'metadata': {'source_evidence': evidence, 'source_variants': variants,
'presentations': presentations, 'decision_date_precision': 'not_supplied',
'identity_note': 'Official arXiv link, not title matching; validate canonical identity before import.',
'decision_evidence': 'Archived accepted list, not an original decision note.'},
'tags': [{'namespace': SOURCE+':presentation', 'value': value} for value in presentations]})
return result
def read_page(year, *, cache=None, snapshots=None):
if bool(cache) == bool(snapshots):
raise ValueError('Choose exactly one cache or frozen snapshot directory')
url = PAGES[year][0]
if snapshots:
root = Path(snapshots).resolve()
receipts = json.loads((root/'receipts.json').read_bytes())['receipts']
matches = [r for r in receipts if r['url'] == url]
if len(matches) != 1:
raise ValueError('Expected one exact official response receipt')
receipt, = matches
filename = Path(receipt['file'])
path = root/filename
if (filename.is_absolute() or '..' in filename.parts or not path.resolve().is_relative_to(root)
or receipt['status'] != 200):
raise ValueError('Unsafe or unsuccessful archive snapshot')
data = path.read_bytes()
if (len(data) > 8*1024**2 or len(data) != receipt['bytes']
or hashlib.sha256(data).hexdigest() != receipt['sha256']):
raise ValueError('Archive snapshot checksum or size differs')
observed = timestamp(receipt['finished_at'])
else:
data = fetch(url, cache, 8*1024**2, delay=1)
receipt = json.loads((Path(cache)/hashlib.sha256(url.encode()).hexdigest()).with_suffix('.json').read_bytes())
observed = timestamp(receipt['retrieved_at'])
evidence = {'url': url, 'sha256': hashlib.sha256(data).hexdigest(), 'bytes': len(data),
'file': 'evidence/'+hashlib.sha256(data).hexdigest()+'.html',
'observed_at': observed, 'http_status': 200, 'response_receipt': receipt}
return data, evidence
def collect(year, out, *, cache=None, snapshots=None):
if year not in PAGES:
raise ValueError('Only independently reviewed ICLR archive years are supported')
out = Path(out)
out.mkdir(parents=True, exist_ok=True)
with (out/'iclr-archive.collect.lock').open('a') as lock:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
report = out/f'iclr-archive-{year}.json'
if report.exists():
raise FileExistsError('Existing completed archive output must be inspected, not overwritten')
data, evidence = read_page(year, cache=cache, snapshots=snapshots)
records = parse_page(data.decode('utf-8'), year, evidence)
counts = Counter(r['track'] for r in records)
if dict(counts) != PAGES[year][1]:
raise ValueError('Distinct archive entries differ from reviewed inventory')
files = []
with tempfile.TemporaryDirectory(prefix='.iclr-archive-', dir=out) as temporary:
stage = Path(temporary)
(stage/'evidence').mkdir()
(stage/evidence['file']).write_bytes(data)
for track in sorted(counts):
group = [r for r in records if r['track'] == track]
path = stage/f'iclr-archive-{year}-{track}.jsonl'
path.write_text(''.join(json.dumps(r, ensure_ascii=False)+'\n' for r in group))
manifest = {'source': SOURCE, 'parser_version': 1, 'venue': 'ICLR', 'year': year,
'track': track, 'count': len(group), 'file': str(out/path.name),
'file_sha256': hashlib.sha256(path.read_bytes()).hexdigest(),
'list_url': PAGES[year][0], 'status': 'partial', 'missing_abstract_count': len(group)}
path.with_suffix('.manifest.json').write_text(json.dumps(manifest, ensure_ascii=False, indent=2)+'\n')
files.append(manifest)
result = {'source': SOURCE, 'year': year, 'records': len(records), 'files': files,
'source_evidence': evidence, 'database_written': False, 'pdfs_downloaded': False,
'status': 'collected_pending_identity_review',
'note': 'Accepted list only. Oral/poster are presentation evidence; absent papers are not rejected. '
'ICLR2014 workshop and other unlisted tracks remain outside these reviewed pages.'}
(stage/report.name).write_text(json.dumps(result, ensure_ascii=False, indent=2)+'\n')
(out/'evidence').mkdir(exist_ok=True)
for path in (stage/'evidence').iterdir():
path.replace(out/'evidence'/path.name)
for path in stage.iterdir():
if path.is_file() and path.name != report.name:
path.replace(out/path.name)
(stage/report.name).replace(report)
return result
def main(argv=None, *, prog=None):
parser = argparse.ArgumentParser(prog=prog)
parser.add_argument('--year', type=int, choices=sorted(PAGES), required=True)
parser.add_argument('--out', required=True)
source = parser.add_mutually_exclusive_group(required=True)
source.add_argument('--cache')
source.add_argument('--snapshots')
args = parser.parse_args(argv)
print(json.dumps(collect(args.year, args.out, cache=args.cache, snapshots=args.snapshots), ensure_ascii=False))
+279
View File
@@ -0,0 +1,279 @@
"""Reviewed RSS 2025 proceedings HTML; no PDF or private review access."""
import argparse
from collections import Counter
from datetime import date
import fcntl
import hashlib
import json
from pathlib import Path
import re
import tempfile
import unicodedata
from paper_atlas.db.submissions import timestamp
from paper_atlas.io.html import Document, Node, clean
from paper_atlas.io.http import fetch
SOURCE = 'rss_proceedings'
ROOT = 'https://www.roboticsproceedings.org/rss21/'
HEADING = 'Robotics: Science and Systems XXI'
PAGES = {'index': ROOT,
'accepted': 'https://roboticsconference.org/2025/program/papers/',
'demos': 'https://roboticsconference.org/2025/program/demos/',
'authorinfo': 'https://roboticsconference.org/2025/information/authorinfo/'}
EXPECTED = 163
DEMO_IDS = {2, 20, 21, 33, 58, 59, 62, 84, 86, 92, 93, 107, 129, 132, 141}
def one(values, description):
if len(values) != 1:
raise ValueError('Expected one '+description)
return values[0]
def comparable(text):
return unicodedata.normalize('NFC', clean(text))
def parse_index(html):
document = Document(html)
nodes = list(document.root.walk())
if [clean(n.text()) for n in nodes if n.tag == 'h1'] != [HEADING]:
raise ValueError('Unexpected RSS proceedings edition')
result = {}
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, 'proceedings entry link')
pid = int(link.attrs['href'][1:4])
title = clean(link.text())
authors = clean(one([n for n in row.walk() if n.tag == 'i'], 'index authors').text())
if pid in result or pid < 1 or not title or not authors:
raise ValueError('Empty or repeated RSS index entry')
result[pid] = {'title': title, 'authors_display': authors,
'url': ROOT+link.attrs['href']}
occurrences = sum(n.tag == 'a' and bool(re.fullmatch(r'p[0-9]{3}\.html', n.attrs.get('href', '')))
for n in nodes)
if not result or len(result) != occurrences:
raise ValueError('Unparsed or duplicate proceedings links')
return result
def parse_program(html, heading, columns):
nodes = list(Document(html).root.walk())
if [clean(n.text()) for n in nodes if n.tag == 'h1'] != [heading]:
raise ValueError('Unexpected RSS program page')
headers = [[clean(n.text()) for n in row.children if isinstance(n, Node) and n.tag == 'th']
for row in nodes if row.tag == 'tr']
if list(columns) not in headers:
raise ValueError('Unexpected RSS program columns')
result = {}
for row in (n for n in nodes 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) != len(columns):
raise ValueError('Unexpected RSS program row width')
raw_id = clean(cells[0].text())
if not re.fullmatch('[1-9][0-9]*', raw_id):
raise ValueError('Invalid program ID')
pid = int(raw_id)
if pid in result:
raise ValueError('Duplicate program ID')
result[pid] = {key: clean(node.text()) for key, node in zip(columns, cells)}
if not result[pid]['Title']:
raise ValueError('Missing program title')
if not result:
raise ValueError('No RSS program rows')
return result
def validate_program(index, accepted, demos, authorinfo):
if set(index) != set(accepted) or len(index) != EXPECTED or set(demos) != DEMO_IDS:
raise ValueError('RSS program and reviewed proceedings census differ')
text = clean(Document(authorinfo).root.text())
if ('There are two types of submissions to RSS' not in text
or 'Both types of papers appear in the RSS proceedings.' not in text):
raise ValueError('Submission category evidence changed')
for pid, item in index.items():
variants = [accepted[pid]['Title']] + ([demos[pid]['Title']] if pid in demos else [])
if any(comparable(title) != comparable(item['title']) for title in variants):
raise ValueError('Conflicting official program title for '+str(pid))
def parse_paper(html, pid, entry, accepted, demo, evidence):
nodes = list(Document(html).root.walk())
meta = {}
for node in nodes:
if node.tag == 'meta' and node.attrs.get('name', '').startswith('citation_'):
meta.setdefault(node.attrs['name'], []).append(node.attrs.get('content', ''))
title = one(meta.get('citation_title', []), 'citation title')
authors = meta.get('citation_author', [])
publication_date = one(meta.get('citation_publication_date', []), 'publication date')
if not re.fullmatch(r'2025/[0-9]{2}/[0-9]{2}', publication_date):
raise ValueError('Unexpected publication year/date')
date.fromisoformat(publication_date.replace('/', '-'))
if (one(meta.get('citation_conference_title', []), 'conference') != HEADING
or one(meta.get('citation_volume', []), 'volume') != '21'
or [clean(n.text()) for n in nodes if n.tag == 'h1'] != [HEADING]
or [comparable(n.text()) for n in nodes if n.tag == 'h3'] != [comparable(title)]
or comparable(title) != comparable(entry['title']) or not authors
or any(not clean(author) for author in authors)
or comparable(', '.join(authors)) != comparable(entry['authors_display'])):
raise ValueError('Proceedings/citation title, authors or edition disagree')
pdf = one(meta.get('citation_pdf_url', []), '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 differs; URL is metadata only')
bibtex = one([n.text() for n in nodes if n.tag == 'pre'], 'original BibTeX')
doi = one(re.findall(r'^\s*DOI\s*=\s*\{([^{}]+)\}', bibtex, re.I | re.M), 'BibTeX DOI')
year = one(re.findall(r'^\s*YEAR\s*=\s*\{([^{}]+)\}', bibtex, re.I | re.M), 'BibTeX year')
if doi != f'10.15607/RSS.2025.XXI.{pid:03d}' or year != '2025':
raise ValueError('BibTeX DOI/year differs from volume and paper ID')
abstract_parts = []
inside = False
markers = []
for node in (n for n in nodes if n.tag == 'p'):
labels = [clean(n.text()) for n in node.walk() if n.tag == 'b']
if 'Abstract:' in labels:
markers.append('start')
inside = True
value = clean(node.text()).removeprefix('Abstract:').strip()
if value:
abstract_parts.append(value)
elif 'Download:' in labels:
markers.append('end')
inside = False
elif inside:
abstract_parts.append(clean(node.text()))
if markers != ['start', 'end']:
raise ValueError('Abstract boundaries missing or repeated')
abstract = clean(' '.join(abstract_parts)) or None
track = 'demo' if demo else 'proceedings_unspecified'
tags = [{'namespace': SOURCE+':session', 'value': accepted['Session']}]
if demo:
tags.append({'namespace': SOURCE+':program_role', 'value': 'demonstration'})
return {'source': SOURCE, 'external_id': f'2025/p{pid:03d}', 'url': entry['url'],
'title': title, 'abstract': abstract, 'authors': authors,
'identifiers': {'doi': doi.lower()},
'submission_identifiers': [{'scheme': 'publication_doi', 'value': doi.lower()}],
'venue': 'RSS', 'venue_aliases': [], 'year': 2025, 'track': track,
'status': 'accepted', 'observed_at': evidence['paper']['observed_at'],
'raw_decision': 'listed in official RSS 2025 proceedings and accepted program',
'tags': tags,
'metadata': {'publisher_paper_id': pid, 'citation_metadata': meta,
'publication_date': publication_date, 'original_bibtex': bibtex,
'index_entry': entry, 'accepted_program_entry': accepted,
'demo_program_entry': demo, 'source_evidence': evidence,
'missing_abstract_reason': None if abstract else 'empty_official_abstract_section',
'track_scope_note': ('Explicit official demo program ID; not inferred from title.' if demo else
'Proceedings and accepted list do not individually identify submission type; session is not a track.'),
'decision_date_precision': 'not_supplied',
'publication_evidence': 'Published proceedings and accepted program; not an original decision note.'}}
class Pages:
def __init__(self, *, cache=None, snapshots=None):
if bool(cache) == bool(snapshots):
raise ValueError('Choose exactly one cache or frozen snapshot directory')
self.cache = Path(cache) if cache else None
self.root = Path(snapshots).resolve() if snapshots else None
self.receipts = json.loads((self.root/'receipts.json').read_bytes())['receipts'] if self.root else []
self.evidence = {}
self.data = {}
def read(self, url):
if self.root:
receipt = one([r for r in self.receipts if r['url'] == url], 'exact frozen response receipt')
filename = Path(receipt['file'])
path = self.root/filename
if (filename.is_absolute() or '..' in filename.parts or not path.resolve().is_relative_to(self.root)
or receipt['http_status'] != 200 or receipt['content_type'] != 'text/html'
or receipt.get('final_url', url) != url):
raise ValueError('Unsafe or unsuccessful frozen response')
data = path.read_bytes()
observed = receipt['at']
else:
data = fetch(url, self.cache, 2*1024**2, delay=1)
receipt = json.loads((self.cache/hashlib.sha256(url.encode()).hexdigest()).with_suffix('.json').read_bytes())
observed = receipt['retrieved_at']
if (len(data) > 2*1024**2 or len(data) != receipt['bytes']
or hashlib.sha256(data).hexdigest() != receipt['sha256'] or receipt['url'] != url):
raise ValueError('Response checksum, URL or size differs')
evidence = {'url': url, 'sha256': hashlib.sha256(data).hexdigest(), 'bytes': len(data),
'file': 'evidence/'+hashlib.sha256(data).hexdigest()+'.html',
'observed_at': timestamp(observed), 'http_status': 200, 'response_receipt': receipt}
self.evidence[url] = evidence
self.data[url] = data
return data.decode('utf-8'), evidence
def collect(year, out, *, cache=None, snapshots=None):
if year != 2025:
raise ValueError('Only the independently reviewed RSS 2025 edition is supported')
out = Path(out)
out.mkdir(parents=True, exist_ok=True)
with (out/'rss.collect.lock').open('a') as lock:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
report_path = out/'rss-2025.json'
if report_path.exists() or list(out.glob('rss-2025-*.jsonl')):
raise FileExistsError('Inspect existing output; never overwrite a completed or partial queue')
pages = Pages(cache=cache, snapshots=snapshots)
context = {key: pages.read(url) for key, url in PAGES.items()}
index = parse_index(context['index'][0])
accepted = parse_program(context['accepted'][0], 'Accepted Papers', ('ID', 'Session', 'Title', 'Authors'))
demos = parse_program(context['demos'][0], 'Demos', ('ID', 'Location', 'Title', 'Day', 'Time'))
validate_program(index, accepted, demos, context['authorinfo'][0])
records = []
for pid, entry in sorted(index.items()):
html, evidence = pages.read(entry['url'])
sources = {key: item[1] for key, item in context.items()}
sources['paper'] = evidence
records.append(parse_paper(html, pid, entry, accepted[pid], demos.get(pid), sources))
counts = Counter(r['track'] for r in records)
if counts != {'demo': len(DEMO_IDS), 'proceedings_unspecified': EXPECTED-len(DEMO_IDS)}:
raise ValueError('Track census differs; review official source changes')
files = []
with tempfile.TemporaryDirectory(prefix='.rss-', dir=out) as temporary:
stage = Path(temporary)
(stage/'evidence').mkdir()
for url, data in pages.data.items():
(stage/pages.evidence[url]['file']).write_bytes(data)
for track in sorted(counts):
group = [r for r in records if r['track'] == track]
path = stage/f'rss-2025-{track}.jsonl'
path.write_text(''.join(json.dumps(r, ensure_ascii=False)+'\n' for r in group))
manifest = {'source': SOURCE, 'parser_version': 1, 'venue': 'RSS', 'year': 2025,
'track': track, 'count': len(group), 'file': str(out/path.name),
'file_sha256': hashlib.sha256(path.read_bytes()).hexdigest(),
'list_url': ROOT, 'status': 'partial',
'missing_abstract_count': sum(r['abstract'] is None for r in group)}
path.with_suffix('.manifest.json').write_text(json.dumps(manifest, ensure_ascii=False, indent=2)+'\n')
files.append(manifest)
result = {'source': SOURCE, 'year': 2025, 'records': len(records), 'files': files,
'source_evidence': list(pages.evidence.values()), 'database_written': False,
'pdfs_downloaded': False, 'status': 'collected_pending_identity_review',
'note': 'Only the published proceedings. Demo IDs have explicit program evidence; '
'other individual submission types remain unknown. No absent-based negative decisions. '
'Separate source identity review and independent production acceptance are required.'}
(stage/report_path.name).write_text(json.dumps(result, ensure_ascii=False, indent=2)+'\n')
(out/'evidence').mkdir(exist_ok=True)
for path in (stage/'evidence').iterdir():
path.replace(out/'evidence'/path.name)
for path in stage.iterdir():
if path.is_file() and path.name != report_path.name:
path.replace(out/path.name)
(stage/report_path.name).replace(report_path)
return result
def main(argv=None, *, prog=None):
parser = argparse.ArgumentParser(prog=prog)
parser.add_argument('--year', type=int, choices=[2025], required=True)
parser.add_argument('--out', required=True)
group = parser.add_mutually_exclusive_group(required=True)
group.add_argument('--cache')
group.add_argument('--snapshots')
args = parser.parse_args(argv)
print(json.dumps(collect(args.year, args.out, cache=args.cache, snapshots=args.snapshots), ensure_ascii=False))
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1646
View File
File diff suppressed because it is too large Load Diff
+1585
View File
File diff suppressed because it is too large Load Diff
+148
View File
@@ -0,0 +1,148 @@
<html>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<head>
<meta name="citation_title" content="Learned Perceptive Forward Dynamics Model for Safe and Platform-aware Robotic Navigation" />
<meta name="citation_author" content="Pascal Roth" />
<meta name="citation_author" content="Jonas Frey" />
<meta name="citation_author" content="Cesar Cadena" />
<meta name="citation_author" content="Marco Hutter" />
<meta name="citation_publication_date" content="2025/06/21" />
<meta name="citation_conference_title" content="Robotics: Science and Systems XXI" />
<meta name="citation_isbn" content="979-8-9902848-1-4" />
<meta name="citation_volume" content="21" />
<meta name="citation_pdf_url" content="http://www.roboticsproceedings.org/rss21/p001.pdf" />
<title>Robotics: Science and Systems XXI - Online Proceedings</title>
<link type="text/css" rel="stylesheet" href="../style.css">
<!--[if IE 5]>
<link type="text/css" rel="stylesheet" href="../ie5.css">
<![endif]-->
<!--[if IE 6]>
<link type="text/css" rel="stylesheet" href="../ie6.css">
<![endif]-->
</head>
<body>
<div class="menu">
<ul>
<li class="here"><a class="menu" href="../index.html">Home</a></li>
<li class="label">RSS XXII</span></li>
<li class="menu"><a class="menu" href="../rss22/index.html">Content</a></li>
<li class="menu"><a class="menu" href="../rss22/authors.html">Authors</a></li>
<li class="label">RSS XXI</span></li>
<li class="menu"><a class="menu" href="../rss21/index.html">Content</a></li>
<li class="menu"><a class="menu" href="../rss21/authors.html">Authors</a></li>
<li class="label">RSS XX</span></li>
<li class="menu"><a class="menu" href="../rss20/index.html">Content</a></li>
<li class="menu"><a class="menu" href="../rss20/authors.html">Authors</a></li>
<li class="label">RSS XIX</span></li>
<li class="menu"><a class="menu" href="../rss19/index.html">Content</a></li>
<li class="menu"><a class="menu" href="../rss19/authors.html">Authors</a></li>
<li class="label">RSS XVIII</span></li>
<li class="menu"><a class="menu" href="../rss18/index.html">Content</a></li>
<li class="menu"><a class="menu" href="../rss18/authors.html">Authors</a></li>
<li class="label">RSS XVII</span></li>
<li class="menu"><a class="menu" href="../rss17/index.html">Content</a></li>
<li class="menu"><a class="menu" href="../rss17/authors.html">Authors</a></li>
<li class="label">RSS XVI</span></li>
<li class="menu"><a class="menu" href="../rss16/index.html">Content</a></li>
<li class="menu"><a class="menu" href="../rss16/authors.html">Authors</a></li>
<li class="label">RSS XV</span></li>
<li class="menu"><a class="menu" href="../rss15/index.html">Content</a></li>
<li class="menu"><a class="menu" href="../rss15/authors.html">Authors</a></li>
<li class="label">RSS XIV</span></li>
<li class="menu"><a class="menu" href="../rss14/index.html">Content</a></li>
<li class="menu"><a class="menu" href="../rss14/authors.html">Authors</a></li>
<li class="label">RSS XIII</span></li>
<li class="menu"><a class="menu" href="../rss13/index.html">Content</a></li>
<li class="menu"><a class="menu" href="../rss13/authors.html">Authors</a></li>
<li class="label">RSS XII</span></li>
<li class="menu"><a class="menu" href="../rss12/index.html">Content</a></li>
<li class="menu"><a class="menu" href="../rss12/authors.html">Authors</a></li>
<li class="label">RSS XI</span></li>
<li class="menu"><a class="menu" href="../rss11/index.html">Content</a></li>
<li class="menu"><a class="menu" href="../rss11/authors.html">Authors</a></li>
<li class="label">RSS X</span></li>
<li class="menu"><a class="menu" href="../rss10/index.html">Content</a></li>
<li class="menu"><a class="menu" href="../rss10/authors.html">Authors</a></li>
<li class="label">RSS IX</span></li>
<li class="menu"><a class="menu" href="../rss09/index.html">Content</a></li>
<li class="menu"><a class="menu" href="../rss09/authors.html">Authors</a></li>
<li class="label">RSS VIII</span></li>
<li class="menu"><a class="menu" href="../rss08/index.html">Content</a></li>
<li class="menu"><a class="menu" href="../rss08/authors.html">Authors</a></li>
<li class="label">RSS VII</span></li>
<li class="menu"><a class="menu" href="../rss07/index.html">Content</a></li>
<li class="menu"><a class="menu" href="../rss07/authors.html">Authors</a></li>
<li class="label">RSS VI</span></li>
<li class="menu"><a class="menu" href="../rss06/index.html">Content</a></li>
<li class="menu"><a class="menu" href="../rss06/authors.html">Authors</a></li>
<li class="label">RSS V</span></li>
<li class="menu"><a class="menu" href="../rss05/index.html">Content</a></li>
<li class="menu"><a class="menu" href="../rss05/authors.html">Authors</a></li>
<li class="label">RSS IV</span></li>
<li class="menu"><a class="menu" href="../rss04/index.html">Content</a></li>
<li class="menu"><a class="menu" href="../rss04/authors.html">Authors</a></li>
<li class="label">RSS III</span></li>
<li class="menu"><a class="menu" href="../rss03/index.html">Content</a></li>
<li class="menu"><a class="menu" href="../rss03/authors.html">Authors</a></li>
<li class="label">RSS II</span></li>
<li class="menu"><a class="menu" href="../rss02/index.html">Content</a></li>
<li class="menu"><a class="menu" href="../rss02/authors.html">Authors</a></li>
<li class="label">RSS I</span></li>
<li class="menu"><a class="menu" href="../rss01/index.html">Content</a></li>
<li class="menu"><a class="menu" href="../rss01/authors.html">Authors</a></li>
<li class="label">Ethics and Malpractice</span></li>
<li class="menu"><a class="menu" href="../statement.html">Statement</a></li>
<li class="label">Contact</span></li>
<li class="last"><a class="menu" href="../contact.html">Webmaster</a></li>
<li class="no-style"><center><img border="0" src="../robot-small.jpg"></center>
<br><br><br>
<center>We are using google analytics for statistics!</center>
</ul>
</div>
<div class="content">
<h1>Robotics: Science and Systems XXI</h1>
<h3>Learned Perceptive Forward Dynamics Model for Safe and Platform-aware Robotic Navigation</h3>
<i>Pascal Roth, Jonas Frey, Cesar Cadena, Marco Hutter</i><br>
<p>
<b>Abstract:</b>
</p><p style="text-align: justify;">
Ensuring safe navigation in complex environments requires accurate real-time traversability assessment and understanding of environmental interactions relative to the robot's capabilities. Traditional methods, which assume simplified dynamics, often require designing and tuning cost functions to safely guide paths or actions toward the goal. This process is tedious, environment-dependent, and not generalizable. To overcome these issues, we propose a novel learned perceptive Forward Dynamics Model (FDM) that predicts the robot's future state conditioned on the surrounding geometry and history of proprioceptive measurements, proposing a more scalable, safer, and heuristic-free solution. The FDM is trained on multiple years of simulated navigation experience, including high-risk maneuvers, and real-world interactions to incorporate the full system dynamics beyond rigid body simulation. We integrate our perceptive FDM into a zero-shot Model Predictive Path Integral (MPPI) planning framework, leveraging the learned mapping between actions, future states, and failure probability. This allows for optimizing a simplified cost function, eliminating the need for extensive cost-tuning to ensure safety. On the legged robot ANYmal, the proposed perceptive FDM improves the position estimation by on average 41% over competitive baselines, which translates into a 27% higher navigation success rate in rough simulation environments. Moreover, we demonstrate effective sim-to-real transfer and showcase the benefit of training on synthetic and real data. Code and models are made publicly available under https://github.com/leggedrobotics/fdm.
</p>
<p>
<b>Download:</b> <a href="p001.pdf" target="_blank" onclick="_gaq.push(['_trackEvent','rss10/p001.pdf','PDF',this.href]);"><img src="../icon-pdf.png" border=0></a>
</p>
<p>
<b>Bibtex:</b>
<pre>
@INPROCEEDINGS{RothP-RSS-25,
AUTHOR = {Pascal Roth AND Jonas Frey AND Cesar Cadena AND Marco Hutter},
TITLE = {{Learned Perceptive Forward Dynamics Model for Safe and Platform-aware Robotic Navigation}},
BOOKTITLE = {Proceedings of Robotics: Science and Systems},
YEAR = {2025},
ADDRESS = {LosAngeles, CA, USA},
MONTH = {June},
DOI = {10.15607/RSS.2025.XXI.001}
}
</pre>
</p>
</div>
<div class="analyt">
<script type="text/javascript">
var _gaq = _gaq || [];
_gaq.push(['_setAccount', 'UA-24631810-1']);
_gaq.push(['_trackPageview']);
(function() {
var ga = document.createElement('script'); ga.type = 'text/javascript'; ga.async = true;
ga.src = ('https:' == document.location.protocol ? 'https://ssl' : 'http://www') + '.google-analytics.com/ga.js';
var s = document.getElementsByTagName('script')[0]; s.parentNode.insertBefore(ga, s);
})();
</script>
</div>
</body>
</html>
+155
View File
@@ -0,0 +1,155 @@
<html>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<head>
<meta name="citation_title" content="Demonstrating ViSafe: Vision-enabled Safety for High-speed Detect and Avoid" />
<meta name="citation_author" content="Parv Kapoor" />
<meta name="citation_author" content="Ian Higgins" />
<meta name="citation_author" content="Nikhil Varma Keetha" />
<meta name="citation_author" content="Jay Patrikar" />
<meta name="citation_author" content="Brady Moon" />
<meta name="citation_author" content="Zelin Ye" />
<meta name="citation_author" content="Yao He" />
<meta name="citation_author" content="Ivan Cisneros" />
<meta name="citation_author" content="Changliu Liu" />
<meta name="citation_author" content="Eunsuk Kang" />
<meta name="citation_author" content="Sebastian Scherer" />
<meta name="citation_publication_date" content="2025/06/21" />
<meta name="citation_conference_title" content="Robotics: Science and Systems XXI" />
<meta name="citation_isbn" content="979-8-9902848-1-4" />
<meta name="citation_volume" content="21" />
<meta name="citation_pdf_url" content="http://www.roboticsproceedings.org/rss21/p002.pdf" />
<title>Robotics: Science and Systems XXI - Online Proceedings</title>
<link type="text/css" rel="stylesheet" href="../style.css">
<!--[if IE 5]>
<link type="text/css" rel="stylesheet" href="../ie5.css">
<![endif]-->
<!--[if IE 6]>
<link type="text/css" rel="stylesheet" href="../ie6.css">
<![endif]-->
</head>
<body>
<div class="menu">
<ul>
<li class="here"><a class="menu" href="../index.html">Home</a></li>
<li class="label">RSS XXII</span></li>
<li class="menu"><a class="menu" href="../rss22/index.html">Content</a></li>
<li class="menu"><a class="menu" href="../rss22/authors.html">Authors</a></li>
<li class="label">RSS XXI</span></li>
<li class="menu"><a class="menu" href="../rss21/index.html">Content</a></li>
<li class="menu"><a class="menu" href="../rss21/authors.html">Authors</a></li>
<li class="label">RSS XX</span></li>
<li class="menu"><a class="menu" href="../rss20/index.html">Content</a></li>
<li class="menu"><a class="menu" href="../rss20/authors.html">Authors</a></li>
<li class="label">RSS XIX</span></li>
<li class="menu"><a class="menu" href="../rss19/index.html">Content</a></li>
<li class="menu"><a class="menu" href="../rss19/authors.html">Authors</a></li>
<li class="label">RSS XVIII</span></li>
<li class="menu"><a class="menu" href="../rss18/index.html">Content</a></li>
<li class="menu"><a class="menu" href="../rss18/authors.html">Authors</a></li>
<li class="label">RSS XVII</span></li>
<li class="menu"><a class="menu" href="../rss17/index.html">Content</a></li>
<li class="menu"><a class="menu" href="../rss17/authors.html">Authors</a></li>
<li class="label">RSS XVI</span></li>
<li class="menu"><a class="menu" href="../rss16/index.html">Content</a></li>
<li class="menu"><a class="menu" href="../rss16/authors.html">Authors</a></li>
<li class="label">RSS XV</span></li>
<li class="menu"><a class="menu" href="../rss15/index.html">Content</a></li>
<li class="menu"><a class="menu" href="../rss15/authors.html">Authors</a></li>
<li class="label">RSS XIV</span></li>
<li class="menu"><a class="menu" href="../rss14/index.html">Content</a></li>
<li class="menu"><a class="menu" href="../rss14/authors.html">Authors</a></li>
<li class="label">RSS XIII</span></li>
<li class="menu"><a class="menu" href="../rss13/index.html">Content</a></li>
<li class="menu"><a class="menu" href="../rss13/authors.html">Authors</a></li>
<li class="label">RSS XII</span></li>
<li class="menu"><a class="menu" href="../rss12/index.html">Content</a></li>
<li class="menu"><a class="menu" href="../rss12/authors.html">Authors</a></li>
<li class="label">RSS XI</span></li>
<li class="menu"><a class="menu" href="../rss11/index.html">Content</a></li>
<li class="menu"><a class="menu" href="../rss11/authors.html">Authors</a></li>
<li class="label">RSS X</span></li>
<li class="menu"><a class="menu" href="../rss10/index.html">Content</a></li>
<li class="menu"><a class="menu" href="../rss10/authors.html">Authors</a></li>
<li class="label">RSS IX</span></li>
<li class="menu"><a class="menu" href="../rss09/index.html">Content</a></li>
<li class="menu"><a class="menu" href="../rss09/authors.html">Authors</a></li>
<li class="label">RSS VIII</span></li>
<li class="menu"><a class="menu" href="../rss08/index.html">Content</a></li>
<li class="menu"><a class="menu" href="../rss08/authors.html">Authors</a></li>
<li class="label">RSS VII</span></li>
<li class="menu"><a class="menu" href="../rss07/index.html">Content</a></li>
<li class="menu"><a class="menu" href="../rss07/authors.html">Authors</a></li>
<li class="label">RSS VI</span></li>
<li class="menu"><a class="menu" href="../rss06/index.html">Content</a></li>
<li class="menu"><a class="menu" href="../rss06/authors.html">Authors</a></li>
<li class="label">RSS V</span></li>
<li class="menu"><a class="menu" href="../rss05/index.html">Content</a></li>
<li class="menu"><a class="menu" href="../rss05/authors.html">Authors</a></li>
<li class="label">RSS IV</span></li>
<li class="menu"><a class="menu" href="../rss04/index.html">Content</a></li>
<li class="menu"><a class="menu" href="../rss04/authors.html">Authors</a></li>
<li class="label">RSS III</span></li>
<li class="menu"><a class="menu" href="../rss03/index.html">Content</a></li>
<li class="menu"><a class="menu" href="../rss03/authors.html">Authors</a></li>
<li class="label">RSS II</span></li>
<li class="menu"><a class="menu" href="../rss02/index.html">Content</a></li>
<li class="menu"><a class="menu" href="../rss02/authors.html">Authors</a></li>
<li class="label">RSS I</span></li>
<li class="menu"><a class="menu" href="../rss01/index.html">Content</a></li>
<li class="menu"><a class="menu" href="../rss01/authors.html">Authors</a></li>
<li class="label">Ethics and Malpractice</span></li>
<li class="menu"><a class="menu" href="../statement.html">Statement</a></li>
<li class="label">Contact</span></li>
<li class="last"><a class="menu" href="../contact.html">Webmaster</a></li>
<li class="no-style"><center><img border="0" src="../robot-small.jpg"></center>
<br><br><br>
<center>We are using google analytics for statistics!</center>
</ul>
</div>
<div class="content">
<h1>Robotics: Science and Systems XXI</h1>
<h3>Demonstrating ViSafe: Vision-enabled Safety for High-speed Detect and Avoid</h3>
<i>Parv Kapoor, Ian Higgins, Nikhil Varma Keetha, Jay Patrikar, Brady Moon, Zelin Ye, Yao He, Ivan Cisneros, Changliu Liu, Eunsuk Kang, Sebastian Scherer</i><br>
<p>
<b>Abstract:</b>
</p><p style="text-align: justify;">
Assured safe-separation is essential for achieving seamless high-density operation of airborne vehicles in a shared airspace. To equip resource-constrained aerial systems with this safety-critical capability, we present ViSafe, a high-speed vision-only airborne collision avoidance system. ViSafe offers a full-stack solution to the Detect and Avoid (DAA) problem by tightly integrating a learning-based edge-AI framework with a custom multi-camera hardware prototype designed under SWaP-C constraints. By leveraging perceptual input-focused control barrier functions (CBF) to design, encode, and enforce safety thresholds, ViSafe can provide provably safe runtime guarantees for self-separation in high-speed aerial operations. We evaluate ViSafe’s performance through an extensive test campaign involving both simulated digital twins and real-world flight scenarios. By independently varying agent types, closure rates, interaction geometries, and environmental conditions (e.g., weather and lighting), we demonstrate that ViSafe consistently ensures self-separation across diverse scenarios. In first-of-its-kind real-world high-speed collision avoidance tests with closure rates reaching 144 km/h, ViSafe sets a new benchmark for vision-only autonomous collision avoidance, establishing a new standard for safety in high-speed aerial navigation.
</p>
<p>
<b>Download:</b> <a href="p002.pdf" target="_blank" onclick="_gaq.push(['_trackEvent','rss10/p002.pdf','PDF',this.href]);"><img src="../icon-pdf.png" border=0></a>
</p>
<p>
<b>Bibtex:</b>
<pre>
@INPROCEEDINGS{KapoorP-RSS-25,
AUTHOR = {Parv Kapoor AND Ian Higgins AND Nikhil Varma Keetha AND Jay Patrikar AND Brady Moon AND Zelin Ye AND Yao He AND Ivan Cisneros AND Changliu Liu AND Eunsuk Kang AND Sebastian Scherer},
TITLE = {{Demonstrating ViSafe: Vision-enabled Safety for High-speed Detect and Avoid}},
BOOKTITLE = {Proceedings of Robotics: Science and Systems},
YEAR = {2025},
ADDRESS = {LosAngeles, CA, USA},
MONTH = {June},
DOI = {10.15607/RSS.2025.XXI.002}
}
</pre>
</p>
</div>
<div class="analyt">
<script type="text/javascript">
var _gaq = _gaq || [];
_gaq.push(['_setAccount', 'UA-24631810-1']);
_gaq.push(['_trackPageview']);
(function() {
var ga = document.createElement('script'); ga.type = 'text/javascript'; ga.async = true;
ga.src = ('https:' == document.location.protocol ? 'https://ssl' : 'http://www') + '.google-analytics.com/ga.js';
var s = document.getElementsByTagName('script')[0]; s.parentNode.insertBefore(ga, s);
})();
</script>
</div>
</body>
</html>
+65
View File
@@ -0,0 +1,65 @@
{
"scope": "Six unmodified official HTML fixtures; exact observations from full167 server freeze. No research PDFs.",
"receipts": [
{
"file": "index.html",
"url": "https://www.roboticsproceedings.org/rss21/",
"final_url": "https://www.roboticsproceedings.org/rss21/",
"http_status": 200,
"content_type": "text/html",
"bytes": 77753,
"sha256": "5d8e530b5ce7184795c14a7dca615f9c1a8b0b189661e0e6e795167b68b8516d",
"at": "2026-11-09T09:53:09.121733+00:00"
},
{
"file": "accepted.html",
"url": "https://roboticsconference.org/2025/program/papers/",
"final_url": "https://roboticsconference.org/2025/program/papers/",
"http_status": 200,
"content_type": "text/html",
"bytes": 197708,
"sha256": "fa1a5bce496a85eca8cec79aed8dbc148cda791fe6859375ba0c294983a18683",
"at": "2026-11-09T09:53:10.171450+00:00"
},
{
"file": "demos.html",
"url": "https://roboticsconference.org/2025/program/demos/",
"final_url": "https://roboticsconference.org/2025/program/demos/",
"http_status": 200,
"content_type": "text/html",
"bytes": 43542,
"sha256": "0b154ed110f6d49ebe2ba87f2c9c498df5fc1ea00bfe508b548522dfe4779698",
"at": "2026-11-09T09:53:11.145288+00:00"
},
{
"file": "authorinfo.html",
"url": "https://roboticsconference.org/2025/information/authorinfo/",
"final_url": "https://roboticsconference.org/2025/information/authorinfo/",
"http_status": 200,
"content_type": "text/html",
"bytes": 54508,
"sha256": "24d023daf9a074434c6e004001b863dfd260330e523671d614fa120b052927ab",
"at": "2026-11-09T09:53:12.170246+00:00"
},
{
"file": "p001.html",
"url": "https://www.roboticsproceedings.org/rss21/p001.html",
"final_url": "https://www.roboticsproceedings.org/rss21/p001.html",
"http_status": 200,
"content_type": "text/html",
"bytes": 9048,
"sha256": "973172348096a5cbf3e311bdc1ce33b27447761fa1058a050e611aa17547d821",
"at": "2026-11-09T09:53:13.437943+00:00"
},
{
"file": "p002.html",
"url": "https://www.roboticsproceedings.org/rss21/p002.html",
"final_url": "https://www.roboticsproceedings.org/rss21/p002.html",
"http_status": 200,
"content_type": "text/html",
"bytes": 9335,
"sha256": "30b5b68e7ed6a809ca51282c5efd1416513417ba91eba2845f2e73ed18b90ba4",
"at": "2026-11-09T09:53:14.697857+00:00"
}
]
}
+148
View File
@@ -0,0 +1,148 @@
"""Historical accepted lists, repeated presentations and preserved work histories."""
import hashlib
import json
from pathlib import Path
import sqlite3
import tempfile
import unittest
from unittest.mock import patch
from paper_atlas.collectors import iclr_archive as archive
from paper_atlas.db.store import Store
from paper_atlas.ingestion.records import ingest_file
OBSERVED = '2026-11-09T00:00:00Z'
def entry(identifier, title='A shared research paper', display='Jane Doe and Bob Smith'):
return f'<li><div><a href="http://arxiv.org/abs/{identifier}">{title}</a>, {display}</div></li>'
def page2015():
return ('<h3>Main Conference - Oral Presentations</h3><ol>'+entry('1412.0001v2')+'</ol>'
'<h3>Main Conference - Poster Presentations</h3><ol>'+entry('1412.0001v2')+'</ol>'
'<h3>Workshop Papers</h3><ol>'+entry('1412.0002', 'A workshop paper')+'</ol>')
class IclrArchiveTests(unittest.TestCase):
def frozen(self, root, year, html):
root.mkdir()
data = html.encode()
(root/'page.html').write_bytes(data)
receipt = {'url': archive.PAGES[year][0], 'file': 'page.html', 'status': 200,
'bytes': len(data), 'sha256': hashlib.sha256(data).hexdigest(), 'finished_at': OBSERVED}
(root/'receipts.json').write_text(json.dumps({'receipts': [receipt]}))
return root
def test_oral_poster_repeat_is_one_attempt_with_all_source_variants(self):
rows = archive.parse_page(page2015(), 2015, {'observed_at': OBSERVED})
main, workshop = rows
self.assertEqual((main['track'], workshop['track']), ('main', 'workshop'))
self.assertEqual(main['identifiers'], {'arxiv': '1412.0001'})
self.assertEqual(main['external_id'], '2015/main/1412.0001')
self.assertEqual(main['metadata']['presentations'], ['oral', 'poster'])
self.assertEqual(len(main['metadata']['source_variants']), 2)
self.assertEqual(main['metadata']['source_variants'][0]['version'], 'v2')
self.assertEqual(main['authors'], ['Jane Doe', 'Bob Smith'])
self.assertTrue(all(r['status'] == 'accepted' and r['abstract'] is None for r in rows))
def test_2014_author_span_outside_paragraph_and_trailing_separator(self):
html = ('<h3>Conference Proceedings</h3><p>conference papers accepted</p>'
'<div><b><a href="http://arxiv.org/abs/1312.0001">A <i>research</i> title</a></b></div>'
'<p><span style="font-style: italic">Jane Doe; </span>'
'<span style="font-style:italic">Bob Smith; Carol Wu;</span></p>'
'<div><a href="http://arxiv.org/abs/1312.0002">Another title</a>'
'<div><i><span>First Author; </span><span>Second Author</span></i></div></div>')
row, unwrapped = archive.parse_page(html, 2014, {'observed_at': OBSERVED})
self.assertEqual(row['title'], 'A research title')
self.assertEqual(row['authors'], ['Jane Doe', 'Bob Smith', 'Carol Wu'])
self.assertEqual(row['metadata']['source_variants'][0]['authors_display'], 'Jane Doe; Bob Smith; Carol Wu;')
self.assertEqual(row['track'], 'main')
self.assertEqual(unwrapped['authors'], ['First Author', 'Second Author'])
def test_same_arxiv_in_different_tracks_keeps_separate_source_attempts(self):
html = page2015().replace('1412.0002', '1412.0001v3')
rows = archive.parse_page(html, 2015, {'observed_at': OBSERVED})
self.assertEqual(len(rows), 2)
self.assertEqual({r['external_id'] for r in rows}, {'2015/main/1412.0001', '2015/workshop/1412.0001'})
self.assertEqual(rows[1]['metadata']['source_variants'][0]['version'], 'v3')
def test_2016_code_links_and_breaks_do_not_become_authors(self):
html = ('<h1>Accepted Papers (Conference Track)</h1><h3>Oral Presentations</h3><ol>'
'<li><a href="http://arxiv.org/abs/1511.00001">A title</a>'
'<a href="https://example.org/code">[code]</a><br>Jane Doe, Bob Smith</li></ol>'
'<h3>Poster Presentations</h3>')
row, = archive.parse_page(html, 2016, {'observed_at': OBSERVED})
self.assertEqual(row['authors'], ['Jane Doe', 'Bob Smith'])
self.assertEqual(row['metadata']['presentations'], ['oral'])
def test_conflicting_repeat_unknown_sections_and_unparsed_links_fail(self):
variants = [page2015().replace('Poster Presentations', 'Other Presentations'),
page2015().replace('</ol>', '</ol><a href="http://arxiv.org/abs/1412.0003">stray</a>', 1),
page2015().replace('Jane Doe and Bob Smith', 'Different Author', 1)]
for html in variants:
with self.subTest(html=html), self.assertRaises(ValueError):
archive.parse_page(html, 2015, {'observed_at': OBSERVED})
def test_arxiv_versions_are_preserved_and_unsafe_links_rejected(self):
self.assertEqual(archive.arxiv_link('https://arxiv.org/pdf/1511.00001v3.pdf'), ('1511.00001', 'v3'))
self.assertIsNone(archive.arxiv_link('https://arxiv.org.example.org/abs/1511.00001'))
for url in ['https://arxiv.org/abs/1511.00001?other=1', 'ftp://arxiv.org/abs/1511.00001',
'https://user@arxiv.org/abs/1511.00001', 'https://arxiv.org/abs/not-a-paper']:
with self.subTest(url=url), self.assertRaises(ValueError):
archive.arxiv_link(url)
def test_receipt_hash_status_path_and_symlink_cannot_escape_snapshot(self):
for failure in ['hash', 'status', 'path', 'symlink']:
with self.subTest(failure=failure), tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
source = self.frozen(root/'source', 2015, page2015())
file = source/'receipts.json'
data = json.loads(file.read_text())
receipt = data['receipts'][0]
if failure == 'hash': receipt['sha256'] = '0'*64
elif failure == 'status': receipt['status'] = 404
elif failure == 'path': receipt['file'] = '../outside.html'
else:
(root/'outside.html').write_text(page2015())
(source/'page.html').unlink()
(source/'page.html').symlink_to(root/'outside.html')
file.write_text(json.dumps(data))
with self.assertRaises(ValueError):
archive.read_page(2015, snapshots=source)
def test_changed_inventory_leaves_no_completed_output(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
source = self.frozen(root/'source', 2015, page2015())
with self.assertRaises(ValueError):
archive.collect(2015, root/'out', snapshots=source)
self.assertFalse((root/'out/iclr-archive-2015.json').exists())
def test_native_import_keeps_rejected_to_accepted_history_and_abstract(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
source = self.frozen(root/'source', 2015, page2015())
with patch.dict(archive.PAGES, {2015: (archive.PAGES[2015][0], {'main': 1, 'workshop': 1})}):
result = archive.collect(2015, root/'out', snapshots=source)
with self.assertRaises(FileExistsError):
archive.collect(2015, root/'out', snapshots=source)
db = root/'sample.sqlite'
store = Store(db)
pid, rid = store.record('arxiv', '1412.0001', 'https://arxiv.org/abs/1412.0001',
'A shared research paper', 'Keep this canonical abstract.',
['Jane Doe', 'Bob Smith'], {'arxiv': '1412.0001'}, {})
store.decision(pid, rid, 'Earlier Conference', 2014, 'main', 'original-attempt',
'https://example.org/rejected', 'rejected', 'explicit earlier decision', observed_at=OBSERVED)
store.conn.commit()
store.conn.close()
for manifest in result['files']:
ingest_file(db, manifest['file'])
ingest_file(db, manifest['file'])
with sqlite3.connect(db) as c:
self.assertEqual(c.execute('SELECT count(*) FROM papers').fetchone()[0], 2)
self.assertEqual(c.execute('SELECT abstract FROM papers WHERE id=?', (pid,)).fetchone()[0], 'Keep this canonical abstract.')
history = c.execute('SELECT v.acronym,e.year,d.status FROM decisions d JOIN submissions s ON s.id=d.submission_id JOIN editions e ON e.id=s.edition_id JOIN venues v ON v.id=e.venue_id WHERE s.paper_id=? ORDER BY e.year', (pid,)).fetchall()
self.assertEqual(history, [('Earlier Conference', 2014, 'rejected'), ('ICLR', 2015, 'accepted')])
self.assertEqual(c.execute('SELECT count(*) FROM submissions').fetchone()[0], 3)
self.assertEqual(c.execute('PRAGMA foreign_key_check').fetchall(), [])
+186
View File
@@ -0,0 +1,186 @@
"""Frozen publisher examples, conflicting evidence and preserved attempt identity."""
import hashlib
import json
from pathlib import Path
import sqlite3
import tempfile
import unittest
from unittest.mock import patch
from paper_atlas.collectors import rss
from paper_atlas.ingestion.records import ingest_file
FIXTURES = Path(__file__).parent/'fixtures/rss2025'
OBSERVED = '2026-11-09T10:00:00Z'
def fixture(name):
return (FIXTURES/name).read_text()
class RssProceedingsTests(unittest.TestCase):
def setUp(self):
self.index = rss.parse_index(fixture('index.html'))
self.accepted = rss.parse_program(fixture('accepted.html'), 'Accepted Papers', ('ID', 'Session', 'Title', 'Authors'))
self.demos = rss.parse_program(fixture('demos.html'), 'Demos', ('ID', 'Location', 'Title', 'Day', 'Time'))
def paper(self, pid=1, html=None, entry=None):
return rss.parse_paper(html or fixture(f'p{pid:03d}.html'), pid, entry or self.index[pid],
self.accepted[pid], self.demos.get(pid), {'paper': {'observed_at': OBSERVED}})
def test_frozen_official_census_and_all_fixture_hashes(self):
receipts = json.loads((FIXTURES/'receipts.json').read_bytes())['receipts']
self.assertEqual(len(receipts), 6)
for r in receipts:
data = (FIXTURES/r['file']).read_bytes()
self.assertEqual((len(data), hashlib.sha256(data).hexdigest()), (r['bytes'], r['sha256']))
self.assertEqual(len(self.index), 163)
self.assertEqual(set(self.index), set(self.accepted))
self.assertEqual(len(self.demos), 15)
rss.validate_program(self.index, self.accepted, self.demos, fixture('authorinfo.html'))
self.assertFalse(self.demos[93]['Title'].startswith('Demonstrating'))
def test_complete_authors_abstract_and_source_metadata_are_preserved(self):
row = self.paper()
self.assertEqual(row['authors'], ['Pascal Roth', 'Jonas Frey', 'Cesar Cadena', 'Marco Hutter'])
self.assertTrue(row['abstract'].startswith('Ensuring safe navigation'))
self.assertTrue(row['abstract'].endswith('https://github.com/leggedrobotics/fdm.'))
self.assertNotIn('Download:', row['abstract'])
self.assertEqual(row['identifiers'], {'doi': '10.15607/rss.2025.xxi.001'})
self.assertEqual(row['metadata']['publication_date'], '2025/06/21')
self.assertIn('AUTHOR', row['metadata']['original_bibtex'])
self.assertEqual(row['status'], 'accepted')
self.assertNotIn('decision_date', row)
self.assertEqual(row['metadata']['decision_date_precision'], 'not_supplied')
def test_demo_evidence_is_independent_of_title_and_session_topic(self):
row = self.paper(2)
self.assertEqual(row['track'], 'demo')
self.assertEqual(row['metadata']['demo_program_entry']['ID'], '2')
self.assertEqual(self.paper()['track'], 'proceedings_unspecified')
self.assertEqual({t['namespace'] for t in row['tags']}, {'rss_proceedings:session', 'rss_proceedings:program_role'})
self.assertFalse(any('research_type' in t['namespace'] for t in row['tags']))
html = fixture('p001.html').replace('Learned Perceptive Forward Dynamics Model', 'Demonstrating a Learned Model')
entry = {**self.index[1], 'title': self.index[1]['title'].replace('Learned Perceptive Forward Dynamics Model', 'Demonstrating a Learned Model')}
self.assertEqual(self.paper(html=html, entry=entry)['track'], 'proceedings_unspecified')
def test_missing_abstract_is_explicit_and_multiple_paragraphs_are_complete(self):
html = fixture('p001.html')
beginning = html.index('<p style="text-align: justify;">')
end = html.index('</p>', beginning)
empty = html[:beginning]+'<p style="text-align: justify;">'+html[end:]
row = self.paper(html=empty)
self.assertIsNone(row['abstract'])
self.assertEqual(row['metadata']['missing_abstract_reason'], 'empty_official_abstract_section')
multi = html.replace('</p> \n<p> \n<b>Download:', '</p><p>Second abstract paragraph.</p><p><b>Download:', 1)
self.assertTrue(self.paper(html=multi)['abstract'].endswith('Second abstract paragraph.'))
def test_metadata_drift_author_truncation_and_wrong_paper_doi_fail(self):
original = fixture('p001.html')
for altered in [original.replace('XXI.001}', 'XXI.002}'),
original.replace('content="2025/06/21"', 'content="2026/06/21"'),
original.replace('<meta name="citation_author" content="Marco Hutter" />', ''),
original.replace('<b>Abstract:</b>', '<b>Summary:</b>'),
original.replace('rss21/p001.pdf', 'rss21/p002.pdf'),
original.replace('citation_volume" content="21', 'citation_volume" content="22')]:
with self.subTest(altered=altered[:60]), self.assertRaises(ValueError):
self.paper(html=altered)
def test_duplicate_and_unparsed_index_entries_fail(self):
for html in [fixture('index.html').replace('p002.html', 'p001.html'),
fixture('index.html')+'<a href="p999.html">Stray paper</a>',
fixture('index.html').replace( rss.HEADING, 'A different volume')]:
with self.subTest(html=html[:40]), self.assertRaises(ValueError):
rss.parse_index(html)
def test_program_title_id_category_and_header_changes_require_review(self):
for change in ['title', 'id', 'category']:
index = {k: dict(v) for k, v in self.index.items()}
demos = {k: dict(v) for k, v in self.demos.items()}
if change == 'title':
demos[2]['Title'] = 'Another paper'
elif change == 'id':
del index[1]
else:
del demos[93]
with self.subTest(change=change), self.assertRaises(ValueError):
rss.validate_program(index, self.accepted, demos, fixture('authorinfo.html'))
with self.assertRaises(ValueError):
rss.parse_program(fixture('demos.html').replace('>Location<', '>Other<'), 'Demos', ('ID', 'Location', 'Title', 'Day', 'Time'))
def snapshot(self, root):
root.mkdir()
data = (FIXTURES/'p001.html').read_bytes()
(root/'paper.html').write_bytes(data)
receipt = {'file': 'paper.html', 'url': self.index[1]['url'], 'final_url': self.index[1]['url'],
'http_status': 200, 'content_type': 'text/html', 'bytes': len(data),
'sha256': hashlib.sha256(data).hexdigest(), 'at': OBSERVED}
(root/'receipts.json').write_text(json.dumps({'receipts': [receipt]}))
return receipt
def test_frozen_receipt_hash_status_redirect_and_path_guards(self):
for failure in ['hash', 'status', 'redirect', 'path', 'duplicate', 'symlink']:
with self.subTest(failure=failure), tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)/'source'
r = self.snapshot(root)
receipts = [r]
if failure == 'hash': r['sha256'] = '0'*64
elif failure == 'status': r['http_status'] = 404
elif failure == 'redirect': r['final_url'] = 'https://example.org/p001.html'
elif failure == 'path': r['file'] = '../outside.html'
elif failure == 'duplicate': receipts.append(dict(r))
else:
(root.parent/'outside.html').write_bytes((root/'paper.html').read_bytes())
(root/'paper.html').unlink()
(root/'paper.html').symlink_to(root.parent/'outside.html')
(root/'receipts.json').write_text(json.dumps({'receipts': receipts}))
with self.assertRaises(ValueError): rss.Pages(snapshots=root).read(self.index[1]['url'])
def test_unreviewed_edition_and_existing_output_refuse_work(self):
with tempfile.TemporaryDirectory() as tmp:
with self.assertRaises(ValueError): rss.collect(2026, tmp, snapshots=FIXTURES)
(Path(tmp)/'rss-2025.json').write_text('{}')
with self.assertRaises(FileExistsError): rss.collect(2025, tmp, snapshots=FIXTURES)
def test_native_roundtrip_is_idempotent_with_same_doi_and_distinct_tracks(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
original = self.paper()
dblp = {**original, 'source': 'dblp', 'external_id': 'conf/rss/example', 'metadata': {}, 'tags': []}
variant = {**original, 'external_id': '2025/other-track', 'track': 'workshop'}
for n, record in enumerate([dblp, original, variant, original]):
path = root/f'{n}.jsonl'
path.write_text(json.dumps(record)+'\n')
path.with_suffix('.manifest.json').write_text(json.dumps({'source': record['source'], 'list_url': rss.ROOT, 'count': 1}))
ingest_file(root/'sample.sqlite', path)
with sqlite3.connect(root/'sample.sqlite') as c:
self.assertEqual(c.execute('SELECT count(*) FROM papers').fetchone()[0], 1)
self.assertEqual(c.execute('SELECT count(*) FROM source_records').fetchone()[0], 3)
self.assertEqual(c.execute('SELECT count(*) FROM submissions').fetchone()[0], 2)
self.assertEqual(c.execute('SELECT count(*) FROM decisions').fetchone()[0], 2)
self.assertEqual(c.execute('SELECT count(*) FROM submission_sources').fetchone()[0], 3)
self.assertEqual(c.execute('PRAGMA foreign_key_check').fetchall(), [])
def test_two_real_papers_collection_commits_evidence_only_after_success(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
index = {pid: self.index[pid] for pid in [1, 2]}
accepted = {pid: self.accepted[pid] for pid in [1, 2]}
demos = {2: self.demos[2]}
with (patch.object(rss, 'EXPECTED', 2), patch.object(rss, 'DEMO_IDS', {2}),
patch.object(rss, 'parse_index', return_value=index),
patch.object(rss, 'parse_program', side_effect=[accepted, demos])):
result = rss.collect(2025, root/'out', snapshots=FIXTURES)
self.assertEqual((result['records'], len(result['files'])), (2, 2))
self.assertFalse(result['database_written'])
self.assertFalse(result['pdfs_downloaded'])
self.assertEqual(len(result['source_evidence']), 6)
for item in result['source_evidence']:
self.assertEqual(hashlib.sha256((root/'out'/item['file']).read_bytes()).hexdigest(), item['sha256'])
for item in result['files']:
self.assertEqual(item['missing_abstract_count'], 0)
self.assertEqual(hashlib.sha256(Path(item['file']).read_bytes()).hexdigest(), item['file_sha256'])
if __name__ == '__main__':
unittest.main()