Compare commits

..

11 Commits

Author SHA1 Message Date
codex-bot aa696bd7b5 Isolate tiny predecessor fixtures from production disk reserve
Metadata integrity checks / metadata (push) Successful in 2m11s
2026-11-09 15:15:48 +08:00
codex-bot 90bd534edd Preserve reviewed historical learning and planning predecessor series
Metadata integrity checks / metadata (push) Failing after 2m0s
2026-11-09 15:01:12 +08:00
Zayden-Jung 02a408ba8f Merge pull request '保留 AAMAS 发表来源的同届 DOI 投稿身份' (#39) from codex/aamas-publication-identity into main
Metadata integrity checks / metadata (push) Successful in 2m15s
Reviewed-on: #39
2026-11-09 06:43:15 +00:00
codex-bot 714f745bd5 Assert normalized identity tuples and exact negative-status boundaries
Metadata integrity checks / metadata (push) Successful in 2m13s
2026-11-09 14:13:12 +08:00
Zayden-Jung cc78e837d2 Merge pull request '按发表形式采集 AAMAS 官方论文集' (#38) from codex/aamas-proceedings into main
Metadata integrity checks / metadata (push) Successful in 2m26s
Reviewed-on: #38
2026-11-09 05:52:57 +00:00
codex-bot c0b2146ae8 Recognize AAMAS publisher DOI identities within submission scopes
Metadata integrity checks / metadata (push) Failing after 2m1s
2026-11-09 13:51:49 +08:00
codex-bot ae88367fac Scope AAMAS tags to their exact source namespace
Metadata integrity checks / metadata (push) Successful in 8m56s
2026-11-09 13:31:46 +08:00
codex-bot a305d00c75 Collect evidenced AAMAS proceedings metadata by publication format
Metadata integrity checks / metadata (push) Failing after 2m2s
2026-11-09 13:27:23 +08:00
Zayden-Jung 57fcfc7693 Merge pull request '按官方证据识别早期 ICML 与 IROS 主系列' (#37) from codex/historical-primary-series into main
Metadata integrity checks / metadata (push) Successful in 2m58s
Reviewed-on: #37
2026-11-09 05:19:49 +00:00
Zayden-Jung a07b1e42a8 Merge pull request '修正早期 ACL 来源误匹配并记录官方目录缺口' (#36) from codex/history-source-scope into main
Metadata integrity checks / metadata (push) Successful in 2m48s
Reviewed-on: #36
2026-11-09 05:19:43 +00:00
codex-bot 7c6f4c9f1d Recognize evidenced early ICML and IROS primary editions
Metadata integrity checks / metadata (push) Successful in 9m26s
2026-11-09 02:39:18 +08:00
17 changed files with 1257 additions and 5 deletions
+1
View File
@@ -49,6 +49,7 @@ jobs:
subprocess.run([str(venv/'bin/paper-atlas'),'--help'],cwd=outside,check=True)
subprocess.run([python,'-I','-B','-m','paper_atlas','collect','cvf','--help'],cwd=outside,check=True)
subprocess.run([python,'-I','-B','-m','paper_atlas','collect','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','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)
+6
View File
@@ -60,6 +60,12 @@ paper-atlas collect colm --cache /data/paper-index/raw/http-cache \
--out /data/paper-index/collected
```
早期主系列的Workshop名称按[ICML/IROS精确届次证据](docs/coverage/historical-primary-series-2026-11-09.md)识别,保留原名与范围证据;代码支持不代表已补齐生产数据。
EWSL、EPS、ECP、EWSP 的[具体前身卷与完整快照要求](docs/coverage/historical-predecessors-2026-11-09.md)单独核验,保留原始系列及事件/出版年份;旧候选文件缺少 ECP/EWSP 时会明确提示,避免静默遗漏。
AAMAS 的[官方论文集采集器](docs/aamas-proceedings.md)目前核验了 2012 版式,可从缓存或已校验快照采集标题、作者及页面提供的摘要;发表形式单独保存,全文/扩展摘要中未明确的投稿 track 保持待明确,不根据会话主题推定主会轨道。
`paper-atlas collect --help` 查看来源;`paper-atlas import --help` 查看导入方式。完整批次步骤见[使用指南](docs/usage.md)。
共享实例名为 `foundry-postgres`,数据位于 `foundry_data` 库的 `research` schema;其他项目可按权限复用同一组表。库名通过 libpq service 配置、schema 通过 search_path 或 `--pg-schema` 指定,不写死在查询中。PostgreSQL 凭据放在服务器权限为 `0600` 的 libpq service 文件中;命令行只使用服务名称。`paper-atlas maintain postgres --help` 提供迁移入口:先完整核验 21 张业务表,再发布目标 schema。SQLite FTS5 内部表由 PostgreSQL GIN 索引替代。切换后 SQLite 为只读备份;`import records`、`import arxiv` 和 `maintain backfill` 使用 `--pg-service foundry-research` 写入主库,整个写入会话只允许一个协调写入者,会议文件失败时整体回滚并保留失败批次记录。
+43
View File
@@ -0,0 +1,43 @@
# AAMAS 官方论文集元数据
`paper_atlas.collectors.aamas` 是按需运行的采集适配器,通过统一入口 `collect aamas-proceedings` 使用。目前只支持已独立核验的 **AAMAS 2012** HTML 版式;其他年份需先核对官方目录和逐篇结构,再增加适配,不能仅替换年份。
## 来源与范围
[官方论文集介绍](https://www.ifaamas.org/Proceedings/aamas2012/)及其 Full Papers、Extended Abstracts、Demos、Doctorial Consortium Abstracts 四个栏目分别列出 136、146、28、24 条,共 334 条。旧 DBLP 候选卷的 310 条不能替代这四类官方目录,也不能全部标记为主会全文。栏目数量来自 2026-11-09 冻结的原始页面;页面结构或数量发生变化时,采集器停止,留待重新核验。
采集标题、逐篇作者、页面实际提供的摘要、官方条目 ID、目录锚点和 PDF 地址。**只保存 PDF 链接,不下载 PDF。** 全文页面的摘要藏在标题链接内部的 `abstract` 元素,作者链接里的 “has N papers” 是界面徽标,二者不会混入标题或姓名。未提供的摘要保存为 `null`,不会用标题、说明文字或其他论文填充。
官方介绍说明了 general、robotics、virtual agents、innovative applications 等投稿轨道,但逐篇目录的主题会话不能独立证明某篇论文的投稿轨道。因此全文和扩展摘要均使用 `proceedings_unspecified`;演示、博士栏目使用 `demo`、`doctoral`。发表形式保留在 `metadata.publication_format` 和 `aamas_proceedings:publication_format` 标签中,会话原文单独保存。它们不是研究类型标注,也不会写入自定义 annotation。
目录条目仅提供发表录用证据,不是原始决定信。`observed_at` 是页面采集时间,不能当作 2012 年的决定日期;官方介绍中的投稿、撤稿、录用汇总数不能识别逐篇拒稿或撤稿,采集器不会由总量相减生成这些决定。
## 运行
在已确认的远程运行环境中,使用 HTTP 缓存采集:
```sh
paper-atlas collect aamas-proceedings --year 2012 \
--cache /data/paper-index/cache/aamas \
--out /data/paper-index/collected/aamas-2012
```
也可使用已冻结的官方页面:
```sh
paper-atlas collect aamas-proceedings --year 2012 \
--snapshots /data/paper-index/collected/historical-next-source-evidence-v1 \
--out /data/paper-index/collected/aamas-2012
```
快照目录需含 `receipts.json`,其中每个响应包含原始 `url`、HTTP `status`、相对 `file`、`bytes`、`sha256` 和 `finished_at`。要求五个准确官方 URL 均唯一匹配且返回 200;验证全部字节数和哈希后才解析,不接受目录穿越或绝对文件路径。快照中其他来源的响应不会被当成 AAMAS 页面。
输出为按数据库 track 分组的三个 JSONL 文件和对应 manifest、五份原始 HTML 证据,以及最后写出的 `aamas-proceedings-2012.json` 采集报告。锁文件避免同一输出目录并发运行。manifest 始终为 `partial`,记录发表形式数量和缺失摘要数;**采集命令不连接数据库,也不导入。**
## 复核与恢复
每条记录携带对应官方页面的 URL、采集时间、字节数、SHA-256 与输出内证据路径。重新运行前先检查已有采集报告和文件哈希;采集中断不等于主库已导入,报告缺失或哈希不一致的目录不能直接作为导入队列。输出中的原始证据应随文件一起保存。
生产导入前仍需单独与主库核验稳定标识、标题、作者及已有投稿,按证据补充 DOI/arXiv 等标识;目录页上的作者计数徽标和会话 ID 不能作为这些标识。需要另建经过核验的增量清单、保护旧历史,完成独立验收后才能计入覆盖。跨来源同一论文不得仅因同题就新增或合并投稿。
本功能的代码、测试或采集成功均不代表 AAMAS 2012 已导入生产,也不证明拒稿全集完整。全库最终去重、摘要和来源覆盖验收属于独立后续阶段。
@@ -0,0 +1,53 @@
# EWSL 与规划会议前身的来源范围
本项扩展 DBLP 元数据适配:核对具体历史卷,保留 EWSL、EPS、ECP、EWSP 原始系列名称,并使用已有会议别名关系关联 ECML-PKDD 或 ICAPS。它不把历史论文的原始会议名称替换成后来的会议,也不从卷的存在推断有逐篇数据。
## 证据与已核验边界
[ECML-PKDD 2011 官方历史页](https://ecmlpkdd2011.org/pastConferences.php)与 [ICAPS 官方系列历史](https://www.icaps-conference.org/conference-series/)分别提供系列前身关系。2026-11-09 冻结页面 SHA-256 为:
- ECML-PKDD:`c3ecdb032aeacf6a365aace5457773827d1405872ad9bc6be6fc05851d5e68d6`。
- ICAPS:`de1d217660cb819825f58112cdaf774d0f68da6ed87e3e90a59055e52f152081`。
与固定 DBLP 2026-11-01 快照的完整 proceedings 目录对照后,仅对下列具体卷识别前身主系列:
| 原始系列 | 事件年 | DBLP 卷 key | 卷出版年 | 原始逐篇书目计数 |
|---|---:|---|---:|---:|
| EWSL | 1987 | `conf/ecml/1987` | 1987 | 16 |
| EWSL | 1988 | `conf/ecml/1988` | 1988 | 21 |
| EWSL | 1989 | `conf/ecml/1989` | 1989 | 0 |
| EWSL | 1991 | `conf/ecml/1991` | 1991 | 44 |
| EPS | 1990 | `conf/aips/1990` | 1990 | 48 |
| ECP | 1997 | `conf/ecp/1997` | 1997 | 35 |
| ECP | 1999 | `conf/ecp/1999` | 2000 | 28 |
| EWSP | 1991 | `conf/ewsp/1991` | 1991 | 10 |
合计 202 条是原始 `inproceedings` 书目计数,尚未经过前言等材料排除、身份核查与生产验收,不能作为新论文数量。EWSL 1989 在当前 catalogue 中有卷信息,但未匹配到 `inproceedings` 条目;其他记录类型和其他来源仍待核查。代码支持它的范围规则不表示已补上这一年的论文。
核验同时要求来源 family、booktitle 基名、空轨道后缀、完整 proceedings key、完整标题及卷出版年与固定证据一致。邻近年份、标题附加内容、`w` 卷后缀或工作坊 booktitle 均不能继承此范围。ECP、EWSP 及 EPS 的未知卷保持待审。原有 AIPS、ICML/IROS、AISTATS/COLT 规则保持各自边界。
EWSL 的主轨道仍属于 `EWSL`,通过 `ECML-PKDD` 别名纳入家族查询;EPS、ECP、EWSP 保留原名,通过 `ICAPS` 别名关联。`main` 在此表示该原始系列的主轨道。原始 booktitle、作者、论文书目年、卷标题、卷出版年、事件年及历史页 URL 分开保存在 metadata 中。例如 ECP 1999 的事件年 1999 与卷出版年 2000 都保留,不用其中一个覆盖另一个。
## 候选文件与完整快照
旧的窄范围 `dblp-ai-candidates.jsonl` 未纳入 `ecp`、`ewsp` family,但完整 catalogue 的 proceedings 与 volume_counts 中有它们。新的 catalogue 导出会纳入这两个 family。
选择 ICAPS 且截止年达到 EWSP 1991 或 ECP 1997 时,若候选 manifest 缺少相应 family,`collect dblp --candidates` 会明确报错,要求使用原始完整 `--archive`。错误发生在写输出之前,不会把遗漏当作成功采集。选择其他会议或截止 EPS 1990 的旧候选采集不受这项新家族检查影响。
在已确认的远程运行环境中使用完整快照:
```sh
paper-atlas collect dblp \
--catalogue /data/paper-index/collected/dblp/dblp-catalogue.sqlite \
--archive /path/to/pinned-dblp.xml.gz --dtd /path/to/pinned-dblp.dtd \
--venue ECML-PKDD --venue ICAPS --through-year 2001 \
--out /data/paper-index/collected/historical-predecessors
```
完整快照与 DTD 均沿用现有固定身份校验。该命令也会输出已覆盖的其他年度;生产导入前须单独筛选目标卷与年度,检查 raw source、前言材料、稳定标识、标题、作者及现有投稿,不能直接导入整个输出目录。不会下载论文 PDF。
## 验证和当前限制
回归检查使用独立保存的八卷原始目录 fixture,合成论文只用于隔离测试;不代表真实逐篇来源。检查原始系列/别名、完整标题和年份边界、旧候选文件显式失败、扩展 family 的 catalogue 导出、原生导入后的元数据与关系。
代码、测试、采集和生产导入是不同阶段。新范围不表示本批数据已采集或已导入,更不表示公开拒稿、摘要或最终去重完整。原始书目仅提供发表条目证据,不是原始决定信;没有记录不能推断为拒稿或未举办。后续各批需保留独立验收报告,保护既有跨会投稿、决定与标签历史。
@@ -0,0 +1,33 @@
# ICML 与 IROS 早期主系列的来源范围
本轮修正采集阶段的届次识别,不修改任何现有生产投稿轨道或历史。正式主系列曾以Workshop命名,不等于附属workshop;也不能反过来把所有旧workshop升为主会。
## 已核实的精确范围
| 会议 | 论文集键 | 依据与行为 |
| --- | --- | --- |
| ICML | `conf/icml/1988`、`1989`、`1990`、`1991`、`1992` | [官方历史](https://icml.cc/Conferences/2008/past_icmls.shtml.html)将其列为第5–9届,年份与举办地对应固定目录中的完整标题。1989/1991/1992的Workshop名称不再导致主系列被默认排除;1988/1990保留main并增加来源范围证据 |
| IROS | `conf/iros/1988`、`1989`、`1990` | [IEEE RAS官方历届目录](https://www.ieee-ras.org/conferences-workshops/financially-co-sponsored/iros/iros-past-and-future-venues/)列出三届与论文集链接,地点及日期对应固定目录标题;这些是IROS主系列 |
规则同时要求family、booktitle基础别名(ML或IROS)、空轨道后缀、完整精确论文集键、匹配的事件年份及固定完整标题。仍执行原有书目年份核查:与事件年不同且标题无明确事件年时拒绝推断。ML91原始标题没有四位年份,使用精确1991卷键、完整标题、逐篇书目年和官方第8届记录,不从两位标题代码猜世纪。
`1991w`/`1988w`等卷键、booktitle中的Workshops后缀、不同届次/地点/完整标题、未列入的年份均不会获得上述主系列豁免。AISTATS/COLT既有精确规则保留;真正的附属workshop仍受默认不采集workshop的边界约束。
## 原始字段、决定与后续导入
采集器仍保存原始proceedings标题(包括Workshop)、书目年、原始作者名、完整来源书目字段及稳定标识;新增 `track_scope_note` 与官方 `track_scope_evidence_url`。接受状态仍只源自该论文的inproceedings书目证据;不从历史目录生成占位论文或拒稿,也不重新导入已经存在的记录。
新规则的输出必须重新做原始来源/标题/作者/稳定标识与现有库冲突预检,再按增量导入。此前被隔离的ICML1988/1990记录不能仅凭代码规则而直接解除全部冲突。当前正在运行的历史批次使用原固定版本和文件,不能在运行中替换其源码或输入。
## 验证与证据
新增回归覆盖:ICML三届Workshop名称、IROS最初三届、ICML原Conference名称的两届、错误卷键/后缀/标题/未知年份,以及归一化记录经过原生文件导入后保留轨道证据。构建与测试仅在已授权的ces-control远程runner执行,本机仅做语法和文本/JSON一致性检查;结果以相应commit的CI回执为准。
官方HTML在工作证据目录 `paper-index/historical-primary-series-evidence-v1/`,附响应状态、字节数、时间与SHA256;仅取元数据网页,没有下载论文全文。
| 来源 | UTC采样时间 | HTTP | 字节数 | SHA256 |
| --- | --- | ---: | ---: | --- |
| [icml-history](https://icml.cc/Conferences/2008/past_icmls.shtml.html) | 2026-11-08T18:36:43.043135+00:00 | 200 | 11993 | `80cce886fe985551339a52b13c4077acf827e8fcc01555f854dc24077bdebdf0` |
| [iros-history](https://www.ieee-ras.org/conferences-workshops/financially-co-sponsored/iros/iros-past-and-future-venues/) | 2026-11-08T18:36:45.687171+00:00 | 200 | 466059 | `fdb4594621e68567e1099f5c88aae53de835b7b8146025148c3b1968df990697` |
本次不关闭历史年度缺口,也不替代全部导入后的全库去重、来源/年度/track覆盖、摘要和实际查询验收。
+1
View File
@@ -23,6 +23,7 @@ COMMANDS = {
('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','aamas-proceedings'): ('paper_atlas.collectors.aamas', {}, '按官方目录区分 AAMAS 发表形式和摘要'),
('collect','www-archive'): ('paper_atlas.collectors.www_archive', {}, '核验早期WWW官方档案并分开待核查条目'),
('collect','dblp'): ('paper_atlas.collectors.dblp_records', {}, '核对并整理 DBLP 历史发表记录'),
('catalogue','dblp'): ('paper_atlas.collectors.dblp', {}, '盘点 DBLP 历史会议目录'),
+210
View File
@@ -0,0 +1,210 @@
"""Official AAMAS proceedings metadata, with publication format separate from 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 urljoin, 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 = 'aamas_proceedings'
ROOT = 'https://www.ifaamas.org/Proceedings/aamas2012/'
# Counts independently checked against the frozen publisher sections. This
# adapter deliberately refuses other editions/layouts until they are reviewed.
SECTIONS = {
'fullpapers': ('Full Papers', 'full_paper', 'proceedings_unspecified', 136),
'extabstracts': ('Extended Abstracts', 'extended_abstract', 'proceedings_unspecified', 146),
'demos': ('Demos', 'demo', 'demo', 28),
'doctorals': ('Doctorial Consortium Abstracts', 'doctoral_abstract', 'doctoral', 24),
}
def classes(node):
return set(node.attrs.get('class', '').split())
def text_without(node, omitted):
if node.tag in ('script', 'style') or classes(node) & omitted:
return ''
parts = [text_without(child, omitted) if isinstance(child, Node) else child
for child in node.children]
value = ''.join(parts)
return ' '+value+' ' if node.tag in ('p', 'div', 'section', 'li', 'br') else value
def one(nodes, description):
if len(nodes) != 1:
raise ValueError('Expected one '+description)
return nodes[0]
def parse_section(html, section, observed_at, evidence):
heading, publication_format, track, _ = SECTIONS[section]
document = Document(html)
titles = [clean(n.text()) for n in document.root.walk() if n.tag == 'title']
if titles != [heading+' - AAMAS 2012']:
raise ValueError('Unexpected proceedings edition or section title')
headings = [clean(n.text()) for n in document.root.walk() if n.tag == 'h1']
if headings != [heading]:
raise ValueError('Unexpected proceedings section heading')
url = ROOT+'resources/'+section+'.html'
records = []
seen = set()
session = None
for node in document.root.walk():
if node.tag == 'h2' and 'section' in classes(node):
session = {'id': node.attrs.get('id'), 'title': clean(node.text())}
if node.tag != 'div' or 'paper' not in classes(node):
continue
paper_id = node.attrs.get('id', '')
if not re.fullmatch(r'[A-Za-z0-9]+_[0-9]+', paper_id) or paper_id in seen:
raise ValueError('Missing or repeated publisher paper ID')
seen.add(paper_id)
title_node = one([n for n in node.walk() if n.tag == 'span' and 'title' in classes(n)], 'paper title')
link = one([n for n in title_node.children if isinstance(n, Node) and n.tag == 'a'], 'paper link')
paper_url = urljoin(url, link.attrs.get('href', ''))
if paper_url != ROOT+'papers/'+paper_id+'.pdf':
raise ValueError('Paper link differs from publisher ID or official host')
title = clean(text_without(link, {'abstract'}))
abstract_nodes = [n for n in link.walk() if n.tag == 'span' and 'abstract' in classes(n)]
if len(abstract_nodes) > 1:
raise ValueError('Multiple abstracts for one paper')
abstract = (clean(abstract_nodes[0].text()) or None) if abstract_nodes else None
author_node = one([n for n in node.walk() if n.tag == 'span' and 'authors' in classes(n)], 'author list')
author_display = clean(text_without(author_node, {'amount'}))
authors = [clean(name) for name in author_display.split(',')]
if not title or not author_display or any(not name for name in authors):
raise ValueError('Empty paper title or author')
tags = [{'namespace': 'aamas_proceedings:publication_format', 'value': publication_format}]
if session:
tags.append({'namespace': 'aamas_proceedings:session', 'value': session['title']})
records.append({
'source': SOURCE, 'external_id': '2012/'+paper_id,
'url': url+'#'+paper_id, 'title': title, 'abstract': abstract,
'authors': authors, 'identifiers': {}, 'venue': 'AAMAS', 'venue_aliases': [],
'year': 2012, 'track': track, 'status': 'accepted',
'observed_at': observed_at,
'raw_decision': 'listed in official AAMAS 2012 proceedings '+heading,
'metadata': {'publisher_paper_id': paper_id, 'paper_url': paper_url,
'section_url': url, 'section_heading': heading,
'publication_format': publication_format, 'session': session,
'authors_display': author_display, 'source_evidence': evidence,
'publication_evidence': 'official published proceedings entry; not an original decision note',
'track_scope_note': ('Full papers and extended abstracts include general and special submission tracks; '
'session topics do not establish an individual submission track.'
if track == 'proceedings_unspecified' else 'Explicit publisher section.'),
'decision_date_precision': 'not_supplied'},
'tags': tags})
if not records:
raise ValueError('No publisher paper entries found')
return records
def read_pages(cache=None, snapshots=None):
if bool(cache) == bool(snapshots):
raise ValueError('Choose exactly one HTTP cache or frozen snapshot directory')
urls = {'introduction': ROOT, **{key: ROOT+'resources/'+key+'.html' for key in SECTIONS}}
pages = {}
receipts = []
frozen = json.loads((Path(snapshots)/'receipts.json').read_text())['receipts'] if snapshots else []
for key, url in urls.items():
if snapshots:
receipt = one([r for r in frozen if r['url'] == url], 'frozen response receipt')
filename = Path(receipt['file'])
if filename.is_absolute() or '..' in filename.parts or receipt.get('status') != 200:
raise ValueError('Unsafe or unsuccessful frozen response')
data = (Path(snapshots)/filename).read_bytes()
if len(data) > 8*1024**2 or hashlib.sha256(data).hexdigest() != receipt['sha256'] or len(data) != receipt['bytes']:
raise ValueError('Frozen publisher response checksum or size mismatch')
observed = 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_text())
observed = 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}
pages[key] = (data, evidence)
receipts.append(evidence)
return pages, receipts
def _collect(year, out, *, cache=None, snapshots=None):
if year != 2012:
raise ValueError('Only the independently reviewed AAMAS 2012 layout is supported')
pages, receipts = read_pages(cache, snapshots)
intro = Document(pages['introduction'][0].decode('utf-8'))
if [clean(n.text()) for n in intro.root.walk() if n.tag == 'title'] != ['AAMAS 2012']:
raise ValueError('Unexpected proceedings introduction')
records = []
for section, (_, _, _, expected) in SECTIONS.items():
data, evidence = pages[section]
group = parse_section(data.decode('utf-8'), section, evidence['observed_at'], evidence)
if len(group) != expected:
raise ValueError('Publisher section count differs from reviewed inventory: '+section)
records.extend(group)
if len({r['external_id'] for r in records}) != len(records):
raise ValueError('Paper IDs collide across publisher sections')
grouped = defaultdict(list)
for record in records:
grouped[record['track']].append(record)
out = Path(out)
out.mkdir(parents=True, exist_ok=True)
files = []
with tempfile.TemporaryDirectory(prefix='.aamas-', dir=out) as temporary:
stage = Path(temporary)
(stage/'evidence').mkdir()
for data, evidence in pages.values():
(stage/evidence['file']).write_bytes(data)
for track, group in sorted(grouped.items()):
path = stage/f'aamas-proceedings-2012-{track}.jsonl'
path.write_text(''.join(json.dumps(r, ensure_ascii=False)+'\n' for r in group))
manifest = {'source': SOURCE, 'parser_version': 1, 'venue': 'AAMAS', 'year': 2012, '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(not r['abstract'] for r in group),
'publication_format_counts': dict(Counter(r['metadata']['publication_format'] for r in group)),
'note': 'Publisher sections verified; individual full/extended submission track, decision history and independent identities still need review.'}
path.with_suffix('.manifest.json').write_text(json.dumps(manifest, ensure_ascii=False, indent=2)+'\n')
files.append(manifest)
result = {'source': SOURCE, 'year': year, 'finished_at': timestamp(), 'status': 'collected_pending_identity_review',
'records': len(records), 'files': files, 'source_receipts': receipts,
'publication_format_counts': dict(Counter(r['metadata']['publication_format'] for r in records)),
'database_written': False, 'pdfs_downloaded': False,
'note': 'Aggregate submission/withdrawal totals do not identify individual rejected or withdrawn papers.'}
(stage/'aamas-proceedings-2012.json').write_text(json.dumps(result, ensure_ascii=False, indent=2)+'\n')
# Validated files first; write the completed collection report last.
(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 != 'aamas-proceedings-2012.json':
path.replace(out/path.name)
(stage/'aamas-proceedings-2012.json').replace(out/'aamas-proceedings-2012.json')
return result
def collect(year, out, *, cache=None, snapshots=None):
if year != 2012:
raise ValueError('Only the independently reviewed AAMAS 2012 layout is supported')
out = Path(out)
out.mkdir(parents=True, exist_ok=True)
with (out/'aamas.collect.lock').open('a') as lock:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
return _collect(year, out, cache=cache, snapshots=snapshots)
def main(argv=None, *, prog=None):
parser = argparse.ArgumentParser(prog=prog)
parser.add_argument('--year', type=int, choices=[2012], 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))
+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 mm mlsys'.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 ecp ewsp kr atal aamas icassp interspeech eurospeech icslp ismir miccai fat facct lrec mm mlsys'.split())
def records(archive,dtd_bytes):
@@ -0,0 +1,45 @@
"""Exact predecessor volumes, with original names and independent family histories."""
ECML_HISTORY = 'https://ecmlpkdd2011.org/pastConferences.php'
ICAPS_HISTORY = 'https://www.icaps-conference.org/conference-series/'
# family/year: booktitle, original series, target family, publication year, full title
VOLUMES = {
('ecml', 1987): ('EWSL', 'EWSL', 'ECML-PKDD', 1987,
'Progress in Machine Learning - Proceedings of EWSL 87: 2nd European Working Session on Learning, Bled, Yugoslavia, May 1987'),
('ecml', 1988): ('EWSL', 'EWSL', 'ECML-PKDD', 1988,
'Proceedings of the Third European Working Session on Learning, EWSL 1988, Turing Institute, Glasgow, UK, October 3-5, 1988'),
('ecml', 1989): ('EWSL', 'EWSL', 'ECML-PKDD', 1989,
'Proceedings of the 4th European Working Session on Learning, Montpellier, France, 4-6 December 1989'),
('ecml', 1991): ('EWSL', 'EWSL', 'ECML-PKDD', 1991,
'Machine Learning - EWSL-91, European Working Session on Learning, Porto, Portugal, March 6-8, 1991, Proceedings'),
('aips', 1990): ('Expert Planning Systems', 'EPS', 'ICAPS', 1990,
'Proceedings of the First International Conference on Expert Planning Systems, Brighton, UK, June 27-29, 1990'),
('ecp', 1997): ('ECP', 'ECP', 'ICAPS', 1997,
"Recent Advances in AI Planning, 4th European Conference on Planning, ECP'97, Toulouse, France, September 24-26, 1997, Proceedings"),
('ecp', 1999): ('ECP', 'ECP', 'ICAPS', 2000,
"Recent Advances in AI Planning, 5th European Conference on Planning, ECP'99, Durham, UK, September 8-10, 1999, Proceedings"),
('ewsp', 1991): ('EWSP', 'EWSP', 'ICAPS', 1991,
"European Workshop on Planning, EWSP '91, Sankt Augustin, Germany, March 18-19, 1991, Proceedings"),
}
def predecessor_scope(family, base, suffix, volume, year, title):
definition = VOLUMES.get((family, year))
if definition is None:
return None
book, original, target, publication_year, expected_title = definition
if (suffix or base != book or volume['key'] != f'conf/{family}/{year}'
or title != expected_title or volume['fields'].get('year') != [str(publication_year)]):
return None
return {'original_series': original, 'target_family': target,
'history_url': ECML_HISTORY if target == 'ECML-PKDD' else ICAPS_HISTORY,
'proceedings_key': volume['key'], 'proceedings_title': title,
'event_year': year, 'proceedings_year_fields': list(volume['fields']['year'])}
def missing_candidate_families(targets, families, through_year):
# These two families were absent from the earlier narrowed candidate JSONL,
# although their proceedings and volume_counts exist in the full catalogue.
required = {family for (family, year), definition in VOLUMES.items()
if family in ('ecp', 'ewsp') and definition[2] in targets and year <= through_year}
return sorted(family for family in required if not families.get(family))
+5 -1
View File
@@ -15,6 +15,7 @@ from urllib.parse import parse_qs,unquote,urlsplit
from paper_atlas.config import DEFAULT_REGISTRY
from paper_atlas.collectors.dblp import XML_SHA,DTD_MD5,RELEASE,records
from paper_atlas.collectors.dblp_scope import scope
from paper_atlas.collectors.dblp_predecessors import missing_candidate_families
from paper_atlas.db.submissions import timestamp
from paper_atlas.io.snapshots import digest_file
from paper_atlas.io.publication_ids import mlsys_id
@@ -88,6 +89,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('historical_predecessor_evidence'):
metadata['historical_predecessor_evidence'] = edition['historical_predecessor_evidence']
if edition.get('joint_edition_evidence'):
metadata['joint_edition_evidence'] = edition['joint_edition_evidence']
if edition.get('track_scope_note'):
@@ -124,8 +127,9 @@ def collect(catalogue,out,venues=None,candidates=None,archive=None,dtd=None,incl
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)]
missing += ['ICAPS/'+family for family in missing_candidate_families(targets, families, through_year)]
if missing:
raise ValueError('Candidate file predates requested families ' + ', '.join(missing) + '; use the original --archive')
raise ValueError('Candidate file omits 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)
+38 -2
View File
@@ -1,6 +1,8 @@
"""Explicit bibliographic aliases; a directory family alone is not venue evidence."""
import re
from paper_atlas.collectors.dblp_predecessors import predecessor_scope
# Ordered long/joint names precede their shorter forms.
BOOKS = {
@@ -27,7 +29,8 @@ BOOKS = {
'pkdd':[('ECML/PKDD','ECML-PKDD',[]),('ECML PKDD','ECML-PKDD',[]),('PKDD','PKDD',['ECML-PKDD'])],
'icra':[('ICRA','ICRA',[])], 'iros':[('IROS','IROS',[])],
'rss':[('Robotics: Science and Systems','RSS',[])], 'corl':[('CoRL','CoRL',[])],
'aips':[('ICAPS','ICAPS',[]),('AIPS','AIPS',['ICAPS'])], 'icaps':[('ICAPS','ICAPS',[])],
'aips':[('ICAPS','ICAPS',[]),('AIPS','AIPS',['ICAPS']),('Expert Planning Systems','EPS',['ICAPS'])], 'icaps':[('ICAPS','ICAPS',[])],
'ecp':[('ECP','ECP',['ICAPS'])], 'ewsp':[('EWSP','EWSP',['ICAPS'])],
'kr':[('KR','KR',[])], 'atal':[('AAMAS','AAMAS',[])],
'icassp':[('ICASSP','ICASSP',[])],
'interspeech':[('INTERSPEECH','INTERSPEECH',[]),('EUROSPEECH','EUROSPEECH',['INTERSPEECH']),('ICSLP','ICSLP',['INTERSPEECH']),('ECST','ECST',['INTERSPEECH'])],
@@ -54,7 +57,34 @@ HISTORICAL_WORKSHOP_TITLES = {
}
# Exact pinned volume titles corroborated by official series histories. The
# original title (including Workshop) remains in source metadata. In particular
# ML91 has no four-digit year in its title; its exact key/title and bibliographic
# year are checked independently, rather than guessing a title year.
HISTORICAL_PRIMARY_EVIDENCE = {
'icml': ('ML', 'https://icml.cc/Conferences/2008/past_icmls.shtml.html'),
'iros': ('IROS', 'https://www.ieee-ras.org/conferences-workshops/financially-co-sponsored/iros/iros-past-and-future-venues/'),
}
HISTORICAL_PRIMARY_TITLES = {
('icml', 1988): 'Machine Learning, Proceedings of the Fifth International Conference on Machine Learning, Ann Arbor, Michigan, USA, June 12-14, 1988',
('icml', 1989): 'Proceedings of the Sixth International Workshop on Machine Learning (ML 1989), Cornell University, Ithaca, New York, USA, June 26-27, 1989',
('icml', 1990): 'Machine Learning, Proceedings of the Seventh International Conference on Machine Learning, Austin, Texas, USA, June 21-23, 1990',
('icml', 1991): 'Proceedings of the Eighth International Workshop (ML91), Northwestern University, Evanston, Illinois, USA',
('icml', 1992): 'Proceedings of the Ninth International Workshop on Machine Learning (ML 1992), Aberdeen, Scotland, UK, July 1-3, 1992',
('iros', 1988): "IEEE International Workshop on Intelligent Robots and Systems '88, Proceedings. IROS 1988, Tokyo, Japan, October 31 - November 2, 1988",
('iros', 1989): "IEEE International Workshop on Intelligent Robots and Systems '89, Proceedings. IROS 1989, September 4-6, 1989, Tsukuba, Japan",
('iros', 1990): "IEEE International Workshop on Intelligent Robots and Systems '90, Towards a New Frontier of Applications, Proceedings. IROS 1990, July 3-6, 1990, Ibaraki, Japan",
}
def historical_track(family, base, suffix, volume_key, year, title):
primary = HISTORICAL_PRIMARY_EVIDENCE.get(family)
if (primary and not suffix and base.casefold() == primary[0].casefold()
and volume_key == f'conf/{family}/{year}'
and title == HISTORICAL_PRIMARY_TITLES.get((family, year))):
return ('main',
'Historical primary series; exact proceedings key, complete title and bibliographic/event year corroborated by official series history. Workshop in this original title is not an affiliated track.',
primary[1])
if suffix or base.casefold() != family or volume_key != f'conf/{family}/{year}':
return None
prefix = HISTORICAL_WORKSHOP_TITLES.get((family, year))
@@ -108,7 +138,11 @@ def scope(row,volume,through_year=2026):
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'
text=(suffix+' '+title).casefold();track='main'
historical = historical_track(family, base, suffix, volume['key'], year, title)
predecessor = predecessor_scope(family, base, suffix, volume, year, title)
if (family in ('ecp', 'ewsp') or (family == 'aips' and base == 'Expert Planning Systems')) and not predecessor:
return None, 'unverified_historical_predecessor'
historical = (('main', 'Historical predecessor; exact volume, full title, event and publication years verified; original series retained.', predecessor['history_url'])
if predecessor else historical_track(family, base, suffix, volume['key'], year, title))
if historical:track=historical[0]
elif 'findings' in text:track='findings'
elif 'shared task' in text:track='shared_task'
@@ -138,6 +172,8 @@ def scope(row,volume,through_year=2026):
joint_evidence = 'https://www.eurai.org/ecai/history'
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 predecessor:
result['historical_predecessor_evidence'] = predecessor
if joint_evidence:
result['joint_edition_evidence'] = joint_evidence
if historical:
+1 -1
View File
@@ -4,7 +4,7 @@ import re
from paper_atlas.io.publication_ids import mlsys_id
PUBLICATIONS={'pmlr','acl_anthology','colm','neurips','mlsys','cvf','ecva','dblp'}
PUBLICATIONS={'pmlr','acl_anthology','colm','neurips','mlsys','cvf','ecva','dblp','aamas_proceedings'}
def timestamp(value=None):
+314
View File
@@ -0,0 +1,314 @@
{
"source_snapshot_sha256": "0376ad019b216553faff0a14e1757b2d4c7a3b84c1beacf0511ef83695211b18",
"scope": "Pinned proceedings metadata, not authentic example paper records or evidence that all volumes contain indexed papers. Test papers are synthetic.",
"volumes": [
{
"key": "conf/ecml/1987",
"fields": {
"editor": [
"Ivan Bratko",
"Nada Lavrac"
],
"title": [
"Progress in Machine Learning - Proceedings of EWSL 87: 2nd European Working Session on Learning, Bled, Yugoslavia, May 1987"
],
"booktitle": [
"EWSL"
],
"publisher": [
"Sigma Press, Wilmslow"
],
"year": [
"1987"
],
"isbn": [
"1-85058-088-X"
],
"url": [
"db/conf/ecml/ewsl87.html"
]
},
"bibliographic_counts": [
{
"year": "1987",
"booktitle": "EWSL",
"record_type": "inproceedings",
"count": 16
}
],
"expected_original_series": "EWSL",
"expected_target_family": "ECML-PKDD"
},
{
"key": "conf/ecml/1988",
"fields": {
"editor": [
"Derek H. Sleeman"
],
"title": [
"Proceedings of the Third European Working Session on Learning, EWSL 1988, Turing Institute, Glasgow, UK, October 3-5, 1988"
],
"booktitle": [
"EWSL"
],
"publisher": [
"Pitman Publishing"
],
"year": [
"1988"
],
"isbn": [
"0-273-08800-9"
],
"url": [
"db/conf/ecml/ewsl88.html"
]
},
"bibliographic_counts": [
{
"year": "1988",
"booktitle": "EWSL",
"record_type": "inproceedings",
"count": 21
}
],
"expected_original_series": "EWSL",
"expected_target_family": "ECML-PKDD"
},
{
"key": "conf/ecml/1989",
"fields": {
"editor": [
"Katharina Morik"
],
"title": [
"Proceedings of the 4th European Working Session on Learning, Montpellier, France, 4-6 December 1989"
],
"booktitle": [
"EWSL"
],
"publisher": [
"Pitman and Morgan Kaufman Publishers"
],
"year": [
"1989"
]
},
"bibliographic_counts": [],
"expected_original_series": "EWSL",
"expected_target_family": "ECML-PKDD"
},
{
"key": "conf/ecml/1991",
"fields": {
"editor": [
"Yves Kodratoff"
],
"title": [
"Machine Learning - EWSL-91, European Working Session on Learning, Porto, Portugal, March 6-8, 1991, Proceedings"
],
"booktitle": [
"EWSL"
],
"series": [
"Lecture Notes in Computer Science"
],
"volume": [
"482"
],
"publisher": [
"Springer"
],
"year": [
"1991"
],
"isbn": [
"3-540-53816-X"
],
"ee": [
"https://doi.org/10.1007/BFb0016999"
],
"url": [
"db/conf/ecml/ewsl91.html"
]
},
"bibliographic_counts": [
{
"year": "1991",
"booktitle": "EWSL",
"record_type": "inproceedings",
"count": 44
}
],
"expected_original_series": "EWSL",
"expected_target_family": "ECML-PKDD"
},
{
"key": "conf/ecp/1997",
"fields": {
"editor": [
"Sam Steel",
"Rachid Alami 0001"
],
"title": [
"Recent Advances in AI Planning, 4th European Conference on Planning, ECP'97, Toulouse, France, September 24-26, 1997, Proceedings"
],
"booktitle": [
"ECP"
],
"series": [
"Lecture Notes in Computer Science"
],
"volume": [
"1348"
],
"publisher": [
"Springer"
],
"year": [
"1997"
],
"isbn": [
"3-540-63912-8"
],
"ee": [
"https://doi.org/10.1007/3-540-63912-8"
],
"url": [
"db/conf/ecp/ecp1997.html"
]
},
"bibliographic_counts": [
{
"year": "1997",
"booktitle": "ECP",
"record_type": "inproceedings",
"count": 35
}
],
"expected_original_series": "ECP",
"expected_target_family": "ICAPS"
},
{
"key": "conf/ecp/1999",
"fields": {
"editor": [
"Susanne Biundo",
"Maria Fox 0001"
],
"title": [
"Recent Advances in AI Planning, 5th European Conference on Planning, ECP'99, Durham, UK, September 8-10, 1999, Proceedings"
],
"booktitle": [
"ECP"
],
"series": [
"Lecture Notes in Computer Science"
],
"volume": [
"1809"
],
"publisher": [
"Springer"
],
"year": [
"2000"
],
"isbn": [
"3-540-67866-2"
],
"ee": [
"https://doi.org/10.1007/10720246"
],
"url": [
"db/conf/ecp/ecp1999.html"
]
},
"bibliographic_counts": [
{
"year": "1999",
"booktitle": "ECP",
"record_type": "inproceedings",
"count": 28
}
],
"expected_original_series": "ECP",
"expected_target_family": "ICAPS"
},
{
"key": "conf/ewsp/1991",
"fields": {
"editor": [
"Joachim Hertzberg"
],
"title": [
"European Workshop on Planning, EWSP '91, Sankt Augustin, Germany, March 18-19, 1991, Proceedings"
],
"booktitle": [
"EWSP"
],
"series": [
"Lecture Notes in Computer Science"
],
"volume": [
"522"
],
"publisher": [
"Springer"
],
"year": [
"1991"
],
"isbn": [
"3-540-54364-3"
],
"ee": [
"https://doi.org/10.1007/BFb0052944"
],
"url": [
"db/conf/ewsp/ewsp1991.html"
]
},
"bibliographic_counts": [
{
"year": "1991",
"booktitle": "EWSP",
"record_type": "inproceedings",
"count": 10
}
],
"expected_original_series": "EWSP",
"expected_target_family": "ICAPS"
},
{
"key": "conf/aips/1990",
"fields": {
"title": [
"Proceedings of the First International Conference on Expert Planning Systems, Brighton, UK, June 27-29, 1990"
],
"booktitle": [
"Expert Planning Systems"
],
"publisher": [
"AAAI"
],
"year": [
"1990"
],
"url": [
"db/conf/aips/aips1990.html"
]
},
"bibliographic_counts": [
{
"year": "1990",
"booktitle": "Expert Planning Systems",
"record_type": "inproceedings",
"count": 48
}
],
"expected_original_series": "EPS",
"expected_target_family": "ICAPS"
}
]
}
+151
View File
@@ -0,0 +1,151 @@
"""Publisher evidence, section boundaries and native-ingestion regression checks."""
import hashlib
import json
import re
from pathlib import Path
import sqlite3
import tempfile
import unittest
from unittest.mock import patch
from paper_atlas.collectors import aamas
from paper_atlas.ingestion.records import ingest_file
def paper(pid, abstract=True):
summary = '<span class="abstract">Research with <i>evidence</i>.<br>More detail.</span>' if abstract else ''
return f'''<div class="paper" id="{pid}"><span class="id">{pid}</span>
<p class="paper"><span class="title"><a href="../papers/{pid}.pdf">{summary}
A structured &amp; auditable paper {pid}</a></span><span class="authors">
<a href="authors.html#Jane">Jane Doe<span class="amount">has 7 papers</span></a>, Bob Smith
</span></p></div>'''
def page(section, pid, abstract=True):
heading = aamas.SECTIONS[section][0]
return (f'<html><head><title>{heading} - AAMAS 2012</title></head><body>'
f'<h1>{heading}</h1><h2 class="section" id="s-1A">Session 1A – Robotics I</h2>'
+paper(pid, abstract)+'</body></html>')
class AamasProceedingsTests(unittest.TestCase):
def snapshots(self, root):
root.mkdir()
receipts = []
items = {'introduction': ('<title>AAMAS 2012</title><p>Aggregate withdrawals do not identify papers.</p>', aamas.ROOT)}
for index, section in enumerate(aamas.SECTIONS):
items[section] = (page(section, f'P{index}_1', section != 'extabstracts'), aamas.ROOT+'resources/'+section+'.html')
for key, (html, url) in items.items():
data = html.encode()
(root/(key+'.html')).write_bytes(data)
receipts.append({'url': url, 'status': 200, 'file': key+'.html', 'bytes': len(data),
'sha256': hashlib.sha256(data).hexdigest(), 'finished_at': '2026-11-09T00:00:00+00:00'})
(root/'receipts.json').write_text(json.dumps({'receipts': receipts}))
return root
def test_hidden_abstract_and_author_badges_are_not_title_or_authors(self):
rows = aamas.parse_section(page('fullpapers', '1A_1'), 'fullpapers', '2026-11-09T00:00:00Z', {'sha256': 'proof'})
row, = rows
self.assertEqual(row['title'], 'A structured & auditable paper 1A_1')
self.assertEqual(row['abstract'], 'Research with evidence. More detail.')
self.assertEqual(row['authors'], ['Jane Doe', 'Bob Smith'])
self.assertEqual(row['metadata']['authors_display'], 'Jane Doe, Bob Smith')
self.assertEqual(row['url'], aamas.ROOT+'resources/fullpapers.html#1A_1')
self.assertEqual(row['metadata']['paper_url'], aamas.ROOT+'papers/1A_1.pdf')
self.assertEqual(row['metadata']['source_evidence'], {'sha256': 'proof'})
def test_publication_formats_and_session_topics_do_not_invent_submission_tracks(self):
for section, (_, fmt, track, _) in aamas.SECTIONS.items():
row, = aamas.parse_section(page(section, 'P_1', False), section, '2026-11-09T00:00:00Z', {})
self.assertEqual((row['track'], row['metadata']['publication_format']), (track, fmt))
self.assertIsNone(row['abstract'])
self.assertEqual(row['status'], 'accepted')
self.assertEqual(row['metadata']['decision_date_precision'], 'not_supplied')
self.assertEqual(row['metadata']['session']['title'], 'Session 1A – Robotics I')
self.assertIn({'namespace': 'aamas_proceedings:publication_format', 'value': fmt}, row['tags'])
self.assertEqual(aamas.SECTIONS['fullpapers'][2], 'proceedings_unspecified')
self.assertEqual(aamas.SECTIONS['extabstracts'][2], 'proceedings_unspecified')
def test_changed_edition_section_duplicate_id_or_pdf_link_is_rejected(self):
html = page('fullpapers', '1A_1')
for changed in [html.replace('AAMAS 2012', 'AAMAS 2025'),
html.replace('<h1>Full Papers</h1>', '<h1>Rejected Papers</h1>'),
html.replace('</body>', paper('1A_1')+'</body>'),
html.replace('../papers/1A_1.pdf', 'https://example.org/1A_1.pdf'),
html.replace('../papers/1A_1.pdf', '../papers/1A_2.pdf')]:
with self.subTest(changed=changed):
with self.assertRaises(ValueError):
aamas.parse_section(changed, 'fullpapers', '2026-11-09T00:00:00Z', {})
def test_frozen_responses_require_checksums_success_and_safe_paths(self):
for failure in ['checksum', 'status', 'path']:
with self.subTest(failure=failure), tempfile.TemporaryDirectory() as temporary:
root = self.snapshots(Path(temporary)/'source')
path = root/'receipts.json'
receipts = json.loads(path.read_text())
item = receipts['receipts'][0]
if failure == 'checksum': item['sha256'] = '0'*64
elif failure == 'status': item['status'] = 404
else: item['file'] = '../outside.html'
path.write_text(json.dumps(receipts))
with self.assertRaises(ValueError):
aamas.read_pages(snapshots=root)
def test_reviewed_section_counts_and_year_are_required(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
source = self.snapshots(root/'source')
with self.assertRaises(ValueError):
aamas.collect(2012, root/'out', snapshots=source)
self.assertFalse((root/'out/aamas-proceedings-2012.json').exists())
with self.assertRaises(ValueError):
aamas.collect(2025, root/'future', snapshots=source)
self.assertFalse((root/'future').exists())
def test_ids_colliding_across_sections_do_not_produce_a_completed_collection(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
source = self.snapshots(root/'source')
receipts_path = source/'receipts.json'
receipts = json.loads(receipts_path.read_text())
for item in receipts['receipts'][1:]:
path = source/item['file']
data = re.sub(r'P[0-9]_1', 'SAME_1', path.read_text()).encode()
path.write_bytes(data)
item.update(bytes=len(data), sha256=hashlib.sha256(data).hexdigest())
receipts_path.write_text(json.dumps(receipts))
small = {key: (*value[:3], 1) for key, value in aamas.SECTIONS.items()}
with patch.dict(aamas.SECTIONS, small, clear=True), self.assertRaises(ValueError):
aamas.collect(2012, root/'out', snapshots=source)
self.assertFalse((root/'out/aamas-proceedings-2012.json').exists())
def test_collection_and_native_ingestion_preserve_formats_evidence_and_missing_abstracts(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
source = self.snapshots(root/'source')
small = {key: (*value[:3], 1) for key, value in aamas.SECTIONS.items()}
with patch.dict(aamas.SECTIONS, small, clear=True):
result = aamas.collect(2012, root/'out', snapshots=source)
self.assertEqual(result['records'], 4)
self.assertEqual(len(result['files']), 3)
self.assertFalse(result['database_written'])
self.assertFalse(result['pdfs_downloaded'])
db = root/'sample.sqlite'
for manifest in result['files']:
path = Path(manifest['file'])
self.assertEqual(hashlib.sha256(path.read_bytes()).hexdigest(), manifest['file_sha256'])
for raw in path.read_text().splitlines():
record = json.loads(raw)
evidence = record['metadata']['source_evidence']
self.assertEqual(hashlib.sha256((root/'out'/evidence['file']).read_bytes()).hexdigest(), evidence['sha256'])
ingest_file(db, path)
with sqlite3.connect(db) as connection:
self.assertEqual(connection.execute('SELECT count(*) FROM papers').fetchone()[0], 4)
self.assertEqual(connection.execute('SELECT count(*) FROM submissions').fetchone()[0], 4)
self.assertEqual(connection.execute("SELECT count(*) FROM decisions WHERE status='accepted'").fetchone()[0], 4)
self.assertEqual(connection.execute('SELECT count(*) FROM papers WHERE abstract IS NULL').fetchone()[0], 1)
metadata = [json.loads(r[0]) for r in connection.execute('SELECT metadata_json FROM source_records')]
self.assertEqual({r['publication_format'] for r in metadata}, {'full_paper','extended_abstract','demo','doctoral_abstract'})
namespaces = {r[0] for r in connection.execute('SELECT DISTINCT namespace FROM tags')}
self.assertEqual(namespaces, {'aamas_proceedings:publication_format', 'aamas_proceedings:session'})
self.assertEqual(connection.execute('PRAGMA foreign_key_check').fetchall(), [])
+89
View File
@@ -0,0 +1,89 @@
"""Cross-source publisher identities must preserve attempts and decision history."""
import hashlib
import json
from pathlib import Path
import sqlite3
import tempfile
import unittest
from paper_atlas.db.submissions import identities_for_row
from paper_atlas.ingestion.records import ingest_file
DOI = '10.1234/aamas.identity.example'
def row(source, external_id, *, venue='AAMAS', year=2012, track='demo', status='accepted', identifiers=None):
return {'source': source, 'external_id': external_id,
'url': 'https://example.org/'+source+'/'+external_id,
'title': 'A shared publication with independently recorded evidence',
'abstract': 'An independently recorded source abstract.',
'authors': ['Jane Doe', 'Bob Smith'],
'identifiers': identifiers if identifiers is not None else {'doi': DOI},
'venue': venue, 'year': year, 'track': track, 'status': status,
'observed_at': '2026-11-09T00:00:00Z',
'raw_decision': source+' observed '+status,
'metadata': {'original_source': source}, 'tags': []}
def ingest(db, root, record):
path = root/(record['source']+'-'+record['external_id'].replace('/', '-')+'.jsonl')
path.write_text(json.dumps(record)+'\n')
path.with_suffix('.manifest.json').write_text(json.dumps({
'source': record['source'], 'parser_version': 1, 'venue': record['venue'],
'year': record['year'], 'track': record['track'], 'count': 1,
'list_url': 'https://example.org/'+record['source'], 'status': 'partial',
'file_sha256': hashlib.sha256(path.read_bytes()).hexdigest(),
'missing_abstract_count': 0}))
ingest_file(db, path)
class AamasPublicationIdentityTests(unittest.TestCase):
def test_only_accepted_known_publisher_rows_supply_publication_doi_identity(self):
record = row('aamas_proceedings', '2012/D1_1', identifiers={'doi': 'https://doi.org/'+DOI.upper()})
self.assertEqual(identities_for_row(record), [('publication_doi', DOI)])
for status in ['rejected', 'desk_rejected', 'withdrawn', 'under_review', 'unknown']:
with self.subTest(status=status):
self.assertEqual(identities_for_row({**record, 'status': status}), [])
self.assertEqual(identities_for_row({**record, 'source': 'unverified_source'}), [])
def test_same_edition_doi_joins_sources_in_both_orders_without_duplicate_attempts(self):
publisher = row('aamas_proceedings', '2012/D1_1')
bibliography = row('dblp', 'conf/atal/Example12')
for order in [(publisher, bibliography), (bibliography, publisher)]:
with self.subTest(first=order[0]['source']), tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
db = root/'sample.sqlite'
for record in order:
ingest(db, root, record)
ingest(db, root, publisher)
with sqlite3.connect(db) as connection:
self.assertEqual(connection.execute('SELECT count(*) FROM papers').fetchone()[0], 1)
self.assertEqual(connection.execute('SELECT count(*) FROM source_records').fetchone()[0], 2)
self.assertEqual(connection.execute('SELECT count(*) FROM submissions').fetchone()[0], 1)
self.assertEqual(connection.execute('SELECT count(*) FROM submission_sources').fetchone()[0], 2)
self.assertEqual(connection.execute('SELECT count(*) FROM decision_sources').fetchone()[0], 2)
self.assertEqual(connection.execute('SELECT scheme,value FROM submission_identifiers').fetchall(), [('publication_doi', DOI)])
metadata = [json.loads(r[0]) for r in connection.execute('SELECT metadata_json FROM source_records')]
self.assertEqual({r['original_source'] for r in metadata}, {'aamas_proceedings', 'dblp'})
self.assertEqual(connection.execute('PRAGMA foreign_key_check').fetchall(), [])
def test_cross_venue_rejection_and_distinct_tracks_remain_separate_attempts(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
db = root/'sample.sqlite'
rejected = row('openreview_snapshot', 'original-forum', venue='AAAI', year=2011,
track='main', status='rejected', identifiers={'arxiv': '1101.00001', 'openreview': 'original-forum'})
accepted = row('aamas_proceedings', '2012/D1_1', identifiers={'arxiv': '1101.00001', 'doi': DOI})
another_track = row('aamas_proceedings', '2012/F1_1', track='proceedings_unspecified', identifiers={'doi': DOI})
for record in [rejected, accepted, another_track]:
ingest(db, root, record)
with sqlite3.connect(db) as connection:
self.assertEqual(connection.execute('SELECT count(*) FROM papers').fetchone()[0], 1)
self.assertEqual(connection.execute('SELECT count(*) FROM submissions').fetchone()[0], 3)
history = connection.execute('''SELECT v.acronym,e.year,e.track,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 ORDER BY v.acronym,e.track''').fetchall()
self.assertEqual(set(history), {('AAAI',2011,'main','rejected'),('AAMAS',2012,'demo','accepted'),('AAMAS',2012,'proceedings_unspecified','accepted')})
self.assertEqual(connection.execute('SELECT count(*) FROM decision_sources').fetchone()[0], 3)
self.assertEqual(connection.execute('PRAGMA foreign_key_check').fetchall(), [])
+151
View File
@@ -0,0 +1,151 @@
"""Original series, exact edition boundaries, and complete candidate discovery."""
from contextlib import closing
from copy import deepcopy
import gzip
import hashlib
import json
from pathlib import Path
import sqlite3
import tempfile
import unittest
from types import SimpleNamespace
from unittest.mock import patch
from paper_atlas.collectors.dblp import catalogue, XML_SHA
from paper_atlas.collectors.dblp_records import collect, normalize_row
from paper_atlas.ingestion.records import ingest_file
FIXTURE = json.loads((Path(__file__).parent/'fixtures/historical_predecessors.json').read_text())
EXAMPLES = {row['key']: row for row in FIXTURE['volumes']}
def example(key, *, bibliographic_year=None):
fixed = EXAMPLES[key]
family = key.split('/')[1]
year = int(key.split('/')[-1])
volume = {'record_type': 'proceedings', 'key': key, 'fields': deepcopy(fixed['fields'])}
row = {'record_type': 'inproceedings', 'key': f'conf/{family}/SyntheticExample{year}', 'publtype': None,
'fields': {'title': [f'Synthetic historical research example {family} {year}'],
'author': ['Jane Doe 0001', 'John Smith'], 'booktitle': fixed['fields']['booktitle'],
'year': [str(bibliographic_year or year)], 'crossref': [key],
'ee': [f'https://doi.org/10.1234/synthetic.{family}.{year}']}}
return row, volume
class HistoricalPredecessorTests(unittest.TestCase):
def normalize(self, key, **kwargs):
row, volume = example(key, **kwargs)
fixed = EXAMPLES[key]
result, reason = normalize_row(row, {key: volume}, {fixed['expected_target_family']})
self.assertIsNone(reason)
self.assertIsNotNone(result)
return result
def test_ewsl_primary_series_retains_original_name_and_family_alias(self):
for year in [1987, 1988, 1989, 1991]:
with self.subTest(year=year):
record = self.normalize(f'conf/ecml/{year}')
self.assertEqual((record['venue'],record['venue_aliases'],record['year'],record['track']), ('EWSL',['ECML-PKDD'],year,'main'))
proof = record['metadata']['historical_predecessor_evidence']
self.assertEqual(proof['original_series'], 'EWSL')
self.assertEqual(proof['history_url'], 'https://ecmlpkdd2011.org/pastConferences.php')
self.assertEqual(record['metadata']['bibliography']['booktitle'], ['EWSL'])
self.assertEqual(record['metadata']['author_names_raw'], ['Jane Doe 0001','John Smith'])
self.assertEqual(record['authors'], ['Jane Doe','John Smith'])
self.assertEqual(EXAMPLES['conf/ecml/1989']['bibliographic_counts'], [])
def test_eps_ecp_ewsp_keep_original_series_instead_of_renaming_to_icaps(self):
for key in ['conf/aips/1990','conf/ecp/1997','conf/ecp/1999','conf/ewsp/1991']:
with self.subTest(key=key):
record = self.normalize(key)
fixed = EXAMPLES[key]
self.assertEqual(record['venue'], fixed['expected_original_series'])
self.assertEqual(record['venue_aliases'], ['ICAPS'])
self.assertEqual(record['track'], 'main')
proof = record['metadata']['historical_predecessor_evidence']
self.assertEqual(proof['history_url'], 'https://www.icaps-conference.org/conference-series/')
self.assertEqual(proof['proceedings_title'], fixed['fields']['title'][0])
self.assertEqual(proof['proceedings_year_fields'], fixed['fields']['year'])
def test_altered_titles_keys_suffixes_publication_years_and_unreviewed_editions_stay_held(self):
for key in ['conf/ecml/1988','conf/aips/1990','conf/ecp/1997','conf/ewsp/1991']:
row, volume = example(key)
for changed in ['title','key','suffix','publication_year']:
with self.subTest(key=key, changed=changed):
r, v = deepcopy(row), deepcopy(volume)
if changed == 'title': v['fields']['title'][0] += ', Affiliated Workshop'
elif changed == 'key': v['key'] += 'w'; r['fields']['crossref'] = [v['key']]
elif changed == 'suffix': r['fields']['booktitle'][0] += ' (Workshop)'
else: v['fields']['year'] = ['2001']
result, _ = normalize_row(r, {v['key']: v}, {EXAMPLES[key]['expected_target_family']})
self.assertIsNone(result)
row, volume = example('conf/ecp/1997')
row['fields']['year'] = ['2001']; row['fields']['crossref'] = ['conf/ecp/2001']
volume['key'] = 'conf/ecp/2001'; volume['fields']['year'] = ['2001']
self.assertIsNone(normalize_row(row, {volume['key']:volume}, {'ICAPS'})[0])
def test_ecp1999_event_and_bibliographic_years_are_independently_retained(self):
record = self.normalize('conf/ecp/1999', bibliographic_year=2000)
self.assertEqual(record['year'], 1999)
self.assertEqual(record['metadata']['bibliographic_year'], 2000)
self.assertEqual(record['metadata']['bibliography']['year'], ['2000'])
self.assertEqual(record['metadata']['historical_predecessor_evidence']['proceedings_year_fields'], ['2000'])
self.assertEqual(record['metadata']['historical_predecessor_evidence']['event_year'], 1999)
def test_old_candidates_require_new_families_only_when_requested_years_need_them(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary); row, volume = example('conf/aips/1990')
db = root/'dblp-catalogue.sqlite'
with closing(sqlite3.connect(db)) as connection, connection:
connection.execute('CREATE TABLE proceedings(key TEXT PRIMARY KEY,metadata_json TEXT)')
connection.execute('INSERT INTO proceedings VALUES(?,?)', (volume['key'],json.dumps(volume)))
candidate = root/'candidate.jsonl'; candidate.write_text(json.dumps(row)+'\n')
db.with_name('dblp-ai-candidates.manifest.json').write_text(json.dumps({
'snapshot_sha256': XML_SHA, 'candidate_sha256': hashlib.sha256(candidate.read_bytes()).hexdigest(),
'candidate_records': 1, 'candidate_family_counts': {'aips':1},
'record_types': {'proceedings':1,'inproceedings':1}}))
# Tiny CI fixtures may live on tmpfs; production reserve is tested separately.
with patch('paper_atlas.collectors.dblp_records.shutil.disk_usage', return_value=SimpleNamespace(free=16*1024**3)):
result = collect(db, root/'early', ['ICAPS'], candidates=candidate, through_year=1990)
self.assertEqual((result['normalized_records'],result['files'][0]['venue']), (1,'EPS'))
for year, missing in [(1991,'ICAPS/ewsp'),(1997,'ICAPS/ecp')]:
out = root/str(year); out.mkdir(); sentinel=out/'previous.txt';sentinel.write_text('accepted previous output')
with self.subTest(year=year), self.assertRaisesRegex(ValueError, missing):
collect(db, out, ['ICAPS'], candidates=candidate, through_year=year)
self.assertEqual(sentinel.read_text(), 'accepted previous output')
self.assertFalse((out/'dblp-normalization.json').exists())
def test_new_catalogue_includes_previously_missing_families_without_decision_inference(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary);dtd=root/'dblp.dtd';dtd.write_bytes(b'<!ENTITY eacute "&#233;">')
archive=root/'dblp.xml.gz'
body=''.join(f'<inproceedings key="conf/{family}/Synthetic"><title>Synthetic example</title><year>{year}</year><booktitle>{book}</booktitle><crossref>conf/{family}/{year}</crossref></inproceedings>' for family,year,book in [('ecp',1997,'ECP'),('ewsp',1991,'EWSP')])
with gzip.open(archive,'wb') as stream:stream.write(('<?xml version="1.0"?><!DOCTYPE dblp SYSTEM "dblp.dtd"><dblp>'+body+'</dblp>').encode())
result=catalogue(archive,dtd,root/'out',hashlib.sha256(archive.read_bytes()).hexdigest(),hashlib.md5(dtd.read_bytes()).hexdigest())
rows=[json.loads(line)for line in Path(result['file']).read_text().splitlines()]
self.assertEqual({r['key'] for r in rows}, {'conf/ecp/Synthetic','conf/ewsp/Synthetic'})
self.assertEqual(result['candidate_records'], 2)
self.assertTrue(all('status' not in r and 'venue' not in r for r in rows))
def test_native_import_keeps_original_series_aliases_and_full_source_evidence(self):
with tempfile.TemporaryDirectory() as temporary:
root=Path(temporary);db=root/'sample.sqlite'
for key in ['conf/ecml/1988','conf/ecp/1999']:
record=self.normalize(key);path=root/(key.replace('/','-')+'.jsonl')
path.write_text(json.dumps(record,ensure_ascii=False)+'\n')
path.with_suffix('.manifest.json').write_text(json.dumps({'source':'dblp','venue':record['venue'],'year':record['year'],'track':'main','count':1,'list_url':'https://doi.org/10.4230/dblp.xml.2026-11-01','file_sha256':hashlib.sha256(path.read_bytes()).hexdigest(),'status':'partial','missing_abstract_count':1}))
ingest_file(db,path)
with closing(sqlite3.connect(db)) as connection:
rows=connection.execute('SELECT v.acronym,a.alias,e.year FROM editions e JOIN venues v ON v.id=e.venue_id JOIN venue_aliases a ON a.venue_id=v.id ORDER BY v.acronym').fetchall()
self.assertEqual(rows,[('ECP','ICAPS',1999),('EWSL','ECML-PKDD',1988)])
metadata=[json.loads(r[0])for r in connection.execute('SELECT metadata_json FROM source_records')]
self.assertEqual({r['historical_predecessor_evidence']['original_series'] for r in metadata}, {'ECP','EWSL'})
self.assertEqual(connection.execute('SELECT count(*) FROM decisions WHERE status=?',('accepted',)).fetchone()[0],2)
self.assertEqual(connection.execute('PRAGMA foreign_key_check').fetchall(),[])
def test_existing_aips_normalization_remains_original_aips(self):
row, volume=example('conf/aips/1990');row['fields']['booktitle']=['AIPS'];row['fields']['year']=['1994'];row['fields']['crossref']=['conf/aips/1994']
volume['key']='conf/aips/1994';volume['fields']['title']=['AIPS 1994 Proceedings'];volume['fields']['year']=['1994']
record,reason=normalize_row(row,{volume['key']:volume},{'ICAPS'})
self.assertIsNone(reason);self.assertEqual((record['venue'],record['venue_aliases']),('AIPS',['ICAPS']))
self.assertNotIn('historical_predecessor_evidence',record['metadata'])
+115
View File
@@ -0,0 +1,115 @@
"""Regression coverage for primary series once named Workshop, with strict scope boundaries."""
from contextlib import closing
import hashlib
import json
from pathlib import Path
import sqlite3
import tempfile
import unittest
from paper_atlas.collectors.dblp_records import normalize_row
from paper_atlas.collectors.dblp_scope import scope
from paper_atlas.ingestion.records import ingest_file
ICML_HISTORY = 'https://icml.cc/Conferences/2008/past_icmls.shtml.html'
IROS_HISTORY = 'https://www.ieee-ras.org/conferences-workshops/financially-co-sponsored/iros/iros-past-and-future-venues/'
class HistoricalPrimarySeriesTests(unittest.TestCase):
def example(self, family, year, title, book=None, key=None):
key = key or f'conf/{family}/{year}'
book = book or ('ML' if family == 'icml' else 'IROS')
row = {'record_type':'inproceedings', 'key':f'conf/{family}/Paper{year}', 'publtype':None,
'fields':{'title':['An independently identified historical research paper'],
'author':['Jane Doe'], 'booktitle':[book], 'year':[str(year)],
'crossref':[key], 'ee':[f'https://doi.org/10.1234/{family}.{year}']}}
volume = {'record_type':'proceedings', 'key':key,
'fields':{'title':[title], 'year':[str(year)]}}
return row, volume
def test_icml_workshop_primary_editions_are_not_silently_filtered(self):
examples = {
1989:'Proceedings of the Sixth International Workshop on Machine Learning (ML 1989), Cornell University, Ithaca, New York, USA, June 26-27, 1989',
1991:'Proceedings of the Eighth International Workshop (ML91), Northwestern University, Evanston, Illinois, USA',
1992:'Proceedings of the Ninth International Workshop on Machine Learning (ML 1992), Aberdeen, Scotland, UK, July 1-3, 1992',
}
for year, title in examples.items():
with self.subTest(year=year):
row, volume = self.example('icml', year, title)
record, reason = normalize_row(row, {volume['key']:volume}, {'ICML'})
self.assertIsNone(reason)
self.assertEqual((record['venue'],record['year'],record['track'],record['status']), ('ICML',year,'main','accepted'))
self.assertEqual(record['metadata']['proceedings_title'], title)
self.assertEqual(record['metadata']['track_scope_evidence_url'], ICML_HISTORY)
self.assertEqual(record['metadata']['bibliographic_year'], year)
def test_iros_first_three_editions_are_primary_series(self):
examples = {
1988:"IEEE International Workshop on Intelligent Robots and Systems '88, Proceedings. IROS 1988, Tokyo, Japan, October 31 - November 2, 1988",
1989:"IEEE International Workshop on Intelligent Robots and Systems '89, Proceedings. IROS 1989, September 4-6, 1989, Tsukuba, Japan",
1990:"IEEE International Workshop on Intelligent Robots and Systems '90, Towards a New Frontier of Applications, Proceedings. IROS 1990, July 3-6, 1990, Ibaraki, Japan",
}
for year, title in examples.items():
with self.subTest(year=year):
row, volume = self.example('iros', year, title)
record, reason = normalize_row(row, {volume['key']:volume}, {'IROS'})
self.assertIsNone(reason)
self.assertEqual((record['venue'],record['year'],record['track']), ('IROS',year,'main'))
self.assertEqual(record['metadata']['proceedings_title'], title)
self.assertEqual(record['metadata']['track_scope_evidence_url'], IROS_HISTORY)
def test_main_named_icml_predecessors_also_carry_primary_scope_provenance(self):
examples = {
1988:'Machine Learning, Proceedings of the Fifth International Conference on Machine Learning, Ann Arbor, Michigan, USA, June 12-14, 1988',
1990:'Machine Learning, Proceedings of the Seventh International Conference on Machine Learning, Austin, Texas, USA, June 21-23, 1990',
}
for year, title in examples.items():
row, volume = self.example('icml', year, title)
edition, reason = scope(row, volume)
self.assertIsNone(reason)
self.assertEqual(edition['track'], 'main')
self.assertEqual(edition['track_scope_evidence_url'], ICML_HISTORY)
def test_wrong_keys_titles_suffixes_and_unknown_years_keep_workshop_boundary(self):
title = 'Proceedings of the Eighth International Workshop (ML91), Northwestern University, Evanston, Illinois, USA'
for year, changed, book, key in [
(1991,title,'ML','conf/icml/1991w'),
(1991,title,'ML (Workshops)','conf/icml/1991'),
(1991,title.replace('Eighth','Ninth'),'ML','conf/icml/1991'),
(1991,title + ', Affiliated Workshop','ML','conf/icml/1991'),
(1987,'Machine Learning Workshop 1987, Irvine','ML','conf/icml/1987'),
]:
with self.subTest(year=year, book=book, key=key, title=changed):
row, volume = self.example('icml',year,changed,book,key)
record, reason = normalize_row(row,{volume['key']:volume},{'ICML'})
self.assertIsNone(record)
self.assertEqual(reason,'separate_workshop')
self.assertNotIn('track_scope_evidence_url',scope(row,volume)[0])
title = "IEEE International Workshop on Intelligent Robots and Systems '88, Proceedings. IROS 1988, Tokyo, Japan, October 31 - November 2, 1988"
for changed, book, key in [
(title,'IROS','conf/iros/1988w'),
(title,'IROS (Workshops)','conf/iros/1988'),
(title.replace('Tokyo','Another city'),'IROS','conf/iros/1988'),
]:
row, volume = self.example('iros',1988,changed,book,key)
self.assertEqual(normalize_row(row,{volume['key']:volume},{'IROS'})[1],'separate_workshop')
def test_primary_scope_proof_survives_native_file_ingestion(self):
title = 'Proceedings of the Eighth International Workshop (ML91), Northwestern University, Evanston, Illinois, USA'
row, volume = self.example('icml',1991,title)
record, reason = normalize_row(row,{volume['key']:volume},{'ICML'})
self.assertIsNone(reason)
with tempfile.TemporaryDirectory() as temporary:
root=Path(temporary);path=root/'records.jsonl';path.write_text(json.dumps(record)+'\n')
path.with_suffix('.manifest.json').write_text(json.dumps({'source':'dblp','count':1,'list_url':ICML_HISTORY,'file_sha256':hashlib.sha256(path.read_bytes()).hexdigest()}))
ingest_file(root/'test.sqlite',path)
with closing(sqlite3.connect(root/'test.sqlite')) as connection:
self.assertEqual(connection.execute('SELECT track FROM editions').fetchone()[0],'main')
self.assertEqual(connection.execute('SELECT status FROM decisions').fetchone()[0],'accepted')
metadata=json.loads(connection.execute('SELECT metadata_json FROM source_records').fetchone()[0])
self.assertEqual(metadata['proceedings_title'],title)
self.assertEqual(metadata['track_scope_evidence_url'],ICML_HISTORY)
if __name__ == '__main__':
unittest.main()