Compare commits

...

3 Commits

Author SHA1 Message Date
codex-bot 9a279f2eae Handle MLSys year-labelled proceedings and bridge retained publisher evidence
Metadata integrity checks / metadata (push) Successful in 53s
2026-11-06 22:58:50 +08:00
codex-bot 25cc5b865b Preserve unspecified MM tracks and validate MLSys identities at the shared boundary
Metadata integrity checks / metadata (push) Successful in 41s
2026-11-06 22:49:41 +08:00
codex-bot a65edc6b9d Add ACM Multimedia scope and MLSys metadata with shared publication identities
Metadata integrity checks / metadata (push) Successful in 33s
2026-11-06 22:39:55 +08:00
14 changed files with 315 additions and 16 deletions
+1 -1
View File
@@ -43,7 +43,7 @@ jobs:
outside.mkdir()
subprocess.run([str(venv/'bin/paper-atlas'),'--help'],cwd=outside,check=True)
subprocess.run([python,'-I','-B','-m','paper_atlas','collect','cvf','--help'],cwd=outside,check=True)
smoke = "from paper_atlas.db.store import Store; from paper_atlas.config import DEFAULT_REGISTRY; import json; assert len(json.loads(DEFAULT_REGISTRY.read_text())['venues'])==41; s=Store('installed.sqlite'); s.conn.close()"
smoke = "from paper_atlas.db.store import Store; from paper_atlas.config import DEFAULT_REGISTRY; import json; assert {'COLM','MM','MLSys'} <= {v['acronym'] for v in json.loads(DEFAULT_REGISTRY.read_text())['venues']}; s=Store('installed.sqlite'); s.conn.close()"
subprocess.run([python,'-I','-B','-c',smoke],cwd=outside,check=True)
subprocess.run([python,'-I','-B','-m','paper_atlas','query','--db','installed.sqlite','--stats'],cwd=outside,check=True)
PY
+1 -1
View File
@@ -4,7 +4,7 @@
Paper Atlas 将 arXiv、AI 会议论文集及公开投稿决定整理到可追溯的 SQLite 数据库中,用于查询和后续研究分析。它按批次运行,不需要常驻 Web 服务;采集标题、摘要、标识、标签及决定证据,不下载论文全文。
项目仍在建设中。候选范围包含 41 个会议,尚未完成全部历史数据和最终去重复核。实际进度以数据库、来源清单和覆盖报告为准,而不是仓库文件数量。
项目仍在建设中。候选范围包含 43 个会议(含 ACM MM、MLSys),尚未完成全部历史数据和最终去重复核。实际进度以数据库、来源清单和覆盖报告为准,而不是仓库文件数量。
## 项目结构
+20
View File
@@ -16,6 +16,8 @@
- [ACL Anthology 权威 XML 与 API](https://aclanthology.org/info/development/)
- [NeurIPS](https://proceedings.neurips.cc/)
- [COLM](https://proceedings.colm.cc/)
- [MLSys](https://proceedings.mlsys.org/)
- [ACM Multimedia 历史目录](https://dblp.org/db/conf/mm/index.html)
- [CVF](https://openaccess.thecvf.com/)
- [ECVA](https://www.ecva.net/papers.php)
- [IJCAI](https://www.ijcai.org/all_proceedings/)
@@ -29,6 +31,7 @@
| 机器学习、理论 | NeurIPS、ICML、ICLR、COLM、COLT、AISTATS、UAI、CLeaR | OpenReview、PMLR、NeurIPS、COLM 官方论文集 |
| 自然语言 | ACL、EMNLP、NAACL、EACL、COLING、CoNLL | ACL Anthology |
| 视觉 | CVPR、ICCV、ECCV | CVF、ECVA |
| 多模态与机器学习系统 | ACM MM、MLSys | ACM / DBLP、MLSys 官方论文集 |
| 综合 AI | AAAI、IJCAI、ECAI | 官方论文集、DBLP |
| 检索与数据 | KDD、SIGIR、WWW、WSDM、RecSys、CIKM、ICDM、SDM、ECML-PKDD | 官方出版方、DBLP,具体年份入口待逐项登记 |
| 机器人 | ICRA、IROS、RSS、CoRL | 官方论文集、PMLR、DBLP |
@@ -38,6 +41,23 @@
这些是领域覆盖候选,不构成统一排名,也不能据此声称已全部入库。逐年目录以真实举办届次和正式出版清单为准:双年会议不补造不存在的年份;主会、Findings、workshop 分开。`edition_sources` 保存实际采集状态,不能用候选名单代替覆盖率报告。
## ACM MM 与 MLSys
会议注册表包含 `MM`(别名 `ACMMM`、`ACM MM`)和 `MLSys`。MLSys 官方根目录目前列出 2019–2026 年的 8 卷,直接采集每篇标题、摘要、公开作者和引用元数据:
```sh
paper-atlas collect mlsys --cache /data/paper-index/raw/http-cache \
--out /data/paper-index/collected/mlsys
```
每年清单核对官方声明数量,缺摘要标为 partial;未知现代轨道、空目录或读取失败不能记为成功。`SysML 2019` 归于同一 MLSys 系列并保留别名。2018 年官方说明接受的投稿为 non-archival,论文集从 2019 年开始;不拼造 2018 年归档卷,也不把未出现在论文集中的投稿判为拒稿。参见 [2018 年官方说明](https://mlsys.org/Conferences/2018/)。
MLSys 老式 `/paper/<year>/hash/…` 与新式 `/paper_files/paper/<year>/hash/…` 链接规范为同一个 `publication_mlsys` 标识,也支持 2023 年实际使用的 `-Abstract-mlsys2023.html` 及对应年份标签;标签年份必须与目录年份一致。DBLP 和官方摘要源共用它,避免同一发表记录成为两个论文实体或两次投稿。只从自身出版社链接提取,不从摘要参考文献提取,也不把发表标识用于合并某次拒稿尝试。共享投稿身份入口要求 MLSys 源具有有效的同年出版社身份,缺失或错年的文件不能生成投稿事件;旧版 DBLP 整理文件可利用已保留的唯一自身出版社链接补得此身份,不需要丢弃来源或依靠标题猜测。
ACM MM 使用固定 DBLP 原始快照和已核验的 proceedings crossref 盘点历史,主会 `ACM Multimedia`、历史编号分卷与同系列 workshop 分开。主会卷也包含 Doctoral Symposium、Reproducibility Companion Papers、Tutorials 等章节(参见 [2025 年目录](https://dblp.org/db/conf/mm/mm2025.html)),而原始 XML 不含逐篇章节,所以这类记录暂标 `proceedings_unspecified`,不能整卷当作 regular 主轨道;后续需逐篇目录证据补齐。旧版候选 JSONL 没有 `mm` / `mlsys` 家族,不能用它假装覆盖新增会议;应从已经下载并校验的原始 XML 重新筛选这两个会议,保留完整扫描数量、卷证据与遗漏原因。DBLP 没有摘要,后续须通过 DOI、真实 forum 等独立标识关联官方或公开元数据,不能将书目导入成功表述为摘要已齐全。
公开拒稿镜像的 `acmmm` 家族映射到 `MM`,只接受明确的负面状态,继续保留镜像与原始签名决定的区别。现有固定 OpenReview 镜像只含 ACM MM 2024 的录用记录,没有据此证明其他年份没有公开拒稿;[2025 年官方 CFP](https://acmmm2025.org/call-for-papers/) 仅说明审稿期间不公开,不能外推为会后永不公开。
## 官方会议元数据采集
```sh
+1
View File
@@ -18,6 +18,7 @@ COMMANDS = {
('collect','peerread'): ('paper_atlas.collectors.peerread', {}, 'PeerRead 历史决定补充'),
('collect','colm'): ('paper_atlas.collectors.proceedings', {'source':'colm'}, 'COLM 官方论文集'),
('collect','neurips'): ('paper_atlas.collectors.proceedings', {'source':'neurips'}, 'NeurIPS 官方论文集'),
('collect','mlsys'): ('paper_atlas.collectors.proceedings', {'source':'mlsys'}, 'MLSys 官方历年标题与摘要'),
('collect','cvf'): ('paper_atlas.collectors.vision', {'source':'cvf'}, 'CVPR / ICCV 官方开放目录'),
('collect','ecva'): ('paper_atlas.collectors.vision', {'source':'ecva'}, 'ECCV 官方开放目录'),
('collect','dblp'): ('paper_atlas.collectors.dblp_records', {}, '核对并整理 DBLP 历史发表记录'),
+1 -1
View File
@@ -20,7 +20,7 @@ DTD_MD5='96e4283e71886dec2173718957293648'
DTD_NAMES={'dblp.dtd','dblp-2023-06-28.dtd'}
RELEASE='https://doi.org/10.4230/dblp.xml.2026-11-01'
# Broad discovery candidates, including historical names; not a venue mapping.
FAMILIES=set('nips icml iclr colm colt aistats uai clear acl emnlp naacl eacl coling conll cvpr iccv eccv aaai ijcai ecai kdd sigir www wsdm recsys cikm icdm sdm ecml pkdd ecmlpkdd icra iros rss corl aips icaps kr atal aamas icassp interspeech eurospeech icslp ismir miccai fat facct lrec'.split())
FAMILIES=set('nips icml iclr colm colt aistats uai clear acl emnlp naacl eacl coling conll cvpr iccv eccv aaai ijcai ecai kdd sigir www wsdm recsys cikm icdm sdm ecml pkdd ecmlpkdd icra iros rss corl aips icaps kr atal aamas icassp interspeech eurospeech icslp ismir miccai fat facct lrec mm mlsys'.split())
def records(archive,dtd_bytes):
+15 -3
View File
@@ -17,6 +17,7 @@ from paper_atlas.collectors.dblp import XML_SHA,DTD_MD5,RELEASE,records
from paper_atlas.collectors.dblp_scope import scope
from paper_atlas.db.submissions import timestamp
from paper_atlas.io.snapshots import digest_file
from paper_atlas.io.publication_ids import mlsys_id
AS_OF='2026-11-01T00:00:00.000Z'
MATERIAL_PART=r'(?:title(?: page)?|preface|foreword|editorial|front\s?matter|back\s?matter|(?:table of )?contents|(?:author |subject )?index|contributors|(?:conference |program |organizing |steering )?committee|conference organization|reviewers)'
@@ -36,8 +37,10 @@ def material_reason(title,authors,key,year,track):
def identifiers(fields):
dois=set();arxiv=set();forums=set()
dois=set();arxiv=set();forums=set();mlsys=set()
for link in fields.get('ee',[]):
if own_mlsys := mlsys_id(link):
mlsys.add(own_mlsys)
parsed=urlsplit(link);path=unquote(parsed.path)
doi=None
if parsed.netloc in ('doi.org','dx.doi.org','doi.ieeecomputersociety.org'):doi=path.lstrip('/')
@@ -57,9 +60,10 @@ def identifiers(fields):
if match:arxiv.add(re.sub(r'v\d+$','',match[1]))
publications={doi for doi in dois if not doi.startswith('10.48550/arxiv.')}
own={}
for scheme,values in (('doi',publications),('arxiv',arxiv),('openreview',forums)):
groups=(('doi',publications),('arxiv',arxiv),('openreview',forums),('publication_mlsys',mlsys))
for scheme,values in groups:
if len(values)==1:own[scheme]=next(iter(values))
ambiguous={scheme:sorted(values) for scheme,values in (('doi',publications),('arxiv',arxiv),('openreview',forums)) if len(values)>1}
ambiguous={scheme:sorted(values) for scheme,values in groups if len(values)>1}
return own,ambiguous
@@ -84,6 +88,8 @@ def normalize_row(row,volumes,targets,include_workshops=False,through_year=2026)
'scope_rule':edition['rule'],'author_names_raw':fields.get('author',[]),'publtype':row.get('publtype'),
'bibliography':{key:value for key,value in fields.items() if key in ('booktitle','year','ee','url','pages','volume','number','publisher','series','isbn')},
'ambiguous_identifiers':ambiguous,'publication_evidence':'inproceedings bibliography; not an original decision note'}
if edition.get('track_scope_note'):
metadata['track_scope_note']=edition['track_scope_note']
flagged=bool(re.search(r'\b(?:withdrawn|retracted)\b',row.get('publtype') or '',re.I))
result={'source':'dblp','external_id':row['key'],'url':'https://dblp.org/rec/'+row['key']+'.html',
'title':title,'abstract':None,'authors':authors,'identifiers':own,
@@ -110,6 +116,12 @@ def collect(catalogue,out,venues=None,candidates=None,archive=None,dtd=None,incl
allowed={row['acronym'].casefold():row['acronym'] for row in json.loads(DEFAULT_REGISTRY.read_text())['venues']}
if venues and set(name.casefold() for name in venues)-allowed.keys():raise ValueError('Unknown registry venue')
targets={allowed[name.casefold()] for name in venues} if venues else set(allowed.values())
if candidates:
families = manifest.get('candidate_family_counts', {})
missing = [venue for venue, family in [('MM', 'mm'), ('MLSys', 'mlsys')]
if venue in targets and not families.get(family)]
if missing:
raise ValueError('Candidate file predates requested families ' + ', '.join(missing) + '; use the original --archive')
out=Path(out);out.mkdir(parents=True,exist_ok=True)
with (out/'dblp.normalize.lock').open('a') as lock:
fcntl.flock(lock,fcntl.LOCK_EX|fcntl.LOCK_NB)
+12 -2
View File
@@ -33,6 +33,8 @@ BOOKS = {
'interspeech':[('INTERSPEECH','INTERSPEECH',[]),('EUROSPEECH','EUROSPEECH',['INTERSPEECH']),('ICSLP','ICSLP',['INTERSPEECH']),('ECST','ECST',['INTERSPEECH'])],
'ismir':[('ISMIR','ISMIR',[])], 'miccai':[('MICCAI','MICCAI',[])],
'fat':[('FAccT','FAccT',[]),('FAT*','FAccT',[]),('FAT','FAccT',[])],
'mm':[('ACM Multimedia','MM',['ACMMM','ACM MM'])],
'mlsys':[('MLSys','MLSys',[]),('SysML','MLSys',['SysML'])],
}
@@ -45,6 +47,7 @@ def scope(row,volume,through_year=2026):
if not volume:return None,'missing_proceedings'
title=' '.join(volume['fields'].get('title',[]))
if family=='clear2' and 'causal learning and reasoning' not in title.casefold():return None,'ambiguous_venue_name'
if family=='mlsys' and 'machine learning and systems' not in title.casefold():return None,'ambiguous_venue_name'
matched=None
for base,venue,aliases in BOOKS[family]:
match=re.fullmatch(re.escape(base)+r'(?:\s+\((.*)\)|\s+(.*)|,\s*(Vol\.\s*\d+))?',book,re.I)
@@ -67,6 +70,7 @@ def scope(row,volume,through_year=2026):
if year%100!=int(short[1]):return None,'unverified_edition_year'
year_basis='unique explicit event year in proceedings title, corroborated by two-digit proceedings key'
if year>through_year:return None,'future_edition'
if venue=='MLSys' and year<2019:return None,'nonarchival_predecessor'
bibliographic=fields.get('year',[])
if len(bibliographic)!=1 or not re.fullmatch(r'\d{4}',bibliographic[0]):return None,'ambiguous_bibliographic_year'
if int(bibliographic[0])!=year and not re.search(r'\b'+str(year)+r'\b',title):return None,'unverified_year_difference'
@@ -92,5 +96,11 @@ def scope(row,volume,through_year=2026):
elif suffix.casefold() in ('poster','posters') and venue not in ('ICLR','ACL','EMNLP','NAACL','EACL','COLING','CoNLL'):track='poster'
if venue=='AAAI+IAAI':track='proceedings_unspecified'
if venue=='AAMAS' and year<2002:return None,'predecessor_not_aamas'
return {'venue':venue,'venue_aliases':aliases,'year':year,'track':track,'bibliographic_year':int(bibliographic[0]),
'rule':family+':'+base,'proceedings_title':title,'edition_year_basis':year_basis},None
result = {'venue':venue,'venue_aliases':aliases,'year':year,'track':track,'bibliographic_year':int(bibliographic[0]),
'rule':family+':'+base,'proceedings_title':title,'edition_year_basis':year_basis}
if venue=='MM' and track=='main':
# A main MM volume also includes doctoral, tutorials, companion papers
# and other sections. DBLP XML omits the per-paper TOC section.
result['track']='proceedings_unspecified'
result['track_scope_note']='Main proceedings volume; per-paper section is absent from the bibliography. Do not infer a regular research track.'
return result,None
+26 -4
View File
@@ -1,4 +1,4 @@
"""Collect COLM/NeurIPS official abstract pages without downloading PDFs."""
"""Collect COLM, NeurIPS and MLSys official metadata without downloading PDFs."""
import argparse
import json
import re
@@ -8,8 +8,11 @@ from pathlib import Path
from urllib.parse import urljoin,urlsplit
from paper_atlas.io.http import fetch
from paper_atlas.io.publication_ids import mlsys_id
ROOTS={'colm':'https://proceedings.colm.cc/','neurips':'https://proceedings.neurips.cc/'}
ROOTS={'colm':'https://proceedings.colm.cc/','neurips':'https://proceedings.neurips.cc/',
'mlsys':'https://proceedings.mlsys.org/'}
VENUES={'colm':'COLM','neurips':'NeurIPS','mlsys':'MLSys'}
from paper_atlas.io.html import Document,Node,clean
@@ -72,6 +75,22 @@ def parse_paper(html,url,source,year,index_url):
track_text=' '.join(clean(n.text()) for n in nodes if 'paper-track' in n.attrs.get('class','').split())
track=track_key(track_text or url)
identifiers={}
if source == 'mlsys':
if year < 2019:
raise ValueError('SysML 2018 was non-archival; official proceedings begin in 2019')
own = mlsys_id(url)
if not own or int(own.split('/')[0]) != year:
raise ValueError('MLSys paper URL does not identify this publication year')
identifiers['publication_mlsys'] = own
if track_text:
explicit = {'conference': 'main', 'main track': 'main', 'research track': 'main',
'industry track': 'industry', 'industrial track': 'industry',
'mlsys' + str(year): 'main'}
if track_text.casefold() not in explicit:
raise ValueError('Unclassified official MLSys track: ' + track_text)
track = explicit[track_text.casefold()]
elif year >= 2025 and not url.endswith('-Abstract-Conference.html'):
raise ValueError('Modern MLSys paper lacks an explicit conference track')
if meta['citation_doi']:
identifiers['doi']=meta['citation_doi'][0]
action_nodes=[]
@@ -90,8 +109,11 @@ def parse_paper(html,url,source,year,index_url):
from urllib.parse import parse_qs
forum=parse_qs(parsed.query).get('id')
if forum: identifiers['openreview']=forum[0]
venue='COLM' if source=='colm' else 'NeurIPS'
return {'source':source,'external_id':urlsplit(url).path,'url':url,'title':clean(title),'abstract':abstract,'authors':authors,'identifiers':identifiers,'venue':venue,'year':year,'track':track,'status':'accepted','raw_decision':'listed in official proceedings','list_url':index_url,'metadata':{'citation':dict(meta),'publication_state':'pre-proceedings' if 'pre-proceedings' in html.lower() else 'proceedings'}}
venue=VENUES[source]
result = {'source':source,'external_id':urlsplit(url).path,'url':url,'title':clean(title),'abstract':abstract,'authors':authors,'identifiers':identifiers,'venue':venue,'year':year,'track':track,'status':'accepted','raw_decision':'listed in official proceedings','list_url':index_url,'metadata':{'citation':dict(meta),'publication_state':'pre-proceedings' if 'pre-proceedings' in html.lower() else 'proceedings'}}
if source == 'mlsys' and year == 2019:
result['venue_aliases'] = ['SysML']
return result
def discover(source,cache):
+1 -1
View File
@@ -85,7 +85,7 @@ def normalize(row, family, year, provenance, include_workshops=False):
if prefixed and (prefixed[1].casefold() != ('neurips' if family == 'nips' else family) or int(prefixed[2]) != year):
raise ValueError('Status label conflicts with source conference year')
allowed = {v['acronym'].casefold(): v['acronym'] for v in json.loads(DEFAULT_REGISTRY.read_text())['venues']}
venue = allowed.get('neurips' if family == 'nips' else family)
venue = allowed.get({'nips': 'neurips', 'acmmm': 'mm'}.get(family, family))
if venue is None:
return None, 'unmapped_venue'
title = row.get('title')
+20 -1
View File
@@ -1,9 +1,10 @@
"""Canonical submission events with independent source and decision evidence."""
from datetime import datetime, timezone
import re
from paper_atlas.io.publication_ids import mlsys_id
PUBLICATIONS={'pmlr','acl_anthology','colm','neurips','cvf','ecva','dblp'}
PUBLICATIONS={'pmlr','acl_anthology','colm','neurips','mlsys','cvf','ecva','dblp'}
def timestamp(value=None):
@@ -15,11 +16,26 @@ def timestamp(value=None):
def identities_for_row(row):
identities=list(row.get('submission_identifiers') or [])
own=row.get('identifiers') or {}
if row['source']=='mlsys':
value=own.get('publication_mlsys')
if (not isinstance(value,str) or not re.fullmatch(str(row['year'])+r'/[a-f0-9]{32}',value)
or row['venue']!='MLSys'):
raise ValueError('Invalid authoritative MLSys publisher identity')
if own.get('openreview'):
identities.append({'scheme':'openreview','value':own['openreview']})
if row['source'] in PUBLICATIONS and row.get('status')=='accepted' and own.get('doi'):
doi=re.sub(r'^https?://(?:dx\.)?doi\.org/','',str(own['doi']),flags=re.I).strip().lower()
if re.fullmatch(r'10\.\d{4,9}/\S+',doi):identities.append({'scheme':'publication_doi','value':doi})
publication_mlsys = own.get('publication_mlsys')
if not publication_mlsys and row['source']=='dblp' and row['venue']=='MLSys':
# Older normalized DBLP files retain their own publisher links even
# when a newly supported historical URL label was not yet parsed.
links = (row.get('metadata') or {}).get('bibliography', {}).get('ee', [])
candidates = {identity for link in links if (identity := mlsys_id(link))}
if len(candidates)==1:
publication_mlsys = candidates.pop()
if row['source'] in PUBLICATIONS and row.get('status')=='accepted' and publication_mlsys:
identities.append({'scheme':'publication_mlsys','value':publication_mlsys})
meta=row.get('metadata') or {}
if row['source']=='iclr_program' and row['venue']=='ICLR' and meta.get('program_identity_type')=='submission' and meta.get('program_submission_number_verified') is True:
identities.append({'scheme':'iclr_number','value':str(meta['program_source_id'])})
@@ -30,6 +46,9 @@ def identities_for_row(row):
valid=re.fullmatch(r'[A-Za-z0-9_-]+',value) and not re.match(r'20\d{2}-Oral-',value)
elif scheme=='iclr_number':valid=re.fullmatch(r'[1-9]\d*',value) and row['venue']=='ICLR'
elif scheme=='publication_doi':valid=re.fullmatch(r'10\.\d{4,9}/\S+',value)
elif scheme=='publication_mlsys':
valid=(re.fullmatch(str(row['year'])+r'/[a-f0-9]{32}',value)
and row['venue']=='MLSys' and row['source'] in PUBLICATIONS and row.get('status')=='accepted')
else:valid=False
if not valid:raise ValueError('Invalid authoritative submission identity')
result.add((scheme,value))
+13
View File
@@ -0,0 +1,13 @@
"""Stable identities from publisher-owned metadata self-links."""
import re
from urllib.parse import urlsplit
def mlsys_id(url):
parsed = urlsplit(url)
if parsed.scheme not in ('https', 'http') or parsed.netloc != 'proceedings.mlsys.org':
return None
match = re.fullmatch(r'/paper(?:_files/paper)?/((?:19|20)\d{2})/hash/([a-f0-9]{32})-Abstract(?:-(?:Conference|mlsys(20\d{2})))?\.html', parsed.path)
if not match or match[3] and match[3] != match[1]:
return None
return match[1] + '/' + match[2]
+34 -1
View File
@@ -12,7 +12,9 @@
"ijcai": "https://www.ijcai.org/all_proceedings/",
"dblp": "https://dblp.org/db/conf/",
"colm": "https://proceedings.colm.cc/",
"iclr_program": "https://iclr.cc/virtual/2026/papers.html"
"iclr_program": "https://iclr.cc/virtual/2026/papers.html",
"mlsys": "https://proceedings.mlsys.org/",
"acmmm": "https://www.acmmm.org/"
},
"venues": [
{
@@ -643,6 +645,37 @@
"main"
],
"coverage": "pending"
},
{
"acronym": "MLSys",
"field": "ml_systems",
"venue_type": "conference",
"discovery_sources": [
"mlsys",
"dblp"
],
"scope": "user_confirmed_related",
"years": "all_publicly_available",
"tracks": [
"main"
],
"coverage": "pending"
},
{
"acronym": "MM",
"field": "multimodal",
"venue_type": "conference",
"discovery_sources": [
"dblp",
"acmmm",
"openreview"
],
"scope": "user_confirmed_related",
"years": "all_publicly_available",
"tracks": [
"main"
],
"coverage": "pending"
}
],
"additional_tracks": [
+4 -1
View File
@@ -18,7 +18,10 @@ class CliTests(unittest.TestCase):
def test_unified_import_and_query_use_shipped_schema_and_registry(self):
from paper_atlas.config import DEFAULT_REGISTRY
self.assertEqual(len(json.loads(DEFAULT_REGISTRY.read_text())['venues']),41)
venues = json.loads(DEFAULT_REGISTRY.read_text())['venues']
names = [venue['acronym'] for venue in venues]
self.assertTrue({'COLM', 'MM', 'MLSys'} <= set(names))
self.assertEqual(len(names), len(set(names)))
with tempfile.TemporaryDirectory() as tmp:
root=Path(tmp);db=root/'db.sqlite';source=root/'records.jsonl'
source.write_text(json.dumps({'source':'example','external_id':'one','url':'https://example.org','title':'CLI paper','abstract':'Abstract','authors':['Jane Doe'],'venue':'Example','year':2026,'track':'main','status':'accepted'})+'\n')
+166
View File
@@ -0,0 +1,166 @@
import hashlib
import json
from contextlib import closing
from pathlib import Path
import sqlite3
import tempfile
import unittest
from unittest.mock import patch
from paper_atlas.collectors.proceedings import discover, parse_paper
from paper_atlas.collectors.dblp import XML_SHA
from paper_atlas.collectors.dblp_records import identifiers, normalize_row, collect
from paper_atlas.collectors.dblp_scope import scope
from paper_atlas.collectors.public_openreview import normalize
from paper_atlas.db.submissions import identities_for_row
from paper_atlas.ingestion.records import ingest_file
from paper_atlas.io.publication_ids import mlsys_id
HASH = '0123456789abcdef' * 2
ROOT = 'https://proceedings.mlsys.org/'
def page(year=2026, modern=True):
return ROOT + ('paper_files/paper/' if modern else 'paper/') + str(year) + '/hash/' + HASH + ('-Abstract-Conference.html' if modern else '-Abstract.html')
def raw_row(family='mm', book='ACM Multimedia', year=2024):
return {'record_type': 'inproceedings', 'key': f'conf/{family}/Paper24', 'publtype': None,
'fields': {'title': ['A published paper'], 'author': ['Jane Doe', 'John Smith'],
'booktitle': [book], 'year': [str(year)], 'crossref': [f'conf/{family}/{year}']}}
def volume(family='mm', year=2024, title=None):
return {'record_type': 'proceedings', 'key': f'conf/{family}/{year}',
'fields': {'title': [title or f'ACM Multimedia {year}'], 'year': [str(year)]}}
class MlsysMultimediaTests(unittest.TestCase):
def test_mlsys_discovery_retains_all_official_years_and_ignores_pdf_or_other_host(self):
html = ''.join(f'<a href="/paper_files/paper/{year}">MLSys {year}</a>' for year in range(2019, 2027))
html += '<a href="https://example.org/paper_files/paper/2018">Other</a><a href="/paper/2026/file/paper.pdf">PDF</a>'
with patch('paper_atlas.collectors.proceedings.fetch', return_value=html.encode()):
self.assertEqual([year for _, year in discover('mlsys', '/unused')], list(range(2019, 2027)))
def test_mlsys_metadata_has_stable_publisher_identity_and_explicit_track(self):
html = '<meta name="citation_title" content="A title"><meta name="citation_author" content="Doe, Jane"><span class="paper-track">Conference</span><h2>Abstract</h2><p>Real abstract.</p><h4>Name Change Policy</h4><p>Footer.</p>'
row = parse_paper(html, page(), 'mlsys', 2026, ROOT + 'paper_files/paper/2026')
self.assertEqual((row['venue'], row['track'], row['abstract'], row['authors']), ('MLSys', 'main', 'Real abstract.', ['Jane Doe']))
self.assertEqual(row['identifiers']['publication_mlsys'], '2026/' + HASH)
with self.assertRaisesRegex(ValueError, 'Unclassified'):
parse_paper(html.replace('Conference', 'New Unknown Track'), page(), 'mlsys', 2026, ROOT)
with self.assertRaisesRegex(ValueError, 'publication year'):
parse_paper(html, page(), 'mlsys', 2025, ROOT)
with self.assertRaisesRegex(ValueError, 'non-archival'):
parse_paper(html, page(2018), 'mlsys', 2018, ROOT)
def test_year_label_and_retained_bibliography_links_bridge_the_2023_format(self):
url = page(2023).replace('-Conference.html', '-mlsys2023.html')
self.assertEqual(mlsys_id(url), '2023/' + HASH)
self.assertIsNone(mlsys_id(url.replace('mlsys2023.html', 'mlsys2022.html')))
official = parse_paper('<h1>Updated title</h1><span class="paper-track">mlsys2023</span><p class="paper-abstract">Official abstract</p>', url, 'mlsys', 2023, ROOT)
self.assertEqual(official['track'], 'main')
legacy = {'source': 'dblp', 'external_id': 'conf/mlsys/paper23', 'url': 'https://dblp.org/rec/conf/mlsys/paper23',
'venue': 'MLSys', 'year': 2023, 'track': 'main', 'status': 'accepted', 'title': 'Bibliographic title',
'abstract': None, 'authors': [], 'identifiers': {}, 'metadata': {'bibliography': {'ee': [url]}}}
self.assertEqual(identities_for_row(legacy), [('publication_mlsys', '2023/' + HASH)])
self.assertEqual(identities_for_row({**legacy, 'status': 'unknown'}), [])
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp); db = root / 'papers.sqlite'
for row in [legacy, official]:
path = root / (row['source'] + '.jsonl'); path.write_text(json.dumps(row) + '\n')
path.with_suffix('.manifest.json').write_text(json.dumps({'source': row['source'], 'parser_version': 2, 'list_url': ROOT, 'count': 1}))
ingest_file(db, path)
with closing(sqlite3.connect(db)) as c:
self.assertEqual(c.execute('SELECT count(*) FROM papers').fetchone()[0], 1)
self.assertEqual(c.execute('SELECT count(*) FROM submissions').fetchone()[0], 1)
self.assertEqual(c.execute('SELECT count(*) FROM submission_sources').fetchone()[0], 2)
def test_legacy_mlsys_and_sysml_alias_preserve_same_publication_identity(self):
self.assertEqual(mlsys_id(page(2019, False)), mlsys_id(page(2019)))
self.assertIsNone(mlsys_id(page().replace('proceedings.mlsys.org', 'example.org')))
own, ambiguous = identifiers({'ee': [page(2019, False), page(2019)], 'cite': [page(2025)]})
self.assertEqual(own, {'publication_mlsys': '2019/' + HASH}); self.assertFalse(ambiguous)
_, ambiguous = identifiers({'ee': [page(2019), page(2019).replace(HASH, 'a' * 32)]})
self.assertEqual(len(ambiguous['publication_mlsys']), 2)
row = parse_paper('<h1>Legacy</h1><p class="paper-abstract">Abstract</p>', page(2019, False), 'mlsys', 2019, ROOT)
self.assertEqual(row['venue_aliases'], ['SysML'])
self.assertEqual(row['track'], 'main')
def test_mm_main_numbered_parts_and_workshops_remain_separate(self):
for book in ['ACM Multimedia', 'ACM Multimedia (1)', 'ACM Multimedia (2)']:
result, reason = scope(raw_row(book=book), volume())
self.assertIsNone(reason); self.assertEqual(result['venue'], 'MM'); self.assertEqual(result['track'], 'proceedings_unspecified')
self.assertIn('per-paper section', result['track_scope_note'])
self.assertIn('ACMMM', result['venue_aliases'])
self.assertEqual(scope(raw_row(book='ACM Multimedia Workshops'), volume())[0]['track'], 'workshop')
self.assertEqual(scope(raw_row(book='ACM Multimedia (Thematic Workshops)'), volume())[0]['track'], 'workshop')
self.assertIsNone(scope(raw_row(book='MMSports@MM'), volume())[0])
self.assertEqual(normalize_row(raw_row(book='ACM Multimedia Workshops'), {'conf/mm/2024': volume()}, {'MM'})[1], 'separate_workshop')
def test_sysml_alias_requires_machine_learning_proceedings(self):
row = raw_row('mlsys', 'SysML', 2019)
result, reason = scope(row, volume('mlsys', 2019, 'Conference on Machine Learning and Systems, SysML 2019'))
self.assertIsNone(reason); self.assertEqual(result['venue'], 'MLSys'); self.assertEqual(result['venue_aliases'], ['SysML'])
self.assertIsNone(scope(row, volume('mlsys', 2019, 'Systems Modeling Language 2019'))[0])
def test_bibliography_and_official_abstract_import_as_one_paper_and_submission(self):
raw = raw_row('mlsys', 'SysML', 2019); raw['fields']['ee'] = [page(2019, False)]
bib, reason = normalize_row(raw, {'conf/mlsys/2019': volume('mlsys', 2019, 'Machine Learning and Systems 2019')}, {'MLSys'})
self.assertIsNone(reason)
official = parse_paper('<meta name="citation_title" content="An updated title"><p class="paper-abstract">The official abstract.</p>', page(2019), 'mlsys', 2019, ROOT)
# Different title and absent official authors deliberately prevent a title-based bridge.
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp); db = root / 'papers.sqlite'
for row in [bib, official]:
path = root / (row['source'] + '.jsonl'); path.write_text(json.dumps(row) + '\n')
path.with_suffix('.manifest.json').write_text(json.dumps({'source': row['source'], 'parser_version': 2,
'list_url': ROOT, 'count': 1, 'venue': 'MLSys', 'year': 2019, 'track': 'main'}))
ingest_file(db, path)
with closing(sqlite3.connect(db)) as c:
self.assertEqual(c.execute('SELECT count(*) FROM papers').fetchone()[0], 1)
self.assertEqual(c.execute('SELECT count(*) FROM submissions').fetchone()[0], 1)
self.assertEqual(c.execute('SELECT count(*) FROM submission_sources').fetchone()[0], 2)
self.assertEqual(c.execute('SELECT abstract FROM papers').fetchone()[0], 'The official abstract.')
self.assertEqual(c.execute('PRAGMA foreign_key_check').fetchall(), [])
def test_publisher_identity_cannot_bridge_rejection_other_year_or_venue(self):
row = {'source': 'mlsys', 'venue': 'MLSys', 'year': 2026, 'status': 'accepted',
'identifiers': {'publication_mlsys': '2026/' + HASH}}
self.assertEqual(identities_for_row(row), [('publication_mlsys', '2026/' + HASH)])
self.assertEqual(identities_for_row({**row, 'status': 'rejected'}), [])
with self.assertRaisesRegex(ValueError, 'Invalid authoritative'):
identities_for_row({**row, 'identifiers': {}})
for changed in [dict(year=2025), dict(venue='MM'), dict(source='curated')]:
bad = {**row, **changed, 'submission_identifiers': [{'scheme': 'publication_mlsys', 'value': '2026/' + HASH}]}
with self.assertRaisesRegex(ValueError, 'Invalid authoritative'):
identities_for_row(bad)
def test_explicit_mm_negative_assertion_maps_to_mm_without_inventing_labels(self):
provenance = {'source_file': 'https://example.org/pinned', 'source_line': 1}
row = {'id': 'realForum1', 'title': 'A title', 'abstract': 'An abstract', 'track': 'main', 'status': 'Reject'}
normalized, reason = normalize(row, 'acmmm', 2025, provenance)
self.assertIsNone(reason); self.assertEqual(normalized['venue'], 'MM')
self.assertEqual(normalized['status'], 'rejected')
self.assertIsNone(normalize({**row, 'status': 'Submitted'}, 'acmmm', 2025, provenance)[0])
def test_old_candidate_subset_cannot_silently_omit_new_requested_venues(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp); catalogue = root / 'dblp-catalogue.sqlite'
with closing(sqlite3.connect(catalogue)) as c, c:
c.execute('CREATE TABLE proceedings(key TEXT PRIMARY KEY,metadata_json TEXT)')
c.execute('INSERT INTO proceedings VALUES(?,?)', ('conf/mm/2024', json.dumps(volume())))
candidates = root / 'candidates.jsonl'; candidates.write_text(json.dumps(raw_row()) + '\n')
original = candidates.read_bytes()
catalogue.with_name('dblp-ai-candidates.manifest.json').write_text(json.dumps({
'snapshot_sha256': XML_SHA, 'candidate_sha256': hashlib.sha256(original).hexdigest(),
'candidate_family_counts': {'aaai': 1}, 'record_types': {'proceedings': 1}}))
with self.assertRaisesRegex(ValueError, 'original --archive'):
collect(catalogue, root / 'out', ['MM'], candidates=candidates)
self.assertEqual(candidates.read_bytes(), original)
self.assertFalse((root / 'out').exists())
if __name__ == '__main__':
unittest.main()