Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5c78a36fd5 | |||
| 7facde6c94 | |||
| c06981b244 | |||
| b226b3b98b | |||
| 05ada8daf8 | |||
| 13408869c2 | |||
| 443a79dce2 | |||
| ec9edd88d4 | |||
| b5c0618be5 | |||
| 81864e48ae | |||
| 8ad052f577 | |||
| 348ebe0695 | |||
| aa696bd7b5 | |||
| 90bd534edd | |||
| 02a408ba8f | |||
| 714f745bd5 | |||
| cc78e837d2 | |||
| c0b2146ae8 | |||
| ae88367fac | |||
| a305d00c75 | |||
| 57fcfc7693 | |||
| a07b1e42a8 | |||
| 7c6f4c9f1d | |||
| 3f0ac694a0 | |||
| 32c7edd0a5 | |||
| 658d799334 | |||
| 53fbe9c75c | |||
| dac6edbe1e | |||
| 7a674ac797 | |||
| eacc1e3292 | |||
| 2275bc7530 | |||
| d2c016e309 | |||
| ea3393144d | |||
| 7035cf33b9 | |||
| 60726a8c6b | |||
| a1bbd26a5c | |||
| 90f3fb213e | |||
| a68e36907f | |||
| 9040649320 | |||
| 64ced7af42 |
@@ -12,7 +12,7 @@ jobs:
|
||||
- name: Verify metadata storage and query commands
|
||||
run: |
|
||||
python3 -B - <<'PY'
|
||||
import io, os, pathlib, re, subprocess, tarfile, tempfile, urllib.request
|
||||
import gzip, io, json, os, pathlib, re, subprocess, tarfile, tempfile, urllib.request
|
||||
sha = os.environ['ARCHIVE_SHA']
|
||||
if not re.fullmatch(r'[0-9a-fA-F]{40,64}', sha):
|
||||
raise SystemExit('Invalid source commit')
|
||||
@@ -48,7 +48,25 @@ 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)
|
||||
subprocess.run([python,'-I','-B','-m','paper_atlas','collect','www-archive','--help'],cwd=outside,check=True)
|
||||
subprocess.run([python,'-I','-B','-m','paper_atlas','collect','aamas-proceedings','--help'],cwd=outside,check=True)
|
||||
subprocess.run([python,'-I','-B','-m','paper_atlas','collect','aamas-proceedings','--year','2025','--snapshots',str(src/'tests/fixtures/aamas2025'),'--out',str(root/'installed-aamas2025')],cwd=outside,check=True)
|
||||
subprocess.run([python,'-I','-B','-m','paper_atlas','collect','iclr-archive','--help'],cwd=outside,check=True)
|
||||
subprocess.run([python,'-I','-B','-m','paper_atlas','collect','acl','--help'],cwd=outside,check=True)
|
||||
subprocess.run([python,'-I','-B','-m','paper_atlas','collect','springer-selected','--help'],cwd=outside,check=True)
|
||||
springer = json.loads(gzip.decompress((src/'tests/fixtures/springer-aistats1993-fixture.json.gz').read_bytes()))
|
||||
for group in ('toc','chapters'):
|
||||
folder = root/'springer-snapshots'/group
|
||||
folder.mkdir(parents=True)
|
||||
for name, content in springer[group].items():
|
||||
(folder/name).write_text(content)
|
||||
subprocess.run([python,'-I','-B','-m','paper_atlas','collect','springer-selected','--toc-snapshots',str(root/'springer-snapshots/toc'),'--chapter-snapshots',str(root/'springer-snapshots/chapters'),'--out',str(root/'installed-springer')],cwd=outside,check=True)
|
||||
springer_report = json.loads((root/'installed-springer/collection-report.json').read_bytes())
|
||||
assert springer_report['records'] == 49 and not springer_report['database_written']
|
||||
springer_manifest, = springer_report['files']
|
||||
assert springer_manifest['status'] == 'partial' and springer_manifest['missing_abstract_count'] == 0
|
||||
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)
|
||||
subprocess.run([python,'-I','-B','-m','paper_atlas','maintain','event-identity','--help'],cwd=outside,check=True)
|
||||
subprocess.run([python,'-I','-B','-m','paper_atlas','import','records','--help'],cwd=outside,check=True)
|
||||
|
||||
@@ -6,8 +6,14 @@ Paper Atlas 将 arXiv、AI 会议论文集及公开投稿决定整理到可追
|
||||
|
||||
项目仍在建设中。候选范围包含 43 个会议(含 ACM MM、MLSys),尚未完成全部历史数据和最终去重复核。实际进度以数据库、来源清单和覆盖报告为准,而不是仓库文件数量。
|
||||
|
||||
历史 ICLR 2014–2016 官方录用页由 `collect iclr-archive` 适配,保留主会/workshop及重复展示证据;运行和身份核验边界见 [历史档案说明](docs/coverage/iclr-archive-2014-2016.md)。
|
||||
|
||||
`collect springer-selected` 核验限定的会后选集,分开保存会议年份、出版年份及选集事件身份。目前支持 AISTATS 1993 对应的 1994 年出版选集,原始会议决定仍须单独核查;配置、证据和恢复见 [会后选集说明](docs/springer-selected-volumes.md)。
|
||||
|
||||
## 项目结构
|
||||
|
||||
ACL 1978 的年会与 TINLAP-2 来源关系按精确卷适配,保留原系列和不明逐篇角色;1965 的替代关系不转成全局别名。来源、排除条目和恢复边界见 [ACL 早期来源核查](docs/coverage/acl-early-sources-2026-11-09.md)。
|
||||
|
||||
```text
|
||||
paper_atlas/
|
||||
cli.py 统一命令入口
|
||||
@@ -60,6 +66,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、2025 版式,可从缓存或已校验快照采集元数据。2025 年目录含 476 条非 keynote 出版物,保留完整作者和机构,HTML 未提供摘要时留空;发表形式与投稿 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` 写入主库,整个写入会话只允许一个协调写入者,会议文件失败时整体回滚并保留失败批次记录。
|
||||
@@ -88,8 +100,11 @@ CI 在已授权的远程 runner 上运行测试、构建 wheel,并从源码目
|
||||
|
||||
开发通过工作分支和 PR 交付,由用户手动合并。
|
||||
|
||||
早期WWW档案的可重放元数据采集、论文/海报分轨与待核查条目见 [WWW档案采集](docs/www-archive.md)。
|
||||
历史联合会议的既有届次归属可通过 [`maintain joint-editions`](docs/joint-editions.md) 先只读规划,再按计划哈希执行;保留论文与投稿决定来源,不重导入。
|
||||
同年多届的 `event_key`、显式迁移、旧文件刷新与查询边界见 [会议事件身份](docs/event-identity.md)。
|
||||
研究类型与自定义标签可通过 [`annotate`](docs/annotations.md) 显式初始化、添加和撤回;两者独立查询,保留证据及完整更正历史,不从会议轨道推断研究类型。
|
||||
|
||||
整合后的运行版本依赖、交互约束和生产切换顺序见 [运行版本整合](docs/research-runtime.md)。
|
||||
|
||||
已导入整卷的逐条轨道修正见 [edition-tracks 维护流程](docs/edition-tracks.md):按完整证据生成只读计划,保留原投稿和决定 ID,事务性修正 edition、标识与覆盖,并防止旧整卷刷新覆盖分类。
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# AAMAS 官方论文集元数据
|
||||
|
||||
`paper_atlas.collectors.aamas` 是按需运行的采集适配器,通过统一入口 `collect aamas-proceedings` 使用。支持已独立核验的 **AAMAS 2012、2025** HTML 版式;其他年份需先核对官方目录和逐篇结构,再增加适配,不能仅替换年份。2025 解析单独封装在 `paper_atlas.collectors.aamas2025`。
|
||||
|
||||
2025 版式使用[官方首页](https://www.ifaamas.org/Proceedings/aamas2025/)和[出版目录](https://www.ifaamas.org/Proceedings/aamas2025/forms/contents.htm)。原始目录共 479 条:3 keynote、260 research paper、148 extended abstract、12 Blue Sky、25 doctoral、22 Demo 栏目、9 JAAMAS。排除 keynote,输出 476 条;Demo 栏目中的 p3000 是明确标题的 COMP24 竞赛报告,发表年份仍为 2025,格式记 `competition_report`、投稿 track 留作 `proceedings_unspecified`,保留原 Demo 栏目证据。其余输出按 track 分组为 409 unspecified、12 blue_sky、25 doctoral、21 demo、9 jaamas。
|
||||
|
||||
作者按逐篇 BR 行解析,斜体机构原文另存,保留 Unicode、顺序及完整名单。HTML 没有提供摘要或逐篇 DOI,故 `abstract=null`、标识字典为空;只保存 PDF 链接,不下载研究 PDF。不能把 Research Paper Track 的所有条目推成 main,不能把目录缺席推为 rejected/withdrawn,也不能把出版格式标签当研究类型。
|
||||
|
||||
2025 用法:`paper-atlas collect aamas-proceedings --year 2025 --snapshots /data/review/aamas2025 --out /data/paper-index/collected/aamas-2025`;在线模式改用 `--cache /data/paper-index/cache/aamas2025`,只请求上述两个 HTML 页面。快照目录包含原始 `index.html`、`contents.html` 和 `receipts.json`,凭据必须列出各原始 URL、文件、字节数、SHA256、`http_status=200` 和带时区 `at`。已恢复的凭据需保留 `at_precision=filesystem_write_time_recovered` 等说明,不能伪造 HTTP 头或请求时间。2026-11-09 保存的两个页面通过哈希校验;独立录用清单 URL 返回 403,失败保留,不绕过或依赖其完成采集。
|
||||
|
||||
2025 命令同样使用目录锁、临时目录、最后写入采集报告。五个 JSONL/manifest 均保持 `partial`,记录摘要缺失数量;它不连接数据库。首次采集需独立核验全部标题/作者/栏目和 source IDs,随后做 PostgreSQL 身份预检、必要的逐条版本审核、原生导入与独立验收。若失败且没有完成报告,应先检查原始响应和错误,再使用新输出目录重跑,不重放已提交批次。代码验证、原始目录盘点和采集成功均不代表已导入生产或最终去重完成。
|
||||
|
||||
## 来源与范围
|
||||
|
||||
[官方论文集介绍](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 已导入生产,也不证明拒稿全集完整。全库最终去重、摘要和来源覆盖验收属于独立后续阶段。
|
||||
@@ -188,3 +188,9 @@ MM 2026 的[官方 CFP](https://2026.acmmm.org/site/cfp-guidelines.html)说明
|
||||
WWW早期逐来源核查见 `www-early-proceedings-2026-11-08.json`:1995春季论文三目录均为38个相同条目ID,海报摘要53个条目与作者链接47个条目不齐;1994春季50个Title字段与主页49场报告说法待逐项核对。上述为源目录观察,不是已导入或全部录用数量;主会、海报、教程等继续独立核验。
|
||||
|
||||
WWW事件标识生产迁移已独立验收(报告SHA `f5dbb8a8`):保留1399个原届次字段及IDs、59个Fall投稿和完整全库历史哈希。服务器已缓存46个官方HTML来源(约223KB),只作为后续维护式采集器的输入;尚未补导早期WWW论文,也未启用可选注解表。
|
||||
|
||||
IJCAI 联办届次的逐条后续核查见 [2018/2022 分区与目录差集](ijcai-sections-2026-11-08.md) 和 [稳定 ID 映射](ijcai-sections-2026-11-08.json)。已定位 345 条非 main 分类及 2022 官方 HTML 缺失但官方文件仍存在的一条记录;生产分类尚未修正,不能把整卷数量作为 main 覆盖率。
|
||||
|
||||
2026年11月9日的[当前历史年度缺口核对](historical-gaps-2026-11-09.md)单列135个历史会议—年份、14个2026年度及85个经范围修正的初步候选来源年度;它使用当前生产edition/别名快照,保留原固定快照不变。年度有记录不等于逐篇、逐track或公开拒稿历史完整。
|
||||
|
||||
[早期NLP来源范围复核](historical-source-scope-2026-11-09.md)保留3份官方网页的响应哈希,排除4个COLING卷对ACL缺口的误匹配,并区分会议举办记录、目录可获取性与论文级覆盖;尚未导入或关闭这些年度缺口。
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
# ACL 早期年份:会议存在、出版目录与逐篇资料分开核验
|
||||
|
||||
固定普查中 ACL 1963–1978 仍有年份缺口。不能因为会议年表连续,就假设每年都有独立的正式论文集;也不能因为当前目录始于 1979,就断言此前没有摘要、通知或其他公开资料。本次不修改固定的 1,266 个目标年份基准。
|
||||
|
||||
## 1978 年:限定 T78-1 的来源关系
|
||||
|
||||
[同期年会通知](https://aclanthology.org/J77-4001.pdf)的 PDF 第 9–10 页(印刷页码 9–10)明确说明,1978 年 ACL 年会采用第二届 Theoretical Issues in Natural Language Processing 的形式,地点为 Urbana/Champaign,日期为 7 月 25–27 日。通知中的 ACM 联合赞助确认仍处于待确认状态,不能将该预告改写为已完成的行政决定。
|
||||
|
||||
[1982 年 Donald E. Walker 的历史回顾](https://aclanthology.org/www.mt-archive.info/70/ACL-1982-Walker-1.pdf)第 3 页(印刷页码 91)的年会表及缩写说明,独立对应了 1978 年的时间、地点与 TNLP/TINLAP-2 关系。两份扫描件相关页已渲染并逐页目视核对,不仅依靠 OCR。
|
||||
|
||||
[官方卷目录](https://aclanthology.org/volumes/T78-1/)与固定 XML `T78.xml` 保留原系列名 TINLAP、完整卷名、1978 年及编辑 David L. Waltz。适配仅接受精确的 collection `T78`、volume `1` 和上述字段组合;关键元数据变化会拒绝处理,等待重新核查。
|
||||
|
||||
- 规范事件名使用 `ACL+TINLAP`,原系列名 `TINLAP`、原始 XML 的 `venue_codes: [tinlap]` 及证据均保留。
|
||||
- ACL/TINLAP 别名只挂在这一规范事件系列上,不把所有 TINLAP 年份都算成 ACL。T75、T87 及 T78 其他卷没有此例外。
|
||||
- 通知描述了邀请与提交论文两种参与方式,XML 没有逐篇角色。统一暂存为 `proceedings_unspecified`,不猜测每篇属于 invited、main 或 workshop。
|
||||
- XML 共 36 条条目,其中 T78-1036 是无作者的场次讨论问题列表。精确识别并在 manifest 的 `excluded_materials` 保留其标识、标题与原因;其余 35 条作为具名贡献整理。该排除不推广到所有无作者条目,已识别条目发生变化也须重新核查。
|
||||
- 全部 35 条 XML 均无摘要。出版目录中的 accepted 是来源观察;通知提及录用/不录用通知时间,不提供任何可识别的拒稿决定,不能制造 rejected 或 withdrawn。
|
||||
|
||||
采集器仅生成元数据文件,不写业务数据库。真实来源逐条验收、PostgreSQL 身份预检、串行导入与独立验收仍是后续门槛。35 条来源不等于 35 个新增论文实体,更不等于完成全库去重。
|
||||
|
||||
## 1965 年:替代关系不改写为别名
|
||||
|
||||
Walker 回顾第 1 页(印刷页码 89)说明,1965 年 ICCL(后来称 COLING)替代了当年的年会。[官方 C65-1 目录](https://aclanthology.org/volumes/C65-1/)及 XML 仍以 COLING 标记该卷。这是限定年份的历史关系,不证明 COLING 与 ACL 是两个可全局互换的名字。
|
||||
|
||||
本次保留 C65 原有 `COLING` 归属,不添加 ACL 全局别名、不重放已导入记录、不将某一年的日历关系推广到整个系列,也不据此悄悄减少固定普查缺口。其他 1963–1977 年资料仍须逐年核查;本次不能证明这些年份整体属于非归档例外。
|
||||
|
||||
## 固定来源与恢复
|
||||
|
||||
服务器证据目录:`/data/paper-index/collected/acl-early-source-evidence-v1`。五份官方响应均 HTTP 200,另有两份来自既有官方 XML checkout 的文件,已与 Git 对象逐字节核对。未下载论文全文集合;两份 PDF 是本次历史/行政取证资料,不进入论文全文库。
|
||||
|
||||
| 文件 | SHA256 |
|
||||
|---|---|
|
||||
| `acl-venue.html` | `c60f64cb4d72e677aeb84a5844cde28a8d50579e1995f08e8c7801eed1e3fac5` |
|
||||
| `acl-history-1982.pdf` | `35c21bd4983975bcaccbb94bd8ab91c9019fc190feec1791255407cf18a7e49a` |
|
||||
| `acl-1978-notice.pdf` | `14b5deb471c55afb07d789529b95d0462a2a1a77df3dfba7fa1f5d3ab75eb411` |
|
||||
| `tinlap1978-volume.html` | `7e20d709f72f415819e4d17e3674b19dc7d871eabcf0cf8b4935f0392d775729` |
|
||||
| `coling1965-volume.html` | `0d2f2f49f2a311f9c0b80a8832f6476eb32f94f35abf36f6d2f2e10063ac880f` |
|
||||
| `T78.xml` | `f712f94cff0cba05f786b7ac31dad2ea9408fde2a17a04531130df623f57dd84` |
|
||||
| `C65.xml` | `572791610e20b7c6045d984d968e73a18a144bcb5c3da64112001c781826808e` |
|
||||
|
||||
XML commit:`a7be623d01c48c61405dec31c871121cdbed5a01`。测试 fixture `tests/fixtures/acl-t78.xml` 是该原始文件,不是手工模拟论文;不包含 PDF。PDF 哈希建立完整性,不单独证明语义,语义依据是上述具体页面。
|
||||
|
||||
在服务器使用既有命令,固定 checkout 并输出到新目录:
|
||||
|
||||
```sh
|
||||
paper-atlas collect acl \
|
||||
--checkout /data/paper-index/raw/metadata-checkouts/acl-anthology \
|
||||
--collection T78 \
|
||||
--out /data/paper-index/collected/acl-t78-source-v1
|
||||
```
|
||||
|
||||
该命令会校验/更新官方 metadata checkout,实际 commit 必须以生成清单为准;严格重现本次取证时,使用已冻结的 XML 和上述 commit,不把更新后的内容冒充旧快照。不要将无 collection 限制的全部历史输出直接作为增量队列。采集完成不代表导入完成,恢复前先检查当前单元和报告,不因日志安静重启已执行的写入。
|
||||
@@ -0,0 +1,867 @@
|
||||
{
|
||||
"snapshot_at": "2026-11-08 16:34:40.741334+00:00",
|
||||
"scope": "Official first-pass conference-year census vs current populated editions and real aliases. Missing year means no imported attempt in that year, not missing paper count or proof all other years complete. Includes announced/nonarchival exceptions below.",
|
||||
"venues": [
|
||||
{
|
||||
"venue": "NeurIPS",
|
||||
"expected_years": 40,
|
||||
"observed_expected_years": 39,
|
||||
"missing_years": [
|
||||
2026
|
||||
],
|
||||
"missing_count": 1,
|
||||
"canonical_matches": [
|
||||
"NeurIPS"
|
||||
]
|
||||
},
|
||||
{
|
||||
"venue": "ICML",
|
||||
"expected_years": 43,
|
||||
"observed_expected_years": 14,
|
||||
"missing_years": [
|
||||
1980,
|
||||
1983,
|
||||
1985,
|
||||
1987,
|
||||
1988,
|
||||
1989,
|
||||
1990,
|
||||
1991,
|
||||
1992,
|
||||
1993,
|
||||
1994,
|
||||
1995,
|
||||
1996,
|
||||
1997,
|
||||
1998,
|
||||
1999,
|
||||
2000,
|
||||
2001,
|
||||
2002,
|
||||
2003,
|
||||
2004,
|
||||
2005,
|
||||
2006,
|
||||
2007,
|
||||
2008,
|
||||
2009,
|
||||
2010,
|
||||
2011,
|
||||
2012
|
||||
],
|
||||
"missing_count": 29,
|
||||
"canonical_matches": [
|
||||
"ICML"
|
||||
]
|
||||
},
|
||||
{
|
||||
"venue": "ICLR",
|
||||
"expected_years": 14,
|
||||
"observed_expected_years": 11,
|
||||
"missing_years": [
|
||||
2014,
|
||||
2015,
|
||||
2016
|
||||
],
|
||||
"missing_count": 3,
|
||||
"canonical_matches": [
|
||||
"ICLR"
|
||||
]
|
||||
},
|
||||
{
|
||||
"venue": "COLM",
|
||||
"expected_years": 3,
|
||||
"observed_expected_years": 3,
|
||||
"missing_years": [],
|
||||
"missing_count": 0,
|
||||
"canonical_matches": [
|
||||
"COLM"
|
||||
]
|
||||
},
|
||||
{
|
||||
"venue": "COLT",
|
||||
"expected_years": 39,
|
||||
"observed_expected_years": 39,
|
||||
"missing_years": [],
|
||||
"missing_count": 0,
|
||||
"canonical_matches": [
|
||||
"COLT"
|
||||
]
|
||||
},
|
||||
{
|
||||
"venue": "AISTATS",
|
||||
"expected_years": 29,
|
||||
"observed_expected_years": 25,
|
||||
"missing_years": [
|
||||
1985,
|
||||
1989,
|
||||
1991,
|
||||
1993
|
||||
],
|
||||
"missing_count": 4,
|
||||
"canonical_matches": [
|
||||
"AISTATS"
|
||||
]
|
||||
},
|
||||
{
|
||||
"venue": "UAI",
|
||||
"expected_years": 42,
|
||||
"observed_expected_years": 42,
|
||||
"missing_years": [],
|
||||
"missing_count": 0,
|
||||
"canonical_matches": [
|
||||
"UAI"
|
||||
]
|
||||
},
|
||||
{
|
||||
"venue": "CLeaR",
|
||||
"expected_years": 5,
|
||||
"observed_expected_years": 5,
|
||||
"missing_years": [],
|
||||
"missing_count": 0,
|
||||
"canonical_matches": [
|
||||
"CLEAR"
|
||||
]
|
||||
},
|
||||
{
|
||||
"venue": "ACL",
|
||||
"expected_years": 64,
|
||||
"observed_expected_years": 48,
|
||||
"missing_years": [
|
||||
1963,
|
||||
1964,
|
||||
1965,
|
||||
1966,
|
||||
1967,
|
||||
1968,
|
||||
1969,
|
||||
1970,
|
||||
1971,
|
||||
1972,
|
||||
1973,
|
||||
1974,
|
||||
1975,
|
||||
1976,
|
||||
1977,
|
||||
1978
|
||||
],
|
||||
"missing_count": 16,
|
||||
"canonical_matches": [
|
||||
"ACL",
|
||||
"ACL+COLING",
|
||||
"ACL+EACL"
|
||||
]
|
||||
},
|
||||
{
|
||||
"venue": "EMNLP",
|
||||
"expected_years": 31,
|
||||
"observed_expected_years": 31,
|
||||
"missing_years": [],
|
||||
"missing_count": 0,
|
||||
"canonical_matches": [
|
||||
"CoNLL+EMNLP",
|
||||
"EMNLP"
|
||||
]
|
||||
},
|
||||
{
|
||||
"venue": "NAACL",
|
||||
"expected_years": 18,
|
||||
"observed_expected_years": 18,
|
||||
"missing_years": [],
|
||||
"missing_count": 0,
|
||||
"canonical_matches": [
|
||||
"NAACL"
|
||||
]
|
||||
},
|
||||
{
|
||||
"venue": "EACL",
|
||||
"expected_years": 19,
|
||||
"observed_expected_years": 19,
|
||||
"missing_years": [],
|
||||
"missing_count": 0,
|
||||
"canonical_matches": [
|
||||
"ACL+EACL",
|
||||
"EACL"
|
||||
]
|
||||
},
|
||||
{
|
||||
"venue": "COLING",
|
||||
"expected_years": 31,
|
||||
"observed_expected_years": 28,
|
||||
"missing_years": [
|
||||
1971,
|
||||
1976,
|
||||
1978
|
||||
],
|
||||
"missing_count": 3,
|
||||
"canonical_matches": [
|
||||
"ACL+COLING",
|
||||
"COLING",
|
||||
"COLING+LREC"
|
||||
]
|
||||
},
|
||||
{
|
||||
"venue": "CoNLL",
|
||||
"expected_years": 30,
|
||||
"observed_expected_years": 30,
|
||||
"missing_years": [],
|
||||
"missing_count": 0,
|
||||
"canonical_matches": [
|
||||
"CoNLL",
|
||||
"CoNLL+EMNLP"
|
||||
]
|
||||
},
|
||||
{
|
||||
"venue": "CVPR",
|
||||
"expected_years": 39,
|
||||
"observed_expected_years": 14,
|
||||
"missing_years": [
|
||||
1983,
|
||||
1985,
|
||||
1986,
|
||||
1988,
|
||||
1989,
|
||||
1991,
|
||||
1992,
|
||||
1993,
|
||||
1994,
|
||||
1996,
|
||||
1997,
|
||||
1998,
|
||||
1999,
|
||||
2000,
|
||||
2001,
|
||||
2003,
|
||||
2004,
|
||||
2005,
|
||||
2006,
|
||||
2007,
|
||||
2008,
|
||||
2009,
|
||||
2010,
|
||||
2011,
|
||||
2012
|
||||
],
|
||||
"missing_count": 25,
|
||||
"canonical_matches": [
|
||||
"CVPR"
|
||||
]
|
||||
},
|
||||
{
|
||||
"venue": "ICCV",
|
||||
"expected_years": 20,
|
||||
"observed_expected_years": 7,
|
||||
"missing_years": [
|
||||
1987,
|
||||
1988,
|
||||
1990,
|
||||
1993,
|
||||
1995,
|
||||
1998,
|
||||
1999,
|
||||
2001,
|
||||
2003,
|
||||
2005,
|
||||
2007,
|
||||
2009,
|
||||
2011
|
||||
],
|
||||
"missing_count": 13,
|
||||
"canonical_matches": [
|
||||
"ICCV"
|
||||
]
|
||||
},
|
||||
{
|
||||
"venue": "ECCV",
|
||||
"expected_years": 19,
|
||||
"observed_expected_years": 4,
|
||||
"missing_years": [
|
||||
1990,
|
||||
1992,
|
||||
1994,
|
||||
1996,
|
||||
1998,
|
||||
2000,
|
||||
2002,
|
||||
2004,
|
||||
2006,
|
||||
2008,
|
||||
2010,
|
||||
2012,
|
||||
2014,
|
||||
2016,
|
||||
2026
|
||||
],
|
||||
"missing_count": 15,
|
||||
"canonical_matches": [
|
||||
"ECCV"
|
||||
]
|
||||
},
|
||||
{
|
||||
"venue": "AAAI",
|
||||
"expected_years": 40,
|
||||
"observed_expected_years": 40,
|
||||
"missing_years": [],
|
||||
"missing_count": 0,
|
||||
"canonical_matches": [
|
||||
"AAAI",
|
||||
"AAAI+IAAI"
|
||||
]
|
||||
},
|
||||
{
|
||||
"venue": "IJCAI",
|
||||
"expected_years": 35,
|
||||
"observed_expected_years": 35,
|
||||
"missing_years": [],
|
||||
"missing_count": 0,
|
||||
"canonical_matches": [
|
||||
"ECAI+IJCAI",
|
||||
"IJCAI"
|
||||
]
|
||||
},
|
||||
{
|
||||
"venue": "ECAI",
|
||||
"expected_years": 29,
|
||||
"observed_expected_years": 27,
|
||||
"missing_years": [
|
||||
1974,
|
||||
1980
|
||||
],
|
||||
"missing_count": 2,
|
||||
"canonical_matches": [
|
||||
"ECAI",
|
||||
"ECAI+IJCAI"
|
||||
]
|
||||
},
|
||||
{
|
||||
"venue": "KDD",
|
||||
"expected_years": 32,
|
||||
"observed_expected_years": 32,
|
||||
"missing_years": [],
|
||||
"missing_count": 0,
|
||||
"canonical_matches": [
|
||||
"KDD"
|
||||
]
|
||||
},
|
||||
{
|
||||
"venue": "SIGIR",
|
||||
"expected_years": 50,
|
||||
"observed_expected_years": 50,
|
||||
"missing_years": [],
|
||||
"missing_count": 0,
|
||||
"canonical_matches": [
|
||||
"SIGIR"
|
||||
]
|
||||
},
|
||||
{
|
||||
"venue": "WWW",
|
||||
"expected_years": 33,
|
||||
"observed_expected_years": 27,
|
||||
"missing_years": [
|
||||
1994,
|
||||
1996,
|
||||
1997,
|
||||
1998,
|
||||
1999,
|
||||
2000
|
||||
],
|
||||
"missing_count": 6,
|
||||
"canonical_matches": [
|
||||
"WWW"
|
||||
]
|
||||
},
|
||||
{
|
||||
"venue": "WSDM",
|
||||
"expected_years": 19,
|
||||
"observed_expected_years": 19,
|
||||
"missing_years": [],
|
||||
"missing_count": 0,
|
||||
"canonical_matches": [
|
||||
"WSDM"
|
||||
]
|
||||
},
|
||||
{
|
||||
"venue": "RecSys",
|
||||
"expected_years": 20,
|
||||
"observed_expected_years": 19,
|
||||
"missing_years": [
|
||||
2026
|
||||
],
|
||||
"missing_count": 1,
|
||||
"canonical_matches": [
|
||||
"RecSys"
|
||||
]
|
||||
},
|
||||
{
|
||||
"venue": "CIKM",
|
||||
"expected_years": 35,
|
||||
"observed_expected_years": 34,
|
||||
"missing_years": [
|
||||
2026
|
||||
],
|
||||
"missing_count": 1,
|
||||
"canonical_matches": [
|
||||
"CIKM"
|
||||
]
|
||||
},
|
||||
{
|
||||
"venue": "ICDM",
|
||||
"expected_years": 26,
|
||||
"observed_expected_years": 25,
|
||||
"missing_years": [
|
||||
2026
|
||||
],
|
||||
"missing_count": 1,
|
||||
"canonical_matches": [
|
||||
"ICDM"
|
||||
]
|
||||
},
|
||||
{
|
||||
"venue": "SDM",
|
||||
"expected_years": 26,
|
||||
"observed_expected_years": 25,
|
||||
"missing_years": [
|
||||
2026
|
||||
],
|
||||
"missing_count": 1,
|
||||
"canonical_matches": [
|
||||
"SDM"
|
||||
]
|
||||
},
|
||||
{
|
||||
"venue": "ECML-PKDD",
|
||||
"expected_years": 38,
|
||||
"observed_expected_years": 33,
|
||||
"missing_years": [
|
||||
1986,
|
||||
1987,
|
||||
1988,
|
||||
1989,
|
||||
1991
|
||||
],
|
||||
"missing_count": 5,
|
||||
"canonical_matches": [
|
||||
"ECML",
|
||||
"ECML-PKDD",
|
||||
"PKDD"
|
||||
]
|
||||
},
|
||||
{
|
||||
"venue": "ICRA",
|
||||
"expected_years": 43,
|
||||
"observed_expected_years": 43,
|
||||
"missing_years": [],
|
||||
"missing_count": 0,
|
||||
"canonical_matches": [
|
||||
"ICRA"
|
||||
]
|
||||
},
|
||||
{
|
||||
"venue": "IROS",
|
||||
"expected_years": 39,
|
||||
"observed_expected_years": 35,
|
||||
"missing_years": [
|
||||
1988,
|
||||
1989,
|
||||
1990,
|
||||
2026
|
||||
],
|
||||
"missing_count": 4,
|
||||
"canonical_matches": [
|
||||
"IROS"
|
||||
]
|
||||
},
|
||||
{
|
||||
"venue": "RSS",
|
||||
"expected_years": 22,
|
||||
"observed_expected_years": 20,
|
||||
"missing_years": [
|
||||
2025,
|
||||
2026
|
||||
],
|
||||
"missing_count": 2,
|
||||
"canonical_matches": [
|
||||
"RSS"
|
||||
]
|
||||
},
|
||||
{
|
||||
"venue": "CoRL",
|
||||
"expected_years": 10,
|
||||
"observed_expected_years": 9,
|
||||
"missing_years": [
|
||||
2026
|
||||
],
|
||||
"missing_count": 1,
|
||||
"canonical_matches": [
|
||||
"CORL"
|
||||
]
|
||||
},
|
||||
{
|
||||
"venue": "ICAPS",
|
||||
"expected_years": 37,
|
||||
"observed_expected_years": 28,
|
||||
"missing_years": [
|
||||
1990,
|
||||
1991,
|
||||
1992,
|
||||
1993,
|
||||
1995,
|
||||
1997,
|
||||
1999,
|
||||
2001,
|
||||
2026
|
||||
],
|
||||
"missing_count": 9,
|
||||
"canonical_matches": [
|
||||
"AIPS",
|
||||
"ICAPS"
|
||||
]
|
||||
},
|
||||
{
|
||||
"venue": "KR",
|
||||
"expected_years": 23,
|
||||
"observed_expected_years": 23,
|
||||
"missing_years": [],
|
||||
"missing_count": 0,
|
||||
"canonical_matches": [
|
||||
"KR"
|
||||
]
|
||||
},
|
||||
{
|
||||
"venue": "AAMAS",
|
||||
"expected_years": 25,
|
||||
"observed_expected_years": 22,
|
||||
"missing_years": [
|
||||
2012,
|
||||
2025,
|
||||
2026
|
||||
],
|
||||
"missing_count": 3,
|
||||
"canonical_matches": [
|
||||
"AAMAS"
|
||||
]
|
||||
},
|
||||
{
|
||||
"venue": "ICASSP",
|
||||
"expected_years": 51,
|
||||
"observed_expected_years": 49,
|
||||
"missing_years": [
|
||||
1977,
|
||||
2026
|
||||
],
|
||||
"missing_count": 2,
|
||||
"canonical_matches": [
|
||||
"ICASSP"
|
||||
]
|
||||
},
|
||||
{
|
||||
"venue": "INTERSPEECH",
|
||||
"expected_years": 39,
|
||||
"observed_expected_years": 39,
|
||||
"missing_years": [],
|
||||
"missing_count": 0,
|
||||
"canonical_matches": [
|
||||
"ECST",
|
||||
"EUROSPEECH",
|
||||
"ICSLP",
|
||||
"INTERSPEECH"
|
||||
]
|
||||
},
|
||||
{
|
||||
"venue": "ISMIR",
|
||||
"expected_years": 27,
|
||||
"observed_expected_years": 26,
|
||||
"missing_years": [
|
||||
2026
|
||||
],
|
||||
"missing_count": 1,
|
||||
"canonical_matches": [
|
||||
"ISMIR"
|
||||
]
|
||||
},
|
||||
{
|
||||
"venue": "MICCAI",
|
||||
"expected_years": 29,
|
||||
"observed_expected_years": 29,
|
||||
"missing_years": [],
|
||||
"missing_count": 0,
|
||||
"canonical_matches": [
|
||||
"MICCAI"
|
||||
]
|
||||
},
|
||||
{
|
||||
"venue": "FAccT",
|
||||
"expected_years": 9,
|
||||
"observed_expected_years": 9,
|
||||
"missing_years": [],
|
||||
"missing_count": 0,
|
||||
"canonical_matches": [
|
||||
"FAccT"
|
||||
]
|
||||
},
|
||||
{
|
||||
"venue": "MLSys",
|
||||
"expected_years": 9,
|
||||
"observed_expected_years": 8,
|
||||
"missing_years": [
|
||||
2018
|
||||
],
|
||||
"missing_count": 1,
|
||||
"canonical_matches": [
|
||||
"MLSys"
|
||||
]
|
||||
},
|
||||
{
|
||||
"venue": "MM",
|
||||
"expected_years": 34,
|
||||
"observed_expected_years": 33,
|
||||
"missing_years": [
|
||||
2026
|
||||
],
|
||||
"missing_count": 1,
|
||||
"canonical_matches": [
|
||||
"MM"
|
||||
]
|
||||
}
|
||||
],
|
||||
"expected_venue_years": 1266,
|
||||
"missing_venue_years": 150,
|
||||
"venues_with_missing_years": 25,
|
||||
"known_nonpublication_exceptions": [
|
||||
{
|
||||
"venue": "NeurIPS",
|
||||
"year": 2026,
|
||||
"reason": "announced;not in proceedings index at census"
|
||||
},
|
||||
{
|
||||
"venue": "CoRL",
|
||||
"year": 2026,
|
||||
"reason": "not in publisher proceedings index at census"
|
||||
},
|
||||
{
|
||||
"venue": "MLSys",
|
||||
"year": 2018,
|
||||
"reason": "first non-archival SysML meeting; not a missing formal publication volume"
|
||||
}
|
||||
],
|
||||
"historical_missing_years_excluding_nonarchival_2018": 135,
|
||||
"historical_venues_with_missing_years": 16,
|
||||
"historical_gaps": [
|
||||
{
|
||||
"venue": "ICML",
|
||||
"years": [
|
||||
1980,
|
||||
1983,
|
||||
1985,
|
||||
1987,
|
||||
1988,
|
||||
1989,
|
||||
1990,
|
||||
1991,
|
||||
1992,
|
||||
1993,
|
||||
1994,
|
||||
1995,
|
||||
1996,
|
||||
1997,
|
||||
1998,
|
||||
1999,
|
||||
2000,
|
||||
2001,
|
||||
2002,
|
||||
2003,
|
||||
2004,
|
||||
2005,
|
||||
2006,
|
||||
2007,
|
||||
2008,
|
||||
2009,
|
||||
2010,
|
||||
2011,
|
||||
2012
|
||||
]
|
||||
},
|
||||
{
|
||||
"venue": "ICLR",
|
||||
"years": [
|
||||
2014,
|
||||
2015,
|
||||
2016
|
||||
]
|
||||
},
|
||||
{
|
||||
"venue": "AISTATS",
|
||||
"years": [
|
||||
1985,
|
||||
1989,
|
||||
1991,
|
||||
1993
|
||||
]
|
||||
},
|
||||
{
|
||||
"venue": "ACL",
|
||||
"years": [
|
||||
1963,
|
||||
1964,
|
||||
1965,
|
||||
1966,
|
||||
1967,
|
||||
1968,
|
||||
1969,
|
||||
1970,
|
||||
1971,
|
||||
1972,
|
||||
1973,
|
||||
1974,
|
||||
1975,
|
||||
1976,
|
||||
1977,
|
||||
1978
|
||||
]
|
||||
},
|
||||
{
|
||||
"venue": "COLING",
|
||||
"years": [
|
||||
1971,
|
||||
1976,
|
||||
1978
|
||||
]
|
||||
},
|
||||
{
|
||||
"venue": "CVPR",
|
||||
"years": [
|
||||
1983,
|
||||
1985,
|
||||
1986,
|
||||
1988,
|
||||
1989,
|
||||
1991,
|
||||
1992,
|
||||
1993,
|
||||
1994,
|
||||
1996,
|
||||
1997,
|
||||
1998,
|
||||
1999,
|
||||
2000,
|
||||
2001,
|
||||
2003,
|
||||
2004,
|
||||
2005,
|
||||
2006,
|
||||
2007,
|
||||
2008,
|
||||
2009,
|
||||
2010,
|
||||
2011,
|
||||
2012
|
||||
]
|
||||
},
|
||||
{
|
||||
"venue": "ICCV",
|
||||
"years": [
|
||||
1987,
|
||||
1988,
|
||||
1990,
|
||||
1993,
|
||||
1995,
|
||||
1998,
|
||||
1999,
|
||||
2001,
|
||||
2003,
|
||||
2005,
|
||||
2007,
|
||||
2009,
|
||||
2011
|
||||
]
|
||||
},
|
||||
{
|
||||
"venue": "ECCV",
|
||||
"years": [
|
||||
1990,
|
||||
1992,
|
||||
1994,
|
||||
1996,
|
||||
1998,
|
||||
2000,
|
||||
2002,
|
||||
2004,
|
||||
2006,
|
||||
2008,
|
||||
2010,
|
||||
2012,
|
||||
2014,
|
||||
2016
|
||||
]
|
||||
},
|
||||
{
|
||||
"venue": "ECAI",
|
||||
"years": [
|
||||
1974,
|
||||
1980
|
||||
]
|
||||
},
|
||||
{
|
||||
"venue": "WWW",
|
||||
"years": [
|
||||
1994,
|
||||
1996,
|
||||
1997,
|
||||
1998,
|
||||
1999,
|
||||
2000
|
||||
]
|
||||
},
|
||||
{
|
||||
"venue": "ECML-PKDD",
|
||||
"years": [
|
||||
1986,
|
||||
1987,
|
||||
1988,
|
||||
1989,
|
||||
1991
|
||||
]
|
||||
},
|
||||
{
|
||||
"venue": "IROS",
|
||||
"years": [
|
||||
1988,
|
||||
1989,
|
||||
1990
|
||||
]
|
||||
},
|
||||
{
|
||||
"venue": "RSS",
|
||||
"years": [
|
||||
2025
|
||||
]
|
||||
},
|
||||
{
|
||||
"venue": "ICAPS",
|
||||
"years": [
|
||||
1990,
|
||||
1991,
|
||||
1992,
|
||||
1993,
|
||||
1995,
|
||||
1997,
|
||||
1999,
|
||||
2001
|
||||
]
|
||||
},
|
||||
{
|
||||
"venue": "AAMAS",
|
||||
"years": [
|
||||
2012,
|
||||
2025
|
||||
]
|
||||
},
|
||||
{
|
||||
"venue": "ICASSP",
|
||||
"years": [
|
||||
1977
|
||||
]
|
||||
}
|
||||
],
|
||||
"current_2026_gaps": 14
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
# 历史年度缺口的当前核对
|
||||
|
||||
生产只读快照:2026-11-08 16:34:40.741334 UTC(北京时间11月9日00:34),foundry_data/research。对比已核实的43份首轮官方会议年度清单,使用当前有投稿记录的edition及真实venue_aliases,不沿用旧固定快照的missing_years。
|
||||
|
||||
- 清单共1266个会议—年份位置,当前有150个位置尚无投稿记录,涉及25个目标会议。
|
||||
- 其中2025年及以前136个;排除已明确非存档的SysML2018首届,剩**135个历史年度待补采或核实,涉及16个会议**。
|
||||
- 另有14个2026年度待核实。已知NeurIPS2026和CoRL2026在首轮核查时尚未进入论文集索引,不能一律当已公开论文遗漏;其他年度也需逐个核实发布时间。
|
||||
|
||||
这些是“整年没有任何投稿记录”的数量,不是缺论文篇数,也不是完整性百分比。一个年份只有少数记录、只有公开拒稿或缺了某个track,仍会被计作“已有年度”;这些更细缺口及公开拒稿的逐年可获得性尚须继续核验。会议别名共享的联合届次不能当成两批独立论文,未见录用也不能推断拒稿。
|
||||
|
||||
| 会议 | 历史缺口年份数 |
|
||||
| --- | ---: |
|
||||
| ICML | 29 |
|
||||
| CVPR | 25 |
|
||||
| ACL | 16 |
|
||||
| ECCV | 14 |
|
||||
| ICCV | 13 |
|
||||
| ICAPS | 8 |
|
||||
| WWW | 6 |
|
||||
| ECML-PKDD | 5 |
|
||||
| AISTATS | 4 |
|
||||
| ICLR、COLING、IROS | 各3 |
|
||||
| ECAI、AAMAS | 各2 |
|
||||
| RSS、ICASSP | 各1 |
|
||||
|
||||
[完整逐年结果](historical-gaps-2026-11-09.json) 保留43目标的官方预期、现有年份数、缺失年份与规范会议匹配。原始只读生产导出保存在 `/data/paper-index/collected/historical-gap-current-20261109-v1.json`。 导出SHA256 `580cdea6dd9869adafe9bc28fb75cf3854bb290ad65aa5a600d9bc368a528b93`。年度对照清单为 `docs/coverage/inventory-2026-11-08.json`;候选来源文件绑定的输入哈希与本目录缺口JSON逐字节一致。来源清单的年份和哈希沿用相应 `*-editions-2026-11-08.json`,不是本轮重新访问所有会议网站;2026发布状态需要后续更新。
|
||||
|
||||
## 候选来源与下一步
|
||||
|
||||
另一次只读目录核查在现有、已校验的DBLP快照中,为135个历史年度中的**85个**匹配到初步候选论文集,另**50个**尚未按本轮规则匹配到,需要继续识别目录键或寻找官方来源。未匹配不等于会议没举办、未出版或没有公开来源。
|
||||
|
||||
[候选卷清单](historical-source-candidates-2026-11-09.json) 保留卷key、标题、出版年份、原始bibliographic counts和出版者链接。初筛使用目标family映射内的四位卷key年份或卷标题唯一显式年份,再按具体会议身份复核;两位键、出版年份与会议年份差异、前身系列及其他目录命名可能漏匹配。原始计数可能含workshop、前言、索引或多个卷,**不能当可直接导入的录用论文数量**。
|
||||
|
||||
后续[来源范围复核](historical-source-scope-2026-11-09.md)排除了4个误归ACL的COLING卷,因此候选年度由89修正为85、未匹配年度由46增至50。原卷及排除理由保留在JSON中;135个历史年度缺口不因此改变。ACL1963–1978与COLING1971/1976/1978在当前Anthology目录未列出,继续记为来源待核实,不能一律称作已公开但漏采的论文集。
|
||||
|
||||
优先核查已有候选卷较集中的CVPR22年、ICCV12年、ECCV14年和ICML25年。其中ICML早期workshop名称可能属于官方主系列前身,也可能是附属workshop,须对照官方届次证据逐卷区分,不能靠标题包含workshop一刀切。所有候选须经过范围、稳定标识、重复/冲突、原始字段和覆盖预检;这一步没有导入候选卷。
|
||||
|
||||
## 复核方法
|
||||
|
||||
只读获取当前有记录的edition,不连接papers大文本或标签表:
|
||||
|
||||
```sql
|
||||
WITH counts AS (
|
||||
SELECT edition_id,count(*) AS submissions
|
||||
FROM submissions GROUP BY edition_id
|
||||
)
|
||||
SELECT e.id,e.venue_id,v.acronym,e.year,e.track,e.event_key,counts.submissions
|
||||
FROM editions e JOIN venues v ON v.id=e.venue_id
|
||||
JOIN counts ON counts.edition_id=e.id ORDER BY e.id;
|
||||
|
||||
SELECT venue_id,alias FROM venue_aliases ORDER BY venue_id,alias;
|
||||
```
|
||||
|
||||
逐目标取规范会议名或别名相等的edition年份集合,与该目标edition_census.expected_years做集合差;再把2026与明确非存档的SysML2018单列。保留原始导出与校验哈希,不把每个matched year误当逐篇/逐track完整。
|
||||
|
||||
与本清单同一阶段的WWW补采只补1995年现有poster轨道,不新增缺失年度,所以即使其15条增量已验收,135这个年度计数也不会随之下降。早期1994年的50条正式展示候选、307条混合节目候选共357条仍需核实;其中并非每条都是论文。
|
||||
|
||||
本清单用于明确第14步剩余范围,不代表第14步完成,也不替代所有导入结束后的第16步全库去重、摘要、约束和实际查询验收。
|
||||
@@ -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覆盖、摘要和实际查询验收。
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,32 @@
|
||||
# 早期 NLP 来源范围复核
|
||||
|
||||
核查时间:2026-11-08 18:27 UTC(北京时间11月9日)。本轮只修正来源候选和记录可获得性,没有操作生产数据库。
|
||||
|
||||
## 跨会议候选修正
|
||||
|
||||
先前候选清单将 `conf/coling/1965`、`1967`、`1969`、`1973` 匹配到 ACL 年会缺口。这四卷标题明确属于 COLING;[COLING 官方 Anthology 目录](https://aclanthology.org/venues/coling/)将它们列在 COLING 下。出版者写作 ACL 或目标映射包含 COLING,不足以证明某一年是 ACL 联办届次。
|
||||
|
||||
从 ACL 候选中排除上述四卷,原始卷字段和排除理由保留在[候选JSON](historical-source-candidates-2026-11-09.json)的 `excluded_cross_venue_candidates` 中。135个历史会议—年份位置仍未关闭;有初步候选来源的年度由89修正为85,尚未匹配的由46增至50。这些数字是目录初筛结果,不是已验证可导入的论文数。
|
||||
|
||||
联合届次仍按具体年度的明确会议身份处理,例如有官方联办证据的卷可以属于多个会议。不能一律排除跨family卷,也不能把联合卷重复导入为两批论文。
|
||||
|
||||
## 来源待核实的早期年份
|
||||
|
||||
| 范围 | 本轮官方来源观察 | 处理 |
|
||||
| --- | --- | --- |
|
||||
| ACL 1963–1978,共16年 | [ACL 官方 Anthology 目录](https://aclanthology.org/venues/acl/)目前从1979年列起 | 保留历史举办清单;逐年核实是否有可索引发表材料,不能据会议举办就断言漏采正式论文集,也不据目录缺失断言未出版 |
|
||||
| COLING 1971、1976、1978 | [主办委员会历史](https://ufal.mff.cuni.cz/iccl-old)列出这三届;当前Anthology COLING目录未列出 | 来源缺口继续开放,需要主办方或出版者逐篇索引;不生成占位论文或录用/拒稿记录 |
|
||||
|
||||
这19个位置不能用其他会议的卷补齐,也不能因本轮未找到目录而删除。公开拒稿可获得性是独立核查,不从论文集或录用清单缺失推断。
|
||||
|
||||
## 证据与复核
|
||||
|
||||
三份原始HTML及 `receipts.json` 保存在工作证据目录 `paper-index/nlp-history-source-review-v1/`;结构化响应URL、时间、状态、字节数和完整SHA256收录于候选JSON的 `scope_review.evidence`。三次响应均为HTTP200;未获取论文全文。
|
||||
|
||||
| 官方来源 | 响应SHA256 |
|
||||
| --- | --- |
|
||||
| [acl-venue](https://aclanthology.org/venues/acl/) | `d04c188c0769e065f18ca9c0246f70086e2f7027ce3efe5ea0f2cdc641796b72` |
|
||||
| [coling-venue](https://aclanthology.org/venues/coling/) | `8e7ec0a2915bfbccaa26b7a7558a9cf977c4aadc119794516ecebb826f61623f` |
|
||||
| [iccl-history](https://ufal.mff.cuni.cz/iccl-old) | `c5286dc51236c90685d22543ff1f7b209adcb3eedb82a7b1ac2115c8dd0169fc` |
|
||||
|
||||
复核时先校验保存的原始响应哈希,再读取目录中的会议身份和年度。实时网页若已变化,应另存新响应和时间,不能把新内容套用旧哈希。本次不改最初会议年度清单或生产年度快照,不代表第14步覆盖验收或第16步最终全库审计完成。
|
||||
@@ -0,0 +1,44 @@
|
||||
# ICLR 2014–2016 官方历史录用档案
|
||||
|
||||
此适配器是长期维护工具的一部分,按需运行。只识别已经独立核对的三种页面布局;后续页面发生变化必须重新核查,不能放松计数或轨道边界以使采集成功。
|
||||
|
||||
| 官方页面 | 页面链接次数 | 整理后的不同记录 |
|
||||
|---|---:|---:|
|
||||
| [2014 Conference Proceedings](https://iclr.cc/archive/2014/conference-proceedings/) | 35 | main 35 |
|
||||
| [2015 accepted-main](https://iclr.cc/archive/www/doku.php%3Fid=iclr2015:accepted-main.html) | 116 | main 31、workshop 74 |
|
||||
| [2016 accepted-main](https://iclr.cc/archive/www/doku.php%3Fid=iclr2016:accepted-main.html) | 80 | main 80 |
|
||||
|
||||
2015 的 11 篇 oral 全部再次出现在 poster 段落中。相同年份、轨道及 arXiv 标识的重复展示只生成一条来源记录,但每次展示的标题、作者显示字符串、链接、版本和段落均保留在 `source_variants`。两处标题或解析作者冲突时停止采集、等待人工核查。不同年份或不同轨道不合并投稿。
|
||||
|
||||
2014 的作者以分号列出,一条有末尾分号,一条标题和作者斜体块不在普通段落中,部分作者段落拆成多个格式片段;必须读取完整作者段落或独立斜体块,不能只读首个格式片段。2016 的代码链接不属于作者。保留原文,解析结果不替代来源证据。oral、poster 作为 `iclr_archive:presentation` 标签,不改成独立投稿轨道。arXiv 版本留在来源变体中,稳定标识使用去版本号的编号。
|
||||
|
||||
## 来源快照与运行
|
||||
|
||||
2026-11-09 已冻结的官方响应 SHA256:
|
||||
|
||||
- 2014:`a33858176fbffae0066ed3c10310f7dd98a66d12a36334b6339ef1f915ef07af`
|
||||
- 2015:`131491663742cb084662a4804009b8318e0fa7bc184d4a9044d16dd28f05b888`
|
||||
- 2016:`7320b8652431793b6ea0743f871ee446bf08b6361ccd4e421b6b53449e71f4f6`
|
||||
|
||||
在服务器运行,输出新目录,不下载论文 PDF:
|
||||
|
||||
```sh
|
||||
paper-atlas collect iclr-archive --year 2015 \
|
||||
--snapshots /data/paper-index/collected/historical-next-source-evidence-v1 \
|
||||
--out /data/paper-index/collected/iclr-archive-source-v1/2015
|
||||
|
||||
# 或重新下载官方 HTML,并保留 HTTP 缓存与收据
|
||||
paper-atlas collect iclr-archive --year 2016 \
|
||||
--cache /data/paper-index/cache/iclr-archive \
|
||||
--out /data/paper-index/collected/iclr-archive-source-v1/2016
|
||||
```
|
||||
|
||||
冻结输入目录必须包含 `receipts.json`,每项有精确 `url`、HTTP `status: 200`、相对 `file`、`bytes`、`sha256` 和带时区的 `finished_at`。读取时重新核对尺寸、哈希和路径;拒绝越界及指向目录外的软链接。已完成输出不可覆盖,应检查报告后另建明确版本目录。解析或计数失败时不生成完成报告,也不导入数据库。
|
||||
|
||||
输出分轨道 JSONL、manifest、原始 HTML 证据和年度采集报告。仅支持这三年已审查的页面。2014 workshop、其他未列轨道、逐篇摘要及公开拒稿范围尚未通过这些页面核验,不能据此声称完整。缺席不代表 rejected、withdrawn 或从未被录用。
|
||||
|
||||
## 导入前后的边界
|
||||
|
||||
这三页合计 220 条不同来源记录,不等于新增 220 篇论文;同一工作可能已存在于 arXiv 或其他会议。先核验来源字段与原始页面,再执行 PostgreSQL 只读预检,比较稳定标识、标题、作者和原始来源。标题/作者差异、版本关联及冲突必须保留证据并明确评估;不能仅靠标题猜测身份,也不能覆盖原有摘要。
|
||||
|
||||
采集器不连接数据库。正式导入使用既有 `import records`,必须有新的生产保护基线和独立验收;全部导入结束后仍需全库去重和历史覆盖审计。来自官方录用清单的 accepted 是清单观察,不是原始审稿决定签名或决定日期。
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,40 @@
|
||||
# IJCAI 2018、2022 逐条目录与 track 核查
|
||||
|
||||
2026-11-08 的只读生产快照中,edition 867(2018)和 871(2022)已正确属于 IJCAI/ECAI 联办会议,但整本论文集的所有投稿仍归在 `main`。本核查用官方目录的逐条 DOI 和分区定位问题,不修改生产数据。
|
||||
|
||||
| 年份 | 库中投稿 | 官方目录条目 | DOI 一对一对应 | 官方 Main track | 明确非 main | 当前目录缺项 |
|
||||
| --- | ---: | ---: | ---: | ---: | ---: | ---: |
|
||||
| 2018 | 870 | 870 | 870 | 709 | 161 | 0 |
|
||||
| 2022 | 863 | 862 | 862 | 678 | 184 | 1 |
|
||||
|
||||
2018 官方分区为 Main track 709、Invited Speakers 3、Evolution of the contours of AI 6、Sister Conferences Best Papers 46、Survey track 16、Journal track 24、Early Career 19、Doctoral Consortium 17、Demos 30。
|
||||
|
||||
2022 官方分区为 Main Track 678、Special Track on AI, the Arts and Creativity 17、Special Track on AI for Good 33、Sister Conferences Best Papers 26、Survey Track 38、Journal Track 19、Early Career 8、Doctoral Consortium 25、Demo Track 18。Special Track 保留独立范围,不因同在正式论文集就合进 main。
|
||||
|
||||
## 862/863 差异已定位,不能删一条凑数
|
||||
|
||||
数据库独有条目:`conf/ijcai/Fang22`,题名 **Fixed-Budget Pure Exploration in Multinomial Logit Bandits**,DOI `10.24963/ijcai.2022/409`,页码 2951–2957。submission 333599、source_record 3578445、paper 3442268。
|
||||
|
||||
[当前官方总目录](https://ijcai.org/proceedings/2022/) 没有这一条,详情页 `/proceedings/2022/409` 本次 HTTP 404;但[官方论文文件](https://ijcai.org/proceedings/2022/0409.pdf) HEAD 返回 HTTP 200、application/pdf、153657 字节。浏览器读取的官方文件标题、作者 Boli Fang、IJCAI-22 页脚及首页 2951 与数据库对应,共七页。服务器没有下载保存 PDF 正文。
|
||||
|
||||
因此这是已定位的“当前 HTML 目录缺项但官方文件仍存在”的来源差异。没有证据证明撤稿、拒稿或数据库多收了一篇;保留原条目与发表证据。其当前官方 track 仍待明确,不根据相邻编号擅自归 main。
|
||||
|
||||
## 可复核证据
|
||||
|
||||
同目录 JSON 保存两份官方 HTML 的 URL、HTTP 状态、时间、字节数和 SHA256,以及生产只读导出 SHA256。服务器快照目录 `/data/paper-index/collected/ijcai-section-audit-v1/` 保留 HTML、来源回执、原始数据库来源导出和 409 HTTP 回执。
|
||||
|
||||
JSON 的 `years[].sections[].records` 每条为 `[submission_id, source_record_id, doi_suffix]`;DOI 为 `10.24963/ijcai.<year>/<doi_suffix>`。这些是现有稳定 ID,不是待插入的新投稿。全部 1732 个当前目录条目均对应唯一数据库来源 DOI,目录与数据库均无重复 DOI,官方目录独有条目为零。
|
||||
|
||||
标题仅作辅助核验:NFKC、casefold、去除 Unicode 非 word 字符后,2018 全部一致;2022 有一个拼写差异,DOI `/552` 的 DBLP 标题将 Revisiting、Re-Construction 拼为 Revisting、Re-Constrcution。原始标题均保留,未因此创建或合并论文。
|
||||
|
||||
## 未完成事项及修正边界
|
||||
|
||||
本次交付只完成来源核查和逐条分区映射,**345 条非 main 的生产分类尚未修正**,第 14 步尚未完成。下一步需要可复用的、带快照前提和验收报告的分区修正流程:
|
||||
|
||||
- 保持原 submission、source_record、paper、decision 的 ID 和证据内容;纠正 edition 归属,不制造“新一次投稿”。
|
||||
- 同步处理依赖 edition 的 submission_identifiers、覆盖记录与联合会议别名;不能只移动 submissions 一个字段。
|
||||
- 保存官方分区文字、URL、源文件 SHA 和原始分类,防止以后 DBLP 整卷刷新把精确 track 覆盖回 main。
|
||||
- 对 409 保持单独待核实状态,不推断撤稿、拒稿或当前 main 归属。
|
||||
- 修正后独立核验完整投稿/决定历史、稳定标识、各 track 行数及实际查询。研究方法类型不能由 track 自动生成。
|
||||
|
||||
这是 2018、2022 两届的完整目录差集和分区证据,不代表其他年份已逐条验收,也不替代所有导入结束后的全库去重。
|
||||
@@ -0,0 +1,201 @@
|
||||
{
|
||||
"format": 1,
|
||||
"source_cache_sha256": "8a65562f3490497cada2919ec77827134beabd5ec9c3690dec3625e2318aa54e",
|
||||
"records": [
|
||||
{
|
||||
"entry_id": "45",
|
||||
"individual_url": "https://archives.iw3c2.org/www3/proceedings/posters/45/index.html",
|
||||
"individual_sha256": "83bede6afc9a7039fae2dd1f28274a394418b34f5d5632590b913617580bda1a",
|
||||
"title": "WISE - World Wide Information System for R&D; Efforts",
|
||||
"authors": [
|
||||
"Fritz Loseries",
|
||||
"Veronika Samara",
|
||||
"Bodo Urban"
|
||||
],
|
||||
"author_source": "canonical_byline",
|
||||
"reason": "Reviewed canonical H1 and explicit authorship for this exact linked poster entry; retain abstract-heading and author-index title variants without fuzzy matching.",
|
||||
"missing_abstract_reason": ""
|
||||
},
|
||||
{
|
||||
"entry_id": "16",
|
||||
"individual_url": "https://archives.iw3c2.org/www3/proceedings/posters/16/index.html",
|
||||
"individual_sha256": "cf76da903eaeaa27d19857fdaa13220f6821559bf4ac22844fae10abbaa6f3f3",
|
||||
"title": "The World Wide Web as a Tool for Collaborative Inter-Agency Projects: Creation of an Environmental Metadata Database for the Analysis of the Venice Lagoon Ecosystem.",
|
||||
"authors": [
|
||||
"Mirko Labbri"
|
||||
],
|
||||
"author_source": "canonical_byline",
|
||||
"reason": "Reviewed canonical H1 and explicit authorship for this exact linked poster entry; retain abstract-heading and author-index title variants without fuzzy matching.",
|
||||
"missing_abstract_reason": ""
|
||||
},
|
||||
{
|
||||
"entry_id": "17",
|
||||
"individual_url": "https://archives.iw3c2.org/www3/proceedings/posters/17/index.html",
|
||||
"individual_sha256": "0578fec9c82e8f2f98c4de0f1bcc44011ebb84c53353fdcc423a69cf70673bc2",
|
||||
"title": "NetRepreneur",
|
||||
"authors": [
|
||||
"Kari-Pekka Turtiainen",
|
||||
"Antti Auer"
|
||||
],
|
||||
"author_source": "canonical_byline",
|
||||
"reason": "Reviewed canonical H1 and explicit authorship for this exact linked poster entry; retain abstract-heading and author-index title variants without fuzzy matching.",
|
||||
"missing_abstract_reason": ""
|
||||
},
|
||||
{
|
||||
"entry_id": "29",
|
||||
"individual_url": "https://archives.iw3c2.org/www3/proceedings/posters/29/index.html",
|
||||
"individual_sha256": "624ad3f685de1163411e33e90488975d848a63243fd7ffbed0862c6fb58c4140",
|
||||
"title": "Mosaic TueV 2.4.3: Multilingual GUI and Improvements towards User Friendliness",
|
||||
"authors": [
|
||||
"Mike Bretz",
|
||||
"Uwe Koch",
|
||||
"Paul de Bra"
|
||||
],
|
||||
"author_source": "canonical_byline",
|
||||
"reason": "Reviewed canonical H1 and explicit authorship for this exact linked poster entry; retain abstract-heading and author-index title variants without fuzzy matching.",
|
||||
"missing_abstract_reason": ""
|
||||
},
|
||||
{
|
||||
"entry_id": "13",
|
||||
"individual_url": "https://archives.iw3c2.org/www3/proceedings/posters/13/index.html",
|
||||
"individual_sha256": "02409321a3403a94d8362a9862f4960386ea9c344fb47f4bcdf9eae128e8b46e",
|
||||
"title": "Using a WWW Browser as an Alternative User Interface for Interactive Applications",
|
||||
"authors": [
|
||||
"Frank Lonczewski"
|
||||
],
|
||||
"author_source": "canonical_byline",
|
||||
"reason": "Reviewed canonical H1 and explicit authorship for this exact linked poster entry; retain abstract-heading and author-index title variants without fuzzy matching.",
|
||||
"missing_abstract_reason": ""
|
||||
},
|
||||
{
|
||||
"entry_id": "36",
|
||||
"individual_url": "https://archives.iw3c2.org/www3/proceedings/posters/36/index.html",
|
||||
"individual_sha256": "ed9dada4ea5903cb8f0a4e75f2958416a6e32647fbf3530e58edd9abf26070ce",
|
||||
"title": "HyperSource: A Hypermedia Program Development And Documentation System",
|
||||
"authors": [
|
||||
"Jan Oliver Borchers"
|
||||
],
|
||||
"author_source": "canonical_byline",
|
||||
"reason": "Reviewed canonical H1 and explicit authorship for this exact linked poster entry; retain abstract-heading and author-index title variants without fuzzy matching.",
|
||||
"missing_abstract_reason": ""
|
||||
},
|
||||
{
|
||||
"entry_id": "57",
|
||||
"individual_url": "https://archives.iw3c2.org/www3/proceedings/posters/57/index.html",
|
||||
"individual_sha256": "3ad4206ad28a19823172cd2845f636fe7a461c251940e9a93deb8f6484b8a984",
|
||||
"title": "Progressive Image Transmission Capability (PROTRAC) on the World Wide Web",
|
||||
"authors": [
|
||||
"Vojislav Lalich-Petrich",
|
||||
"Gaurav Bhatia",
|
||||
"Larry Davis"
|
||||
],
|
||||
"author_source": "canonical_byline",
|
||||
"reason": "Reviewed canonical H1 and explicit authorship for this exact linked poster entry; retain abstract-heading and author-index title variants without fuzzy matching.",
|
||||
"missing_abstract_reason": ""
|
||||
},
|
||||
{
|
||||
"entry_id": "38",
|
||||
"individual_url": "https://archives.iw3c2.org/www3/proceedings/posters/38/index.html",
|
||||
"individual_sha256": "940370acc5e6a954b197053fe451e466a904d1f0ac82c8fe4e6ca455faf2c927",
|
||||
"title": "Measuring the Web with Lycos",
|
||||
"authors": [
|
||||
"Michael L. Mauldin"
|
||||
],
|
||||
"author_source": "canonical_byline",
|
||||
"reason": "Reviewed canonical H1 and explicit authorship for this exact linked poster entry; retain abstract-heading and author-index title variants without fuzzy matching.",
|
||||
"missing_abstract_reason": "Official abstract listing has no abstract. Canonical HTML has body sections but no explicit abstract; body is not substituted as a summary."
|
||||
},
|
||||
{
|
||||
"entry_id": "05",
|
||||
"individual_url": "https://archives.iw3c2.org/www3/proceedings/posters/05/index.html",
|
||||
"individual_sha256": "1858be87d6e7f47476e1cb8fb4793c0fce3b8a6ff653bcfd4a35089e0520f177",
|
||||
"title": "Virtual Places - Adding people to the web",
|
||||
"authors": [
|
||||
"Yosi Mass"
|
||||
],
|
||||
"author_source": "author_index",
|
||||
"reason": "Reviewed canonical H1 and explicit authorship for this exact linked poster entry; retain abstract-heading and author-index title variants without fuzzy matching.",
|
||||
"missing_abstract_reason": "Official abstract listing and canonical HTML have no abstract. Canonical page identifies a contact and links PostScript; no full-text download. Author identity comes from author index, not inferred from contact role."
|
||||
},
|
||||
{
|
||||
"entry_id": "37",
|
||||
"individual_url": "https://archives.iw3c2.org/www3/proceedings/posters/37/index.html",
|
||||
"individual_sha256": "b700a0a51b7a82a5f88373ae0283bb369d1cf6f455acff7d91cd7313fa8b3adf",
|
||||
"title": "The Internet Public Library: Serving a Global Community",
|
||||
"authors": [
|
||||
"Susan Schweitzer"
|
||||
],
|
||||
"author_source": "canonical_byline",
|
||||
"reason": "Reviewed canonical H1 and explicit authorship for this exact linked poster entry; retain abstract-heading and author-index title variants without fuzzy matching.",
|
||||
"missing_abstract_reason": ""
|
||||
},
|
||||
{
|
||||
"entry_id": "41",
|
||||
"individual_url": "https://archives.iw3c2.org/www3/proceedings/posters/41/index.html",
|
||||
"individual_sha256": "397b474109f9c1240a77d0f490f2b02dd35fae93febbb7397d1b2a31759a8ec1",
|
||||
"title": "Graphical Browsing in the WorldWideWeb and the MultiMedia Forum",
|
||||
"authors": [
|
||||
"Rainer Pagé",
|
||||
"Klaus Süllow"
|
||||
],
|
||||
"author_source": "canonical_byline",
|
||||
"reason": "Reviewed canonical H1 and explicit authorship for this exact linked poster entry; retain abstract-heading and author-index title variants without fuzzy matching.",
|
||||
"missing_abstract_reason": ""
|
||||
},
|
||||
{
|
||||
"entry_id": "47",
|
||||
"individual_url": "https://archives.iw3c2.org/www3/proceedings/posters/47/index.html",
|
||||
"individual_sha256": "52eb4bfdd658730a222c98170b72dabf9cbcc5ffd2ada1e900b826baf2b02833",
|
||||
"title": "Interoperating Advanced Hypermedia Systems with WWW",
|
||||
"authors": [
|
||||
"Jian Zhang",
|
||||
"Martin Fruehauf",
|
||||
"Thomas Kirste"
|
||||
],
|
||||
"author_source": "canonical_byline",
|
||||
"reason": "Reviewed canonical H1 and explicit authorship for this exact linked poster entry; retain abstract-heading and author-index title variants without fuzzy matching.",
|
||||
"missing_abstract_reason": ""
|
||||
},
|
||||
{
|
||||
"entry_id": "50",
|
||||
"individual_url": "https://archives.iw3c2.org/www3/proceedings/posters/50/index.html",
|
||||
"individual_sha256": "8dc8b0561bd8b1b106c5b209a973c695c3cc9df5288450d4a01fe32541476b7e",
|
||||
"title": "Web Enabled Agile Manufacturing",
|
||||
"authors": [
|
||||
"S. V. Iyer",
|
||||
"T. Singh",
|
||||
"U. Ravaioli",
|
||||
"R. E. DeVor",
|
||||
"S. G. Kapoor",
|
||||
"G. Veknatasubramian"
|
||||
],
|
||||
"author_source": "canonical_byline",
|
||||
"reason": "Reviewed canonical H1 and explicit authorship for this exact linked poster entry; retain abstract-heading and author-index title variants without fuzzy matching.",
|
||||
"missing_abstract_reason": ""
|
||||
},
|
||||
{
|
||||
"entry_id": "55",
|
||||
"individual_url": "https://archives.iw3c2.org/www3/proceedings/posters/55/index.html",
|
||||
"individual_sha256": "21780cd4047f513e8c9aeced5247002e27cf71719f08de85c6531b57a9e5058d",
|
||||
"title": "Weblets: Fundamental Building Blocks for WWW Tools",
|
||||
"authors": [
|
||||
"Thomas J. Watt Jr."
|
||||
],
|
||||
"author_source": "canonical_byline",
|
||||
"reason": "Reviewed canonical H1 and explicit authorship for this exact linked poster entry; retain abstract-heading and author-index title variants without fuzzy matching.",
|
||||
"missing_abstract_reason": ""
|
||||
},
|
||||
{
|
||||
"entry_id": "59",
|
||||
"individual_url": "https://archives.iw3c2.org/www3/proceedings/posters/59/index.html",
|
||||
"individual_sha256": "fed4cc9dcaee350a12c5e4b6fa9c99be9bead25a81632512d31566400d3d0fad",
|
||||
"title": "Usenet and the World-Wide Web",
|
||||
"authors": [
|
||||
"M. L. Grant"
|
||||
],
|
||||
"author_source": "canonical_byline",
|
||||
"reason": "Reviewed canonical H1 and explicit authorship for this exact linked poster entry; retain abstract-heading and author-index title variants without fuzzy matching.",
|
||||
"missing_abstract_reason": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
# 按证据修正既有投稿的轨道
|
||||
|
||||
`paper-atlas maintain edition-tracks` 用于已导入整卷论文集、后来取得逐条官方分区证据的情形。它更正原投稿的 edition 归属,保留投稿 ID、所有决定和来源关系;不会重新导入论文、合并实体、生成研究方法标签或推断拒稿。支持独立 SQLite 样本和共享 PostgreSQL;不得写入已冻结的生产 SQLite 备份。
|
||||
|
||||
## 证据与完整范围
|
||||
|
||||
先核实官方目录、稳定 DOI、原始来源记录和分区,保存原始页面及 SHA256。输入 JSON 为 `{"format":1,"records":[...]}`,每条必须包含:
|
||||
|
||||
| 字段 | 含义 |
|
||||
| --- | --- |
|
||||
| `submission_id`、`source_record_id`、`external_id` | 原有投稿、主来源记录及主来源投稿标识 |
|
||||
| `source`、`doi` | 原来源名称及其 metadata.record_identifiers 中的 DOI |
|
||||
| `from_edition_id`、`year`、`venue` | 原 edition 及其真实年份和规范会议名 |
|
||||
| `venue_names` | 规范会议名加全部已有别名;必须与库中一致 |
|
||||
| `track` | 经核实的目标轨道,使用小写字母、数字、下划线;范围未明确可用 proceedings_unspecified |
|
||||
| `section` | 官方分区原文或明确的未确定说明,不推断研究方法 |
|
||||
| `evidence_url`、`evidence_sha256` | 已人工/独立核实的 HTTPS 原始证据和内容哈希 |
|
||||
|
||||
输入必须列出每个原 edition 的**全部**投稿,包含留在原轨道的记录。工具核对身份、DOI、单来源发表观察、现有别名、完整范围以及目标冲突;不自动解释 HTML,也不把填写了 URL/哈希当成已证明内容。核查者应先独立证明每条分区对应关系。
|
||||
|
||||
多来源共同拥有的投稿、有非 accepted 决定历史的投稿、已有分类修订、非空目标 edition、目标已有覆盖或标识等情形会停止,需要专门核对。每个原 edition 须有一个明确来源清单;其他来源或多清单覆盖需先另行协调。本功能不是任意批量移动或跨会合并工具。
|
||||
|
||||
## 只读计划、事务执行和验收
|
||||
|
||||
```sh
|
||||
paper-atlas maintain edition-tracks \
|
||||
--pg-service foundry-research --pg-schema research \
|
||||
--inventory reviewed-inventory.json --out plan.json
|
||||
|
||||
sha256sum plan.json
|
||||
|
||||
paper-atlas maintain edition-tracks \
|
||||
--pg-service foundry-research --pg-schema research \
|
||||
--plan plan.json --plan-sha256 <上一步的完整SHA256> --out applied.json
|
||||
```
|
||||
|
||||
报告路径必须是已有目录中的新文件。预检只读。执行使用 PostgreSQL 单写入 advisory lock,或 SQLite BEGIN IMMEDIATE;整个分类修正、逐来源证据、标识、覆盖及验收在一个事务中完成。计划绑定数据库身份、全部来源文本/非修订元数据、标签、可选 annotation_events、投稿/决定/来源/标识内容及原覆盖快照;过期即停止。失败回滚数据,PostgreSQL 序列可能留下正常间隙,不能用 max(id) 当数量。
|
||||
|
||||
目标 edition 沿用原会议、年份、event_key,不删除或重命名原 edition。只更正既有 submissions.edition_id 和对应 submission_identifiers.edition_id。原文、来源检索时间、决定 ID/状态/证据及各表来源关系不变。
|
||||
|
||||
分类证据写入来源元数据的保留字段 `edition_scope_curation_v1`,包括原范围、目标 edition、官方分区、URL、哈希和计划摘要。普通来源导入不能伪造或替换此字段;后续 record 刷新保留修订。相同来源投稿通过旧范围调用 decision 时仍路由到修正后的 edition。旧整卷 main 文件通过 import records 刷新会报错并回滚,要求先按核实轨道重新整理,避免覆盖正确的统计。
|
||||
|
||||
覆盖保留原清单 URL、cursor、成功时间,按实际目标分配 expected_count/imported_count 并标 partial;本工具核实分类,未核实摘要或所有来源完整性。原覆盖整行保留在计划中,不能继续将整卷数当 main 论文数。应用后校验原 edition 身份、全部受保护内容、分类、标识、修订字段和覆盖;相同计划再次执行仅在这些验收条件仍成立时返回 already_applied。
|
||||
|
||||
生产使用前必须在已确认 runner 完成软件验证。正式数据修正后仍需独立验收,并在最终全库审计中复核;单元测试和应用回执不能代替生产验收。
|
||||
@@ -0,0 +1,36 @@
|
||||
# 会后选集的元数据采集
|
||||
|
||||
`collect springer-selected` 读取已冻结、带 HTTP 回执的公开 HTML,不访问 PDF、不写业务库。目前仅支持核验过的 *Selecting Models from Data: Artificial Intelligence and Statistics IV*,书籍 DOI 为 `10.1007/978-1-4612-2660-4`。增加其他卷前必须另行核对会议关系、目录、年份和字段格式。
|
||||
|
||||
[会议官方旧站](https://www.gatsby.ucl.ac.uk/aistats/workshops.html)区分现场分发的 Preliminary Papers 与之后修订、再次评审的选集。[出版社卷说明](https://link.springer.com/book/10.1007/978-1-4612-2660-4)将本书关联到 1993 年 1 月的第四届 workshop,书籍出版年份为 1994。三页目录共有 49 个章节 DOI;分区 Front Matter 不作为章节论文导入。
|
||||
|
||||
规范记录保留:
|
||||
|
||||
- 会议年份 1993、独立 track `selected_volume` 与事件标识 `aistats-1993-selected-volume-1994`;出版年份单独记为 1994。
|
||||
- 章节本身的 DOI、作者原始引用写法及顺序、姓名首字母、标题、摘要、页码和逐页原始证据。不扩写作者首字母。
|
||||
- 明确提供本章 `publication_doi` 投稿出版标识,不把它转移到旧的原始会议评审记录,也不因短标题相同而强制合并实体。
|
||||
- `springer_selected_volume:publication_format` 标签与独立来源 namespace。
|
||||
- `accepted` 仅表示章节已列入这本出版选集,原始 1993 投稿评审决定、决定日期和全部录用清单均未知。后续原会议 main、拒稿或撤稿证据可以独立保留。
|
||||
|
||||
不能据此宣称会议全部录用、全部投稿或公开拒稿覆盖完整,也不能把出版社机器添加的关键词视为作者标签。未命中选集不等于被拒。
|
||||
|
||||
## 按需运行与恢复
|
||||
|
||||
输入分别是卷目录/旧站说明和章节页的快照目录。每个目录的 `receipts.json` 包含 `responses` 列表,每条保留 `url`、`final_url`、`file`、`http_status`、`headers`、`bytes`、`sha256`、`at`。需要三页目录、官方旧站说明及全部 49 个章节页面。
|
||||
|
||||
```sh
|
||||
paper-atlas collect springer-selected \
|
||||
--toc-snapshots /data/paper-index/collected/aistats1993-book-evidence-v1 \
|
||||
--chapter-snapshots /data/paper-index/collected/aistats1993-chapter-evidence-v1 \
|
||||
--out /data/paper-index/collected/aistats1993-source-v1
|
||||
```
|
||||
|
||||
采集器验证回执和内容哈希、文件路径、HTTP 类型、目录/章节 DOI 和标题、作者格式、出版年份及页码。Springer 对公开元数据返回的同路径 `cookies_not_supported` 诊断参数会原样保留;不接受其他域、路径、分页变化或未知查询参数。官方旧站说明固定哈希为 `6db205b9dbc82b4a096556efd2aa67e065cad8e0bd6351176b1962ffd1fca179`,变化时须重新审核。
|
||||
|
||||
全部文件先核验,输出使用排他锁和暂存目录,完成报告最后发布。已有输出拒绝覆盖;发现不完整或已消费文件,应先查运行状态与报告,另建明确版本目录,不能重放生产导入。
|
||||
|
||||
输出 JSONL 配套 manifest 始终为 `partial`。收集完成后还必须做 PostgreSQL 稳定标识/标题/作者身份预检、排除已导入记录、准备限定增量文件、保存旧数据保护基线、原生导入和独立验收。49 条来源不代表 49 个新增论文实体,全部来源处理后仍须全库去重审计。
|
||||
|
||||
## 固定测试资料
|
||||
|
||||
`tests/fixtures/springer-aistats1993-fixture.json.gz` 保存 4 份范围/目录 HTML、49 份章节 HTML、原始请求回执及独立原始字段审计的预期元数据,SHA256 为 `94028fd37080f9d8027e5790e379cc151ee6eab17ed0d1dcc0f59e6e75f9d4d0`。摘要按 HTML 段落边界分隔;未修改原始 HTML 或回执。它是压缩的公开元数据测试资料,不包含论文 PDF。测试覆盖全部真实章节字段、缺失和漂移输入、来源安全边界、幂等导入及旧拒稿历史保留。构建和测试只在已授权远程 runner 执行。
|
||||
@@ -0,0 +1,111 @@
|
||||
# Early WWW archive collector
|
||||
|
||||
`paper-atlas collect www-archive` is a maintained metadata adapter. It reads official
|
||||
IW3C2 archive HTML, keeps exact source hashes, and separates importable rows from
|
||||
program entries that still need review. It fetches no PDF, follows no paper-body or
|
||||
reference link, and makes no database connection.
|
||||
|
||||
For a fresh source snapshot on the server:
|
||||
|
||||
```sh
|
||||
paper-atlas collect www-archive \
|
||||
--http-cache /data/paper-index/cache/www-archive \
|
||||
--source-cache /data/paper-index/collected/www-archive-source-v2 \
|
||||
--out /data/paper-index/collected/www-archive-normalized-v2
|
||||
```
|
||||
|
||||
For reproducible offline replay of the already verified snapshot:
|
||||
|
||||
```sh
|
||||
paper-atlas collect www-archive \
|
||||
--source-cache /data/paper-index/collected/www-early-archive-source-v1 \
|
||||
--source-cache-sha256 c9388ccef44486e0684c9ee9e8620a465cdc4574e6bc9ea9391d743e01a3312c \
|
||||
--out /data/paper-index/collected/www-archive-normalized-v1
|
||||
```
|
||||
|
||||
Use a new output directory for another batch. Existing snapshots and normalized
|
||||
batches are never overwritten. The manifest hash binds every source URL, byte count,
|
||||
response hash and cache filename. Plain files must remain directly within the cache;
|
||||
symlinks, escaping paths, duplicate URLs, non-200 responses and altered bytes fail.
|
||||
The command uses UTF-8 where valid and explicitly records an ISO-8859-1 fallback for
|
||||
older pages; original bytes remain authoritative for encoding corrections.
|
||||
|
||||
## Evidence and outputs
|
||||
|
||||
- WWW3 Spring 1995 papers use `event_key=www-1995-spring`, `track=main`. The author,
|
||||
session and abstract indexes must have the same nonempty entry-ID set. The ID is
|
||||
local to this exact event and proceedings section, never a DOI or OpenReview ID.
|
||||
- Posters remain `track=poster`. An abstract without a linked author-index entry
|
||||
stays in review even when another index has a placeholder `#` link for it.
|
||||
- Paper URLs must agree between author and session indexes. A malformed whitespace
|
||||
URL in the abstract list may use the exact event/entry-ID match only when both
|
||||
independent indexes corroborate the same clean URL. The original href is retained.
|
||||
- Canonical names come from the explicitly labeled author index. The source lists
|
||||
them alphabetically; this is **not** original byline order. Both the raw index names
|
||||
and the full abstract-page byline/affiliation text remain in metadata. No person ID
|
||||
or cross-source identity is inferred from a name. Conflicting titles or missing
|
||||
abstracts stay in review. Topic keywords are not treated as an abstract.
|
||||
- WWW1 1994 formal presentations and WWW2 1994 mixed categories remain review
|
||||
candidates. The first list contains uncertain business-track annotations and a
|
||||
count discrepancy; the second contains panels, tutorials, products and other
|
||||
activities. Neither presentation listing nor a missing full-text link establishes
|
||||
a regular-paper acceptance or a rejection. Raw category/title/name claims are kept.
|
||||
|
||||
The main and poster JSONL files have independent manifests with explicit event key,
|
||||
track, count and SHA256. `source_listing_count` records the full recognized abstract
|
||||
listing, while `held_entry_count` records excluded entries; neither is a ready-row
|
||||
count. Their status stays `partial`: a verified linked subset does
|
||||
not prove complete event coverage. `review-candidates.jsonl` contains nested
|
||||
candidates and reasons, with no importable decision status. It is excluded from
|
||||
`import-queue.json`. `report.json` accounts for ready/held records and every source
|
||||
used. Newlines delimit JSONL; Unicode U+2028/U+2029 inside metadata are not line breaks.
|
||||
The output is published only after all required source and scope checks pass.
|
||||
|
||||
## Before importing
|
||||
|
||||
This independent collector branch starts from main; it does not include or silently
|
||||
install the event-aware writer. Its output requires the reviewed integrated runtime
|
||||
from PR31 (or a main revision containing that implementation). The production event
|
||||
schema migration has been independently accepted, preserving existing edition1212
|
||||
and its 59 Fall submissions; that is not an import of the Spring proceedings.
|
||||
|
||||
Inspect held entries and original author names, preflight every ready row against
|
||||
current PostgreSQL stable identifiers and candidate identities, and verify the
|
||||
source/year/track/event coverage before running `import records`. When a ready subset
|
||||
excludes held entries, preserve the independently known listing total in the existing
|
||||
incremental-coverage protocol: bind a freshly checked parent count and expected count,
|
||||
and set the full expected count from this manifest. A new source uses previous count
|
||||
0 and previous expected count null; an existing source requires its actual current
|
||||
values. These live parent values are not guessed by the offline collector. Do not automatically
|
||||
run this queue against an older writer. Preserve extended versions and independent
|
||||
submission/decision evidence, and keep unresolved identities held. A normalization
|
||||
report or passing tests alone do not establish production import, complete historical
|
||||
coverage or final global deduplication.
|
||||
|
||||
Initial fixed-cache replay (64ced7a, CI1057): 38 main papers and38 posters ready;
|
||||
372 entries held (50 first-conference fields,307 mixed program entries,15 posters).
|
||||
Independent report44dfd070 verified source hashes,counts and author evidence, finding
|
||||
one existing exact-title candidate. That publication is explicitly linked to
|
||||
arXiv1811.12161 by its WWW1995/journal-reference declaration; arXiv1810.07232 is a
|
||||
different1997 invited chapter and remains separate despite the overlap note. This
|
||||
is a preparation observation, not evidence of completed production import or final
|
||||
global deduplication.
|
||||
|
||||
## 以单篇官方页面核实待处理 poster
|
||||
|
||||
作者索引漏链接、摘要目录使用短标题或标题存在拼写差异时,不放宽全局标题匹配。可以先取得单篇官方 HTML,人工核对 H1、署名区与原目录同一 entry URL,再提供带 SHA256 的逐条核查文件:
|
||||
|
||||
```sh
|
||||
paper-atlas collect www-archive \
|
||||
--source-cache /data/paper-index/collected/www-poster-reviewed-source-v2 \
|
||||
--source-cache-sha256 8a65562f3490497cada2919ec77827134beabd5ec9c3690dec3625e2318aa54e \
|
||||
--reviewed-posters docs/coverage/www-1995-posters-reviewed-2026-11-09.json \
|
||||
--reviewed-posters-sha256 <核查文件的SHA256> \
|
||||
--out <新的输出目录>
|
||||
```
|
||||
|
||||
[1995 年15条poster的核查输入](coverage/www-1995-posters-reviewed-2026-11-09.json) 绑定61页来源快照:原46页和补取的15页单篇HTML。程序验证输入文件及快照哈希、单篇页面哈希、完全一致的 canonical H1、原摘要目录中的同一URL/entry ID及作者证据,不通过相似标题猜身份。规范标题采用单篇H1,摘要目录标题及作者索引标题均在 metadata 中保留。
|
||||
|
||||
`author_source=canonical_byline` 表示名字出现在H1之后、摘要/关键词或下一节标题之前的署名区域,由逐条核查明确选出,不从全文引用或机构猜作者;保留原始缩写、顺序、重音和拼写。只有 `author_source=author_index` 时使用原作者索引顺序并逐项核对;05的单篇页面写的是联系人,作者身份使用原作者索引证据,不由联系人角色推断。核查输入是审阅后的数据声明,程序的字符串/哈希检查不代替人工识别署名语义。
|
||||
|
||||
38和05的来源没有可明确引用的摘要。保留 `abstract=null` 和逐条缺失原因,manifest准确报告 missing_abstract_count,继续标partial;正文、联系方式及PostScript链接不当作摘要,也不下载正文。缺摘要不等于拒稿或未发表。本批输出仍包含原先已验收记录,正式导入前必须只取新增external ID,检查实时覆盖父记录并按incremental模式准备,不能直接重导入全部输出。
|
||||
@@ -13,6 +13,8 @@ COMMANDS = {
|
||||
('download',): ('paper_atlas.io.snapshots', {}, '下载并校验固定版本快照'),
|
||||
('collect','pmlr'): ('paper_atlas.collectors.pmlr', {}, 'PMLR 官方元数据'),
|
||||
('collect','acl'): ('paper_atlas.collectors.acl', {}, 'ACL Anthology XML'),
|
||||
('collect','springer-selected'): ('paper_atlas.collectors.springer_selected', {}, '核验限定的会后选集章节与原会议关系'),
|
||||
('collect','iclr-archive'): ('paper_atlas.collectors.iclr_archive', {}, '核对ICLR 2014–2016官方历史录用档案'),
|
||||
('collect','iclr-program'): ('paper_atlas.collectors.iclr', {}, 'ICLR 官方公开程序'),
|
||||
('collect','iclr-snapshot'): ('paper_atlas.collectors.openreview', {}, 'ICLR 公开投稿与决定快照'),
|
||||
('collect','public-openreview'): ('paper_atlas.collectors.public_openreview', {}, '核验其他会议的公开拒稿及撤稿标签'),
|
||||
@@ -23,10 +25,13 @@ 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 历史会议目录'),
|
||||
('import','arxiv'): ('paper_atlas.ingestion.arxiv', {}, '导入 arXiv 元数据 ZIP'),
|
||||
('import','records'): ('paper_atlas.ingestion.records', {}, '导入核验过的会议文件'),
|
||||
('maintain','edition-tracks'): ('paper_atlas.ingestion.edition_tracks', {}, '按逐条来源证据修正既有投稿轨道并保护刷新'),
|
||||
('maintain','joint-editions'): ('paper_atlas.ingestion.joint_editions', {}, '按已核验计划修正既有联合会议届次归属'),
|
||||
('maintain','event-identity'): ('paper_atlas.ingestion.event_identity', {}, '按核验计划增加同年会议事件身份'),
|
||||
('maintain','merge'): ('paper_atlas.ingestion.maintenance', {}, '协调单写入合并与恢复'),
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
"""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 == 2025:
|
||||
from paper_atlas.collectors.aamas2025 import collect as collect_2025
|
||||
return collect_2025(out, cache=cache, snapshots=snapshots)
|
||||
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 not in (2012, 2025):
|
||||
raise ValueError('Only independently reviewed AAMAS 2012/2025 layouts are 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, 2025], 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))
|
||||
@@ -0,0 +1,183 @@
|
||||
"""Reviewed AAMAS 2025 publisher TOC; HTML metadata only, never paper PDFs."""
|
||||
from collections import Counter, defaultdict
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import tempfile
|
||||
|
||||
from paper_atlas.db.submissions import timestamp
|
||||
from paper_atlas.io.html import Document, Node, clean
|
||||
from paper_atlas.io.http import fetch
|
||||
|
||||
ROOT = 'https://www.ifaamas.org/Proceedings/aamas2025/'
|
||||
URLS = {'index': ROOT, 'contents': ROOT+'forms/contents.htm'}
|
||||
TITLE = 'AAMAS 2025: Proceedings of the 2025 International Conference on Autonomous Agents and Multiagent Systems'
|
||||
SECTIONS = {
|
||||
'1': ('Keynote Talks', 'keynote', 'keynote', 3),
|
||||
'2': ('Research Paper Track', 'full_paper', 'proceedings_unspecified', 260),
|
||||
'3': ('Extended Abstracts', 'extended_abstract', 'proceedings_unspecified', 148),
|
||||
'4': ('Blue Sky Ideas', 'blue_sky_paper', 'blue_sky', 12),
|
||||
'5': ('Doctoral Consortium', 'doctoral_abstract', 'doctoral', 25),
|
||||
'6': ('Demo Track', 'demo', 'demo', 22),
|
||||
'7': ('JAAMAS Track', 'journal_presentation', 'jaamas', 9),
|
||||
}
|
||||
COMPETITION_TITLE = '[COMP24] The Automated Negotiating Agents Competition (ANAC) 2024 Challenges and Results'
|
||||
PAPER = re.compile(r'\.\./pdfs/p([0-9]+)\.pdf')
|
||||
|
||||
|
||||
def one(items, label):
|
||||
if len(items) != 1:
|
||||
raise ValueError('Expected one '+label)
|
||||
return items[0]
|
||||
|
||||
|
||||
def parse_contents(html, evidence):
|
||||
doc = Document(html)
|
||||
if [clean(n.text()) for n in doc.root.walk() if n.tag == 'title'] != [TITLE]:
|
||||
raise ValueError('Unexpected AAMAS proceedings title/year')
|
||||
for paragraph in doc.root.walk():
|
||||
if paragraph.tag != 'p' or any(n is not paragraph and n.tag == 'p' for n in paragraph.walk()):
|
||||
continue
|
||||
anchors = [n.attrs['name'] for n in paragraph.walk() if n.tag == 'a' and n.attrs.get('name') in SECTIONS]
|
||||
if anchors and (len(anchors) != 1 or clean(paragraph.text()) != SECTIONS[anchors[0]][0]):
|
||||
raise ValueError('Publisher section heading differs from reviewed layout')
|
||||
section = None
|
||||
headers = []
|
||||
records, excluded, counts, seen = [], [], Counter(), set()
|
||||
for node in doc.root.walk():
|
||||
if node.tag == 'a' and node.attrs.get('name') in SECTIONS:
|
||||
section = node.attrs['name']
|
||||
headers.append(section)
|
||||
if node.tag != 'p' or any(n is not node and n.tag == 'p' for n in node.walk()):
|
||||
continue
|
||||
links = [n for n in node.walk() if n.tag == 'a' and PAPER.fullmatch(n.attrs.get('href', ''))]
|
||||
if not links:
|
||||
continue
|
||||
link = one(links, 'paper link per paragraph')
|
||||
if section not in SECTIONS:
|
||||
raise ValueError('Paper outside a reviewed publisher section')
|
||||
page = int(PAPER.fullmatch(link.attrs['href'])[1])
|
||||
if page in seen:
|
||||
raise ValueError('Repeated publisher paper/page ID')
|
||||
seen.add(page)
|
||||
title = clean(link.text())
|
||||
if not title:
|
||||
raise ValueError('Empty paper title')
|
||||
# Names are outside italicized affiliations and separated by explicit BRs.
|
||||
font = one([n for n in node.walk() if n.tag == 'font' and n.attrs.get('color', '').lower() == '#222222'], 'ordered author block')
|
||||
authors, affiliations = [], []
|
||||
name, affiliation = '', ''
|
||||
def flush():
|
||||
nonlocal name, affiliation
|
||||
if clean(name) or clean(affiliation):
|
||||
if not clean(name):
|
||||
raise ValueError('Affiliation without author')
|
||||
authors.append(clean(name)); affiliations.append(clean(affiliation))
|
||||
name, affiliation = '', ''
|
||||
for child in font.children:
|
||||
if isinstance(child, str):
|
||||
name += child
|
||||
elif child.tag == 'br':
|
||||
flush()
|
||||
elif child.tag == 'i':
|
||||
affiliation += child.text()
|
||||
else:
|
||||
raise ValueError('Unreviewed author markup: '+child.tag)
|
||||
flush()
|
||||
if not authors:
|
||||
raise ValueError('Empty author list')
|
||||
heading, publication_format, track, _ = SECTIONS[section]
|
||||
counts[section] += 1
|
||||
if section == '1':
|
||||
excluded.append({'publisher_page': page, 'title': title, 'authors': authors, 'reason': 'publisher keynote talk, not a research-paper submission'})
|
||||
continue
|
||||
note = 'Explicit publisher section; section topics are not research types.'
|
||||
if track == 'proceedings_unspecified':
|
||||
note = 'Published format is known; individual main/special submission track is not provided.'
|
||||
if page == 3000:
|
||||
if section != '6' or title != COMPETITION_TITLE:
|
||||
raise ValueError('Competition report differs from reviewed publisher entry')
|
||||
publication_format, track = 'competition_report', 'proceedings_unspecified'
|
||||
note = 'Publisher places this competition report in Demo Track; no individual demonstration submission is inferred.'
|
||||
records.append({
|
||||
'source': 'aamas_proceedings', 'external_id': '2025/p'+str(page),
|
||||
'url': URLS['contents']+'#'+section, 'title': title, 'abstract': None,
|
||||
'authors': authors, 'identifiers': {}, 'venue': 'AAMAS', 'venue_aliases': [],
|
||||
'year': 2025, 'track': track, 'status': 'accepted',
|
||||
'observed_at': evidence['observed_at'],
|
||||
'raw_decision': 'listed in official AAMAS 2025 published proceedings '+heading,
|
||||
'tags': [{'namespace': 'aamas_proceedings:publication_format', 'value': publication_format}],
|
||||
'metadata': {'publisher_page': page, 'paper_url': ROOT+'pdfs/p'+str(page)+'.pdf',
|
||||
'section_url': URLS['contents'], 'section_anchor': section,
|
||||
'section_heading': heading, 'publication_format': publication_format,
|
||||
'author_affiliations': affiliations, 'source_evidence': evidence,
|
||||
'track_scope_note': note, 'decision_date_precision': 'not_supplied',
|
||||
'missing_abstract_reason': 'Publisher HTML supplies no abstract; research PDF not downloaded.',
|
||||
'publication_evidence': 'Published proceedings entry, not an original decision note.'}})
|
||||
if headers != list(SECTIONS) or dict(counts) != {k: v[3] for k, v in SECTIONS.items()}:
|
||||
raise ValueError('Publisher section inventory differs from reviewed 2025 layout')
|
||||
if len(records) != 476 or len(excluded) != 3 or sum(r['metadata']['publication_format'] == 'competition_report' for r in records) != 1:
|
||||
raise ValueError('Unexpected publication partition')
|
||||
return records, excluded
|
||||
|
||||
|
||||
def read_pages(cache, snapshots):
|
||||
if bool(cache) == bool(snapshots):
|
||||
raise ValueError('Choose exactly one cache or frozen snapshot directory')
|
||||
frozen = json.loads((Path(snapshots)/'receipts.json').read_text())['receipts'] if snapshots else []
|
||||
pages = {}
|
||||
for key, url in URLS.items():
|
||||
if snapshots:
|
||||
r = one([r for r in frozen if r['url'] == url], 'frozen response receipt')
|
||||
filename = Path(r['file']); base = Path(snapshots).resolve()
|
||||
if filename.is_absolute() or '..' in filename.parts or not (base/filename).resolve().is_relative_to(base) or r.get('http_status') != 200:
|
||||
raise ValueError('Unsafe or unsuccessful publisher response')
|
||||
data = (base/filename).read_bytes()
|
||||
if len(data) > 8*1024**2 or len(data) != r['bytes'] or hashlib.sha256(data).hexdigest() != r['sha256']:
|
||||
raise ValueError('Frozen publisher response checksum/size mismatch')
|
||||
observed = r['at']
|
||||
else:
|
||||
data = fetch(url, cache, 8*1024**2, delay=1)
|
||||
r = json.loads((Path(cache)/hashlib.sha256(url.encode()).hexdigest()).with_suffix('.json').read_text())
|
||||
observed = r['retrieved_at']
|
||||
timestamp(observed)
|
||||
pages[key] = data, {'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': r}
|
||||
return pages
|
||||
|
||||
|
||||
def collect(out, *, cache=None, snapshots=None):
|
||||
pages = read_pages(cache, snapshots)
|
||||
if [clean(n.text()) for n in Document(pages['index'][0].decode('utf-8')).root.walk() if n.tag == 'title'] != [TITLE]:
|
||||
raise ValueError('Unexpected AAMAS 2025 introduction')
|
||||
records, excluded = parse_contents(pages['contents'][0].decode('utf-8'), pages['contents'][1])
|
||||
grouped = defaultdict(list)
|
||||
for row in records: grouped[row['track']].append(row)
|
||||
out = Path(out); out.mkdir(parents=True, exist_ok=True); files = []
|
||||
with tempfile.TemporaryDirectory(prefix='.aamas2025-', dir=out) as temporary:
|
||||
stage = Path(temporary); (stage/'evidence').mkdir()
|
||||
for data, e in pages.values(): (stage/e['file']).write_bytes(data)
|
||||
for track, group in sorted(grouped.items()):
|
||||
p = stage/f'aamas-proceedings-2025-{track}.jsonl'
|
||||
p.write_text(''.join(json.dumps(r, ensure_ascii=False)+'\n' for r in group))
|
||||
m = {'source': 'aamas_proceedings', 'parser_version': 2, 'venue': 'AAMAS', 'year': 2025,
|
||||
'track': track, 'count': len(group), 'file': str(out/p.name), 'file_sha256': hashlib.sha256(p.read_bytes()).hexdigest(),
|
||||
'list_url': URLS['contents'], 'status': 'partial', 'missing_abstract_count': len(group),
|
||||
'publication_format_counts': dict(Counter(r['metadata']['publication_format'] for r in group)),
|
||||
'note': 'Complete published TOC partition; HTML abstracts absent, identities and submission-track scope require independent review.'}
|
||||
p.with_suffix('.manifest.json').write_text(json.dumps(m, ensure_ascii=False, indent=2)+'\n'); files.append(m)
|
||||
result = {'source': 'aamas_proceedings', 'year': 2025, 'finished_at': timestamp(),
|
||||
'status': 'collected_pending_identity_review', 'records': len(records), 'files': files,
|
||||
'excluded_publications': excluded, 'source_receipts': [e for _, e in pages.values()],
|
||||
'publication_format_counts': dict(Counter(r['metadata']['publication_format'] for r in records)),
|
||||
'database_written': False, 'pdfs_downloaded': False,
|
||||
'note': 'Neither absence from proceedings nor aggregate rejection/withdrawal counts identify an individual negative decision.'}
|
||||
report = 'aamas-proceedings-2025.json'; (stage/report).write_text(json.dumps(result, ensure_ascii=False, indent=2)+'\n')
|
||||
(out/'evidence').mkdir(exist_ok=True)
|
||||
for p in (stage/'evidence').iterdir(): p.replace(out/'evidence'/p.name)
|
||||
for p in stage.iterdir():
|
||||
if p.is_file() and p.name != report: p.replace(out/p.name)
|
||||
(stage/report).replace(out/report)
|
||||
return result
|
||||
@@ -6,6 +6,7 @@ import xml.etree.ElementTree as ET
|
||||
from pathlib import Path
|
||||
|
||||
from paper_atlas.io.git_metadata import checkout_metadata
|
||||
from paper_atlas.collectors.acl_historical import event_scope, material_reason
|
||||
|
||||
VENUES={'acl':'ACL','emnlp':'EMNLP','naacl':'NAACL','eacl':'EACL','coling':'COLING','conll':'CoNLL'}
|
||||
|
||||
@@ -29,7 +30,10 @@ def paper_id(collection,volume,number):
|
||||
return f'{collection}-{int(volume):0{width}d}{int(number):0{digits}d}'
|
||||
|
||||
|
||||
def volume_scope(meta,volume):
|
||||
def volume_scope(meta,volume,collection=None):
|
||||
historical=event_scope(meta,volume,collection)
|
||||
if historical:
|
||||
return historical['canonical_venue'],historical['venue_aliases'],historical['track']
|
||||
codes=[text(n).lower() for n in meta.findall('venue')]
|
||||
book=text(meta.find('booktitle'))
|
||||
lower=book.lower()
|
||||
@@ -59,7 +63,7 @@ def collect_xml(path,out,commit):
|
||||
vid=volume.attrib['id']
|
||||
meta=volume.find('meta')
|
||||
if meta is None:raise ValueError('Volume metadata missing')
|
||||
scope=volume_scope(meta,vid)
|
||||
scope=volume_scope(meta,vid,collection)
|
||||
key=f'{collection}-{vid}'
|
||||
if scope is None:
|
||||
results.append({'volume':key,'status':'excluded_by_venue','venue_codes':[text(n) for n in meta.findall('venue')],'booktitle':text(meta.find('booktitle')),'year':text(meta.find('year'))})
|
||||
@@ -70,12 +74,18 @@ def collect_xml(path,out,commit):
|
||||
year=int(year_text)
|
||||
records=[]
|
||||
frontmatter=0
|
||||
excluded_materials=[]
|
||||
historical=event_scope(meta,vid,collection)
|
||||
for paper in volume.findall('paper'):
|
||||
pid=paper.attrib['id']
|
||||
if int(pid)==0:
|
||||
frontmatter+=1
|
||||
continue
|
||||
identifier=paper_id(collection,vid,pid)
|
||||
reason=material_reason(collection,vid,paper)
|
||||
if reason:
|
||||
excluded_materials.append({'external_id':identifier,'title':text(paper.find('title')),'reason':reason})
|
||||
continue
|
||||
doi=text(paper.find('doi'))
|
||||
if doi.startswith('10.18653/v1/') and doi.split('10.18653/v1/',1)[1].casefold()!=identifier.casefold():
|
||||
raise ValueError('Official DOI disagrees with constructed Anthology ID: '+identifier)
|
||||
@@ -83,6 +93,8 @@ def collect_xml(path,out,commit):
|
||||
if not title:raise ValueError('Missing paper title: '+identifier)
|
||||
authors=[{'given':text(a.find('first')),'family':text(a.find('last'))} for a in paper.findall('author')]
|
||||
records.append({'source':'acl_anthology','external_id':identifier,'url':f'https://aclanthology.org/{identifier}/','title':title,'abstract':text(paper.find('abstract')) or None,'authors':authors,'identifiers':{'acl':identifier,'doi':doi},'venue':venue,'venue_aliases':aliases if len(aliases)>1 else [],'year':year,'track':track,'status':'accepted','raw_decision':'listed in authoritative ACL Anthology proceedings XML','metadata':{'collection':collection,'volume':vid,'booktitle':text(meta.find('booktitle')),'venue_codes':[text(n) for n in meta.findall('venue')],'pages':text(paper.find('pages')),'publisher':text(meta.find('publisher')),'metadata_commit':commit,'source_xml':f'https://github.com/acl-org/acl-anthology/blob/{commit}/data/xml/{collection}.xml'}})
|
||||
if historical:
|
||||
records[-1]['metadata']['historical_event_scope']=historical
|
||||
if not records:
|
||||
results.append({'volume':key,'status':'unavailable','reason':'no research paper elements after excluding front matter'})
|
||||
continue
|
||||
@@ -93,7 +105,7 @@ def collect_xml(path,out,commit):
|
||||
for record in records:output.write(json.dumps(record,ensure_ascii=False)+'\n')
|
||||
part.replace(target)
|
||||
missing=sum(not row['abstract'] for row in records)
|
||||
manifest={'source':'acl_anthology','venue':venue,'year':year,'track':track,'count':len(records),'xml_paper_elements':len(volume.findall('paper')),'excluded_frontmatter':frontmatter,'missing_abstract_count':missing,'list_url':f'https://aclanthology.org/{key}/','metadata_commit':commit,'file':str(target),'status':'partial' if missing else 'collected'}
|
||||
manifest={'source':'acl_anthology','venue':venue,'year':year,'track':track,'count':len(records),'xml_paper_elements':len(volume.findall('paper')),'excluded_frontmatter':frontmatter,'excluded_materials':excluded_materials,'missing_abstract_count':missing,'list_url':f'https://aclanthology.org/{key}/','metadata_commit':commit,'file':str(target),'status':'partial' if missing else 'collected'}
|
||||
target.with_suffix('.manifest.json').write_text(json.dumps(manifest,indent=2))
|
||||
results.append(manifest)
|
||||
print(json.dumps(manifest),flush=True)
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Edition-specific historical scope; never alias every TINLAP year to ACL."""
|
||||
|
||||
HISTORY_URL = 'https://aclanthology.org/www.mt-archive.info/70/ACL-1982-Walker-1.pdf'
|
||||
NOTICE_URL = 'https://aclanthology.org/J77-4001.pdf'
|
||||
BOOK = 'Theoretical Issues in Natural Language Processing-2'
|
||||
MATERIAL_TITLE = 'List of questions suggested for consideration in each session'
|
||||
|
||||
|
||||
def text(node):
|
||||
return ' '.join(''.join(node.itertext()).split()) if node is not None else ''
|
||||
|
||||
|
||||
def event_scope(meta, volume, collection):
|
||||
if (collection, volume) != ('T78', '1'):
|
||||
return None
|
||||
if ([text(n) for n in meta.findall('venue')] != ['tinlap']
|
||||
or [text(n) for n in meta.findall('year')] != ['1978']
|
||||
or [text(n) for n in meta.findall('booktitle')] != [BOOK]
|
||||
or [(text(n.find('first')), text(n.find('last'))) for n in meta.findall('editor')]
|
||||
!= [('David L.', 'Waltz')]):
|
||||
raise ValueError('Reviewed T78-1 scope metadata changed; independent review required')
|
||||
return {
|
||||
'collection': 'T78', 'volume': '1', 'original_venue': 'TINLAP',
|
||||
'event_year': 1978, 'event_dates': ['1978-07-25', '1978-07-27'],
|
||||
'relationship': 'ACL annual meeting took the form of TINLAP-2',
|
||||
'canonical_venue': 'ACL+TINLAP', 'venue_aliases': ['ACL', 'TINLAP'],
|
||||
'track': 'proceedings_unspecified',
|
||||
'track_scope_note': 'Invited and submitted contributions are described at volume level; individual roles are not identified in XML.',
|
||||
'evidence': [
|
||||
{'url': NOTICE_URL, 'sha256': '14b5deb471c55afb07d789529b95d0462a2a1a77df3dfba7fa1f5d3ab75eb411',
|
||||
'pdf_pages': [9, 10], 'printed_pages': ['9', '10'],
|
||||
'scope': 'Contemporary annual meeting notice, not individual decision notes; ACM confirmation was pending in this notice.'},
|
||||
{'url': HISTORY_URL, 'sha256': '35c21bd4983975bcaccbb94bd8ab91c9019fc190feec1791255407cf18a7e49a',
|
||||
'pdf_pages': [3], 'printed_pages': ['91'],
|
||||
'scope': 'Retrospective annual meeting table and TNLP abbreviation confirm the 1978 event association.'},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def material_reason(collection, volume, paper):
|
||||
if (collection, volume, paper.attrib.get('id')) != ('T78', '1', '36'):
|
||||
return None
|
||||
if text(paper.find('title')) != MATERIAL_TITLE or paper.findall('author'):
|
||||
raise ValueError('Reviewed T78-1036 material changed; independent review required')
|
||||
return 'Session question list, not an authored research contribution; no decision inferred.'
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
"""Frozen official ICLR 2014–2016 lists; presentation evidence is not a track."""
|
||||
import argparse
|
||||
from collections import Counter, defaultdict
|
||||
import fcntl
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import tempfile
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from paper_atlas.db.submissions import timestamp
|
||||
from paper_atlas.io.html import Document, Node, clean
|
||||
from paper_atlas.io.http import fetch
|
||||
|
||||
SOURCE = 'iclr_archive'
|
||||
PAGES = {
|
||||
2014: ('https://iclr.cc/archive/2014/conference-proceedings/', {'main': 35}),
|
||||
2015: ('https://iclr.cc/archive/www/doku.php%3Fid=iclr2015:accepted-main.html', {'main': 31, 'workshop': 74}),
|
||||
2016: ('https://iclr.cc/archive/www/doku.php%3Fid=iclr2016:accepted-main.html', {'main': 80}),
|
||||
}
|
||||
SECTIONS = {
|
||||
2015: {'Main Conference - Oral Presentations': ('main', 'oral'),
|
||||
'Main Conference - Poster Presentations': ('main', 'poster'),
|
||||
'Workshop Papers': ('workshop', 'workshop')},
|
||||
2016: {'Oral Presentations': ('main', 'oral'), 'Poster Presentations': ('main', 'poster')},
|
||||
}
|
||||
|
||||
|
||||
def arxiv_link(url):
|
||||
parsed = urlsplit(url)
|
||||
if parsed.hostname != 'arxiv.org':
|
||||
return None
|
||||
match = re.fullmatch(r'/(?:abs|pdf)/(\d{4}\.\d{4,5})(v[1-9]\d*)?(?:\.pdf)?', parsed.path)
|
||||
if (parsed.scheme not in ('http', 'https') or parsed.netloc != 'arxiv.org'
|
||||
or parsed.query or parsed.fragment or not match):
|
||||
raise ValueError('Unexpected official arXiv link')
|
||||
return match[1], match[2] or None
|
||||
|
||||
|
||||
def without_links(node):
|
||||
if node.tag in ('a', 'script', 'style'):
|
||||
return ''
|
||||
value = ''.join(without_links(n) if isinstance(n, Node) else n for n in node.children)
|
||||
return ' '+value+' ' if node.tag in ('div', 'li', 'p', 'br') else value
|
||||
|
||||
|
||||
def authors(display, year):
|
||||
parts = display.rstrip(';').split(';') if year == 2014 else re.split(r',\s*(?:and\s+)?|\s+and\s+', display)
|
||||
names = [clean(p) for p in parts]
|
||||
if not names or any(not name for name in names):
|
||||
raise ValueError('Missing author or unsupported author delimiter')
|
||||
return names
|
||||
|
||||
|
||||
def parse_page(html, year, evidence):
|
||||
if year not in PAGES:
|
||||
raise ValueError('Unreviewed ICLR archive year')
|
||||
document = Document(html)
|
||||
nodes = list(document.root.walk())
|
||||
headings = [clean(n.text()) for n in nodes if n.tag in ('h1', 'h2', 'h3')]
|
||||
if year == 2014:
|
||||
if 'Conference Proceedings' not in headings or 'conference papers accepted' not in clean(document.root.text()):
|
||||
raise ValueError('Unexpected ICLR 2014 accepted list')
|
||||
elif (set(SECTIONS[year]) - set(headings)
|
||||
or (year == 2016 and 'Accepted Papers (Conference Track)' not in headings)):
|
||||
raise ValueError('Unexpected ICLR accepted sections')
|
||||
entries = []
|
||||
section = None
|
||||
for index, node in enumerate(nodes):
|
||||
if node.tag in ('h1', 'h2', 'h3'):
|
||||
section = clean(node.text())
|
||||
if year == 2014:
|
||||
if node.tag != 'a' or not arxiv_link(node.attrs.get('href', '')):
|
||||
continue
|
||||
link = node
|
||||
# This archived Google Sites page has an entry outside a paragraph.
|
||||
# Authors can span several differently formatted italic spans.
|
||||
# Read their whole paragraph, never only the first styled fragment.
|
||||
display = None
|
||||
for following in nodes[index+sum(1 for _ in link.walk()):]:
|
||||
if following.tag == 'a' and arxiv_link(following.attrs.get('href', '')):
|
||||
break
|
||||
if following.tag in ('i', 'em') or (following.tag == 'p' and any(
|
||||
n.tag in ('i', 'em') or 'font-style:italic' in n.attrs.get('style', '').replace(' ', '')
|
||||
for n in following.walk())):
|
||||
text = clean(following.text())
|
||||
if text:
|
||||
display = text
|
||||
break
|
||||
if not display:
|
||||
raise ValueError('Missing explicit ICLR 2014 author span')
|
||||
track, presentation = 'main', 'conference'
|
||||
else:
|
||||
if node.tag != 'li':
|
||||
continue
|
||||
links = [n for n in node.walk() if n.tag == 'a' and arxiv_link(n.attrs.get('href', ''))]
|
||||
if not links:
|
||||
continue
|
||||
if len(links) != 1 or section not in SECTIONS[year]:
|
||||
raise ValueError('Ambiguous paper link or unknown accepted section')
|
||||
link, = links
|
||||
display = clean(without_links(node)).lstrip(',').strip()
|
||||
track, presentation = SECTIONS[year][section]
|
||||
arxiv, version = arxiv_link(link.attrs['href'])
|
||||
title = clean(link.text())
|
||||
if not title:
|
||||
raise ValueError('Empty official title')
|
||||
entries.append({'arxiv': arxiv, 'version': version, 'title': title,
|
||||
'authors': authors(display, year), 'authors_display': display,
|
||||
'paper_url': link.attrs['href'], 'track': track,
|
||||
'section': section, 'presentation': presentation})
|
||||
# Check every arXiv occurrence, including links outside the expected layout.
|
||||
occurrences = sum(n.tag == 'a' and bool(arxiv_link(n.attrs.get('href', ''))) for n in nodes)
|
||||
if len(entries) != occurrences or not entries:
|
||||
raise ValueError('Some official paper links were not parsed')
|
||||
grouped = defaultdict(list)
|
||||
for entry in entries:
|
||||
grouped[(entry['track'], entry['arxiv'])].append(entry)
|
||||
result = []
|
||||
for (track, arxiv), variants in grouped.items():
|
||||
first = variants[0]
|
||||
if any((v['title'], v['authors']) != (first['title'], first['authors']) for v in variants):
|
||||
raise ValueError('Repeated presentation has conflicting title or authors; review required')
|
||||
presentations = sorted({v['presentation'] for v in variants})
|
||||
result.append({'source': SOURCE, 'external_id': f'{year}/{track}/{arxiv}',
|
||||
'url': PAGES[year][0], 'title': first['title'], 'abstract': None,
|
||||
'authors': first['authors'], 'identifiers': {'arxiv': arxiv},
|
||||
'venue': 'ICLR', 'venue_aliases': [], 'year': year, 'track': track,
|
||||
'status': 'accepted', 'observed_at': evidence['observed_at'],
|
||||
'raw_decision': 'listed in official ICLR accepted '+track+' list',
|
||||
'metadata': {'source_evidence': evidence, 'source_variants': variants,
|
||||
'presentations': presentations, 'decision_date_precision': 'not_supplied',
|
||||
'identity_note': 'Official arXiv link, not title matching; validate canonical identity before import.',
|
||||
'decision_evidence': 'Archived accepted list, not an original decision note.'},
|
||||
'tags': [{'namespace': SOURCE+':presentation', 'value': value} for value in presentations]})
|
||||
return result
|
||||
|
||||
|
||||
def read_page(year, *, cache=None, snapshots=None):
|
||||
if bool(cache) == bool(snapshots):
|
||||
raise ValueError('Choose exactly one cache or frozen snapshot directory')
|
||||
url = PAGES[year][0]
|
||||
if snapshots:
|
||||
root = Path(snapshots).resolve()
|
||||
receipts = json.loads((root/'receipts.json').read_bytes())['receipts']
|
||||
matches = [r for r in receipts if r['url'] == url]
|
||||
if len(matches) != 1:
|
||||
raise ValueError('Expected one exact official response receipt')
|
||||
receipt, = matches
|
||||
filename = Path(receipt['file'])
|
||||
path = root/filename
|
||||
if (filename.is_absolute() or '..' in filename.parts or not path.resolve().is_relative_to(root)
|
||||
or receipt['status'] != 200):
|
||||
raise ValueError('Unsafe or unsuccessful archive snapshot')
|
||||
data = path.read_bytes()
|
||||
if (len(data) > 8*1024**2 or len(data) != receipt['bytes']
|
||||
or hashlib.sha256(data).hexdigest() != receipt['sha256']):
|
||||
raise ValueError('Archive snapshot checksum or size differs')
|
||||
observed = timestamp(receipt['finished_at'])
|
||||
else:
|
||||
data = fetch(url, cache, 8*1024**2, delay=1)
|
||||
receipt = json.loads((Path(cache)/hashlib.sha256(url.encode()).hexdigest()).with_suffix('.json').read_bytes())
|
||||
observed = timestamp(receipt['retrieved_at'])
|
||||
evidence = {'url': url, 'sha256': hashlib.sha256(data).hexdigest(), 'bytes': len(data),
|
||||
'file': 'evidence/'+hashlib.sha256(data).hexdigest()+'.html',
|
||||
'observed_at': observed, 'http_status': 200, 'response_receipt': receipt}
|
||||
return data, evidence
|
||||
|
||||
|
||||
def collect(year, out, *, cache=None, snapshots=None):
|
||||
if year not in PAGES:
|
||||
raise ValueError('Only independently reviewed ICLR archive years are supported')
|
||||
out = Path(out)
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
with (out/'iclr-archive.collect.lock').open('a') as lock:
|
||||
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
report = out/f'iclr-archive-{year}.json'
|
||||
if report.exists():
|
||||
raise FileExistsError('Existing completed archive output must be inspected, not overwritten')
|
||||
data, evidence = read_page(year, cache=cache, snapshots=snapshots)
|
||||
records = parse_page(data.decode('utf-8'), year, evidence)
|
||||
counts = Counter(r['track'] for r in records)
|
||||
if dict(counts) != PAGES[year][1]:
|
||||
raise ValueError('Distinct archive entries differ from reviewed inventory')
|
||||
files = []
|
||||
with tempfile.TemporaryDirectory(prefix='.iclr-archive-', dir=out) as temporary:
|
||||
stage = Path(temporary)
|
||||
(stage/'evidence').mkdir()
|
||||
(stage/evidence['file']).write_bytes(data)
|
||||
for track in sorted(counts):
|
||||
group = [r for r in records if r['track'] == track]
|
||||
path = stage/f'iclr-archive-{year}-{track}.jsonl'
|
||||
path.write_text(''.join(json.dumps(r, ensure_ascii=False)+'\n' for r in group))
|
||||
manifest = {'source': SOURCE, 'parser_version': 1, 'venue': 'ICLR', 'year': year,
|
||||
'track': track, 'count': len(group), 'file': str(out/path.name),
|
||||
'file_sha256': hashlib.sha256(path.read_bytes()).hexdigest(),
|
||||
'list_url': PAGES[year][0], 'status': 'partial', 'missing_abstract_count': len(group)}
|
||||
path.with_suffix('.manifest.json').write_text(json.dumps(manifest, ensure_ascii=False, indent=2)+'\n')
|
||||
files.append(manifest)
|
||||
result = {'source': SOURCE, 'year': year, 'records': len(records), 'files': files,
|
||||
'source_evidence': evidence, 'database_written': False, 'pdfs_downloaded': False,
|
||||
'status': 'collected_pending_identity_review',
|
||||
'note': 'Accepted list only. Oral/poster are presentation evidence; absent papers are not rejected. '
|
||||
'ICLR2014 workshop and other unlisted tracks remain outside these reviewed pages.'}
|
||||
(stage/report.name).write_text(json.dumps(result, ensure_ascii=False, indent=2)+'\n')
|
||||
(out/'evidence').mkdir(exist_ok=True)
|
||||
for path in (stage/'evidence').iterdir():
|
||||
path.replace(out/'evidence'/path.name)
|
||||
for path in stage.iterdir():
|
||||
if path.is_file() and path.name != report.name:
|
||||
path.replace(out/path.name)
|
||||
(stage/report.name).replace(report)
|
||||
return result
|
||||
|
||||
|
||||
def main(argv=None, *, prog=None):
|
||||
parser = argparse.ArgumentParser(prog=prog)
|
||||
parser.add_argument('--year', type=int, choices=sorted(PAGES), required=True)
|
||||
parser.add_argument('--out', required=True)
|
||||
source = parser.add_mutually_exclusive_group(required=True)
|
||||
source.add_argument('--cache')
|
||||
source.add_argument('--snapshots')
|
||||
args = parser.parse_args(argv)
|
||||
print(json.dumps(collect(args.year, args.out, cache=args.cache, snapshots=args.snapshots), ensure_ascii=False))
|
||||
@@ -0,0 +1,231 @@
|
||||
"""Reviewed postconference selected volumes from frozen public HTML metadata."""
|
||||
import argparse
|
||||
from collections import defaultdict
|
||||
import fcntl
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import tempfile
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
from paper_atlas.db.submissions import timestamp
|
||||
from paper_atlas.io.html import Document, clean
|
||||
|
||||
SOURCE = 'springer_selected_volume'
|
||||
BOOK = '10.1007/978-1-4612-2660-4'
|
||||
BOOK_URL = 'https://link.springer.com/book/'+BOOK
|
||||
HISTORY_URL = 'https://www.gatsby.ucl.ac.uk/aistats/workshops.html'
|
||||
HISTORY_SHA = '6db205b9dbc82b4a096556efd2aa67e065cad8e0bd6351176b1962ffd1fca179'
|
||||
SCOPE = {
|
||||
'venue': 'AISTATS', 'year': 1993, 'track': 'selected_volume',
|
||||
'event_key': 'aistats-1993-selected-volume-1994',
|
||||
'publication_year': 1994, 'book_doi': BOOK,
|
||||
'book_title': 'Selecting Models from Data', 'expected_chapters': 49,
|
||||
'relationship': 'Selected workshop papers were revised and rereviewed for later publication; this is not the original complete workshop decision list.',
|
||||
}
|
||||
|
||||
|
||||
def snapshot_index(root):
|
||||
root = Path(root).resolve()
|
||||
receipts = json.loads((root/'receipts.json').read_bytes())['responses']
|
||||
index = {}
|
||||
for receipt in receipts:
|
||||
url = receipt['url']
|
||||
if url in index:
|
||||
raise ValueError('Duplicate snapshot URL')
|
||||
index[url] = (root, receipt)
|
||||
return index
|
||||
|
||||
|
||||
def read_page(index, url):
|
||||
if url not in index:
|
||||
raise ValueError('Missing required public HTML snapshot: '+url)
|
||||
root, receipt = index[url]
|
||||
filename = Path(receipt['file'])
|
||||
path = root/filename
|
||||
content_type = {k.lower():v for k,v in receipt.get('headers', {}).items()}.get('content-type', '')
|
||||
requested, final = urlsplit(url), urlsplit(receipt.get('final_url', ''))
|
||||
query = parse_qs(final.query, keep_blank_values=True)
|
||||
original_query = parse_qs(requested.query, keep_blank_values=True)
|
||||
# Springer returns the public metadata with an explicit cookie diagnostic.
|
||||
# Retain that final URL verbatim; accept no other redirect or changed page.
|
||||
if query != original_query and requested.netloc == 'link.springer.com':
|
||||
if (query.pop('error', None) != ['cookies_not_supported']
|
||||
or not re.fullmatch(r'[0-9a-f-]{36}', ''.join(query.pop('code', [])))):
|
||||
raise ValueError('Unreviewed Springer redirect query')
|
||||
if (filename.is_absolute() or '..' in filename.parts or not path.resolve().is_relative_to(root)
|
||||
or receipt.get('http_status') != 200
|
||||
or (final.scheme, final.netloc, final.path) != (requested.scheme, requested.netloc, requested.path)
|
||||
or final.fragment or query != original_query
|
||||
or 'text/html' not in content_type.lower()):
|
||||
raise ValueError('Unsafe, redirected or unsuccessful public HTML snapshot')
|
||||
raw = path.read_bytes()
|
||||
digest = hashlib.sha256(raw).hexdigest()
|
||||
if len(raw) > 2_000_000 or len(raw) != receipt['bytes'] or digest != receipt['sha256']:
|
||||
raise ValueError('Snapshot checksum or size differs')
|
||||
evidence = {'url':url, 'sha256':digest, 'bytes':len(raw),
|
||||
'file':'evidence/'+digest+'.html', 'http_status':200,
|
||||
'observed_at':timestamp(receipt['at']), 'response_receipt':receipt}
|
||||
return raw, evidence
|
||||
|
||||
|
||||
def parse_toc(html, *, excluded_materials=None):
|
||||
entries = {}
|
||||
for node in Document(html).root.walk():
|
||||
if node.tag != 'li' or node.attrs.get('data-test') != 'chapter':
|
||||
continue
|
||||
links = [n for n in node.walk() if n.tag == 'a' and re.fullmatch(
|
||||
r'/chapter/'+re.escape(BOOK)+r'_[1-9]\d*', n.attrs.get('href', ''))]
|
||||
if not links:
|
||||
headings = [clean(n.text()) for n in node.walk() if n.tag in ('h3','h4')]
|
||||
frontmatter = [n.attrs.get('href','') for n in node.walk()
|
||||
if n.tag == 'a' and n.attrs.get('data-test') == 'front-matter-pdf']
|
||||
if headings == ['Front Matter'] and len(frontmatter) == 1:
|
||||
if excluded_materials is not None:
|
||||
excluded_materials.append({'title':'Front Matter', 'url':frontmatter[0],
|
||||
'reason':'Section front matter without a chapter DOI; not an authored chapter.'})
|
||||
continue
|
||||
if len(links) != 1:
|
||||
raise ValueError('Chapter entry lacks one reviewed-book DOI')
|
||||
link, = links
|
||||
doi = link.attrs['href'].removeprefix('/chapter/')
|
||||
if doi in entries or not clean(link.text()):
|
||||
raise ValueError('Duplicate DOI or missing chapter title')
|
||||
entries[doi] = clean(link.text())
|
||||
return entries
|
||||
|
||||
|
||||
def parse_chapter(html, doi):
|
||||
nodes = list(Document(html).root.walk())
|
||||
meta = defaultdict(list)
|
||||
for node in nodes:
|
||||
if node.tag == 'meta' and node.attrs.get('name', '').startswith('citation_'):
|
||||
meta[node.attrs['name']].append(node.attrs.get('content', ''))
|
||||
def one(key):
|
||||
values = meta[key]
|
||||
if len(values) != 1 or not values[0]:
|
||||
raise ValueError('Missing or ambiguous citation field: '+key)
|
||||
return values[0]
|
||||
if (one('citation_doi') != doi or one('citation_inbook_title') != SCOPE['book_title']
|
||||
or one('citation_publication_date') != str(SCOPE['publication_year'])):
|
||||
raise ValueError('Chapter DOI, book or publication year differs from reviewed scope')
|
||||
raw_authors = meta['citation_author']
|
||||
if not raw_authors:
|
||||
raise ValueError('Missing named chapter authors')
|
||||
authors = []
|
||||
for raw in raw_authors:
|
||||
if raw.count(',') != 1:
|
||||
raise ValueError('Unreviewed citation author format')
|
||||
family, given = [clean(s) for s in raw.split(',', 1)]
|
||||
if not family or not given:
|
||||
raise ValueError('Incomplete citation author')
|
||||
authors.append({'given':given, 'family':family})
|
||||
abstract_nodes = [n for n in nodes if n.attrs.get('id') == 'Abs1-content']
|
||||
if len(abstract_nodes) > 1:
|
||||
raise ValueError('Ambiguous abstract section')
|
||||
abstract = clean(abstract_nodes[0].text()) if abstract_nodes else None
|
||||
first, last = one('citation_firstpage'), one('citation_lastpage')
|
||||
if not first.isdigit() or not last.isdigit() or int(first) > int(last):
|
||||
raise ValueError('Invalid chapter pages')
|
||||
return {'doi':doi, 'title':one('citation_title'), 'authors':authors,
|
||||
'authors_citation_raw':list(raw_authors), 'abstract':abstract or None,
|
||||
'first_page':first, 'last_page':last}
|
||||
|
||||
|
||||
def record_for(chapter, evidence, scope_evidence):
|
||||
return {
|
||||
'source':SOURCE, 'external_id':chapter['doi'],
|
||||
'url':'https://link.springer.com/chapter/'+chapter['doi'],
|
||||
'title':chapter['title'], 'abstract':chapter['abstract'], 'authors':chapter['authors'],
|
||||
'identifiers':{'doi':chapter['doi']},
|
||||
'submission_identifiers':[{'scheme':'publication_doi','value':chapter['doi']}],
|
||||
**{k:SCOPE[k] for k in ('venue','year','track','event_key')},
|
||||
'status':'accepted', 'observed_at':evidence['observed_at'],
|
||||
'raw_decision':'listed in the 1994 selected postconference volume; original 1993 review decision not supplied',
|
||||
'tags':[{'namespace':SOURCE+':publication_format','value':'selected_volume_chapter'}],
|
||||
'metadata':{
|
||||
'publication_format':'selected_volume_chapter', 'bibliographic_year':1994,
|
||||
'related_workshop_year':1993, 'book_doi':BOOK, 'book_title':SCOPE['book_title'],
|
||||
'year_basis':'Reviewed workshop year; bibliographic publication year is separately retained.',
|
||||
'first_page':chapter['first_page'], 'last_page':chapter['last_page'],
|
||||
'authors_citation_raw':chapter['authors_citation_raw'],
|
||||
'publication_evidence':'Published selected-volume chapter, not an original workshop decision note.',
|
||||
'scope_relationship':SCOPE['relationship'], 'decision_date_precision':'not_supplied',
|
||||
'source_evidence':evidence, 'scope_evidence':scope_evidence,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def collect(out, *, toc_snapshots, chapter_snapshots):
|
||||
toc_index, chapter_index = snapshot_index(toc_snapshots), snapshot_index(chapter_snapshots)
|
||||
frozen = {}
|
||||
def read(index, url):
|
||||
raw, evidence = read_page(index, url)
|
||||
frozen[evidence['file']] = raw
|
||||
return raw.decode('utf-8'), evidence
|
||||
history, history_evidence = read(toc_index, HISTORY_URL)
|
||||
if hashlib.sha256(history.encode()).hexdigest() != HISTORY_SHA:
|
||||
raise ValueError('Reviewed selected-volume relationship evidence changed')
|
||||
scope_evidence = [history_evidence]
|
||||
titles, excluded = {}, []
|
||||
for page in range(1, 4):
|
||||
html, evidence = read(toc_index, BOOK_URL+('?page='+str(page) if page > 1 else ''))
|
||||
scope_evidence.append(evidence)
|
||||
entries = parse_toc(html, excluded_materials=excluded)
|
||||
if titles.keys() & entries.keys():
|
||||
raise ValueError('DOI repeated across TOC pages')
|
||||
titles.update(entries)
|
||||
expected = {BOOK+'_'+str(i) for i in range(1, 50)}
|
||||
if titles.keys() != expected:
|
||||
raise ValueError('Reviewed 49-chapter inventory changed')
|
||||
chapter_urls = {'https://link.springer.com/chapter/'+doi for doi in expected}
|
||||
if chapter_index.keys() != chapter_urls:
|
||||
raise ValueError('Missing or extraneous chapter snapshot')
|
||||
records = []
|
||||
for doi in sorted(expected, key=lambda s:int(s.rsplit('_',1)[1])):
|
||||
html, evidence = read(chapter_index, 'https://link.springer.com/chapter/'+doi)
|
||||
chapter = parse_chapter(html, doi)
|
||||
if chapter['title'] != titles[doi]:
|
||||
raise ValueError('Chapter title disagrees with official TOC')
|
||||
records.append(record_for(chapter, evidence, scope_evidence))
|
||||
out = Path(out)
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
with (out/'springer-selected.collect.lock').open('a') as lock:
|
||||
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
if any(p.name != 'springer-selected.collect.lock' for p in out.iterdir()):
|
||||
raise FileExistsError('Existing output must be inspected, not overwritten')
|
||||
with tempfile.TemporaryDirectory(prefix='.springer-selected-', dir=out) as temporary:
|
||||
stage = Path(temporary)
|
||||
(stage/'evidence').mkdir()
|
||||
for filename, raw in frozen.items():
|
||||
(stage/filename).write_bytes(raw)
|
||||
path = stage/'springer-aistats-1993-selected-volume.jsonl'
|
||||
path.write_text(''.join(json.dumps(r,ensure_ascii=False)+'\n' for r in records))
|
||||
manifest = {'source':SOURCE, 'parser_version':1,
|
||||
**{k:SCOPE[k] for k in ('venue','year','track','event_key')},
|
||||
'count':len(records), 'file':str(out/path.name),
|
||||
'file_sha256':hashlib.sha256(path.read_bytes()).hexdigest(),
|
||||
'list_url':BOOK_URL, 'status':'partial',
|
||||
'excluded_materials':excluded,
|
||||
'missing_abstract_count':sum(not r['abstract'] for r in records)}
|
||||
path.with_suffix('.manifest.json').write_text(json.dumps(manifest,indent=2)+'\n')
|
||||
report = {'status':'collected_pending_identity_review', 'records':len(records),
|
||||
'files':[manifest], 'reviewed_scope':SCOPE, 'database_written':False,
|
||||
'pdfs_downloaded':False, 'scope_evidence':scope_evidence}
|
||||
(stage/'collection-report.json').write_text(json.dumps(report,ensure_ascii=False,indent=2)+'\n')
|
||||
for item in stage.iterdir():
|
||||
if item.name != 'collection-report.json':
|
||||
item.replace(out/item.name)
|
||||
(stage/'collection-report.json').replace(out/'collection-report.json')
|
||||
return report
|
||||
|
||||
|
||||
def main(argv=None, *, prog=None):
|
||||
parser = argparse.ArgumentParser(prog=prog)
|
||||
parser.add_argument('--toc-snapshots', required=True)
|
||||
parser.add_argument('--chapter-snapshots', required=True)
|
||||
parser.add_argument('--out', required=True)
|
||||
args = parser.parse_args(argv)
|
||||
print(json.dumps(collect(args.out, toc_snapshots=args.toc_snapshots,
|
||||
chapter_snapshots=args.chapter_snapshots),ensure_ascii=False))
|
||||
@@ -0,0 +1,340 @@
|
||||
"""Reproducible early WWW metadata; unresolved program entries stay outside imports."""
|
||||
import argparse
|
||||
from collections import defaultdict
|
||||
import hashlib
|
||||
from html.parser import HTMLParser
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import tempfile
|
||||
from urllib.parse import urljoin, urlsplit
|
||||
|
||||
from paper_atlas.io.html import Document, clean
|
||||
from paper_atlas.io.http import fetch
|
||||
|
||||
ROOT = 'https://archives.iw3c2.org/'
|
||||
SPRING = ROOT+'www3/proceedings/'
|
||||
FALL = ROOT+'www2/Proceedings/WWW2_Proceedings.html'
|
||||
SEEDS = [ROOT+'www1/PrelimProcs.html', ROOT+'www1/Proceedings.html', FALL,
|
||||
SPRING+'proceedings.html', *[SPRING+'papers/'+x+'.html' for x in ('author','session','abstract')],
|
||||
*[SPRING+'posters/'+x+'.html' for x in ('author','abstract')]]
|
||||
|
||||
|
||||
def decode(raw):
|
||||
try:
|
||||
return raw.decode('utf-8-sig'), 'utf-8'
|
||||
except UnicodeDecodeError:
|
||||
return raw.decode('iso-8859-1'), 'iso-8859-1 fallback; original bytes retained'
|
||||
|
||||
|
||||
def text(html):
|
||||
return re.sub(r'[ \t\r\n\f\v]+',' ',Document(html).root.text()).strip(' \t\r\n\f\v')
|
||||
|
||||
|
||||
def anchors(html):
|
||||
return [(n.attrs.get('href',''),clean(n.text())) for n in Document(html).root.walk() if n.tag=='a']
|
||||
|
||||
|
||||
def source_url(url):
|
||||
p=urlsplit(url)
|
||||
if p.scheme!='https' or p.netloc!='archives.iw3c2.org' or p.query or p.fragment or not p.path.endswith('.html'):
|
||||
raise ValueError('Only exact official archive HTML source URLs are allowed')
|
||||
return url
|
||||
|
||||
|
||||
class Sources:
|
||||
def __init__(self, directory, expected_sha256):
|
||||
self.root=Path(directory).resolve();manifest=self.root/'manifest.json';raw=manifest.read_bytes()
|
||||
if hashlib.sha256(raw).hexdigest()!=expected_sha256:
|
||||
raise ValueError('Source cache manifest checksum mismatch')
|
||||
self.rows={};self.used=set()
|
||||
for row in json.loads(raw)['sources']:
|
||||
url=source_url(row['url'])
|
||||
if url in self.rows or row.get('status')!=200:
|
||||
raise ValueError('Duplicate or incomplete source response')
|
||||
p=self.root/row['file']
|
||||
if p.is_symlink() or p.parent!=self.root or not p.is_file():
|
||||
raise ValueError('Source cache paths must be plain files directly within the cache')
|
||||
data=p.read_bytes()
|
||||
if len(data)>2*1024**2 or len(data)!=row['bytes'] or hashlib.sha256(data).hexdigest()!=row['sha256']:
|
||||
raise ValueError('Source response checksum or size mismatch')
|
||||
self.rows[url]={'raw':data,'receipt':row}
|
||||
|
||||
def read(self,url):
|
||||
source_url(url)
|
||||
if url not in self.rows:
|
||||
raise ValueError('Required source missing from cache: '+url)
|
||||
self.used.add(url)
|
||||
return decode(self.rows[url]['raw'])[0]
|
||||
|
||||
def proof(self,urls):
|
||||
return [{'url':url,'sha256':self.rows[url]['receipt']['sha256'],
|
||||
'encoding':decode(self.rows[url]['raw'])[1]} for url in sorted(set(urls))]
|
||||
|
||||
|
||||
def download_sources(http_cache, directory):
|
||||
directory=Path(directory)
|
||||
if directory.exists():
|
||||
raise ValueError('Refusing to overwrite a source snapshot')
|
||||
directory.mkdir(parents=True)
|
||||
responses={url:fetch(url,http_cache,2*1024**2) for url in SEEDS}
|
||||
html=decode(responses[FALL])[0]
|
||||
for href,label in anchors(html):
|
||||
if href!='Author_Inst.html' and href.endswith('.html'):
|
||||
url=source_url(urljoin(FALL,href))
|
||||
if not url.startswith(ROOT+'www2/Proceedings/'):
|
||||
raise ValueError('Unexpected fall category source')
|
||||
responses[url]=fetch(url,http_cache,2*1024**2)
|
||||
receipts=[]
|
||||
for i,(url,raw) in enumerate(sorted(responses.items())):
|
||||
name='source-'+str(i).zfill(2)+'.html';(directory/name).write_bytes(raw)
|
||||
receipts.append({'url':url,'status':200,'file':name,'bytes':len(raw),'sha256':hashlib.sha256(raw).hexdigest()})
|
||||
manifest=directory/'manifest.json';manifest.write_text(json.dumps({'sources':receipts},indent=2)+'\n')
|
||||
return hashlib.sha256(manifest.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def identity(href,track):
|
||||
url=urljoin(SPRING+track+'/',href);p=urlsplit(url)
|
||||
match=re.fullmatch(r'/www3/proceedings/'+track+r'/(\d+)/[^?#]+',p.path)
|
||||
if p.scheme=='https' and p.netloc=='archives.iw3c2.org' and not p.query and not p.fragment and match:
|
||||
return match[1],url
|
||||
return None
|
||||
|
||||
|
||||
class AuthorIndex(HTMLParser):
|
||||
def __init__(self,html,track):
|
||||
super().__init__(convert_charrefs=True);self.track=track;self.name='';self.bold=None;self.link=None;self.link_text=[]
|
||||
self.entries=defaultdict(lambda:{'authors':[],'urls':set(),'titles':set()});self.feed(html)
|
||||
|
||||
def handle_starttag(self,tag,attrs):
|
||||
if tag=='b':self.bold=[]
|
||||
if tag=='a':self.link=dict(attrs).get('href');self.link_text=[]
|
||||
|
||||
def handle_data(self,data):
|
||||
if self.bold is not None:self.bold.append(data)
|
||||
if self.link is not None:self.link_text.append(data)
|
||||
|
||||
def handle_endtag(self,tag):
|
||||
if tag=='b' and self.bold is not None:
|
||||
self.name=clean(''.join(self.bold));self.bold=None
|
||||
if tag=='a' and self.link is not None:
|
||||
own=identity(self.link,self.track)
|
||||
if own:
|
||||
key,url=own;entry=self.entries[key];entry['urls'].add(url);entry['titles'].add(clean(''.join(self.link_text)))
|
||||
if not self.name:raise ValueError('Paper link has no archive author context')
|
||||
if self.name not in entry['authors']:entry['authors'].append(self.name)
|
||||
self.link=None
|
||||
|
||||
|
||||
def sections(html):
|
||||
"""Use explicit h3 boundaries; old HTML omits many p/li closing tags."""
|
||||
result=[]
|
||||
for chunk in re.split(r'<h3\b[^>]*>',html,flags=re.I)[1:]:
|
||||
parts=re.split(r'</h3\s*>',chunk,maxsplit=1,flags=re.I)
|
||||
if len(parts)!=2:raise ValueError('Unclosed archive entry heading')
|
||||
heading,body=parts
|
||||
body=re.split(r'<(?:hr|h2|address)\b',body,maxsplit=1,flags=re.I)[0]
|
||||
author=re.search(r'<i\b[^>]*>(.*?)</i\s*>',body,re.I|re.S)
|
||||
paragraphs=[]
|
||||
for paragraph in re.findall(r'<p\b[^>]*>(.*?)(?=</p\s*>|<p\b|$)',body,re.I|re.S):
|
||||
value=text(paragraph)
|
||||
if value and not value.casefold().startswith('keywords:'):paragraphs.append(value)
|
||||
result.append({'heading':heading,'title':text(heading),'abstract':'\n\n'.join(paragraphs)or None,
|
||||
'author_byline_raw':text(author[1])if author else None})
|
||||
if not result:raise ValueError('No explicit archive entry headings')
|
||||
return result
|
||||
|
||||
|
||||
def reviewed_posters(sources, path, expected_sha256, cache_sha256):
|
||||
if path is None:
|
||||
if expected_sha256:raise ValueError('Reviewed poster path required')
|
||||
return {},None
|
||||
raw=Path(path).read_bytes()
|
||||
if hashlib.sha256(raw).hexdigest()!=expected_sha256:raise ValueError('Reviewed poster checksum mismatch')
|
||||
review=json.loads(raw)
|
||||
if set(review)!={'format','source_cache_sha256','records'} or review['format']!=1 or review['source_cache_sha256']!=cache_sha256:
|
||||
raise ValueError('Reviewed poster snapshot binding differs')
|
||||
if not isinstance(review['records'],list) or not 0<len(review['records'])<=100:
|
||||
raise ValueError('Bounded nonempty poster review required')
|
||||
result={}
|
||||
for row in review['records']:
|
||||
if set(row)!={'entry_id','individual_url','individual_sha256','title','authors','author_source','reason','missing_abstract_reason'}:
|
||||
raise ValueError('Unexpected reviewed poster fields')
|
||||
key=row['entry_id'];own=identity(row['individual_url'],'posters')
|
||||
if not isinstance(key,str) or not own or own[0]!=key or key in result:
|
||||
raise ValueError('Reviewed poster identity duplicated or outside canonical entry')
|
||||
if row['author_source'] not in ('canonical_byline','author_index') or not isinstance(row['authors'],list) or not row['authors'] or any(not isinstance(a,str) or not a.strip()for a in row['authors']):
|
||||
raise ValueError('Explicit author evidence required')
|
||||
if len(set(row['authors']))!=len(row['authors']) or not isinstance(row['reason'],str) or not row['reason'].strip():
|
||||
raise ValueError('Reviewed author order/reason invalid')
|
||||
if sources.rows.get(own[1],{}).get('receipt',{}).get('sha256')!=row['individual_sha256']:
|
||||
raise ValueError('Individual poster page checksum differs')
|
||||
page=sources.read(own[1]);headings=re.findall(r'<h1\b[^>]*>(.*?)</h1\s*>',page,re.I|re.S)
|
||||
if len(headings)!=1 or text(headings[0])!=row['title']:
|
||||
raise ValueError('Reviewed title differs from exact canonical H1')
|
||||
header=re.split(r'</h1\s*>',page,maxsplit=1,flags=re.I)[1]
|
||||
header=re.split(r'<h[23]\b',header,maxsplit=1,flags=re.I)[0]
|
||||
header=re.split(r'\b(?:keywords|abstract)\s*:',text(header),maxsplit=1,flags=re.I)[0]
|
||||
if row['author_source']=='canonical_byline' and any(a.casefold() not in header.casefold()for a in row['authors']):
|
||||
raise ValueError('Reviewed author absent from canonical byline region')
|
||||
result[key]={**row,'canonical_byline_region_raw':header}
|
||||
return result,hashlib.sha256(raw).hexdigest()
|
||||
|
||||
|
||||
def reviewed_row(sources,key,url,item,authors,review):
|
||||
if url!=review['individual_url']:
|
||||
raise ValueError('Reviewed canonical page differs from abstract-entry URL')
|
||||
if authors and authors['urls']!={url}:
|
||||
raise ValueError('Reviewed poster author-index URL disagrees')
|
||||
if review['author_source']=='author_index':
|
||||
names=[]
|
||||
for name in authors['authors']if authors else []:
|
||||
parts=name.split(',');names.append(clean(parts[1]+' '+parts[0])if len(parts)==2 else name)
|
||||
if names!=review['authors']:raise ValueError('Reviewed author-index evidence differs')
|
||||
missing=review['missing_abstract_reason']
|
||||
if not isinstance(missing,str) or bool(missing.strip())==bool(item['abstract']):
|
||||
raise ValueError('Missing abstract requires explicit reason; never invent a summary')
|
||||
urls=[SPRING+'posters/author.html',SPRING+'posters/abstract.html',url]
|
||||
return {'source':'www_archive','external_id':'www3:posters:'+key,'url':url,'title':review['title'],
|
||||
'abstract':item['abstract'],'authors':review['authors'],'identifiers':{},'venue':'WWW','year':1995,
|
||||
'track':'poster','event_key':'www-1995-spring','status':'accepted','raw_decision':'listed in official posters proceedings',
|
||||
'metadata':{'entry_id':key,'source_evidence':sources.proof(urls),'abstract_heading_title_raw':item['title'],
|
||||
'author_byline_raw':item['author_byline_raw'],'author_index_raw':authors['authors']if authors else [],
|
||||
'author_index_titles_raw':sorted(authors['titles'])if authors else [],'abstract_href_raw':url,
|
||||
'author_order':review['author_source'],'reviewed_poster_identity':review,
|
||||
'missing_abstract_reason':missing or None},
|
||||
'tags':[{'namespace':'www_archive:proceedings_section','value':'posters'}]}
|
||||
|
||||
|
||||
def spring_rows(sources,track,reviews=None):
|
||||
base=SPRING+track+'/';author_url=base+'author.html';abstract_url=base+'abstract.html'
|
||||
authors=AuthorIndex(sources.read(author_url),track).entries
|
||||
listing=sections(sources.read(abstract_url));by_id={};held=[];session={}
|
||||
if track=='papers':
|
||||
session_url=base+'session.html'
|
||||
for href,title in anchors(sources.read(session_url)):
|
||||
own=identity(href,track)
|
||||
if own:
|
||||
key,url=own
|
||||
if key in session:raise ValueError('Duplicate main session entry ID')
|
||||
session[key]=url
|
||||
for item in listing:
|
||||
links=[identity(h,track)for h,t in anchors(item['heading'])if identity(h,track)]
|
||||
if len(links)!=1:
|
||||
held.append({'reason':'unverified_heading_identity','evidence':abstract_url,'candidate':item});continue
|
||||
key,url=links[0]
|
||||
if key in by_id:raise ValueError('Duplicate abstract entry ID')
|
||||
by_id[key]=(url,item)
|
||||
if not by_id:raise ValueError('No stable abstract entry IDs')
|
||||
if track=='papers' and (set(authors)!=set(by_id) or set(session)!=set(by_id)):
|
||||
raise ValueError('Main author/session/abstract entry ID sets disagree')
|
||||
if set(authors)-set(by_id):raise ValueError('Author index has entries outside the abstract listing')
|
||||
reviews=reviews or {}
|
||||
if set(reviews)-set(by_id):raise ValueError('Reviewed poster outside abstract listing')
|
||||
rows=[]
|
||||
for key,(url,item) in by_id.items():
|
||||
if key in reviews:
|
||||
if track!='posters':raise ValueError('Reviews only apply to poster entries')
|
||||
rows.append(reviewed_row(sources,key,url,item,authors.get(key),reviews[key]));continue
|
||||
if key not in authors:
|
||||
held.append({'reason':'abstract_entry_missing_linked_author_index','entry_id':key,'evidence':abstract_url,'candidate':item});continue
|
||||
alternatives=authors[key]['urls']
|
||||
if len(alternatives)!=1:raise ValueError('Ambiguous author-index paper URL')
|
||||
canonical=next(iter(alternatives))
|
||||
if track=='papers' and session[key]!=canonical:raise ValueError('Main author/session paper URLs disagree')
|
||||
if url!=canonical and (track!='papers' or not re.search(r'\s',url)):
|
||||
raise ValueError('Uncorroborated archive URL discrepancy')
|
||||
if not item['abstract']:
|
||||
held.append({'reason':'missing_abstract','entry_id':key,'evidence':abstract_url,'candidate':item});continue
|
||||
if item['title'].casefold() not in {t.casefold() for t in authors[key]['titles']}:
|
||||
held.append({'reason':'abstract_and_author_index_titles_disagree','entry_id':key,'evidence':abstract_url,'candidate':item,'author_index_titles':sorted(authors[key]['titles'])});continue
|
||||
names=[]
|
||||
for name in authors[key]['authors']:
|
||||
parts=name.split(',')
|
||||
names.append(clean(parts[1]+' '+parts[0])if len(parts)==2 else name)
|
||||
proof_urls=[author_url,abstract_url]+([session_url]if track=='papers'else[])
|
||||
rows.append({'source':'www_archive','external_id':'www3:'+track+':'+key,'url':canonical,
|
||||
'title':item['title'],'abstract':item['abstract'],'authors':names,'identifiers':{},
|
||||
'venue':'WWW','year':1995,'track':'main'if track=='papers'else'poster','event_key':'www-1995-spring',
|
||||
'status':'accepted','raw_decision':'listed in official '+track+' proceedings',
|
||||
'metadata':{'entry_id':key,'source_evidence':sources.proof(proof_urls),
|
||||
'author_index_raw':authors[key]['authors'],'author_index_titles_raw':sorted(authors[key]['titles']),'author_order':'archive author-index order, not original byline rank',
|
||||
'author_byline_raw':item['author_byline_raw'],'abstract_href_raw':url,
|
||||
'url_corroborated_by_author_and_session':url!=canonical},
|
||||
'tags':[{'namespace':'www_archive:proceedings_section','value':track}]})
|
||||
return rows,held,{'abstract_entries':len(by_id),'linked_author_entries':len(authors),'ready':len(rows),'held':len(held)}
|
||||
|
||||
|
||||
def early_review(sources):
|
||||
held=[];url=ROOT+'www1/PrelimProcs.html';html=sources.read(url)
|
||||
for ordinal,chunk in enumerate(re.split(r'<li\b[^>]*>',html,flags=re.I)[1:],1):
|
||||
title=re.search(r'<em>Title:</em>\s*<b>(.*?)</b>',chunk,re.I|re.S)
|
||||
if not title:raise ValueError('Unclassified first-conference entry')
|
||||
names=re.findall(r'<em>Author:</em>(.*?)(?=<br\b)',chunk,re.I|re.S)
|
||||
held.append({'reason':'formal_presentation_scope_and_count_need_review','event_key':'www-1994-spring',
|
||||
'ordinal':ordinal,'evidence':url,'candidate':{'title':text(title[1]),
|
||||
'authors_raw':[re.sub(r'\s*<[^>]+>','',text(n)).strip()for n in names],
|
||||
'keywords_raw':text(re.search(r'<em>Keywords:</em>(.*?)(?=<br\b)',chunk,re.I|re.S)[1]) if re.search(r'<em>Keywords:</em>(.*?)(?=<br\b)',chunk,re.I|re.S)else None},
|
||||
'source_evidence':sources.proof([url])})
|
||||
if not held:raise ValueError('First-conference presentation listing empty')
|
||||
category_urls={}
|
||||
for href,label in anchors(sources.read(FALL)):
|
||||
if href!='Author_Inst.html' and href.endswith('.html'):
|
||||
category_url=source_url(urljoin(FALL,href))
|
||||
if not category_url.startswith(ROOT+'www2/Proceedings/'):raise ValueError('Unexpected fall category')
|
||||
category_urls[category_url]=label
|
||||
if not category_urls:raise ValueError('Fall archive category listing empty')
|
||||
for url,label in sorted(category_urls.items()):
|
||||
html=sources.read(url)
|
||||
for ordinal,chunk in enumerate(re.split(r'<dt\b[^>]*>',html,flags=re.I)[1:],1):
|
||||
fields=re.split(r'<dd\b[^>]*>',chunk,maxsplit=1,flags=re.I)
|
||||
title=text(fields[0]);raw_byline=text(re.split(r'</dl\b',fields[1],maxsplit=1,flags=re.I)[0]) if len(fields)==2 else None
|
||||
if title:
|
||||
held.append({'reason':'mixed_program_category_not_verified_paper_acceptance','event_key':'www-1994-fall',
|
||||
'category':label,'ordinal':ordinal,'evidence':url,'candidate':{'title':title,'authors_raw':raw_byline},
|
||||
'source_evidence':sources.proof([FALL,url])})
|
||||
return held,{'first_conference_fields':sum(r['event_key']=='www-1994-spring'for r in held),'fall_categories':len(category_urls),
|
||||
'fall_program_entries':sum(r['event_key']=='www-1994-fall'for r in held)}
|
||||
|
||||
|
||||
def collect(source_cache,cache_sha256,out,*,reviewed=None,reviewed_sha256=None):
|
||||
out=Path(out)
|
||||
if out.exists():raise ValueError('Refusing to overwrite an existing normalized batch')
|
||||
sources=Sources(source_cache,cache_sha256);groups={};held=[];counts={}
|
||||
reviews,review_sha=reviewed_posters(sources,reviewed,reviewed_sha256,cache_sha256)
|
||||
for track in ('papers','posters'):
|
||||
rows,review,counts[track]=spring_rows(sources,track,reviews if track=='posters'else None);groups[track]=rows;held.extend(review)
|
||||
review,counts['earlier']=early_review(sources);held.extend(review)
|
||||
for item in held:
|
||||
if 'source_evidence' not in item:item['source_evidence']=sources.proof([item['evidence']])
|
||||
out.parent.mkdir(parents=True,exist_ok=True)
|
||||
with tempfile.TemporaryDirectory(prefix='.www-archive-',dir=out.parent)as tmp:
|
||||
stage=Path(tmp);queue=[]
|
||||
for track,rows in groups.items():
|
||||
if not rows:continue
|
||||
filename='www-1995-spring-'+('main'if track=='papers'else'poster')+'.jsonl';raw=''.join(json.dumps(r,ensure_ascii=False)+'\n'for r in rows).encode();(stage/filename).write_bytes(raw)
|
||||
manifest={'source':'www_archive','parser_version':2 if review_sha else 1,'venue':'WWW','year':1995,'track':rows[0]['track'],'event_key':'www-1995-spring',
|
||||
'list_url':SPRING+track+'/abstract.html','count':len(rows),'status':'partial','file_sha256':hashlib.sha256(raw).hexdigest(),
|
||||
'missing_abstract_count':sum(not bool((r['abstract']or '').strip())for r in rows),'source_listing_count':counts[track]['abstract_entries'],'held_entry_count':counts[track]['held'],'source_cache_sha256':cache_sha256,'scope':'Verified linked archive entries; held entries and other event tracks excluded, not complete event coverage.'}
|
||||
(stage/filename).with_suffix('.manifest.json').write_text(json.dumps(manifest,indent=2)+'\n');queue.append(str(out/filename))
|
||||
(stage/'review-candidates.jsonl').write_text(''.join(json.dumps(r,ensure_ascii=False)+'\n'for r in held))
|
||||
report={'status':'normalized_not_imported','source_cache_sha256':cache_sha256,'counts':counts,'ready':sum(len(r)for r in groups.values()),'held':len(held),
|
||||
'source_evidence':sources.proof(sources.used),'files':queue,'scope':'Early WWW archive only; no PDF fetching, no rejected/withdrawn inference, no database access. Event-aware importer required.'}
|
||||
if review_sha:report['reviewed_posters_sha256']=review_sha
|
||||
(stage/'report.json').write_text(json.dumps(report,ensure_ascii=False,indent=2)+'\n')
|
||||
(stage/'import-queue.json').write_text(json.dumps({'files':queue},indent=2)+'\n')
|
||||
stage.rename(out)
|
||||
return report
|
||||
|
||||
|
||||
def main(argv=None,*,prog=None):
|
||||
p=argparse.ArgumentParser(prog=prog);p.add_argument('--source-cache',required=True);p.add_argument('--source-cache-sha256');p.add_argument('--http-cache');p.add_argument('--out',required=True)
|
||||
p.add_argument('--reviewed-posters');p.add_argument('--reviewed-posters-sha256')
|
||||
args=p.parse_args(argv)
|
||||
if bool(args.http_cache)==bool(args.source_cache_sha256):p.error('Choose a fresh --http-cache download or an existing --source-cache-sha256 snapshot')
|
||||
sha=download_sources(args.http_cache,args.source_cache)if args.http_cache else args.source_cache_sha256
|
||||
if bool(args.reviewed_posters)!=bool(args.reviewed_posters_sha256):p.error('Reviewed poster file and SHA256 must be supplied together')
|
||||
print(json.dumps(collect(args.source_cache,sha,args.out,reviewed=args.reviewed_posters,reviewed_sha256=args.reviewed_posters_sha256),ensure_ascii=False),flush=True)
|
||||
|
||||
|
||||
if __name__=='__main__':main()
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Retain reviewed event scopes when a source refresh repeats a whole-volume label."""
|
||||
import json
|
||||
|
||||
KEY = 'edition_scope_curation_v1'
|
||||
|
||||
|
||||
def preserve(metadata, previous):
|
||||
old = json.loads(previous).get(KEY) if previous else None
|
||||
if KEY in metadata and metadata[KEY] != old:
|
||||
raise ValueError('Source import cannot supply or replace reviewed edition scope')
|
||||
if old is not None:
|
||||
metadata[KEY] = old
|
||||
return metadata
|
||||
|
||||
|
||||
def resolve(c, record, external, venue, year, track, event_key):
|
||||
row = c.execute('SELECT metadata_json FROM source_records WHERE id=?', (record,)).fetchone()
|
||||
entries = json.loads(row[0]).get(KEY, []) if row else []
|
||||
if not isinstance(entries, list):
|
||||
raise ValueError('Invalid reviewed edition scope')
|
||||
applicable = []
|
||||
for entry in entries:
|
||||
if entry['external_id'] != external:
|
||||
continue
|
||||
old, target = entry['from'], entry['target']
|
||||
if int(year) != old['year'] or venue not in entry['venue_names']:
|
||||
continue
|
||||
if track not in (old['track'], target['track']):
|
||||
continue
|
||||
if event_key is not None and event_key != old['event_key']:
|
||||
continue
|
||||
current = c.execute('SELECT venue_id,year,track,event_key FROM editions WHERE id=?', (target['edition_id'],)).fetchone()
|
||||
if not current or [current[i] for i in range(4)] != [target[k] for k in ('venue_id','year','track','event_key')]:
|
||||
raise ValueError('Reviewed target edition has changed')
|
||||
applicable.append(target['edition_id'])
|
||||
if len(applicable) > 1:
|
||||
raise ValueError('Ambiguous reviewed edition scope')
|
||||
return applicable[0] if applicable else None
|
||||
@@ -96,6 +96,8 @@ class Store:
|
||||
if found:
|
||||
matches.add(found[0])
|
||||
old = c.execute('SELECT paper_id,title,abstract,retrieved_at,metadata_json FROM source_records WHERE source_id=? AND external_id=?',(source_id:=self.source(source,url),external_id)).fetchone()
|
||||
from paper_atlas.db.edition_scopes import preserve
|
||||
metadata = preserve(dict(metadata or {}), old[4] if old else None)
|
||||
if old:
|
||||
matches.add(old[0])
|
||||
key = normalized(title)
|
||||
@@ -175,7 +177,10 @@ class Store:
|
||||
for alias in aliases or []:
|
||||
c.execute('INSERT INTO venue_aliases VALUES(?,?) ON CONFLICT DO NOTHING',(venue_id,alias))
|
||||
from paper_atlas.db.events import find_edition
|
||||
edition = find_edition(self,venue_id,venue,int(year),track,record,external_id,event_key)
|
||||
from paper_atlas.db.edition_scopes import resolve
|
||||
edition = resolve(c,record,external_id,venue,year,track,event_key)
|
||||
if edition is None:
|
||||
edition = find_edition(self,venue_id,venue,int(year),track,record,external_id,event_key)
|
||||
# A previous event in this row may already have merged the input paper.
|
||||
paper=c.execute('SELECT paper_id FROM source_records WHERE id=?',(record,)).fetchone()[0]
|
||||
submission=attach_submission(self,edition,paper,record,external_id,identities or [])
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
"""Snapshot-bound reclassification of existing attempts, without reimport or merge."""
|
||||
import argparse
|
||||
from collections import Counter
|
||||
from contextlib import closing
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
from paper_atlas.db.backend import database_arguments, open_read_connection, open_store, inserted_id
|
||||
from paper_atlas.db.edition_scopes import KEY
|
||||
from paper_atlas.ingestion.event_identity import database_identity, digest, values
|
||||
|
||||
MARKER = 'edition_tracks_v1:'
|
||||
|
||||
|
||||
def validate(inventory):
|
||||
if (not isinstance(inventory, dict) or inventory.get('format') != 1
|
||||
or not isinstance(inventory.get('records'), list) or not 0 < len(inventory['records']) <= 10000):
|
||||
raise ValueError('Nonempty bounded reviewed inventory required')
|
||||
ids = set(); records = set()
|
||||
for row in inventory['records']:
|
||||
required = {'submission_id','source_record_id','external_id','source','doi','from_edition_id',
|
||||
'year','venue','venue_names','track','section','evidence_url','evidence_sha256'}
|
||||
if not isinstance(row, dict) or set(row) != required:
|
||||
raise ValueError('Unexpected inventory record fields')
|
||||
for field in ('submission_id','source_record_id','from_edition_id','year'):
|
||||
if type(row[field]) is not int or row[field] <= 0:
|
||||
raise ValueError('Positive integer inventory identity required')
|
||||
if row['submission_id'] in ids or row['source_record_id'] in records:
|
||||
raise ValueError('Repeated submission/source identity in inventory')
|
||||
ids.add(row['submission_id']); records.add(row['source_record_id'])
|
||||
for field in ('external_id','source','doi','venue','section','evidence_url'):
|
||||
if not isinstance(row[field], str) or not row[field].strip():
|
||||
raise ValueError('Missing inventory evidence field')
|
||||
if (not isinstance(row['venue_names'], list) or not row['venue_names']
|
||||
or not all(isinstance(v,str) and v for v in row['venue_names'])
|
||||
or row['venue'] not in row['venue_names']
|
||||
or not re.fullmatch(r'[a-z][a-z0-9_]{0,63}', row['track'])
|
||||
or not re.fullmatch(r'[0-9a-f]{64}', row['evidence_sha256'])
|
||||
or not row['evidence_url'].startswith('https://')):
|
||||
raise ValueError('Invalid scope or evidence binding')
|
||||
return inventory
|
||||
|
||||
|
||||
def bounded(c, sql, params):
|
||||
rows = values(c.execute(sql+' LIMIT 100001', params))
|
||||
if len(rows) > 100000:
|
||||
raise ValueError('Protected scope exceeds bounded reclassification limit')
|
||||
return rows
|
||||
|
||||
|
||||
def protect(c, ids):
|
||||
marks = ','.join('?' for _ in ids)
|
||||
subs = 'SELECT id FROM submissions WHERE id IN ('+marks+')'
|
||||
papers = 'SELECT paper_id FROM submissions WHERE id IN ('+marks+')'
|
||||
decisions = 'SELECT id FROM decisions WHERE submission_id IN ('+subs+')'
|
||||
queries = {
|
||||
'submissions': ('SELECT id,paper_id,source_record_id,source_submission_id,submitted_at FROM submissions WHERE id IN ('+marks+') ORDER BY id'),
|
||||
'submission_identifiers': ('SELECT scheme,value,submission_id FROM submission_identifiers WHERE submission_id IN ('+subs+') ORDER BY submission_id,scheme,value'),
|
||||
'submission_sources': ('SELECT * FROM submission_sources WHERE submission_id IN ('+subs+') ORDER BY submission_id,source_record_id,source_submission_id'),
|
||||
'decisions': ('SELECT * FROM decisions WHERE submission_id IN ('+subs+') ORDER BY id'),
|
||||
'decision_sources': ('SELECT * FROM decision_sources WHERE decision_id IN ('+decisions+') ORDER BY decision_id,source_record_id,source_decision_id'),
|
||||
'papers': ('SELECT * FROM papers WHERE id IN ('+papers+') ORDER BY id'),
|
||||
'identifiers': ('SELECT * FROM identifiers WHERE paper_id IN ('+papers+') ORDER BY scheme,value'),
|
||||
'paper_tags': ('SELECT * FROM paper_tags WHERE paper_id IN ('+papers+') ORDER BY paper_id,tag_id,evidence_key'),
|
||||
}
|
||||
from paper_atlas.curation.annotations import available
|
||||
if available(c):
|
||||
queries['annotation_events'] = 'SELECT * FROM annotation_events WHERE paper_id IN ('+papers+') ORDER BY id'
|
||||
result = {table: {'rows':len(rows), 'sha256':digest(rows)} for table, sql in queries.items()
|
||||
for rows in [bounded(c,sql,ids)]}
|
||||
rows = bounded(c, 'SELECT r.* FROM source_records r WHERE r.id IN (SELECT source_record_id FROM submission_sources WHERE submission_id IN ('+subs+')) ORDER BY r.id', ids)
|
||||
# Only the reviewed curation field may change; every other metadata value
|
||||
# and all raw text/IDs/retrieval times remain protected.
|
||||
for row in rows:
|
||||
metadata = json.loads(row[8]); metadata.pop(KEY,None); row[8] = metadata
|
||||
result['source_records'] = {'rows':len(rows), 'sha256':digest(rows)}
|
||||
return result
|
||||
|
||||
|
||||
def inspect(c, inventory):
|
||||
validate(inventory); changes=[]; ids=tuple(sorted(r['submission_id']for r in inventory['records']))
|
||||
original = sorted({r['from_edition_id'] for r in inventory['records']})
|
||||
actual_ids = {r[0] for r in c.execute('SELECT id FROM submissions WHERE edition_id IN ('+','.join('?'for _ in original)+')', original)}
|
||||
if actual_ids != set(ids):
|
||||
raise ValueError('Inventory must cover every attempt in each original edition')
|
||||
for row in inventory['records']:
|
||||
current = c.execute('''SELECT s.edition_id,s.source_record_id,s.source_submission_id,s.paper_id,
|
||||
e.venue_id,e.year,e.track,e.event_key,v.acronym,r.external_id,r.metadata_json,src.name
|
||||
FROM submissions s JOIN editions e ON e.id=s.edition_id JOIN venues v ON v.id=e.venue_id
|
||||
JOIN source_records r ON r.id=s.source_record_id JOIN sources src ON src.id=r.source_id WHERE s.id=?''',(row['submission_id'],)).fetchone()
|
||||
if not current or [current[i]for i in (0,1,2,5,8,9,11)] != [row[k]for k in ('from_edition_id','source_record_id','external_id','year','venue','external_id','source')]:
|
||||
raise ValueError('Submission identity/scope differs from inventory')
|
||||
aliases={r[0]for r in c.execute('SELECT alias FROM venue_aliases WHERE venue_id=?',(current[4],))}|{current[8]}
|
||||
if set(row['venue_names'])!=aliases:
|
||||
raise ValueError('Venue names differ from current aliases')
|
||||
metadata=json.loads(current[10])
|
||||
if KEY in metadata:
|
||||
raise ValueError('Existing curated scope requires explicit reconciliation')
|
||||
if (metadata.get('record_identifiers') or {}).get('doi') != row['doi']:
|
||||
raise ValueError('Stable DOI differs from reviewed source evidence')
|
||||
links=values(c.execute('SELECT source_record_id,source_submission_id FROM submission_sources WHERE submission_id=?',(row['submission_id'],)))
|
||||
if links != [[row['source_record_id'],row['external_id']]]:
|
||||
raise ValueError('Shared-source attempt requires separate reconciliation')
|
||||
decisions=values(c.execute('SELECT status FROM decisions WHERE submission_id=?',(row['submission_id'],)))
|
||||
if not decisions or any(d[0]!='accepted'for d in decisions):
|
||||
raise ValueError('Only publication observations can use this track correction')
|
||||
target=c.execute('SELECT id FROM editions WHERE venue_id=? AND year=? AND track=? AND event_key=?',(current[4],current[5],row['track'],current[7])).fetchone()
|
||||
if target and target[0] != current[0]:
|
||||
if c.execute('SELECT 1 FROM submissions WHERE edition_id=? LIMIT 1',(target[0],)).fetchone():
|
||||
raise ValueError('Nonempty target edition requires separate reconciliation')
|
||||
if c.execute('SELECT 1 FROM submission_identifiers WHERE edition_id=? LIMIT 1',(target[0],)).fetchone():
|
||||
raise ValueError('Target identifier scope already exists')
|
||||
if c.execute('SELECT 1 FROM edition_sources WHERE edition_id=? LIMIT 1',(target[0],)).fetchone():
|
||||
raise ValueError('Target coverage already exists; reconcile first')
|
||||
if c.execute('SELECT 1 FROM submission_identifiers WHERE submission_id=? AND edition_id<>? LIMIT 1',(row['submission_id'],current[0])).fetchone():
|
||||
raise ValueError('Existing identifier edition is inconsistent')
|
||||
changes.append({**row,'from_track':current[6],'event_key':current[7], 'venue_id':current[4],
|
||||
'target_edition_id':target[0]if target else None})
|
||||
coverage=bounded(c,'SELECT * FROM edition_sources WHERE edition_id IN ('+','.join('?'for _ in original)+') ORDER BY edition_id,source_id,list_url',original)
|
||||
if Counter(row[0]for row in coverage)!=Counter({edition:1 for edition in original}):
|
||||
raise ValueError('Exactly one original coverage listing per edition required')
|
||||
return {'records':changes, 'protected':protect(c,ids), 'coverage':coverage,
|
||||
'editions':bounded(c,'SELECT * FROM editions WHERE id IN ('+','.join('?'for _ in original)+') ORDER BY id',original)}
|
||||
|
||||
|
||||
def proposal(c, inventory, identity):
|
||||
return {'format':1,'database':identity,'inventory':inventory,'before':inspect(c,inventory)}
|
||||
|
||||
|
||||
def verify(c, plan):
|
||||
records=plan['before']['records']; ids=tuple(sorted(r['submission_id']for r in records))
|
||||
if protect(c,ids)!=plan['before']['protected']:
|
||||
raise ValueError('Protected history/text/tags/identities changed; roll back')
|
||||
for row in records:
|
||||
event=c.execute('''SELECT e.id,e.venue_id,e.year,e.track,e.event_key FROM submissions s JOIN editions e ON e.id=s.edition_id WHERE s.id=?''',(row['submission_id'],)).fetchone()
|
||||
if not event or [event[i]for i in range(1,5)] != [row['venue_id'],row['year'],row['track'],row['event_key']]:
|
||||
raise ValueError('Target track assignment failed')
|
||||
if c.execute('SELECT 1 FROM submission_identifiers WHERE submission_id=? AND edition_id<>? LIMIT 1',(row['submission_id'],event[0])).fetchone():
|
||||
raise ValueError('Target identifier assignment failed')
|
||||
metadata=json.loads(c.execute('SELECT metadata_json FROM source_records WHERE id=?',(row['source_record_id'],)).fetchone()[0])
|
||||
if row['track'] != row['from_track']:
|
||||
if metadata.get(KEY) != [curation(row,event[0],digest(plan))]:
|
||||
raise ValueError('Reviewed refresh protection missing or changed')
|
||||
elif KEY in metadata:
|
||||
raise ValueError('Unexpected scope revision on unchanged track')
|
||||
original=sorted({r['from_edition_id']for r in records})
|
||||
if bounded(c,'SELECT * FROM editions WHERE id IN ('+','.join('?'for _ in original)+') ORDER BY id',original)!=plan['before']['editions']:
|
||||
raise ValueError('Original edition identities changed')
|
||||
for old in plan['before']['coverage']:
|
||||
edition,source,url,_,_,_,cursor,success,_=old
|
||||
expected=Counter()
|
||||
for row in records:
|
||||
if row['from_edition_id']==edition:
|
||||
target=c.execute('SELECT edition_id FROM submissions WHERE id=?',(row['submission_id'],)).fetchone()[0]
|
||||
expected[target]+=1
|
||||
for target,number in expected.items():
|
||||
actual=c.execute('SELECT status,expected_count,imported_count,cursor,last_success_at,last_error FROM edition_sources WHERE edition_id=? AND source_id=? AND list_url=?',(target,source,url)).fetchone()
|
||||
note='Track scope corrected from reviewed per-record evidence; original whole-volume coverage retained in plan '+digest(plan)
|
||||
if actual is None or [actual[i]for i in range(6)]!=['partial',number,number,cursor,success,note]:
|
||||
raise ValueError('Corrected coverage differs from reviewed scope')
|
||||
return {'records_verified':len(records),'protected':plan['before']['protected']}
|
||||
|
||||
|
||||
def curation(row, target, signature):
|
||||
return {'external_id':row['external_id'], 'venue_names':sorted(set(row['venue_names'])),
|
||||
'from':{'edition_id':row['from_edition_id'],'year':row['year'],'track':row['from_track'],'event_key':row['event_key']},
|
||||
'target':{'edition_id':target,'venue_id':row['venue_id'],'year':row['year'],'track':row['track'],'event_key':row['event_key']},
|
||||
'section':row['section'],'evidence_url':row['evidence_url'],'evidence_sha256':row['evidence_sha256'],'plan_digest':signature}
|
||||
|
||||
|
||||
def apply_plan(c, plan):
|
||||
if plan.get('format') != 1:
|
||||
raise ValueError('Unsupported plan format')
|
||||
signature=digest(plan);marker=MARKER+signature
|
||||
if c.execute('SELECT 1 FROM schema_migrations WHERE name=?',(marker,)).fetchone():
|
||||
return {'status':'already_applied','events_moved':0,**verify(c,plan)}
|
||||
if inspect(c,plan['inventory']) != plan['before']:
|
||||
raise ValueError('Stale track plan; create and review a fresh plan')
|
||||
targets={}; counts=Counter(); moved=0
|
||||
for row in plan['before']['records']:
|
||||
key=(row['venue_id'],row['year'],row['track'],row['event_key'])
|
||||
if key not in targets:
|
||||
found=c.execute('SELECT id FROM editions WHERE venue_id=? AND year=? AND track=? AND event_key=?',key).fetchone()
|
||||
targets[key]=found[0]if found else inserted_id(c,'INSERT INTO editions(venue_id,year,track,event_key) VALUES(?,?,?,?)',key)
|
||||
target=targets[key];counts[(row['from_edition_id'],target)]+=1
|
||||
if target != row['from_edition_id']:
|
||||
c.execute('UPDATE submission_identifiers SET edition_id=? WHERE submission_id=?',(target,row['submission_id']))
|
||||
if c.execute('UPDATE submissions SET edition_id=? WHERE id=? AND edition_id=?',(target,row['submission_id'],row['from_edition_id'])).rowcount!=1:
|
||||
raise ValueError('Concurrent submission change')
|
||||
metadata=json.loads(c.execute('SELECT metadata_json FROM source_records WHERE id=?',(row['source_record_id'],)).fetchone()[0]);metadata[KEY]=[curation(row,target,signature)]
|
||||
c.execute('UPDATE source_records SET metadata_json=? WHERE id=?',(json.dumps(metadata,ensure_ascii=False),row['source_record_id']));moved+=1
|
||||
# Preserve each original source URL and timestamps, but do not retain its
|
||||
# old whole-volume count as main-track coverage. Partial is deliberate:
|
||||
# this operation verifies scope, not abstracts or all possible sources.
|
||||
for old in plan['before']['coverage']:
|
||||
edition,source,url,status,expected,imported,cursor,success,error=old
|
||||
own={r['submission_id'] for r in plan['before']['records']if r['from_edition_id']==edition}
|
||||
source_name=c.execute('SELECT name FROM sources WHERE id=?',(source,)).fetchone()[0]
|
||||
if {r['source']for r in plan['before']['records']if r['submission_id']in own}!={source_name}:
|
||||
raise ValueError('Coverage includes a source outside the reviewed inventory')
|
||||
for (origin,target),number in counts.items():
|
||||
if origin!=edition:continue
|
||||
note='Track scope corrected from reviewed per-record evidence; original whole-volume coverage retained in plan '+signature
|
||||
c.execute('''INSERT INTO edition_sources(edition_id,source_id,list_url,status,expected_count,imported_count,cursor,last_success_at,last_error)
|
||||
VALUES(?,?,?,?,?,?,?,?,?) ON CONFLICT(edition_id,source_id,list_url) DO UPDATE SET status=excluded.status,
|
||||
expected_count=excluded.expected_count,imported_count=excluded.imported_count,last_error=excluded.last_error''',
|
||||
(target,source,url,'partial',number,number,cursor,success,note))
|
||||
result=verify(c,plan)
|
||||
c.execute('INSERT INTO schema_migrations(name) VALUES(?)',(marker,))
|
||||
return {'status':'applied','events_moved':moved,**result}
|
||||
|
||||
|
||||
def run(db,out,*,pg_service=None,pg_schema=None,inventory=None,plan=None,plan_sha256=None):
|
||||
out=Path(out)
|
||||
if out.exists() or not out.parent.is_dir():raise ValueError('New report path in existing directory required')
|
||||
expected=None
|
||||
if plan:
|
||||
raw=Path(plan).read_bytes()
|
||||
if hashlib.sha256(raw).hexdigest()!=plan_sha256:raise ValueError('Plan hash differs')
|
||||
expected=json.loads(raw);validate(expected['inventory'])
|
||||
elif plan_sha256:raise ValueError('Plan path required')
|
||||
if bool(expected)==bool(inventory):raise ValueError('Choose inventory preflight or apply plan')
|
||||
if expected is None:
|
||||
with closing(open_read_connection(db,pg_service=pg_service,pg_schema=pg_schema))as c:
|
||||
result=proposal(c,validate(json.loads(Path(inventory).read_bytes())),database_identity(c,db))
|
||||
else:
|
||||
store=open_store(db,pg_service=pg_service,pg_schema=pg_schema)
|
||||
with closing(store.conn)as c:
|
||||
try:
|
||||
if expected['database']!=database_identity(c,db):raise ValueError('Plan belongs to another database')
|
||||
if not getattr(c,'postgres',False):c.commit();c.execute('BEGIN IMMEDIATE')
|
||||
result=apply_plan(c,expected);c.commit()
|
||||
except BaseException:c.rollback();raise
|
||||
out.write_text(json.dumps(result,ensure_ascii=False,indent=2)+'\n');return result
|
||||
|
||||
|
||||
def main(argv=None,*,prog=None):
|
||||
p=argparse.ArgumentParser(prog=prog);database_arguments(p)
|
||||
p.add_argument('--inventory');p.add_argument('--plan');p.add_argument('--plan-sha256');p.add_argument('--out',required=True)
|
||||
a=p.parse_args(argv)
|
||||
if bool(a.plan)!=bool(a.plan_sha256):p.error('--plan and --plan-sha256 must be supplied together')
|
||||
r=run(a.db,a.out,pg_service=a.pg_service,pg_schema=a.pg_schema,inventory=a.inventory,plan=a.plan,plan_sha256=a.plan_sha256)
|
||||
print(json.dumps({'status':r.get('status','planned'),'events_moved':r.get('events_moved',0),'out':a.out}),flush=True)
|
||||
@@ -134,6 +134,12 @@ def ingest_file(db,path, *, pg_service=None, pg_schema=None):
|
||||
if events and status is None:raise ValueError('Explicit decision event requires a status')
|
||||
if status is not None and status not in ('accepted','rejected','desk_rejected','withdrawn','under_review','unknown'):raise ValueError('Unsupported decision event')
|
||||
edition=store.decision(paper,record,row['venue'],row['year'],row['track'],external,event.get('evidence_url',row['url']),status,event.get('raw_value',''),row.get('venue_aliases'),identities=identities,observed_at=event.get('observed_at') or row_observed,decision_date=event.get('decision_date'),source_decision_id=event.get('source_decision_id',''),submitted_at=row.get('submitted_at'),guard_status=source_name in (PUBLIC_SOURCE,ARCHIVE_SOURCE),event_key=row.get('event_key'))
|
||||
from paper_atlas.db.edition_scopes import KEY
|
||||
scope_metadata=json.loads(c.execute('SELECT metadata_json FROM source_records WHERE id=?',(record,)).fetchone()[0])
|
||||
if scope_metadata.get(KEY):
|
||||
actual_track=c.execute('SELECT track FROM editions WHERE id=?',(edition,)).fetchone()[0]
|
||||
if row['track']!=actual_track:
|
||||
raise ValueError('Recollect this whole-volume source with reviewed per-record tracks before refresh')
|
||||
editions.add(edition)
|
||||
else:
|
||||
raise ValueError('This importer requires an explicit supported decision status')
|
||||
|
||||
+4281
File diff suppressed because it is too large
Load Diff
+9968
File diff suppressed because it is too large
Load Diff
Vendored
+54
@@ -0,0 +1,54 @@
|
||||
<HTML><HEAD><TITLE>AAMAS 2025: Proceedings of the 2025 International Conference on Autonomous Agents and Multiagent Systems</TITLE>
|
||||
<META http-equiv=Content-Type content="text/html; charset=utf-8"><LINK
|
||||
href="forms/index_files/factemplate.css" type=text/css rel=stylesheet>
|
||||
<META content="MSHTML 6.00.2900.3562" name=GENERATOR></HEAD>
|
||||
<BODY bgcolor="#010101" bgproperties="fixed"><DIV style="TEXT-ALIGN: center" >
|
||||
<table width="724" border="0" align="center" cellpadding="0" cellspacing="0" valign="top">
|
||||
<td colspan="2" align="center" valign="top" bgcolor="#FFFFFF"><img src="forms/index_files/aamas25.png" width="750" height="300" alt=""/></td>
|
||||
</tr>
|
||||
<tr bgcolor="#FFFFFF">
|
||||
<td width="125" align="center" valign="top" bgcolor="#FFFFFF"><table width="125" border="1" align="center" cellpadding="1" cellspacing="1" bordercolor="#444444" style="margin-bottom: 2">
|
||||
<tr>
|
||||
<td bgcolor="#FFFFFF"><p style="text-indent: 0; margin-left: 6; margin-right: 6; margin-top: 5; margin-bottom: 4" align="left"><A href="forms/index.htm">Main
|
||||
Page</A></p></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td bgcolor="#FFFFFF"><p style="text-indent: 0; margin-left: 6; margin-right: 6; margin-top: 5; margin-bottom: 4" align="left"><A href="forms/contents.htm">Table
|
||||
of Contents</A></p></td>
|
||||
</tr>
|
||||
</table></td>
|
||||
<td width="599"><p style="text-indent: 0; margin-left: 12; margin-right: 14; margin-top: 12; margin-bottom: 12" align="left"><font color="#010101" size="4" face="Arial, Helvetica, sans-serif"><strong>Welcome
|
||||
to the Proceedings of AAMAS-2025</strong></font></p>
|
||||
<p style="text-indent: 0; margin-left: 12; margin-right: 14; margin-top: 12; margin-bottom: 12;" align="left"><font color="#333333" size="2" face="Times New Roman, Times, serif">These
|
||||
are the proceedings of the 24th International Conference on Autonomous
|
||||
Agents and Multiagent Systems (AAMAS-2025). They are published by the
|
||||
International Foundation for Autonomous Agents and Multiagent Systems
|
||||
(IFAAMAS).</font></p></td>
|
||||
</tr>
|
||||
<tr bgcolor="#FFFFFF">
|
||||
<td align="center" valign="middle" bgcolor="#FFFFFF"> </td>
|
||||
<td><table width="96%" border="0" cellspacing="2" cellpadding="2">
|
||||
<tr align="left" valign="top">
|
||||
<td> <p style="text-indent: 0; margin-left: 12; margin-right: 6; margin-top: 0; margin-bottom: 6;" align="left"><font color="#333333" face="Times New Roman, Times, serif"><font size="2"><strong>ISBN: 979-8-4007-1426-9</strong></font></font></p></td>
|
||||
<td> <p style="text-indent: 0; margin-left: 12; margin-right: 6; margin-top: 0; margin-bottom: 6;" align="left"><font color="#333333" face="Times New Roman, Times, serif"></font></p></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="42%"><div align="left"><img src="forms/index_files/ifaamas.jpg" width="256" height="104"></div></td>
|
||||
<td width="58%"><div align="center"><img src="forms/index_files/ACM-In-Cooperation_medium.jpg" width="285" height="86"></div></td>
|
||||
</tr>
|
||||
</table>
|
||||
<p style="text-indent: 0; margin-left: 12; margin-right: 14; margin-top: 12; margin-bottom: 12;" align="left"><font color="#333333" size="2" face="Times New Roman, Times, serif">Copyright
|
||||
© 2025 by International Foundation for Autonomous Agents and Multiagent
|
||||
Systems (IFAAMAS). Permission to make digital or hard copies of portions
|
||||
of this work for personal or classroom use is granted without fee provided
|
||||
that copies are not made or distributed for profit or commercial advantage
|
||||
and that copies bear this notice and the full citation on the first page.
|
||||
Copyright for components of this work owned by others than IFAAMAS must
|
||||
be honored. Abstracting with credit is permitted. To copy otherwise, to
|
||||
republish, to post on servers or to redistribute to lists, requires prior
|
||||
specific permission and/or a fee. </font></p></td>
|
||||
</tr>
|
||||
</table>
|
||||
<p> </p>
|
||||
|
||||
</BODY></HTML>
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"status": "two_successful_official_metadata_pages_recovered_third403_not_retried",
|
||||
"receipts": [
|
||||
{
|
||||
"file": "index.html",
|
||||
"url": "https://www.ifaamas.org/Proceedings/aamas2025/",
|
||||
"bytes": 4107,
|
||||
"sha256": "850b7825c66d0147ac8a8bbf03a554590e4a9e8f4b5c576e043999bbd43317f4",
|
||||
"at": "2026-11-09T12:31:49.347577+00:00",
|
||||
"at_precision": "filesystem_write_time_recovered",
|
||||
"http_status": 200,
|
||||
"content_type": "text/html",
|
||||
"receipt_recovered": true,
|
||||
"scope": "Collectorasserted200/htmlbeforewrite;request3acceptedprogram403;noheaders/finalredirectURLpersisted;no refetch or rawbytesmutation."
|
||||
},
|
||||
{
|
||||
"file": "contents.html",
|
||||
"url": "https://www.ifaamas.org/Proceedings/aamas2025/forms/contents.htm",
|
||||
"bytes": 370650,
|
||||
"sha256": "732847b51972f34c721a49d8bd963a80a029a48f3ec08022c9a0d5ddc2945549",
|
||||
"at": "2026-11-09T12:31:52.006642+00:00",
|
||||
"at_precision": "filesystem_write_time_recovered",
|
||||
"http_status": 200,
|
||||
"content_type": "text/html",
|
||||
"receipt_recovered": true,
|
||||
"scope": "Collectorasserted200/htmlbeforewrite;request3acceptedprogram403;noheaders/finalredirectURLpersisted;no refetch or rawbytesmutation."
|
||||
}
|
||||
],
|
||||
"failed_url": "https://aamas2025.org/index.php/conference/program/accepted-papers/",
|
||||
"not_attempted_url": "https://aamas2025.org/index.php/conference/calls/submission-instructions-main-technical-track/",
|
||||
"original_error": {
|
||||
"error": "HTTP Error 403: Forbidden",
|
||||
"type": "HTTPError",
|
||||
"downloaded": 2
|
||||
},
|
||||
"research_fulltext_downloaded": false,
|
||||
"database_written": false
|
||||
}
|
||||
Vendored
+273
@@ -0,0 +1,273 @@
|
||||
<?xml version='1.0' encoding='UTF-8'?>
|
||||
<collection id="T78">
|
||||
<volume id="1" type="proceedings">
|
||||
<!-- https://aclanthology.org/T78-1/ -->
|
||||
<meta>
|
||||
<booktitle>Theoretical Issues in Natural Language <fixed-case>P</fixed-case>rocessing-2</booktitle>
|
||||
<editor id="david-l-waltz"><first>David L.</first><last>Waltz</last></editor>
|
||||
<venue>tinlap</venue>
|
||||
<year>1978</year>
|
||||
</meta>
|
||||
<frontmatter>
|
||||
<!-- https://aclanthology.org/T78-1000/ -->
|
||||
<pdf hash="fb6164ad"/>
|
||||
<bibkey>tinlap-1978-theoretical</bibkey>
|
||||
</frontmatter>
|
||||
<paper id="1">
|
||||
<!-- https://aclanthology.org/T78-1001/ -->
|
||||
<title>Testing The Psychological Reality of a Representational Model</title>
|
||||
<author><first>Dedre</first><last>Gentner</last></author>
|
||||
<pdf hash="f723a956"/>
|
||||
<bibkey>gentner-1978-testing-psychological</bibkey>
|
||||
</paper>
|
||||
<paper id="2">
|
||||
<!-- https://aclanthology.org/T78-1002/ -->
|
||||
<title>What Makes Something “Ad Hoc”</title>
|
||||
<author id="roger-c-schank"><first>Roger C.</first><last>Schank</last></author>
|
||||
<pdf hash="636853f9"/>
|
||||
<bibkey>schank-1978-makes-something</bibkey>
|
||||
</paper>
|
||||
<paper id="3">
|
||||
<!-- https://aclanthology.org/T78-1003/ -->
|
||||
<title>The Relation of Grammar to Cognition–a Synopsis</title>
|
||||
<author><first>Leonard</first><last>Talmy</last></author>
|
||||
<pdf hash="101c67dc"/>
|
||||
<bibkey>talmy-1978-relation-grammar</bibkey>
|
||||
</paper>
|
||||
<paper id="4">
|
||||
<!-- https://aclanthology.org/T78-1004/ -->
|
||||
<title>On primitives, prototypes, and other semantic anomalies</title>
|
||||
<author><first>Terry</first><last>Winograd</last></author>
|
||||
<pdf hash="f8e47023"/>
|
||||
<bibkey>winograd-1978-primitives-prototypes</bibkey>
|
||||
</paper>
|
||||
<paper id="5">
|
||||
<!-- https://aclanthology.org/T78-1005/ -->
|
||||
<title>Taxonomic Lattice Structures for Situation Recognition</title>
|
||||
<author id="william-a-woods"><first>William A.</first><last>Woods</last></author>
|
||||
<pdf hash="92957ddf"/>
|
||||
<bibkey>woods-1978-taxonomic-lattice</bibkey>
|
||||
</paper>
|
||||
<paper id="6">
|
||||
<!-- https://aclanthology.org/T78-1006/ -->
|
||||
<title>Description Formation and Discourse Model Synthesis</title>
|
||||
<author id="bonnie-webber"><first>Bonnie Lynn</first><last>Webber</last></author>
|
||||
<pdf hash="ffded1d2"/>
|
||||
<bibkey>webber-1978-description-formation</bibkey>
|
||||
</paper>
|
||||
<paper id="7">
|
||||
<!-- https://aclanthology.org/T78-1007/ -->
|
||||
<title>The Processing of Referring Expressions within a Semantic Network</title>
|
||||
<author><first>John R.</first><last>Anderson</last></author>
|
||||
<pdf hash="8bcc083a"/>
|
||||
<bibkey>anderson-1978-processing-referring</bibkey>
|
||||
</paper>
|
||||
<paper id="8">
|
||||
<!-- https://aclanthology.org/T78-1008/ -->
|
||||
<title>Reference Diaries</title>
|
||||
<author><first>Herbert H.</first><last>Clark</last></author>
|
||||
<author><first>Catherine</first><last>Marshall</last></author>
|
||||
<pdf hash="d06b7ea2"/>
|
||||
<bibkey>clark-marshall-1978-reference-diaries</bibkey>
|
||||
</paper>
|
||||
<paper id="9">
|
||||
<!-- https://aclanthology.org/T78-1009/ -->
|
||||
<title>Subsequent Reference: Syntactic and Rhetorical Constraints</title>
|
||||
<author id="david-d-mcdonald"><first>David D.</first><last>McDonald</last></author>
|
||||
<pdf hash="2d424559"/>
|
||||
<bibkey>mcdonald-1978-subsequent-reference</bibkey>
|
||||
</paper>
|
||||
<paper id="10">
|
||||
<!-- https://aclanthology.org/T78-1010/ -->
|
||||
<title>Some Psycholinguistic Constraints on the Construction and Interpretation of Definite Descriptions</title>
|
||||
<author><first>Andrew</first><last>Ortony</last></author>
|
||||
<pdf hash="69807727"/>
|
||||
<bibkey>ortony-1978-psycholinguistic-constraints</bibkey>
|
||||
</paper>
|
||||
<paper id="11">
|
||||
<!-- https://aclanthology.org/T78-1011/ -->
|
||||
<title>Bound Variables and Other Anaphors</title>
|
||||
<author id="barbara-h-partee"><first>Barbara H.</first><last>Partee</last></author>
|
||||
<pdf hash="e8e6a510"/>
|
||||
<bibkey>partee-1978-bound-variables</bibkey>
|
||||
</paper>
|
||||
<paper id="12">
|
||||
<!-- https://aclanthology.org/T78-1012/ -->
|
||||
<title>The Use of Focus as a Tool for Disambiguation of Definite Noun Phrases</title>
|
||||
<author id="candace-l-sidner"><first>Candace L.</first><last>Sidner</last></author>
|
||||
<pdf hash="7b96d2b7"/>
|
||||
<bibkey>sidner-1978-use-focus</bibkey>
|
||||
</paper>
|
||||
<paper id="13">
|
||||
<!-- https://aclanthology.org/T78-1013/ -->
|
||||
<title>Focusing in Dialog</title>
|
||||
<author id="barbara-j-grosz"><first>Barbara J.</first><last>Grosz</last></author>
|
||||
<pdf hash="8c8d4c2e"/>
|
||||
<bibkey>grosz-1978-focusing-dialog</bibkey>
|
||||
</paper>
|
||||
<paper id="14">
|
||||
<!-- https://aclanthology.org/T78-1014/ -->
|
||||
<title>Topic Levels</title>
|
||||
<author><first>Joseph E.</first><last>Grimes</last></author>
|
||||
<pdf hash="f485b2ba"/>
|
||||
<bibkey>grimes-1978-topic-levels</bibkey>
|
||||
</paper>
|
||||
<paper id="15">
|
||||
<!-- https://aclanthology.org/T78-1015/ -->
|
||||
<title>Toward a Rational Model of Discourse Comprehension</title>
|
||||
<author><first>Jerry L.</first><last>Morgan</last></author>
|
||||
<pdf hash="5e12a597"/>
|
||||
<bibkey>morgan-1978-toward-rational</bibkey>
|
||||
</paper>
|
||||
<paper id="16">
|
||||
<!-- https://aclanthology.org/T78-1016/ -->
|
||||
<title>Assent and Compliance in Children’s Language Comprehension</title>
|
||||
<author><first>David R.</first><last>Olson</last></author>
|
||||
<pdf hash="708bc4a0"/>
|
||||
<bibkey>olson-1978-assent-compliance</bibkey>
|
||||
</paper>
|
||||
<paper id="17">
|
||||
<!-- https://aclanthology.org/T78-1017/ -->
|
||||
<title>Speech Acts as a Basis for Understanding Dialogue Coherence</title>
|
||||
<author id="c-raymond-perrault"><first>C. Raymond</first><last>Perrault</last></author>
|
||||
<author id="james-allen"><first>James F.</first><last>Allen</last></author>
|
||||
<pdf hash="d111402b"/>
|
||||
<bibkey>perrault-allen-1978-speech</bibkey>
|
||||
</paper>
|
||||
<paper id="18">
|
||||
<!-- https://aclanthology.org/T78-1018/ -->
|
||||
<title>A Framework for Comparing Language Experiences (with particular emphasis on: The Effect of Audience on Discourse Models)</title>
|
||||
<author><first>Andee</first><last>Rubin</last></author>
|
||||
<pdf hash="5d9a6150"/>
|
||||
<bibkey>rubin-1978-framework-comparing</bibkey>
|
||||
</paper>
|
||||
<paper id="19">
|
||||
<!-- https://aclanthology.org/T78-1019/ -->
|
||||
<title>Intentlonallty and Human Conversations</title>
|
||||
<author id="jaime-g-carbonell"><first>Jaime G.</first><last>Carbonell Jr</last></author>
|
||||
<pdf hash="c50fc619"/>
|
||||
<bibkey>carbonell-jr-1978-intentlonallty-human</bibkey>
|
||||
</paper>
|
||||
<paper id="20">
|
||||
<!-- https://aclanthology.org/T78-1020/ -->
|
||||
<title>On the Interdependence of Language and Perception</title>
|
||||
<author id="david-l-waltz"><first>David L.</first><last>Waltz</last></author>
|
||||
<pdf hash="3c708191"/>
|
||||
<bibkey>waltz-1978-interdependence-language</bibkey>
|
||||
</paper>
|
||||
<paper id="21">
|
||||
<!-- https://aclanthology.org/T78-1021/ -->
|
||||
<title>The Problem of Naming Shapes: Vision-Language Interface</title>
|
||||
<author id="ruzena-bajcsy"><first>R.</first><last>Bajcsy</last></author>
|
||||
<author id="aravind-joshi"><first>A. K.</first><last>Joshi</last></author>
|
||||
<pdf hash="50331b4e"/>
|
||||
<bibkey>bajcsy-joshi-1978-problem-naming</bibkey>
|
||||
</paper>
|
||||
<paper id="22">
|
||||
<!-- https://aclanthology.org/T78-1022/ -->
|
||||
<title>An Argument about the Composition of Conceptual Structure</title>
|
||||
<author><first>Ray</first><last>Jackendoff</last></author>
|
||||
<pdf hash="57d001f8"/>
|
||||
<bibkey>jackendoff-1978-argument-composition</bibkey>
|
||||
</paper>
|
||||
<paper id="23">
|
||||
<!-- https://aclanthology.org/T78-1023/ -->
|
||||
<title>On the Ontological Status of Visual Mental Images</title>
|
||||
<author><first>Stephen Michael</first><last>Kosslyn</last></author>
|
||||
<pdf hash="fd06e265"/>
|
||||
<bibkey>kosslyn-1978-ontological-status</bibkey>
|
||||
</paper>
|
||||
<paper id="24">
|
||||
<!-- https://aclanthology.org/T78-1024/ -->
|
||||
<title>What has language to do with perception? Some speculations on the <i>Lingua Mentis</i></title>
|
||||
<author><first>Zenon W.</first><last>Pylyshyn</last></author>
|
||||
<pdf hash="a2f3a044"/>
|
||||
<bibkey>pylyshyn-1978-language-perception</bibkey>
|
||||
</paper>
|
||||
<paper id="25">
|
||||
<!-- https://aclanthology.org/T78-1025/ -->
|
||||
<title>Semantic Primitives in Language and Vision</title>
|
||||
<author id="yorick-wilks"><first>Yorick</first><last>Wilks</last></author>
|
||||
<pdf hash="b4e13896"/>
|
||||
<bibkey>wilks-1978-semantic-primitives</bibkey>
|
||||
</paper>
|
||||
<paper id="26">
|
||||
<!-- https://aclanthology.org/T78-1026/ -->
|
||||
<title>A Note on Partial Match of Descriptions. Can One Simultaneously Question (Retrieve) and Inform (Update)?</title>
|
||||
<author id="aravind-joshi"><first>Aravind K.</first><last>Joshi</last></author>
|
||||
<pdf hash="74969243"/>
|
||||
<bibkey>joshi-1978-note-partial</bibkey>
|
||||
</paper>
|
||||
<paper id="27">
|
||||
<!-- https://aclanthology.org/T78-1027/ -->
|
||||
<title>With a Spoon in Hand This Must Be the Eating Frame</title>
|
||||
<author><first>Eugene</first><last>Charniak</last></author>
|
||||
<pdf hash="f1be68bb"/>
|
||||
<bibkey>charniak-1978-spoon-hand</bibkey>
|
||||
</paper>
|
||||
<paper id="28">
|
||||
<!-- https://aclanthology.org/T78-1028/ -->
|
||||
<title>Fragments of a Theory of Human Plausible Reasoning</title>
|
||||
<author><first>Allan</first><last>Collins</last></author>
|
||||
<pdf hash="67b4bca2"/>
|
||||
<bibkey>collins-1978-fragments-theory</bibkey>
|
||||
</paper>
|
||||
<paper id="29">
|
||||
<!-- https://aclanthology.org/T78-1029/ -->
|
||||
<title>Indirect Responses to Loaded Questions</title>
|
||||
<author><first>S. Jerrold</first><last>Kaplan</last></author>
|
||||
<pdf hash="135037f9"/>
|
||||
<bibkey>kaplan-1978-indirect-responses</bibkey>
|
||||
</paper>
|
||||
<paper id="30">
|
||||
<!-- https://aclanthology.org/T78-1030/ -->
|
||||
<title>On Reasoning by Default</title>
|
||||
<author><first>Raymond</first><last>Reiter</last></author>
|
||||
<pdf hash="0de53d33"/>
|
||||
<bibkey>reiter-1978-reasoning-default</bibkey>
|
||||
</paper>
|
||||
<paper id="31">
|
||||
<!-- https://aclanthology.org/T78-1031/ -->
|
||||
<title>Path-Based and Node-Based Inference in Semantic Networks</title>
|
||||
<author id="stuart-c-shapiro"><first>Stuart C.</first><last>Shapiro</last></author>
|
||||
<pdf hash="2ddc5535"/>
|
||||
<bibkey>shapiro-1978-path-based</bibkey>
|
||||
</paper>
|
||||
<paper id="32">
|
||||
<!-- https://aclanthology.org/T78-1032/ -->
|
||||
<title>The Representation of Derivable Information in Memory: When What Might Have Been Left Unsaid Is Said</title>
|
||||
<author><first>Rand J.</first><last>Spiro</last></author>
|
||||
<author><first>Joseph</first><last>Esposito</last></author>
|
||||
<author><first>Richard J.</first><last>Vondruska</last></author>
|
||||
<pdf hash="7758fb45"/>
|
||||
<bibkey>spiro-etal-1978-representation-derivable</bibkey>
|
||||
</paper>
|
||||
<paper id="33">
|
||||
<!-- https://aclanthology.org/T78-1033/ -->
|
||||
<title>A Heuristic for Paradigms</title>
|
||||
<author><first>Joseph E.</first><last>Grimes</last></author>
|
||||
<pdf hash="53b42c16"/>
|
||||
<bibkey>grimes-1978-heuristic-paradigms</bibkey>
|
||||
</paper>
|
||||
<paper id="34">
|
||||
<!-- https://aclanthology.org/T78-1034/ -->
|
||||
<title>A Computational Account of Some Constraints on Language</title>
|
||||
<author id="mitch-marcus"><first>Mitchell</first><last>Marcus</last></author>
|
||||
<pdf hash="c9724c43"/>
|
||||
<bibkey>marcus-1978-computational-account</bibkey>
|
||||
</paper>
|
||||
<paper id="35">
|
||||
<!-- https://aclanthology.org/T78-1035/ -->
|
||||
<title>Remarks on Processing, Constraints, and the Lexicon</title>
|
||||
<author id="thomas-wasow"><first>Thomas</first><last>Wasow</last></author>
|
||||
<pdf hash="8beed275"/>
|
||||
<bibkey>wasow-1978-remarks-processing</bibkey>
|
||||
</paper>
|
||||
<paper id="36">
|
||||
<!-- https://aclanthology.org/T78-1036/ -->
|
||||
<title>List of questions suggested for consideration in each session</title>
|
||||
<pdf hash="1950b908"/>
|
||||
<bibkey>nn-1978-list-questions</bibkey>
|
||||
</paper>
|
||||
</volume>
|
||||
</collection>
|
||||
+314
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,85 @@
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from paper_atlas.collectors import aamas, aamas2025
|
||||
|
||||
|
||||
FIXTURE = Path(__file__).parent/'fixtures/aamas2025'
|
||||
|
||||
|
||||
class Aamas2025Tests(unittest.TestCase):
|
||||
def parse(self, html=None):
|
||||
return aamas2025.parse_contents(html or (FIXTURE/'contents.html').read_text(),
|
||||
{'observed_at': '2026-11-09T12:31:50+00:00', 'url': aamas2025.URLS['contents']})
|
||||
|
||||
def test_real_toc_preserves_every_title_author_and_affiliation(self):
|
||||
expected = json.loads((FIXTURE/'expected-authors.json').read_text())
|
||||
self.assertEqual(hashlib.sha256((FIXTURE/'contents.html').read_bytes()).hexdigest(), expected['source_sha256'])
|
||||
rows, excluded = self.parse()
|
||||
self.assertEqual((len(rows), len(excluded)), (476, 3))
|
||||
by_url = {r['paper_url']: r for r in expected['entries']}
|
||||
for row in rows:
|
||||
original = by_url[row['metadata']['paper_url']]
|
||||
self.assertEqual(row['title'], original['title'])
|
||||
self.assertEqual(row['authors'], [a['name'] for a in original['authors']])
|
||||
self.assertEqual(row['metadata']['author_affiliations'], [a['affiliation'] for a in original['authors']])
|
||||
self.assertIsNone(row['abstract'])
|
||||
self.assertEqual(row['identifiers'], {})
|
||||
self.assertEqual(row['status'], 'accepted')
|
||||
self.assertNotIn('decision_date', row)
|
||||
self.assertTrue(all(t['namespace'] == 'aamas_proceedings:publication_format' for t in row['tags']))
|
||||
|
||||
def test_publication_format_and_track_are_separate(self):
|
||||
rows, excluded = self.parse()
|
||||
groups = {k: sum(r['track'] == k for r in rows) for k in {r['track'] for r in rows}}
|
||||
self.assertEqual(groups, {'proceedings_unspecified': 409, 'blue_sky': 12, 'doctoral': 25, 'demo': 21, 'jaamas': 9})
|
||||
competition = next(r for r in rows if r['external_id'] == '2025/p3000')
|
||||
self.assertEqual(competition['year'], 2025)
|
||||
self.assertEqual(competition['metadata']['publication_format'], 'competition_report')
|
||||
self.assertEqual(competition['metadata']['section_heading'], 'Demo Track')
|
||||
self.assertEqual(competition['track'], 'proceedings_unspecified')
|
||||
self.assertEqual(len({r['external_id'] for r in rows}), 476)
|
||||
self.assertEqual([r['publisher_page'] for r in excluded], [1, 3, 4])
|
||||
|
||||
def test_refuses_changed_edition_inventory_or_author_markup(self):
|
||||
original = (FIXTURE/'contents.html').read_text()
|
||||
for html in [original.replace('AAMAS 2025:', 'AAMAS 2026:', 1),
|
||||
original.replace('../pdfs/p5.pdf', '../pdfs/p14.pdf', 1),
|
||||
original.replace('Paper Track</font>', 'Workshop Track</font>'),
|
||||
original.replace('Amir Abolfazli', '', 1),
|
||||
original.replace('[COMP24]', '[CHANGED]', 1)]:
|
||||
with self.subTest(change=html[:60]):
|
||||
with self.assertRaises(ValueError): self.parse(html)
|
||||
|
||||
def test_frozen_collection_is_partial_and_never_fetches_pdfs(self):
|
||||
with tempfile.TemporaryDirectory() as tmp, patch.object(aamas2025, 'fetch', side_effect=AssertionError('No HTTP in frozen mode')):
|
||||
out = Path(tmp)/'out'
|
||||
report = aamas.collect(2025, out, snapshots=FIXTURE)
|
||||
self.assertEqual(report['records'], 476)
|
||||
self.assertFalse(report['database_written'])
|
||||
self.assertFalse(report['pdfs_downloaded'])
|
||||
self.assertEqual(len(report['files']), 5)
|
||||
for m in report['files']:
|
||||
self.assertEqual(m['status'], 'partial')
|
||||
self.assertEqual(m['missing_abstract_count'], m['count'])
|
||||
self.assertEqual(hashlib.sha256(Path(m['file']).read_bytes()).hexdigest(), m['file_sha256'])
|
||||
receipt = report['source_receipts'][0]['response_receipt']
|
||||
self.assertEqual(receipt['at_precision'], 'filesystem_write_time_recovered')
|
||||
self.assertTrue((out/'aamas-proceedings-2025.json').is_file())
|
||||
|
||||
def test_rejects_tampered_or_unsafe_frozen_response(self):
|
||||
import shutil
|
||||
for mode in ['tamper', 'path', 'status']:
|
||||
with self.subTest(mode=mode), tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp); source = root/'source'; shutil.copytree(FIXTURE, source)
|
||||
p = source/'receipts.json'; r = json.loads(p.read_text())
|
||||
if mode == 'tamper': (source/'contents.html').write_text('bad')
|
||||
elif mode == 'path': r['receipts'][0]['file'] = '../index.html'
|
||||
else: r['receipts'][0]['http_status'] = 403
|
||||
p.write_text(json.dumps(r))
|
||||
with self.assertRaises(ValueError): aamas.collect(2025, root/'out', snapshots=source)
|
||||
self.assertFalse((root/'out/aamas-proceedings-2025.json').exists())
|
||||
@@ -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 & 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(2026, 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(), [])
|
||||
@@ -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(), [])
|
||||
@@ -0,0 +1,114 @@
|
||||
"""Pinned T78 evidence, edition-local aliases and an explicitly identified material."""
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import unittest
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
from paper_atlas.collectors.acl import collect_xml, volume_scope
|
||||
from paper_atlas.collectors.acl_historical import event_scope, material_reason
|
||||
from paper_atlas.db.store import Store
|
||||
from paper_atlas.ingestion.records import ingest_file
|
||||
|
||||
FIXTURE = Path(__file__).parent/'fixtures/acl-t78.xml'
|
||||
COMMIT = 'a7be623d01c48c61405dec31c871121cdbed5a01'
|
||||
|
||||
|
||||
class AclHistoricalTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.root = ET.parse(FIXTURE).getroot()
|
||||
self.volume = self.root.find('volume')
|
||||
self.meta = self.volume.find('meta')
|
||||
|
||||
def test_frozen_volume_matches_official_snapshot(self):
|
||||
self.assertEqual(hashlib.sha256(FIXTURE.read_bytes()).hexdigest(), 'f712f94cff0cba05f786b7ac31dad2ea9408fde2a17a04531130df623f57dd84')
|
||||
self.assertEqual(len(self.volume.findall('paper')), 36)
|
||||
self.assertEqual(volume_scope(self.meta, '1', 'T78'), ('ACL+TINLAP', ['ACL', 'TINLAP'], 'proceedings_unspecified'))
|
||||
|
||||
def test_unknown_year_collection_or_volume_is_not_globally_aliased(self):
|
||||
for collection, volume in [('T75', '1'), ('T87', '1'), ('T78', '2'), ('C65', '1')]:
|
||||
with self.subTest(collection=collection, volume=volume):
|
||||
self.assertIsNone(event_scope(self.meta, volume, collection))
|
||||
self.assertIsNone(volume_scope(self.meta, volume, collection))
|
||||
self.assertIsNone(volume_scope(self.meta, '1'))
|
||||
|
||||
def test_known_volume_scope_rejects_critical_metadata_drift(self):
|
||||
for field, replacement in [('venue', 'acl'), ('year', '1979'), ('booktitle', 'Another TINLAP'), ('editor/first', 'Another')]:
|
||||
changed = copy.deepcopy(self.meta)
|
||||
changed.find(field).text = replacement
|
||||
with self.subTest(field=field), self.assertRaises(ValueError):
|
||||
event_scope(changed, '1', 'T78')
|
||||
changed = copy.deepcopy(self.meta)
|
||||
ET.SubElement(changed, 'year').text = '1978'
|
||||
with self.assertRaises(ValueError):
|
||||
event_scope(changed, '1', 'T78')
|
||||
|
||||
def test_evidence_retains_original_series_dates_and_role_boundary(self):
|
||||
scope = event_scope(self.meta, '1', 'T78')
|
||||
self.assertEqual(scope['original_venue'], 'TINLAP')
|
||||
self.assertEqual(scope['event_dates'], ['1978-07-25', '1978-07-27'])
|
||||
self.assertEqual([e['pdf_pages'] for e in scope['evidence']], [[9, 10], [3]])
|
||||
self.assertIn('not identified', scope['track_scope_note'])
|
||||
self.assertIn('pending', scope['evidence'][0]['scope'])
|
||||
|
||||
def test_only_exact_session_questions_material_is_excluded(self):
|
||||
paper = copy.deepcopy(self.volume.findall('paper')[-1])
|
||||
self.assertIn('Session question list', material_reason('T78', '1', paper))
|
||||
self.assertIsNone(material_reason('C65', '1', paper))
|
||||
self.assertIsNone(material_reason('T78', '2', paper))
|
||||
paper.set('id', '37')
|
||||
self.assertIsNone(material_reason('T78', '1', paper))
|
||||
paper.set('id', '36')
|
||||
ET.SubElement(paper, 'author')
|
||||
with self.assertRaises(ValueError):
|
||||
material_reason('T78', '1', paper)
|
||||
|
||||
def test_all_authored_source_fields_and_material_receipt_survive_collection(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
manifest, = collect_xml(FIXTURE, temporary, COMMIT)
|
||||
rows = [json.loads(raw) for raw in Path(manifest['file']).read_text().splitlines()]
|
||||
self.assertEqual(manifest['count'], 35)
|
||||
self.assertEqual(manifest['xml_paper_elements'], 36)
|
||||
self.assertEqual(manifest['missing_abstract_count'], 35)
|
||||
self.assertEqual(manifest['excluded_materials'][0]['external_id'], 'T78-1036')
|
||||
for number, row in enumerate(rows, 1):
|
||||
paper = self.volume.findall('paper')[number-1]
|
||||
self.assertEqual(row['external_id'], f'T78-1{number:03d}')
|
||||
self.assertEqual(row['title'], ' '.join(''.join(paper.find('title').itertext()).split()))
|
||||
self.assertEqual(row['metadata']['venue_codes'], ['tinlap'])
|
||||
self.assertEqual(row['metadata']['historical_event_scope']['original_venue'], 'TINLAP')
|
||||
self.assertEqual(row['metadata']['metadata_commit'], COMMIT)
|
||||
self.assertEqual((row['status'], row['track'], row['abstract']), ('accepted', 'proceedings_unspecified', None))
|
||||
|
||||
def test_old_coling_scope_is_unchanged_by_calendar_replacement_evidence(self):
|
||||
meta = ET.fromstring('<meta><venue>coling</venue><year>1965</year><booktitle>COLING 1965</booktitle></meta>')
|
||||
self.assertEqual(volume_scope(meta, '1', 'C65'), ('COLING', ['COLING'], 'main'))
|
||||
self.assertIsNone(event_scope(meta, '1', 'C65'))
|
||||
|
||||
def test_native_ingestion_does_not_alias_other_tinlap_years_or_infer_negative_status(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
manifest, = collect_xml(FIXTURE, root/'source', COMMIT)
|
||||
db = root/'sample.sqlite'
|
||||
store = Store(db)
|
||||
pid, rid = store.record('test_control', 'tinlap1975', 'https://example.org/tinlap1975',
|
||||
'An earlier TINLAP contribution', None, ['Jane Doe'])
|
||||
store.decision(pid, rid, 'TINLAP', 1975, 'main', 'tinlap1975', 'https://example.org/tinlap1975', 'accepted', 'test control')
|
||||
store.conn.commit()
|
||||
store.conn.close()
|
||||
ingest_file(db, manifest['file'])
|
||||
ingest_file(db, manifest['file'])
|
||||
with sqlite3.connect(db) as c:
|
||||
self.assertEqual(c.execute('SELECT count(*) FROM papers').fetchone()[0], 36)
|
||||
self.assertEqual(c.execute('SELECT count(*) FROM submissions').fetchone()[0], 36)
|
||||
self.assertEqual(c.execute('SELECT count(*) FROM decisions').fetchone()[0], 36)
|
||||
self.assertEqual(c.execute("SELECT count(*) FROM decisions WHERE status!='accepted'").fetchone()[0], 0)
|
||||
self.assertIsNone(c.execute("SELECT 1 FROM source_records WHERE external_id='T78-1036'").fetchone())
|
||||
aliases = c.execute('SELECT v.acronym,a.alias FROM venue_aliases a JOIN venues v ON v.id=a.venue_id ORDER BY a.alias').fetchall()
|
||||
self.assertEqual(aliases, [('ACL+TINLAP', 'ACL'), ('ACL+TINLAP', 'TINLAP')])
|
||||
metadata = json.loads(c.execute("SELECT metadata_json FROM source_records WHERE external_id='T78-1001'").fetchone()[0])
|
||||
self.assertEqual(metadata['historical_event_scope']['event_year'], 1978)
|
||||
self.assertEqual(c.execute('PRAGMA foreign_key_check').fetchall(), [])
|
||||
@@ -0,0 +1,159 @@
|
||||
"""Stable-attempt correction, rollback and stale whole-volume refresh on both engines."""
|
||||
from contextlib import closing, redirect_stdout
|
||||
import copy
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
import uuid
|
||||
|
||||
from paper_atlas.db.backend import open_store
|
||||
from paper_atlas.db.edition_scopes import KEY
|
||||
from paper_atlas.db.store import Store
|
||||
from paper_atlas.ingestion.edition_tracks import proposal,apply_plan,verify,validate,run
|
||||
from paper_atlas.ingestion.event_identity import database_identity,values
|
||||
from paper_atlas.ingestion.records import ingest_file
|
||||
from paper_atlas.postgres.client import connect
|
||||
from paper_atlas.postgres.migration import migrate
|
||||
from paper_atlas.postgres.schema import quote
|
||||
|
||||
|
||||
def seed(db):
|
||||
s=Store(db); rows=[]
|
||||
for i,track in enumerate(['main','survey','doctoral_consortium','proceedings_unspecified'],1):
|
||||
external='entry'+str(i);doi='10.1234/entry'+str(i)
|
||||
p,r=s.record('fixture',external,'https://example.org/'+external,'Publication '+external,
|
||||
'Abstract with NUL\x00 and SOH\x01',[],{'doi':doi})
|
||||
eid=s.decision(p,r,'JOINT',2022,'main',external,'https://example.org/'+external,'accepted','published',identities=[('doi',doi)],observed_at='2022-01-01T00:00:00Z')
|
||||
sid=s.conn.execute('SELECT id FROM submissions WHERE source_record_id=?',(r,)).fetchone()[0]
|
||||
s.record_tags(p,r,'fixture',[{'namespace':'fixture:subject','value':'Original'}])
|
||||
rows.append(dict(submission_id=sid,source_record_id=r,external_id=external,source='fixture',doi=doi,
|
||||
from_edition_id=eid,year=2022,venue='JOINT',venue_names=['JOINT'],track=track,
|
||||
section=track,evidence_url='https://example.org/proceedings/2022/',evidence_sha256='a'*64))
|
||||
source=s.conn.execute("SELECT id FROM sources WHERE name='fixture'").fetchone()[0]
|
||||
s.conn.execute("INSERT INTO edition_sources VALUES(?,?,?,'complete',4,4,'checkpoint','2022-01-01T00:00:00Z',NULL)",(eid,source,'https://example.org/volume'))
|
||||
s.conn.commit();s.conn.close();return {'format':1,'records':rows}
|
||||
|
||||
|
||||
class TrackCases:
|
||||
def store(self):return open_store(**self.target)
|
||||
|
||||
def plan(self,c):return proposal(c,self.inventory,database_identity(c,self.target.get('db')))
|
||||
|
||||
def test_move_preserves_all_history_tags_text_and_stable_identities(self):
|
||||
with closing(self.store().conn)as c:
|
||||
plan=self.plan(c);before=plan['before']['protected'];r=apply_plan(c,plan);c.commit()
|
||||
self.assertEqual((r['events_moved'],r['records_verified']),(3,4))
|
||||
self.assertEqual(r['protected'],before)
|
||||
self.assertEqual(apply_plan(c,plan)['status'],'already_applied')
|
||||
self.assertEqual(values(c.execute('SELECT status,expected_count,imported_count FROM edition_sources ORDER BY edition_id')),[['partial',1,1]]*4)
|
||||
self.assertEqual(c.execute('SELECT count(*) FROM submissions').fetchone()[0],4)
|
||||
self.assertEqual(c.execute('SELECT count(*) FROM decisions').fetchone()[0],4)
|
||||
self.assertEqual(c.execute('SELECT count(*) FROM paper_merges').fetchone()[0],0)
|
||||
|
||||
def test_source_record_refresh_keeps_scope_and_original_attempt(self):
|
||||
with closing(self.store().conn)as c:
|
||||
plan=self.plan(c);apply_plan(c,plan);c.commit()
|
||||
with closing(self.store().conn)as c:
|
||||
# Use Store methods with the same writer connection as production.
|
||||
s=object.__new__(Store);s.conn=c;s.event_identity=True
|
||||
r=self.inventory['records'][1];metadata=json.loads(c.execute('SELECT metadata_json FROM source_records WHERE id=?',(r['source_record_id'],)).fetchone()[0]);saved=metadata[KEY]
|
||||
p,record=s.record('fixture',r['external_id'],'https://example.org/new','Refreshed title',None,[],{'doi':r['doi']},{'new_source_field':True})
|
||||
eid=s.decision(p,record,'JOINT',2022,'main',r['external_id'],'https://example.org/entry2','accepted','published',identities=[('doi',r['doi'])],observed_at='2022-01-01T00:00:00Z')
|
||||
self.assertEqual(c.execute('SELECT track FROM editions WHERE id=?',(eid,)).fetchone()[0],'survey')
|
||||
self.assertEqual(c.execute('SELECT count(*) FROM submissions').fetchone()[0],4)
|
||||
self.assertEqual(json.loads(c.execute('SELECT metadata_json FROM source_records WHERE id=?',(record,)).fetchone()[0])[KEY],saved)
|
||||
with self.assertRaisesRegex(ValueError,'cannot supply'):
|
||||
s.record('fixture',r['external_id'],'https://example.org/new','Forged',None,[],metadata={KEY:[]})
|
||||
c.rollback()
|
||||
|
||||
def test_stale_snapshot_and_partial_inventory_are_rejected(self):
|
||||
with closing(self.store().conn)as c:
|
||||
plan=self.plan(c)
|
||||
c.execute("UPDATE papers SET title='Changed' WHERE id=(SELECT paper_id FROM submissions WHERE id=?)",(self.inventory['records'][0]['submission_id'],))
|
||||
with self.assertRaisesRegex(ValueError,'Stale'):apply_plan(c,plan)
|
||||
c.rollback()
|
||||
bad=copy.deepcopy(self.inventory);bad['records'].pop()
|
||||
with self.assertRaisesRegex(ValueError,'every attempt'):proposal(c,bad,database_identity(c,self.target.get('db')))
|
||||
bad=copy.deepcopy(self.inventory);bad['records'][0]['doi']='10.1234/wrong'
|
||||
with self.assertRaisesRegex(ValueError,'DOI'):proposal(c,bad,database_identity(c,self.target.get('db')))
|
||||
|
||||
def test_failure_after_updates_rolls_back_all_scope_and_metadata_changes(self):
|
||||
with closing(self.store().conn)as c:
|
||||
plan=self.plan(c)
|
||||
# A late coverage failure must roll back earlier moved attempts.
|
||||
source=c.execute("SELECT id FROM sources WHERE name='fixture'").fetchone()[0]
|
||||
c.execute("INSERT INTO sources(name,homepage) VALUES('other','https://example.org')")
|
||||
other=c.execute("SELECT id FROM sources WHERE name='other'").fetchone()[0]
|
||||
c.execute('UPDATE edition_sources SET source_id=? WHERE source_id=?',(other,source));c.commit()
|
||||
plan=self.plan(c)
|
||||
try:
|
||||
with self.assertRaisesRegex(ValueError,'outside'):apply_plan(c,plan)
|
||||
finally:c.rollback()
|
||||
self.assertEqual(self.plan(c)['before'],plan['before'])
|
||||
self.assertEqual(c.execute('SELECT count(*) FROM editions').fetchone()[0],1)
|
||||
|
||||
def test_import_old_whole_volume_fails_without_corrupting_coverage(self):
|
||||
with closing(self.store().conn)as c:
|
||||
plan=self.plan(c);apply_plan(c,plan);c.commit()
|
||||
r=self.inventory['records'][1];path=self.root/'old.jsonl'
|
||||
row=dict(source='fixture',external_id=r['external_id'],url='https://example.org/entry2',title='Old title',authors=[],identifiers={'doi':r['doi']},venue='JOINT',year=2022,track='main',status='accepted')
|
||||
path.write_text(json.dumps(row)+'\n');path.with_suffix('.manifest.json').write_text(json.dumps(dict(source='fixture',venue='JOINT',year=2022,track='main',count=1,list_url='https://example.org/volume',status='complete')))
|
||||
with self.assertRaisesRegex(ValueError,'Recollect'):ingest_file(self.target.get('db'),path,**{k:v for k,v in self.target.items()if k!='db'})
|
||||
with closing(self.store().conn)as c:verify(c,plan)
|
||||
|
||||
def test_nonempty_target_and_alias_drift_fail_before_moving(self):
|
||||
with closing(self.store().conn)as c:
|
||||
c.execute("INSERT INTO venue_aliases VALUES((SELECT id FROM venues WHERE acronym='JOINT'),'ADDED')")
|
||||
with self.assertRaisesRegex(ValueError,'aliases'):self.plan(c)
|
||||
c.rollback()
|
||||
s=object.__new__(Store);s.conn=c;s.event_identity=True
|
||||
p,r=s.record('fixture','collision','https://example.org/collision','Distinct contribution',None,[],{'doi':'10.1234/collision'})
|
||||
s.decision(p,r,'JOINT',2022,'survey','collision','https://example.org/collision','accepted','published')
|
||||
with self.assertRaisesRegex(ValueError,'Nonempty target'):self.plan(c)
|
||||
c.rollback()
|
||||
self.assertEqual(c.execute('SELECT count(*) FROM submissions').fetchone()[0],4)
|
||||
|
||||
def test_repeated_plan_detects_coverage_or_scope_evidence_drift(self):
|
||||
with closing(self.store().conn)as c:
|
||||
plan=self.plan(c);apply_plan(c,plan);c.commit()
|
||||
c.execute("UPDATE edition_sources SET imported_count=99")
|
||||
with self.assertRaisesRegex(ValueError,'coverage'):apply_plan(c,plan)
|
||||
c.rollback()
|
||||
record=self.inventory['records'][0]['source_record_id']
|
||||
m=json.loads(c.execute('SELECT metadata_json FROM source_records WHERE id=?',(record,)).fetchone()[0]);m[KEY]=[]
|
||||
c.execute('UPDATE source_records SET metadata_json=? WHERE id=?',(json.dumps(m),record))
|
||||
with self.assertRaisesRegex(ValueError,'unchanged track'):apply_plan(c,plan)
|
||||
c.rollback()
|
||||
|
||||
def test_cli_hash_binding_preflight_and_duplicate_inventory(self):
|
||||
inventory=self.root/'inventory.json';inventory.write_text(json.dumps(self.inventory))
|
||||
planfile=self.root/'plan.json';p=run(self.target.get('db'),planfile,inventory=inventory,**{k:v for k,v in self.target.items()if k!='db'})
|
||||
with self.assertRaisesRegex(ValueError,'hash'):run(self.target.get('db'),self.root/'bad.json',plan=planfile,plan_sha256='0'*64,**{k:v for k,v in self.target.items()if k!='db'})
|
||||
report=run(self.target.get('db'),self.root/'applied.json',plan=planfile,plan_sha256=hashlib.sha256(planfile.read_bytes()).hexdigest(),**{k:v for k,v in self.target.items()if k!='db'})
|
||||
self.assertEqual(report['events_moved'],3)
|
||||
bad=copy.deepcopy(self.inventory);bad['records'].append(bad['records'][0])
|
||||
with self.assertRaisesRegex(ValueError,'Repeated'):validate(bad)
|
||||
|
||||
|
||||
class SQLiteTrackTests(TrackCases,unittest.TestCase):
|
||||
def setUp(self):
|
||||
t=tempfile.TemporaryDirectory();self.addCleanup(t.cleanup);self.root=Path(t.name);db=self.root/'source.sqlite'
|
||||
self.inventory=seed(db);self.target={'db':db}
|
||||
|
||||
|
||||
@unittest.skipUnless(os.environ.get('PAPER_ATLAS_TEST_PG_SERVICE'),'Requires approved remote PostgreSQL service')
|
||||
class PostgreSQLTrackTests(TrackCases,unittest.TestCase):
|
||||
def setUp(self):
|
||||
t=tempfile.TemporaryDirectory();self.addCleanup(t.cleanup);self.root=Path(t.name);db=self.root/'source.sqlite';self.inventory=seed(db)
|
||||
self.service=os.environ['PAPER_ATLAS_TEST_PG_SERVICE'];self.schema='pa_tracks_'+uuid.uuid4().hex[:12];self.addCleanup(self.cleanup)
|
||||
with redirect_stdout(io.StringIO()):migrate(db,self.service,self.root/'migration.json',schema=self.schema,min_free_gib=0)
|
||||
self.target={'pg_service':self.service,'pg_schema':self.schema}
|
||||
|
||||
def cleanup(self):
|
||||
with connect(self.service)as c:
|
||||
for name, in c.execute('SELECT nspname FROM pg_namespace WHERE starts_with(nspname,%s)',(self.schema,)):
|
||||
c.execute('DROP SCHEMA '+quote(name)+' CASCADE')
|
||||
@@ -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 "é">')
|
||||
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'])
|
||||
@@ -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()
|
||||
@@ -0,0 +1,148 @@
|
||||
"""Historical accepted lists, repeated presentations and preserved work histories."""
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from paper_atlas.collectors import iclr_archive as archive
|
||||
from paper_atlas.db.store import Store
|
||||
from paper_atlas.ingestion.records import ingest_file
|
||||
|
||||
OBSERVED = '2026-11-09T00:00:00Z'
|
||||
|
||||
|
||||
def entry(identifier, title='A shared research paper', display='Jane Doe and Bob Smith'):
|
||||
return f'<li><div><a href="http://arxiv.org/abs/{identifier}">{title}</a>, {display}</div></li>'
|
||||
|
||||
|
||||
def page2015():
|
||||
return ('<h3>Main Conference - Oral Presentations</h3><ol>'+entry('1412.0001v2')+'</ol>'
|
||||
'<h3>Main Conference - Poster Presentations</h3><ol>'+entry('1412.0001v2')+'</ol>'
|
||||
'<h3>Workshop Papers</h3><ol>'+entry('1412.0002', 'A workshop paper')+'</ol>')
|
||||
|
||||
|
||||
class IclrArchiveTests(unittest.TestCase):
|
||||
def frozen(self, root, year, html):
|
||||
root.mkdir()
|
||||
data = html.encode()
|
||||
(root/'page.html').write_bytes(data)
|
||||
receipt = {'url': archive.PAGES[year][0], 'file': 'page.html', 'status': 200,
|
||||
'bytes': len(data), 'sha256': hashlib.sha256(data).hexdigest(), 'finished_at': OBSERVED}
|
||||
(root/'receipts.json').write_text(json.dumps({'receipts': [receipt]}))
|
||||
return root
|
||||
|
||||
def test_oral_poster_repeat_is_one_attempt_with_all_source_variants(self):
|
||||
rows = archive.parse_page(page2015(), 2015, {'observed_at': OBSERVED})
|
||||
main, workshop = rows
|
||||
self.assertEqual((main['track'], workshop['track']), ('main', 'workshop'))
|
||||
self.assertEqual(main['identifiers'], {'arxiv': '1412.0001'})
|
||||
self.assertEqual(main['external_id'], '2015/main/1412.0001')
|
||||
self.assertEqual(main['metadata']['presentations'], ['oral', 'poster'])
|
||||
self.assertEqual(len(main['metadata']['source_variants']), 2)
|
||||
self.assertEqual(main['metadata']['source_variants'][0]['version'], 'v2')
|
||||
self.assertEqual(main['authors'], ['Jane Doe', 'Bob Smith'])
|
||||
self.assertTrue(all(r['status'] == 'accepted' and r['abstract'] is None for r in rows))
|
||||
|
||||
def test_2014_author_span_outside_paragraph_and_trailing_separator(self):
|
||||
html = ('<h3>Conference Proceedings</h3><p>conference papers accepted</p>'
|
||||
'<div><b><a href="http://arxiv.org/abs/1312.0001">A <i>research</i> title</a></b></div>'
|
||||
'<p><span style="font-style: italic">Jane Doe; </span>'
|
||||
'<span style="font-style:italic">Bob Smith; Carol Wu;</span></p>'
|
||||
'<div><a href="http://arxiv.org/abs/1312.0002">Another title</a>'
|
||||
'<div><i><span>First Author; </span><span>Second Author</span></i></div></div>')
|
||||
row, unwrapped = archive.parse_page(html, 2014, {'observed_at': OBSERVED})
|
||||
self.assertEqual(row['title'], 'A research title')
|
||||
self.assertEqual(row['authors'], ['Jane Doe', 'Bob Smith', 'Carol Wu'])
|
||||
self.assertEqual(row['metadata']['source_variants'][0]['authors_display'], 'Jane Doe; Bob Smith; Carol Wu;')
|
||||
self.assertEqual(row['track'], 'main')
|
||||
self.assertEqual(unwrapped['authors'], ['First Author', 'Second Author'])
|
||||
|
||||
def test_same_arxiv_in_different_tracks_keeps_separate_source_attempts(self):
|
||||
html = page2015().replace('1412.0002', '1412.0001v3')
|
||||
rows = archive.parse_page(html, 2015, {'observed_at': OBSERVED})
|
||||
self.assertEqual(len(rows), 2)
|
||||
self.assertEqual({r['external_id'] for r in rows}, {'2015/main/1412.0001', '2015/workshop/1412.0001'})
|
||||
self.assertEqual(rows[1]['metadata']['source_variants'][0]['version'], 'v3')
|
||||
|
||||
def test_2016_code_links_and_breaks_do_not_become_authors(self):
|
||||
html = ('<h1>Accepted Papers (Conference Track)</h1><h3>Oral Presentations</h3><ol>'
|
||||
'<li><a href="http://arxiv.org/abs/1511.00001">A title</a>'
|
||||
'<a href="https://example.org/code">[code]</a><br>Jane Doe, Bob Smith</li></ol>'
|
||||
'<h3>Poster Presentations</h3>')
|
||||
row, = archive.parse_page(html, 2016, {'observed_at': OBSERVED})
|
||||
self.assertEqual(row['authors'], ['Jane Doe', 'Bob Smith'])
|
||||
self.assertEqual(row['metadata']['presentations'], ['oral'])
|
||||
|
||||
def test_conflicting_repeat_unknown_sections_and_unparsed_links_fail(self):
|
||||
variants = [page2015().replace('Poster Presentations', 'Other Presentations'),
|
||||
page2015().replace('</ol>', '</ol><a href="http://arxiv.org/abs/1412.0003">stray</a>', 1),
|
||||
page2015().replace('Jane Doe and Bob Smith', 'Different Author', 1)]
|
||||
for html in variants:
|
||||
with self.subTest(html=html), self.assertRaises(ValueError):
|
||||
archive.parse_page(html, 2015, {'observed_at': OBSERVED})
|
||||
|
||||
def test_arxiv_versions_are_preserved_and_unsafe_links_rejected(self):
|
||||
self.assertEqual(archive.arxiv_link('https://arxiv.org/pdf/1511.00001v3.pdf'), ('1511.00001', 'v3'))
|
||||
self.assertIsNone(archive.arxiv_link('https://arxiv.org.example.org/abs/1511.00001'))
|
||||
for url in ['https://arxiv.org/abs/1511.00001?other=1', 'ftp://arxiv.org/abs/1511.00001',
|
||||
'https://user@arxiv.org/abs/1511.00001', 'https://arxiv.org/abs/not-a-paper']:
|
||||
with self.subTest(url=url), self.assertRaises(ValueError):
|
||||
archive.arxiv_link(url)
|
||||
|
||||
def test_receipt_hash_status_path_and_symlink_cannot_escape_snapshot(self):
|
||||
for failure in ['hash', 'status', 'path', 'symlink']:
|
||||
with self.subTest(failure=failure), tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
source = self.frozen(root/'source', 2015, page2015())
|
||||
file = source/'receipts.json'
|
||||
data = json.loads(file.read_text())
|
||||
receipt = data['receipts'][0]
|
||||
if failure == 'hash': receipt['sha256'] = '0'*64
|
||||
elif failure == 'status': receipt['status'] = 404
|
||||
elif failure == 'path': receipt['file'] = '../outside.html'
|
||||
else:
|
||||
(root/'outside.html').write_text(page2015())
|
||||
(source/'page.html').unlink()
|
||||
(source/'page.html').symlink_to(root/'outside.html')
|
||||
file.write_text(json.dumps(data))
|
||||
with self.assertRaises(ValueError):
|
||||
archive.read_page(2015, snapshots=source)
|
||||
|
||||
def test_changed_inventory_leaves_no_completed_output(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
source = self.frozen(root/'source', 2015, page2015())
|
||||
with self.assertRaises(ValueError):
|
||||
archive.collect(2015, root/'out', snapshots=source)
|
||||
self.assertFalse((root/'out/iclr-archive-2015.json').exists())
|
||||
|
||||
def test_native_import_keeps_rejected_to_accepted_history_and_abstract(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
source = self.frozen(root/'source', 2015, page2015())
|
||||
with patch.dict(archive.PAGES, {2015: (archive.PAGES[2015][0], {'main': 1, 'workshop': 1})}):
|
||||
result = archive.collect(2015, root/'out', snapshots=source)
|
||||
with self.assertRaises(FileExistsError):
|
||||
archive.collect(2015, root/'out', snapshots=source)
|
||||
db = root/'sample.sqlite'
|
||||
store = Store(db)
|
||||
pid, rid = store.record('arxiv', '1412.0001', 'https://arxiv.org/abs/1412.0001',
|
||||
'A shared research paper', 'Keep this canonical abstract.',
|
||||
['Jane Doe', 'Bob Smith'], {'arxiv': '1412.0001'}, {})
|
||||
store.decision(pid, rid, 'Earlier Conference', 2014, 'main', 'original-attempt',
|
||||
'https://example.org/rejected', 'rejected', 'explicit earlier decision', observed_at=OBSERVED)
|
||||
store.conn.commit()
|
||||
store.conn.close()
|
||||
for manifest in result['files']:
|
||||
ingest_file(db, manifest['file'])
|
||||
ingest_file(db, manifest['file'])
|
||||
with sqlite3.connect(db) as c:
|
||||
self.assertEqual(c.execute('SELECT count(*) FROM papers').fetchone()[0], 2)
|
||||
self.assertEqual(c.execute('SELECT abstract FROM papers WHERE id=?', (pid,)).fetchone()[0], 'Keep this canonical abstract.')
|
||||
history = c.execute('SELECT v.acronym,e.year,d.status FROM decisions d JOIN submissions s ON s.id=d.submission_id JOIN editions e ON e.id=s.edition_id JOIN venues v ON v.id=e.venue_id WHERE s.paper_id=? ORDER BY e.year', (pid,)).fetchall()
|
||||
self.assertEqual(history, [('Earlier Conference', 2014, 'rejected'), ('ICLR', 2015, 'accepted')])
|
||||
self.assertEqual(c.execute('SELECT count(*) FROM submissions').fetchone()[0], 3)
|
||||
self.assertEqual(c.execute('PRAGMA foreign_key_check').fetchall(), [])
|
||||
@@ -0,0 +1,160 @@
|
||||
"""Real 49-chapter snapshots compared with a separately frozen metadata audit."""
|
||||
import copy
|
||||
import gzip
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from paper_atlas.collectors.springer_selected import (
|
||||
BOOK, SOURCE, collect, parse_chapter, parse_toc, read_page, snapshot_index,
|
||||
)
|
||||
from paper_atlas.db.store import Store
|
||||
from paper_atlas.ingestion.records import ingest_file
|
||||
|
||||
FIXTURE = Path(__file__).parent/'fixtures/springer-aistats1993-fixture.json.gz'
|
||||
|
||||
|
||||
class SpringerSelectedTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
raw = FIXTURE.read_bytes()
|
||||
if hashlib.sha256(raw).hexdigest() != '94028fd37080f9d8027e5790e379cc151ee6eab17ed0d1dcc0f59e6e75f9d4d0':
|
||||
raise AssertionError('Fixed source fixture differs')
|
||||
cls.fixture = json.loads(gzip.decompress(raw))
|
||||
|
||||
def snapshots(self, root):
|
||||
for group in ['toc','chapters']:
|
||||
folder = root/group
|
||||
folder.mkdir()
|
||||
for name, text in self.fixture[group].items():
|
||||
(folder/name).write_text(text)
|
||||
return {'toc_snapshots':root/'toc','chapter_snapshots':root/'chapters'}
|
||||
|
||||
def test_all49_original_fields_against_independent_audit(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
report = collect(root/'out', **self.snapshots(root))
|
||||
manifest, = report['files']
|
||||
rows = [json.loads(s) for s in Path(manifest['file']).read_text().splitlines()]
|
||||
self.assertEqual(len(rows), 49)
|
||||
self.assertEqual(manifest['status'], 'partial')
|
||||
self.assertEqual(manifest['missing_abstract_count'], 0)
|
||||
self.assertTrue(manifest['excluded_materials'])
|
||||
self.assertTrue(all(x['title'] == 'Front Matter' for x in manifest['excluded_materials']))
|
||||
for row, expected in zip(rows, self.fixture['expected']):
|
||||
for field in ['title','abstract']:
|
||||
self.assertEqual(row[field], expected[field])
|
||||
self.assertEqual(row['identifiers'], {'doi':expected['doi']})
|
||||
self.assertEqual(row['submission_identifiers'], [{'scheme':'publication_doi','value':expected['doi']}])
|
||||
self.assertEqual(row['external_id'], expected['doi'])
|
||||
self.assertEqual(row['metadata']['authors_citation_raw'], expected['authors_citation_raw'])
|
||||
self.assertEqual(row['authors'], [{'family':s.split(',',1)[0].strip(), 'given':s.split(',',1)[1].strip()} for s in expected['authors_citation_raw']])
|
||||
self.assertEqual(row['metadata']['first_page'], expected['first_page'])
|
||||
self.assertEqual(row['metadata']['last_page'], expected['last_page'])
|
||||
self.assertEqual((row['year'],row['metadata']['bibliographic_year']), (1993,1994))
|
||||
self.assertEqual(row['track'], 'selected_volume')
|
||||
self.assertEqual(row['event_key'], 'aistats-1993-selected-volume-1994')
|
||||
self.assertNotIn('decision_date', row)
|
||||
self.assertIn('original 1993 review decision not supplied', row['raw_decision'])
|
||||
for e in [row['metadata']['source_evidence'], *row['metadata']['scope_evidence']]:
|
||||
self.assertEqual(hashlib.sha256((root/'out'/e['file']).read_bytes()).hexdigest(), e['sha256'])
|
||||
self.assertFalse(report['database_written'])
|
||||
with self.assertRaises(FileExistsError):
|
||||
collect(root/'out', toc_snapshots=root/'toc', chapter_snapshots=root/'chapters')
|
||||
|
||||
def test_native_history_namespaces_and_idempotent_import(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
report = collect(root/'out', **self.snapshots(root))
|
||||
manifest, = report['files']
|
||||
first = json.loads(Path(manifest['file']).read_text().splitlines()[0])
|
||||
db = root/'sample.sqlite'
|
||||
store = Store(db)
|
||||
pid, rid = store.record('prior_public','earlier','https://example.org/earlier',first['title'],first['abstract'],first['authors'])
|
||||
store.decision(pid,rid,'AISTATS',1993,'main','earlier','https://example.org/earlier','rejected','original public rejection')
|
||||
# Explicitly verified work identity; the short title alone must not
|
||||
# force a merge or attach this publication DOI to the old attempt.
|
||||
store.conn.execute('INSERT INTO identifiers VALUES(?,?,?)', ('doi',first['identifiers']['doi'],pid))
|
||||
store.conn.commit()
|
||||
old = store.conn.execute('SELECT * FROM decisions').fetchall()
|
||||
store.conn.close()
|
||||
ingest_file(db, manifest['file'])
|
||||
ingest_file(db, manifest['file'])
|
||||
with sqlite3.connect(db) as c:
|
||||
self.assertEqual(c.execute('SELECT * FROM decisions WHERE status=\'rejected\'').fetchall(), [tuple(r) for r in old])
|
||||
self.assertEqual(c.execute('SELECT count(*) FROM papers').fetchone()[0], 49)
|
||||
self.assertEqual(c.execute('SELECT count(*) FROM submissions').fetchone()[0], 50)
|
||||
self.assertEqual(c.execute('SELECT count(*) FROM decisions').fetchone()[0], 50)
|
||||
self.assertEqual(c.execute('SELECT e.year,e.track,e.event_key FROM editions e ORDER BY e.id').fetchall(), [(1993,'main',''),(1993,'selected_volume','aistats-1993-selected-volume-1994')])
|
||||
self.assertEqual(c.execute('SELECT count(*) FROM paper_tags pt JOIN tags t ON t.id=pt.tag_id WHERE t.namespace=?', (SOURCE+':publication_format',)).fetchone()[0], 49)
|
||||
self.assertEqual(c.execute('SELECT count(*) FROM submission_identifiers WHERE scheme=\'publication_doi\'').fetchone()[0], 49)
|
||||
self.assertIsNone(c.execute("SELECT 1 FROM submission_identifiers si JOIN submissions s ON s.id=si.submission_id JOIN editions e ON e.id=s.edition_id WHERE e.track='main'").fetchone())
|
||||
|
||||
def test_short_unanchored_title_keeps_identity_unconfirmed(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
store = Store(Path(tmp)/'identity.sqlite')
|
||||
title = 'Statistical strategy: step 1'
|
||||
authors = [{'given':'D. J.','family':'Hand'}]
|
||||
old, _ = store.record('prior_public','earlier','https://example.org/old',title,'Same abstract',authors)
|
||||
new, _ = store.record(SOURCE,BOOK+'_1','https://link.springer.com/chapter/'+BOOK+'_1',title,'Same abstract',authors,{'doi':BOOK+'_1'})
|
||||
self.assertNotEqual(old, new)
|
||||
self.assertEqual(store.conn.execute('SELECT count(*) FROM match_candidates WHERE method=\'exact_title_unconfirmed\'').fetchone()[0], 1)
|
||||
store.conn.close()
|
||||
|
||||
def test_receipt_hash_path_redirect_and_content_type_guards(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
self.snapshots(root)
|
||||
index = snapshot_index(root/'chapters')
|
||||
url = 'https://link.springer.com/chapter/'+BOOK+'_1'
|
||||
self.assertTrue(read_page(index,url)[0])
|
||||
for key, value in [('sha256','0'*64),('file','../escape.html'),('final_url','https://example.org/chapter'),('http_status',403),('headers',{'Content-Type':'application/pdf'})]:
|
||||
changed = copy.deepcopy(index)
|
||||
changed[url][1][key] = value
|
||||
with self.subTest(key=key), self.assertRaises((ValueError,KeyError)):
|
||||
read_page(changed,url)
|
||||
changed = copy.deepcopy(index)
|
||||
changed[url][1]['final_url'] = url+'?page=2'
|
||||
with self.assertRaises(ValueError):
|
||||
read_page(changed,url)
|
||||
|
||||
def test_missing_or_duplicate_snapshot_stops_collection(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
args = self.snapshots(root)
|
||||
path = root/'chapters/receipts.json'
|
||||
d = json.loads(path.read_bytes())
|
||||
original = copy.deepcopy(d)
|
||||
d['responses'].pop()
|
||||
path.write_text(json.dumps(d))
|
||||
with self.assertRaises(ValueError):
|
||||
collect(root/'out', **args)
|
||||
self.assertFalse((root/'out').exists())
|
||||
original['responses'].append(original['responses'][0])
|
||||
path.write_text(json.dumps(original))
|
||||
with self.assertRaises(ValueError):
|
||||
snapshot_index(root/'chapters')
|
||||
|
||||
def test_book_doi_year_and_duplicate_citation_fields_reject_drift(self):
|
||||
html = self.fixture['chapters']['chapter-01.html']
|
||||
for key, value in [('citation_doi',BOOK+'_2'),('citation_inbook_title','Another Book'),('citation_publication_date','1993')]:
|
||||
import re
|
||||
changed, count = re.subn(r'(<meta[^>]*name="'+key+r'"[^>]*content=")[^"]*', lambda m:m[1]+value, html)
|
||||
self.assertEqual(count, 1)
|
||||
with self.subTest(key=key), self.assertRaises(ValueError):
|
||||
parse_chapter(changed, BOOK+'_1')
|
||||
with self.assertRaises(ValueError):
|
||||
parse_chapter(html+'<meta name="citation_doi" content="'+BOOK+'_1">', BOOK+'_1')
|
||||
|
||||
def test_missing_abstract_is_explicit_and_does_not_use_other_page_text(self):
|
||||
html = self.fixture['chapters']['chapter-01.html'].replace('id="Abs1-content"','id="not-an-abstract"')
|
||||
self.assertIsNone(parse_chapter(html, BOOK+'_1')['abstract'])
|
||||
|
||||
def test_toc_ignores_frontmatter_but_rejects_duplicate_publications(self):
|
||||
html = self.fixture['toc']['springer-toc1.html']
|
||||
self.assertEqual(len(parse_toc(html)), 17)
|
||||
with self.assertRaises(ValueError):
|
||||
parse_toc(html+html)
|
||||
@@ -0,0 +1,155 @@
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from paper_atlas.collectors.www_archive import (ROOT, SPRING, FALL, Sources, collect, decode,
|
||||
early_review, spring_rows)
|
||||
|
||||
|
||||
class ArchiveTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
t=tempfile.TemporaryDirectory();self.addCleanup(t.cleanup);self.root=Path(t.name);self.cache=self.root/'cache';self.cache.mkdir()
|
||||
self.pages={ROOT+'www1/PrelimProcs.html':'''<h1>Formal presentations</h1><ol><li><em>Keywords:</em> BUSINESS TRACK?<br><em>Title:</em><b> First contribution</b><br><em>Author:</em>Jane Doe <jane@example.org><br><li><em>Title:</em><b>Unlinked contribution</b><br><em>Author:</em>John Roe<br></ol>''',
|
||||
FALL:'<h1>Conference Tracks</h1><ol><li><a href="Agents.html">Agents</a><li><a href="Tutor.html">Tutorials</a></ol>',
|
||||
ROOT+'www2/Proceedings/Agents.html':'<h3>Panel</h3><dl><dt><a href="a.html">Linked talk</a><dd>Jane Doe<dt>Unlinked talk</a><dd>John Roe</dl>',
|
||||
ROOT+'www2/Proceedings/Tutor.html':'<h1>Tutorials</h1><dl><dt>Training session<dd>Teacher</dl>'}
|
||||
for track in ['papers','posters']:
|
||||
self.pages[SPRING+track+'/author.html']='<b>Doe, Jane</b><ul><li><a href="01/paper.html">Same contribution</a></li></ul><b>Roe, John</b><a href="01/paper.html">Same contribution</a>'
|
||||
self.pages[SPRING+track+'/abstract.html']='''<h1>Abstracts</h1><a href="#one">Same contribution</a><h3><a href="01/paper.html">Same contribution</a></h3><i>Author: Jane Doe, John Roe<br>Institute</i><p>Actual abstract with <i>inline emphasis</i> and a separator\u2028 preserved.</p><hr><h2>Another session</h2>'''
|
||||
self.pages[SPRING+'papers/session.html']='<a href="01/paper.html">Same contribution</a>'
|
||||
|
||||
def sources(self):
|
||||
rows=[]
|
||||
for i,(url,html) in enumerate(sorted(self.pages.items())):
|
||||
raw=html.encode()if isinstance(html,str)else html;name='source-'+str(i)+'.html';(self.cache/name).write_bytes(raw)
|
||||
rows.append(dict(url=url,status=200,file=name,bytes=len(raw),sha256=hashlib.sha256(raw).hexdigest()))
|
||||
p=self.cache/'manifest.json';p.write_text(json.dumps({'sources':rows}));sha=hashlib.sha256(p.read_bytes()).hexdigest()
|
||||
return Sources(self.cache,sha),sha
|
||||
|
||||
def test_offline_batch_separates_tracks_review_and_importable_rows(self):
|
||||
_,sha=self.sources();out=self.root/'batch';report=collect(self.cache,sha,out)
|
||||
self.assertEqual(report['ready'],2);self.assertEqual(report['held'],5)
|
||||
queue=json.loads((out/'import-queue.json').read_text())['files'];self.assertEqual(len(queue),2)
|
||||
for name in queue:
|
||||
p=Path(name);rows=[json.loads(l)for l in p.read_text().split('\n') if l];self.assertEqual(len(rows),1)
|
||||
row=rows[0];self.assertEqual(row['event_key'],'www-1995-spring');self.assertEqual(row['authors'],['Jane Doe','John Roe'])
|
||||
self.assertIn(row['track'],['main','poster']);self.assertIn('inline emphasis',row['abstract']);self.assertNotIn('Another session',row['abstract'])
|
||||
self.assertIn('\u2028',row['abstract']);manifest=json.loads(p.with_suffix('.manifest.json').read_text());self.assertEqual(manifest['file_sha256'],hashlib.sha256(p.read_bytes()).hexdigest());self.assertEqual(manifest['status'],'partial')
|
||||
held=[json.loads(l)for l in(out/'review-candidates.jsonl').read_text().split('\n') if l]
|
||||
self.assertTrue(all('status'not in r and 'source_evidence'in r for r in held))
|
||||
self.assertFalse(any('review-candidates'in p for p in queue))
|
||||
|
||||
def test_partial_manifest_preserves_full_listing_size_separately_from_ready_count(self):
|
||||
self.pages[SPRING+'posters/abstract.html']+='<h3><a href="02/other.html">Unlinked poster</a></h3><i>Author: Other Person</i><p>Actual abstract</p>'
|
||||
_,sha=self.sources();out=self.root/'batch';collect(self.cache,sha,out)
|
||||
manifest=json.loads((out/'www-1995-spring-poster.manifest.json').read_text())
|
||||
self.assertEqual((manifest['count'],manifest['source_listing_count'],manifest['held_entry_count']),(1,2,1))
|
||||
self.assertNotIn('coverage_previous_count',manifest) # Live parent must be independently preflighted.
|
||||
|
||||
def test_malformed_href_requires_exact_id_and_two_clean_index_anchors(self):
|
||||
self.pages[SPRING+'papers/abstract.html']=self.pages[SPRING+'papers/abstract.html'].replace('01/paper.html','01/ paper.html')
|
||||
sources,_=self.sources();rows,held,_=spring_rows(sources,'papers');self.assertEqual(held,[])
|
||||
self.assertEqual(rows[0]['url'],SPRING+'papers/01/paper.html');self.assertTrue(rows[0]['metadata']['url_corroborated_by_author_and_session'])
|
||||
self.assertIn(' ',rows[0]['metadata']['abstract_href_raw'])
|
||||
|
||||
def test_main_index_disagreement_fails_without_publishing_batch(self):
|
||||
self.pages[SPRING+'papers/session.html']='<a href="02/other.html">Other work</a>';_,sha=self.sources();out=self.root/'batch'
|
||||
with self.assertRaisesRegex(ValueError,'ID sets disagree'):collect(self.cache,sha,out)
|
||||
self.assertFalse(out.exists())
|
||||
|
||||
def test_same_entry_id_with_conflicting_clean_url_is_rejected(self):
|
||||
self.pages[SPRING+'papers/session.html']='<a href="01/other.html">Same contribution</a>';sources,_=self.sources()
|
||||
with self.assertRaisesRegex(ValueError,'URLs disagree'):spring_rows(sources,'papers')
|
||||
|
||||
def test_poster_without_linked_author_entry_is_held_even_with_abstract(self):
|
||||
self.pages[SPRING+'posters/author.html']='<b>Doe, Jane</b><a href="#">Same contribution</a>';sources,_=self.sources();rows,held,counts=spring_rows(sources,'posters')
|
||||
self.assertEqual(rows,[]);self.assertEqual(counts['abstract_entries'],1);self.assertEqual(held[0]['reason'],'abstract_entry_missing_linked_author_index')
|
||||
|
||||
def test_unlinked_and_panel_entries_never_become_acceptance_or_rejection(self):
|
||||
sources,_=self.sources();held,counts=early_review(sources);self.assertEqual(counts,{'first_conference_fields':2,'fall_categories':2,'fall_program_entries':3})
|
||||
self.assertTrue(any(r['candidate']['title']=='Unlinked talk'for r in held));self.assertTrue(all('status'not in r for r in held))
|
||||
self.assertEqual(held[0]['candidate']['authors_raw'],['Jane Doe'])
|
||||
|
||||
def test_title_disagreement_goes_to_review_with_source_claims(self):
|
||||
self.pages[SPRING+'papers/abstract.html']=self.pages[SPRING+'papers/abstract.html'].replace('Same contribution','Different contribution');sources,_=self.sources();rows,held,_=spring_rows(sources,'papers')
|
||||
self.assertEqual(rows,[]);self.assertEqual(held[0]['reason'],'abstract_and_author_index_titles_disagree')
|
||||
|
||||
def test_cache_tampering_and_manifest_hash_mismatch_fail_closed(self):
|
||||
_,sha=self.sources()
|
||||
with self.assertRaisesRegex(ValueError,'manifest checksum'):Sources(self.cache,'0'*64)
|
||||
p=next(self.cache.glob('source-*.html'));p.write_bytes(p.read_bytes()+b'x')
|
||||
with self.assertRaisesRegex(ValueError,'checksum or size'):Sources(self.cache,sha)
|
||||
|
||||
def test_cache_cannot_read_paths_outside_snapshot(self):
|
||||
_,sha=self.sources();p=self.cache/'manifest.json';d=json.loads(p.read_text());d['sources'][0]['file']='../outside.html';p.write_text(json.dumps(d))
|
||||
with self.assertRaisesRegex(ValueError,'plain files'):Sources(self.cache,hashlib.sha256(p.read_bytes()).hexdigest())
|
||||
|
||||
def test_keywords_only_is_missing_abstract_not_invented_summary(self):
|
||||
self.pages[SPRING+'papers/abstract.html']='<h3><a href="01/paper.html">Same contribution</a></h3><i>Author: Jane Doe</i><p>Keywords: networking, hypertext</p>';sources,_=self.sources();rows,held,_=spring_rows(sources,'papers')
|
||||
self.assertEqual(rows,[]);self.assertEqual(held[0]['reason'],'missing_abstract')
|
||||
|
||||
def test_old_encoding_and_duplicate_abstract_identity(self):
|
||||
self.assertEqual(decode(b'M\xfcller')[0],'Müller')
|
||||
self.pages[SPRING+'papers/abstract.html']*=2;sources,_=self.sources()
|
||||
with self.assertRaisesRegex(ValueError,'Duplicate abstract'):spring_rows(sources,'papers')
|
||||
|
||||
def test_existing_output_is_never_overwritten(self):
|
||||
_,sha=self.sources();out=self.root/'batch';out.mkdir();(out/'keep').write_text('original')
|
||||
with self.assertRaisesRegex(ValueError,'overwrite'):collect(self.cache,sha,out)
|
||||
self.assertEqual((out/'keep').read_text(),'original')
|
||||
|
||||
def review(self,*,authors=None,author_source='canonical_byline',missing='',page=None):
|
||||
url=SPRING+'posters/01/paper.html'
|
||||
self.pages[url]=page or '<h1>Canonical full title</h1><p>Jane Doe, John Roe</p><p>Keywords: web</p><h2>Abstract</h2><p>Body</p>'
|
||||
_,sha=self.sources();raw=self.pages[url].encode()if isinstance(self.pages[url],str)else self.pages[url]
|
||||
row=dict(entry_id='01',individual_url=url,individual_sha256=hashlib.sha256(raw).hexdigest(),
|
||||
title='Canonical full title',authors=authors or ['Jane Doe','John Roe'],author_source=author_source,
|
||||
reason='Reviewed exact canonical entry and its explicit authorship; retain original title variants',missing_abstract_reason=missing)
|
||||
p=self.root/'review.json';p.write_text(json.dumps({'format':1,'source_cache_sha256':sha,'records':[row]}));return sha,p,hashlib.sha256(p.read_bytes()).hexdigest()
|
||||
|
||||
def test_reviewed_variant_keeps_primary_title_and_all_old_title_claims(self):
|
||||
sha,p,h=self.review();out=self.root/'reviewed';r=collect(self.cache,sha,out,reviewed=p,reviewed_sha256=h)
|
||||
row=json.loads((out/'www-1995-spring-poster.jsonl').read_text());self.assertEqual(row['title'],'Canonical full title')
|
||||
self.assertEqual(row['metadata']['abstract_heading_title_raw'],'Same contribution');self.assertEqual(row['metadata']['author_index_titles_raw'],['Same contribution'])
|
||||
self.assertEqual(len(row['metadata']['source_evidence']),3);self.assertEqual(r['reviewed_posters_sha256'],h)
|
||||
self.assertEqual(row['external_id'],'www3:posters:01');self.assertEqual(row['status'],'accepted')
|
||||
|
||||
def test_canonical_byline_can_resolve_unlinked_author_index_without_guessing(self):
|
||||
self.pages[SPRING+'posters/author.html']='<b>Doe, Jane</b><a href="#">Broken index link</a>'
|
||||
sha,p,h=self.review();out=self.root/'reviewed';collect(self.cache,sha,out,reviewed=p,reviewed_sha256=h)
|
||||
row=json.loads((out/'www-1995-spring-poster.jsonl').read_text());self.assertEqual(row['authors'],['Jane Doe','John Roe'])
|
||||
self.assertEqual(row['metadata']['author_index_raw'],[]);self.assertEqual(row['metadata']['author_order'],'canonical_byline')
|
||||
|
||||
def test_reviewed_missing_abstract_is_explicit_partial_not_body_summary(self):
|
||||
self.pages[SPRING+'posters/abstract.html']='<h3><a href="01/paper.html">Same contribution</a></h3><i>Author: Jane Doe</i><p>Keywords: web</p>'
|
||||
sha,p,h=self.review(missing='Neither listing nor canonical page supplies an explicit abstract')
|
||||
out=self.root/'reviewed';collect(self.cache,sha,out,reviewed=p,reviewed_sha256=h)
|
||||
row=json.loads((out/'www-1995-spring-poster.jsonl').read_text());m=json.loads((out/'www-1995-spring-poster.manifest.json').read_text())
|
||||
self.assertIsNone(row['abstract']);self.assertEqual((m['missing_abstract_count'],m['status']),(1,'partial'))
|
||||
sha,p,h=self.review()
|
||||
with self.assertRaisesRegex(ValueError,'Missing abstract'):collect(self.cache,sha,self.root/'bad',reviewed=p,reviewed_sha256=h)
|
||||
self.assertFalse((self.root/'bad').exists())
|
||||
|
||||
def test_reviewed_names_must_be_in_byline_region_not_citations_or_contact_guess(self):
|
||||
sha,p,h=self.review(authors=['Unrelated Person'],page='<h1>Canonical full title</h1><p>Jane Doe</p><h2>Abstract</h2><p>Unrelated Person cited here</p>')
|
||||
with self.assertRaisesRegex(ValueError,'byline region'):collect(self.cache,sha,self.root/'bad',reviewed=p,reviewed_sha256=h)
|
||||
sha,p,h=self.review(author_source='author_index',authors=['Contact Person'])
|
||||
with self.assertRaisesRegex(ValueError,'author-index'):collect(self.cache,sha,self.root/'bad',reviewed=p,reviewed_sha256=h)
|
||||
|
||||
def test_review_hash_cache_binding_and_canonical_h1_are_all_required(self):
|
||||
sha,p,h=self.review()
|
||||
with self.assertRaisesRegex(ValueError,'checksum'):collect(self.cache,sha,self.root/'bad',reviewed=p,reviewed_sha256='0'*64)
|
||||
d=json.loads(p.read_text());d['source_cache_sha256']='0'*64;p.write_text(json.dumps(d));h=hashlib.sha256(p.read_bytes()).hexdigest()
|
||||
with self.assertRaisesRegex(ValueError,'binding'):collect(self.cache,sha,self.root/'bad',reviewed=p,reviewed_sha256=h)
|
||||
d['source_cache_sha256']=sha;d['records'][0]['title']='Wrong title';p.write_text(json.dumps(d));h=hashlib.sha256(p.read_bytes()).hexdigest()
|
||||
with self.assertRaisesRegex(ValueError,'canonical H1'):collect(self.cache,sha,self.root/'bad',reviewed=p,reviewed_sha256=h)
|
||||
|
||||
def test_unlisted_review_entry_and_changed_primary_digest_fail_closed(self):
|
||||
sha,p,h=self.review();d=json.loads(p.read_text());d['records'][0]['individual_sha256']='0'*64;p.write_text(json.dumps(d));h=hashlib.sha256(p.read_bytes()).hexdigest()
|
||||
with self.assertRaisesRegex(ValueError,'page checksum'):collect(self.cache,sha,self.root/'bad',reviewed=p,reviewed_sha256=h)
|
||||
self.pages[SPRING+'posters/abstract.html']=self.pages[SPRING+'posters/abstract.html'].replace('01/paper.html','02/other.html')
|
||||
self.pages[SPRING+'posters/author.html']=self.pages[SPRING+'posters/author.html'].replace('01/paper.html','02/other.html')
|
||||
sha,p,h=self.review()
|
||||
with self.assertRaisesRegex(ValueError,'outside abstract'):collect(self.cache,sha,self.root/'bad',reviewed=p,reviewed_sha256=h)
|
||||
Reference in New Issue
Block a user