From ac08edf572c4fc3304ce9c8e273108df3b0bce73 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Fri, 6 Nov 2026 20:55:49 +0800 Subject: [PATCH] Add ICML 2026 public rejected-submission archive with platform evidence --- docs/data-sources.md | 11 ++ docs/usage.md | 1 + paper_atlas/audits/public_openreview.py | 15 ++- paper_atlas/cli.py | 1 + paper_atlas/collectors/icml_public_archive.py | 125 ++++++++++++++++++ tests/test_icml_public_archive.py | 86 ++++++++++++ 6 files changed, 234 insertions(+), 5 deletions(-) create mode 100644 paper_atlas/collectors/icml_public_archive.py create mode 100644 tests/test_icml_public_archive.py diff --git a/docs/data-sources.md b/docs/data-sources.md index 17f29e2..10f11e4 100644 --- a/docs/data-sources.md +++ b/docs/data-sources.md @@ -77,6 +77,17 @@ paper-atlas audit public-openreview --db /data/paper-index/papers.sqlite \ 入库前审计核验文件哈希、数量、轨道和来源声明,并用真实 forum ID 对比同届同轨道已有投稿。存在不同决定时阻止整批导入,交由来源和时序核查;另一会议的后续录用属于独立投稿历史,不构成该次拒稿的冲突。同一 forum 的镜像不新增投稿事件,但论文实体的跨平台去重仍须在全量结束后核验。审计通过的报告包含带 SHA-256 的导入队列;应在单写入维护锁下及时串行导入,主库变化后重新审计。 +ICML 2026 另有[公开原始投稿快照](https://github.com/HemantKumar01/ICML-2026-data/blob/1a1b9d973a8dfbf254f740fb8100a2fcf36757ea/data/rejected.json),包含 214 条投稿根记录。`collect icml-public-archive` 固定该 commit、文件长度及 Git blob 哈希,逐条要求 `readers` 包含 everyone、forum 等于自身 ID、域及投稿邀请属于主会、平台 `venueid` 明确为 `ICML.cc/2026/Conference/Rejected_Submission`。不能仅根据显示文案 Submitted to ICML 2026 推断拒稿。 + +```sh +paper-atlas collect icml-public-archive --out /data/paper-index/collected/icml-2026-public-archive +paper-atlas audit public-openreview --db /data/paper-index/papers.sqlite \ + --inventory /data/paper-index/collected/icml-2026-public-archive/public-openreview-inventory.json \ + --out /data/paper-index/collected/icml-2026-public-preimport.json +``` + +这批证据标记为 `public_submission_venueid`,保留逐投稿许可及快照出处;它与整理者的 Reject 标签、原始签名决定分别表述。记录修改时间不当作决定日期,观察时间采用快照上界,不采用今天的导入时间。214 是这个公开 opt-in 子集的投稿数,不能当作会议全部拒稿数,也不能直接当作去重论文实体数。该采集器同样只提取元数据与标题摘要,丢弃 authorids、个人资料、评审和 PDF 字段。 + ```sh python3 -B -m paper_atlas collect peerread --cache /data/paper-index/raw/http-cache \ --out /data/paper-index/collected diff --git a/docs/usage.md b/docs/usage.md index 29c014b..f0a84cf 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -15,6 +15,7 @@ paper-atlas collect ecva --cache /data/paper-index/raw/http-cache --out /data/pa paper-atlas catalogue dblp --help paper-atlas audit iclr --help paper-atlas collect public-openreview --help +paper-atlas collect icml-public-archive --help paper-atlas audit public-openreview --help ``` diff --git a/paper_atlas/audits/public_openreview.py b/paper_atlas/audits/public_openreview.py index b46b072..27d9668 100644 --- a/paper_atlas/audits/public_openreview.py +++ b/paper_atlas/audits/public_openreview.py @@ -7,14 +7,19 @@ from pathlib import Path import sqlite3 from paper_atlas.collectors.public_openreview import SOURCE, REVISION +from paper_atlas.collectors.icml_public_archive import SOURCE as ARCHIVE_SOURCE, REVISION as ARCHIVE_REVISION from paper_atlas.db.submissions import timestamp def audit(db, inventory, out): inventory = Path(inventory).resolve() result = json.loads(inventory.read_text()) - if result.get('source') != SOURCE or result.get('revision') != REVISION: - raise ValueError('Unsupported curated source inventory') + specs = {SOURCE: (REVISION, 'curated_public_status_assertion'), + ARCHIVE_SOURCE: (ARCHIVE_REVISION, 'public_submission_venueid')} + source = result.get('source') + if source not in specs or result.get('revision') != specs[source][0]: + raise ValueError('Unsupported public source inventory') + revision, evidence_kind = specs[source] db = Path(db).resolve() if not db.is_file(): raise ValueError('Existing database required for identity and status audit') @@ -38,8 +43,8 @@ def audit(db, inventory, out): if any(row[key] != manifest[key] for key in ('source', 'venue', 'year', 'track')): raise ValueError('Record edition differs from inventory') meta = row['metadata'] - if meta.get('evidence_kind') != 'curated_public_status_assertion' or meta.get('source_revision') != REVISION: - raise ValueError('Missing explicit curated provenance') + if meta.get('evidence_kind') != evidence_kind or meta.get('source_revision') != revision: + raise ValueError('Missing explicit public source provenance') if meta.get('original_decision_signature_verified') is not False: raise ValueError('Curated metadata must not claim original signatures') if row['status'] not in ('rejected', 'desk_rejected', 'withdrawn'): @@ -71,7 +76,7 @@ def audit(db, inventory, out): checksums[str(path)] = manifest['file_sha256'] if checked != result['normalized_records']: raise ValueError('Normalized inventory total differs from checked records') - report = {'source': SOURCE, 'revision': REVISION, 'database': str(db), + report = {'source': source, 'revision': revision, 'database': str(db), 'inventory': str(inventory), 'checked_at': timestamp(), 'records_checked': checked, 'existing_same_edition_attempts': existing_attempts, 'conflicts': conflicts, 'status': 'blocked' if conflicts else 'ready_for_serial_import', diff --git a/paper_atlas/cli.py b/paper_atlas/cli.py index 938dd9f..3e0d414 100644 --- a/paper_atlas/cli.py +++ b/paper_atlas/cli.py @@ -14,6 +14,7 @@ COMMANDS = { ('collect','iclr-program'): ('paper_atlas.collectors.iclr', {}, 'ICLR 官方公开程序'), ('collect','iclr-snapshot'): ('paper_atlas.collectors.openreview', {}, 'ICLR 公开投稿与决定快照'), ('collect','public-openreview'): ('paper_atlas.collectors.public_openreview', {}, '核验其他会议的公开拒稿及撤稿标签'), + ('collect','icml-public-archive'): ('paper_atlas.collectors.icml_public_archive', {}, 'ICML 2026 公开拒稿原始投稿快照'), ('collect','peerread'): ('paper_atlas.collectors.peerread', {}, 'PeerRead 历史决定补充'), ('collect','colm'): ('paper_atlas.collectors.proceedings', {'source':'colm'}, 'COLM 官方论文集'), ('collect','neurips'): ('paper_atlas.collectors.proceedings', {'source':'neurips'}, 'NeurIPS 官方论文集'), diff --git a/paper_atlas/collectors/icml_public_archive.py b/paper_atlas/collectors/icml_public_archive.py new file mode 100644 index 0000000..c0845be --- /dev/null +++ b/paper_atlas/collectors/icml_public_archive.py @@ -0,0 +1,125 @@ +"""ICML 2026 public submission roots with explicit Rejected_Submission IDs.""" +import argparse +import fcntl +import hashlib +import json +from pathlib import Path +import re +import shutil +import tempfile + +from paper_atlas.collectors.openreview import date_ms, invitations, public, value +from paper_atlas.collectors.public_openreview import download +from paper_atlas.db.submissions import timestamp + + +SOURCE = 'openreview_public_archive' +REVISION = '1a1b9d973a8dfbf254f740fb8100a2fcf36757ea' +AS_OF = '2026-08-08T12:03:26.000Z' +URL = 'https://raw.githubusercontent.com/HemantKumar01/ICML-2026-data/' + REVISION + '/data/rejected.json' +BLOB_SHA1 = 'cebdcddffe3eb2145d32d328db89a1626e3f85eb' +BYTES = 1600856 +EXPECTED = 214 +DOMAIN = 'ICML.cc/2026/Conference' +VENUEID = DOMAIN + '/Rejected_Submission' + + +def normalize(note): + forum = note.get('id') + if not isinstance(forum, str) or not re.fullmatch(r'[A-Za-z0-9_-]{8,32}', forum) or note.get('forum') != forum: + raise ValueError('Not a public submission root with a real forum identity') + if not public(note) or note.get('ddate'): + raise ValueError('Submission is not explicitly public and live in the archive') + if note.get('domain') != DOMAIN or DOMAIN + '/-/Submission' not in invitations(note): + raise ValueError('Submission is outside the ICML 2026 conference track') + content = note.get('content') or {} + if value(content, 'venueid') != VENUEID: + raise ValueError('Missing explicit platform Rejected_Submission classification') + title, abstract = value(content, 'title'), value(content, 'abstract') + if not isinstance(title, str) or not title.strip(): + raise ValueError('Submission lacks a title') + if abstract is not None and not isinstance(abstract, str): + raise ValueError('Submission abstract must be text') + authors = value(content, 'authors', []) or [] + if not isinstance(authors, list): + raise ValueError('Unexpected public author format') + authors = [a.strip() for a in authors if isinstance(a, str) and a.strip() and '@' not in a + and not re.fullmatch(r'anonymous(?: authors?)?', a.strip(), re.I)] + tags = [] + for field in ('keywords', 'primary_area'): + raw = value(content, field) + terms = raw if isinstance(raw, list) else re.split(r'[,;]', raw) if field == 'keywords' and isinstance(raw, str) else [raw] + tags.extend({'namespace': SOURCE + ':' + field, 'value': term.strip()} + for term in terms if isinstance(term, str) and term.strip()) + url = 'https://openreview.net/forum?id=' + forum + return {'source': SOURCE, 'external_id': 'icml/2026/' + forum, 'url': url, + 'title': ' '.join(title.split()), 'abstract': abstract.strip() if abstract else None, + 'authors': authors, 'identifiers': {'openreview': forum}, + 'submission_identifiers': [{'scheme': 'openreview', 'value': forum}], + 'venue': 'ICML', 'year': 2026, 'track': 'main', 'status': 'rejected', + 'raw_decision': VENUEID, 'evidence_url': url, 'observed_at': AS_OF, + 'submitted_at': date_ms(note.get('cdate') or note.get('tcdate')), 'tags': tags, + 'metadata': {'source_revision': REVISION, 'source_blob_sha1': BLOB_SHA1, + 'source_file': URL, 'original_forum': forum, 'license': note.get('license'), + 'venue': value(content, 'venue'), 'venueid': VENUEID, + 'public_readers_verified': True, 'original_invitations': invitations(note), + 'source_note_modified_at': date_ms(note.get('mdate')), + 'evidence_kind': 'public_submission_venueid', + 'original_decision_signature_verified': False, + 'source_snapshot_upper_bound': AS_OF}} + + +def collect(out, fetcher=None): + fetcher = fetcher or download + out = Path(out) + out.mkdir(parents=True, exist_ok=True) + with (out / 'collect.lock').open('a') as lock: + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + if (out / 'public-openreview-inventory.json').exists(): + raise ValueError('Completed inventory exists; select a new output directory') + if shutil.disk_usage(out).free < 8 * 1024**3: + raise RuntimeError('Less than 8 GiB free before collection') + data = fetcher(URL, BYTES) + blob = hashlib.sha1(b'blob ' + str(len(data)).encode() + b'\0' + data).hexdigest() + if len(data) != BYTES or blob != BLOB_SHA1: + raise ValueError('Public archive checksum mismatch') + archive = json.loads(data) + notes = archive['notes'] + if archive.get('count') != EXPECTED or not isinstance(notes, list) or len(notes) != EXPECTED: + raise ValueError('Public archive inventory count mismatch') + records = [normalize(note) for note in notes] + if len({row['identifiers']['openreview'] for row in records}) != EXPECTED: + raise ValueError('Repeated submission forum in public archive') + records.sort(key=lambda row: row['external_id']) + with tempfile.TemporaryDirectory(prefix='.icml-public-', dir=out) as temporary: + stage = Path(temporary) + path = stage / 'public-openreview-icml-2026-main.jsonl' + path.write_text(''.join(json.dumps(row, ensure_ascii=False) + '\n' for row in records)) + manifest = {'source': SOURCE, 'parser_version': 1, 'venue': 'ICML', 'year': 2026, + 'track': 'main', 'count': EXPECTED, 'status': 'partial', 'list_url': URL, + 'file': str(out / path.name), 'file_sha256': hashlib.sha256(path.read_bytes()).hexdigest(), + 'source_revision': REVISION, 'missing_abstract_count': sum(not row['abstract'] for row in records), + 'evidence_kind': 'public_submission_venueid', + 'note': 'Public opt-in subset; explicit platform venueid, not original signed PC notes.'} + path.with_suffix('.manifest.json').write_text(json.dumps(manifest, indent=2) + '\n') + result = {'source': SOURCE, 'revision': REVISION, 'finished_at': timestamp(), + 'input_rows': EXPECTED, 'normalized_records': EXPECTED, 'files': [manifest], + 'source_files': [{'url': URL, 'git_blob_sha1': blob, 'input_bytes': len(data)}], + 'note': 'Snapshot preserves per-note public visibility and licenses; no reviews, authorids or PDFs retained.'} + inventory = stage / 'public-openreview-inventory.json' + inventory.write_text(json.dumps(result, indent=2) + '\n') + for child in sorted(stage.iterdir(), key=lambda child: child == inventory): + child.replace(out / child.name) + print(json.dumps(result), flush=True) + return result + + +def main(argv=None, *, prog=None): + parser = argparse.ArgumentParser(prog=prog) + parser.add_argument('--out', required=True) + args = parser.parse_args(argv) + collect(args.out) + + +if __name__ == '__main__': + main() diff --git a/tests/test_icml_public_archive.py b/tests/test_icml_public_archive.py new file mode 100644 index 0000000..7331864 --- /dev/null +++ b/tests/test_icml_public_archive.py @@ -0,0 +1,86 @@ +import hashlib +import json +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +from paper_atlas.collectors import icml_public_archive as archive +from paper_atlas.audits.public_openreview import audit +from paper_atlas.db.store import Store +from paper_atlas.ingestion.records import ingest_file + + +class ICMLPublicArchiveTests(unittest.TestCase): + def note(self, **changes): + return {'id': 'PublicRoot1', 'forum': 'PublicRoot1', 'readers': ['everyone'], + 'domain': archive.DOMAIN, 'invitations': [archive.DOMAIN + '/-/Submission'], + 'license': 'CC BY 4.0', 'cdate': 1769251630136, + 'content': {'title': {'value': 'A public rejected submission'}, + 'abstract': {'value': 'Public abstract'}, + 'authors': {'value': ['Anonymous', 'Jane Doe']}, + 'authorids': {'value': ['private@example.org']}, + 'venue': {'value': 'Submitted to ICML 2026'}, + 'venueid': {'value': archive.VENUEID}, + 'primary_area': {'value': 'learning'}, + 'pdf': {'value': '/private.pdf'}}, + 'details': {'reviews': ['not retained']}, **changes} + + def test_explicit_platform_classification_requires_public_root_and_correct_scope(self): + original = self.note() + normalized = archive.normalize(original) + self.assertEqual(normalized['status'], 'rejected') + self.assertEqual(normalized['raw_decision'], archive.VENUEID) + self.assertEqual(normalized['metadata']['license'], 'CC BY 4.0') + self.assertEqual(normalized['metadata']['evidence_kind'], 'public_submission_venueid') + self.assertFalse(normalized['metadata']['original_decision_signature_verified']) + self.assertNotIn('private@example.org', json.dumps(normalized)) + self.assertNotIn('not retained', json.dumps(normalized)) + for changes in ({'readers': ['authors']}, {'forum': 'AnotherRoot'}, {'ddate': 1000}, + {'domain': 'ICML.cc/2026/Position_Papers'}, {'invitations': []}): + with self.subTest(changes=changes), self.assertRaises(ValueError): + archive.normalize(self.note(**changes)) + for venueid in (None, 'ICML.cc/2026/Conference/Submission', 'ICML.cc/2026/Conference/Accepted_Submission'): + candidate = self.note() + candidate['content']['venueid'] = {'value': venueid} + with self.assertRaisesRegex(ValueError, 'classification'): + archive.normalize(candidate) + + def collect_fixture(self, out, notes, count=None, corrupt=False): + data = json.dumps({'count': len(notes) if count is None else count, 'notes': notes}).encode() + blob = hashlib.sha1(b'blob ' + str(len(data)).encode() + b'\0' + data).hexdigest() + with patch.multiple(archive, BYTES=len(data), BLOB_SHA1=blob, EXPECTED=len(notes)), \ + patch.object(archive.shutil, 'disk_usage', return_value=type('Space', (), {'free': 20 * 1024**3})()): + return archive.collect(out, fetcher=lambda url, limit: data + b'x' if corrupt else data) + + def test_verified_archive_roundtrips_through_preflight_and_idempotent_import(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + result = self.collect_fixture(root / 'source', [self.note()]) + db = root / 'db.sqlite' + Store(db).conn.close() + report = audit(db, root / 'source/public-openreview-inventory.json', root / 'audit.json') + self.assertEqual(report['status'], 'ready_for_serial_import') + self.assertEqual(report['source'], archive.SOURCE) + self.assertEqual(result['files'][0]['status'], 'partial') + for _ in range(2): + ingest_file(db, report['files'][0]) + store = Store(db) + try: + self.assertEqual(store.conn.execute('SELECT count(*) FROM submissions').fetchone()[0], 1) + self.assertEqual(store.conn.execute('SELECT status FROM decisions').fetchall(), [('rejected',)]) + finally: + store.conn.close() + + def test_bad_hash_count_or_duplicate_identity_publishes_nothing(self): + for kwargs in ({'corrupt': True}, {'count': 99}, {}): + notes = [self.note(), self.note()] if not kwargs else [self.note()] + with self.subTest(kwargs=kwargs), tempfile.TemporaryDirectory() as tmp: + with self.assertRaises(ValueError): + self.collect_fixture(tmp, notes, **kwargs) + self.assertEqual(list(Path(tmp).glob('*.jsonl')), []) + self.assertFalse((Path(tmp) / 'public-openreview-inventory.json').exists()) + + +if __name__ == '__main__': + unittest.main()