Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 71d3cf0590 | |||
| ee140cfd40 | |||
| 9cc7b5b050 | |||
| 1b81d1a30a | |||
| 6cf74672a5 | |||
| 5a925457b0 | |||
| bf8178354f | |||
| 067a67f3f7 | |||
| 6fa59c4d26 | |||
| dc41dfa350 | |||
| 6ea150690d | |||
| 426aa042a5 | |||
| 4e62710bba | |||
| 3f040b968f |
@@ -49,7 +49,14 @@ jobs:
|
|||||||
subprocess.run([str(venv/'bin/paper-atlas'),'--help'],cwd=outside,check=True)
|
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','cvf','--help'],cwd=outside,check=True)
|
||||||
subprocess.run([python,'-I','-B','-m','paper_atlas','maintain','postgres','--help'],cwd=outside,check=True)
|
subprocess.run([python,'-I','-B','-m','paper_atlas','maintain','postgres','--help'],cwd=outside,check=True)
|
||||||
|
subprocess.run([python,'-I','-B','-m','paper_atlas','import','records','--help'],cwd=outside,check=True)
|
||||||
|
subprocess.run([python,'-I','-B','-m','paper_atlas','import','arxiv','--help'],cwd=outside,check=True)
|
||||||
|
subprocess.run([python,'-I','-B','-m','paper_atlas','maintain','backfill','--help'],cwd=outside,check=True)
|
||||||
|
subprocess.run([python,'-I','-B','-m','paper_atlas','audit','public-openreview','--help'],cwd=outside,check=True)
|
||||||
|
subprocess.run([python,'-I','-B','-m','paper_atlas','audit','histories','--help'],cwd=outside,check=True)
|
||||||
|
subprocess.run([python,'-I','-B','-c','from paper_atlas.postgres.writer import PostgresStore, WriteConnection'],cwd=outside,check=True)
|
||||||
smoke = "from paper_atlas.db.store import Store; from paper_atlas.config import DEFAULT_REGISTRY; import json; assert {'COLM','MM','MLSys'} <= {v['acronym'] for v in json.loads(DEFAULT_REGISTRY.read_text())['venues']}; s=Store('installed.sqlite'); s.conn.close()"
|
smoke = "from paper_atlas.db.store import Store; from paper_atlas.config import DEFAULT_REGISTRY; import json; assert {'COLM','MM','MLSys'} <= {v['acronym'] for v in json.loads(DEFAULT_REGISTRY.read_text())['venues']}; s=Store('installed.sqlite'); s.conn.close()"
|
||||||
subprocess.run([python,'-I','-B','-c',smoke],cwd=outside,check=True)
|
subprocess.run([python,'-I','-B','-c',smoke],cwd=outside,check=True)
|
||||||
subprocess.run([python,'-I','-B','-m','paper_atlas','query','--db','installed.sqlite','--stats'],cwd=outside,check=True)
|
subprocess.run([python,'-I','-B','-m','paper_atlas','query','--db','installed.sqlite','--stats'],cwd=outside,check=True)
|
||||||
|
subprocess.run([python,'-I','-B','-m','paper_atlas','audit','histories','--db','installed.sqlite','--examples','0'],cwd=outside,check=True)
|
||||||
PY
|
PY
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ paper-atlas collect colm --cache /data/paper-index/raw/http-cache \
|
|||||||
|
|
||||||
`paper-atlas collect --help` 查看来源;`paper-atlas import --help` 查看导入方式。完整批次步骤见[使用指南](docs/usage.md)。
|
`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 为备份,不能继续用旧导入器写入它;后续生产导入须适配 PostgreSQL,不能把 SQLite 更新当作生产更新。
|
共享实例名为 `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` 写入主库,整个写入会话只允许一个协调写入者,会议文件失败时整体回滚并保留失败批次记录。
|
||||||
|
|
||||||
## 数据如何组织
|
## 数据如何组织
|
||||||
|
|
||||||
|
|||||||
@@ -18,14 +18,32 @@ paper-atlas maintain postgres \
|
|||||||
|
|
||||||
全部 21 张业务表保留原 ID、原始 JSON 文本、NULL、来源、投稿、决定、标签出处和合并记录。`sqlite_sequence` 用于重设 PostgreSQL identity 序列,并考虑已合并的历史 ID,避免复用;SQLite FTS5 shadow 表根据完整标题/摘要重建为原生 GIN 索引,不逐字复制引擎文件。
|
全部 21 张业务表保留原 ID、原始 JSON 文本、NULL、来源、投稿、决定、标签出处和合并记录。`sqlite_sequence` 用于重设 PostgreSQL identity 序列,并考虑已合并的历史 ID,避免复用;SQLite FTS5 shadow 表根据完整标题/摘要重建为原生 GIN 索引,不逐字复制引擎文件。
|
||||||
|
|
||||||
|
SQLite 允许文本包含 `U+0000`(NUL),PostgreSQL `text` 不允许。迁移使用 `nul-soh-v1` 可逆编码:NUL 存为 `U+0001 U+0001`,原有 `U+0001` 存为 `U+0001 U+0002`,其他字符不变;包括字串前缀在内的二进制排序保持一致,主键、唯一约束和外键可继续验证。编码版本保存在 `<schema>_migration.run.text_codec`,发布时同时写入 schema 注释 `paper-atlas:text-codec=nul-soh-v1`,其他项目只需研究表的权限即可读取格式信息,无须访问迁移控制表。CLI 的只读连接自动还原结果并编码筛选参数;直接 SQL/ETL 调用者应使用 `paper_atlas.postgres.text.encode/decode`,不能删除或直接替换控制字符。全文检索把这些控制字符当作分隔符,不承担逐字节匹配。
|
||||||
|
|
||||||
先 COPY 到独立暂存 schema,每张表提交和记录哈希。中断后以同一命令、同一报告路径恢复,必须仍是**完全相同的未变更快照**。不同输入、已发布目标、未清空 WAL、新的未知业务表都会拒绝覆盖;恢复只会清空本次运行尚未完成且尚未发布的暂存表。保留错误现场,不盲目从头复制。
|
先 COPY 到独立暂存 schema,每张表提交和记录哈希。中断后以同一命令、同一报告路径恢复,必须仍是**完全相同的未变更快照**。不同输入、已发布目标、未清空 WAL、新的未知业务表都会拒绝覆盖;恢复只会清空本次运行尚未完成且尚未发布的暂存表。保留错误现场,不盲目从头复制。
|
||||||
|
|
||||||
|
恢复旧版本未发布的暂存数据时,在一个事务中转义已提交表里原有的 `U+0001`,然后记录新编码版本;失败回滚,重复恢复不会二次编码。已经提交的表无需从 SQLite 重新复制。报告中的 `source_sha256`、`target_sha256` 比较完整还原后的原始值;`target_storage_sha256` 另记实际存储值的哈希,包含转义的表两种哈希可以不同。
|
||||||
|
|
||||||
发布前逐表按主键完整回读,比较数量及逐行 SHA-256;建立并验证所有主键、唯一约束、CHECK 和外键,重设序列、建立 GIN 并 ANALYZE。全部通过后原子重命名为 `research` schema,报告才为 `complete`。`copying`、`prepared`、部分表通过或 CI 通过均不代表生产迁移完成,也不替代原任务最后的实体去重和会议覆盖审计。
|
发布前逐表按主键完整回读,比较数量及逐行 SHA-256;建立并验证所有主键、唯一约束、CHECK 和外键,重设序列、建立 GIN 并 ANALYZE。全部通过后原子重命名为 `research` schema,报告才为 `complete`。`copying`、`prepared`、部分表通过或 CI 通过均不代表生产迁移完成,也不替代原任务最后的实体去重和会议覆盖审计。
|
||||||
|
|
||||||
发布后核对 PostgreSQL 的统计、全文检索、论文详情,以及 reject 后另会 accept 的历史。确认后保留 SQLite 只读备份,并在旁保存 `<SQLite路径>.postgresql-cutover.json`,记录报告路径、源哈希、目标 service/schema;新版本旧写入入口见此标记会拒绝写入。标记不含密码。后续导入适配属于下一阶段,不可继续更新 SQLite 后声称 PostgreSQL 已同步。
|
发布后核对 PostgreSQL 的统计、全文检索、论文详情,以及 reject 后另会 accept 的历史。确认后保留 SQLite 只读备份,并在旁保存 `<SQLite路径>.postgresql-cutover.json`,记录报告路径、源哈希、目标 service/schema;新版本旧写入入口见此标记会拒绝写入。标记不含密码。后续导入适配属于下一阶段,不可继续更新 SQLite 后声称 PostgreSQL 已同步。
|
||||||
|
|
||||||
远程集成测试通过 `PAPER_ATLAS_TEST_PG_SERVICE` 显式指定,使用随机前缀 schema 并仅清理自己的 fixture;没有该配置时会跳过,应另外报告“未运行”,不能用 SQLite 测试代替 PostgreSQL 验证。
|
远程集成测试通过 `PAPER_ATLAS_TEST_PG_SERVICE` 显式指定,使用随机前缀 schema 并仅清理自己的 fixture;没有该配置时会跳过,应另外报告“未运行”,不能用 SQLite 测试代替 PostgreSQL 验证。
|
||||||
|
|
||||||
|
## PostgreSQL 增量写入
|
||||||
|
|
||||||
|
`import records`、`import arxiv`、`maintain backfill` 使用 `--pg-service` 选择主库,`--pg-schema` 可覆盖配置的 search_path。写入只接受已经发布、拥有完整业务表和受支持编码标记的 schema,不自动建库或引导空表。SQLite 样本仍使用 `--db`,已切换备份继续禁止写入。
|
||||||
|
|
||||||
|
写入连接按数据库/schema 获取会话级 advisory lock,文件提交、回滚以及 arXiv 分批提交均不释放;关闭连接才释放,其他写入者立即得到明确的占用错误。只读查询继续使用独立一致性快照。锁是所有调用方必须遵守的协议,其他项目不要绕过接口并发修改同一论文和投稿;[PostgreSQL 会话锁说明](https://www.postgresql.org/docs/17/explicit-locking.html#ADVISORY-LOCKS)解释了其事务边界。
|
||||||
|
|
||||||
|
论文身份匹配、跨来源合并、投稿/决定来源和标签维护复用同一套领域逻辑。PostgreSQL 使用原生 RETURNING 和 identity 序列分配 ID,继承迁移时设定的历史 ID 高水位;失败和冲突尝试可以留下序列空洞,不能把最大 ID 当作行数。绑定文本按 `nul-soh-v1` 编码,结果解码,GIN 随论文写入自动更新。
|
||||||
|
|
||||||
|
会议文件的数据变化在同一事务内完成,manifest 数量不符等错误会回滚整份文件,`ingest_runs` 另保留 failed、已处理数和零写入数;身份回填整个队列一次提交。arXiv 每 500 行提交数据和 checkpoint,异常只回滚尚未提交部分,重试必须仍为同一压缩包。大批次开始前核对独立审计清单、当前主库冲突和磁盘空间;完成后对数量、实际来源/决定关联及检索做数据验收,代码测试成功不代表生产导入成功。
|
||||||
|
|
||||||
|
`audit public-openreview --pg-service ...` 在只读快照中核验两个受支持公开负面决定来源的固定 revision、逐文件哈希、manifest、原始出处标记和既有同届投稿。最新决定排序与查询端一致:观察时间、决定日期(为空时回退观察时间)、ID。实际导入持有写锁后再次复核该投稿的最新状态,阻止过期预检或弱来源标签覆盖冲突决定;冲突文件整体回滚并等待进一步证据核查,其他会议录用不构成同一次投稿的冲突。
|
||||||
|
|
||||||
|
会议导入按原始字节流计算 SHA-256,在提交前与 manifest 提供的 `file_sha256` 比较;不符则回滚整份文件。成功批次的 checkpoint 会记录实际读取内容的哈希,便于复核导入时使用的文件版本。
|
||||||
|
|
||||||
代码以固定 commit 部署,已经启动的任务继续使用其原版本。目录重构不要求迁移数据库,也不更改已有 JSONL 和 manifest 格式。
|
代码以固定 commit 部署,已经启动的任务继续使用其原版本。目录重构不要求迁移数据库,也不更改已有 JSONL 和 manifest 格式。
|
||||||
|
|
||||||
## arXiv 原始编号与类别证据
|
## arXiv 原始编号与类别证据
|
||||||
@@ -102,3 +120,36 @@ paper-atlas maintain pmlr-tracks --db /data/paper-index/papers.sqlite \
|
|||||||
检查报告后,在同一命令添加 `--apply` 并使用新的 `--out` 路径执行迁移。计划使用共享维护锁,实际迁移使用独占锁;已有 arXiv 类别修复或其他写入运行时会拒绝启动,不会停止或重启它们。一次事务内核对整个来源集合、原始题目/摘要/作者、forum 身份、已有事件及目标冲突,更新原投稿的 edition 和身份归属,回填官方轨道出处及逐轨道覆盖数,最后复查轨道与外键后提交。论文、来源、投稿、决定的 ID 及决定观察均保留,不重新创建事件、不做实体合并、不改论文正文元数据或标签。
|
检查报告后,在同一命令添加 `--apply` 并使用新的 `--out` 路径执行迁移。计划使用共享维护锁,实际迁移使用独占锁;已有 arXiv 类别修复或其他写入运行时会拒绝启动,不会停止或重启它们。一次事务内核对整个来源集合、原始题目/摘要/作者、forum 身份、已有事件及目标冲突,更新原投稿的 edition 和身份归属,回填官方轨道出处及逐轨道覆盖数,最后复查轨道与外键后提交。论文、来源、投稿、决定的 ID 及决定观察均保留,不重新创建事件、不做实体合并、不改论文正文元数据或标签。
|
||||||
|
|
||||||
报告记录每个迁移事件的旧 edition、原身份、完整决定观察及新轨道证据。`planned` 是未执行的计划;`complete` 才表示事务已提交;`error` 表示执行失败。若进程在提交前后中断而报告停在 `prepared`,先对照主库和报告核查,不根据日志缺失自行重启任务。新路径重跑会重新核对当前数据库,已正确的轨道不会再次迁移。共享多个来源、重复目标事件或冲突决定会停止迁移,需另行核对,不能直接删掉其中一条。
|
报告记录每个迁移事件的旧 edition、原身份、完整决定观察及新轨道证据。`planned` 是未执行的计划;`complete` 才表示事务已提交;`error` 表示执行失败。若进程在提交前后中断而报告停在 `prepared`,先对照主库和报告核查,不根据日志缺失自行重启任务。新路径重跑会重新核对当前数据库,已正确的轨道不会再次迁移。共享多个来源、重复目标事件或冲突决定会停止迁移,需另行核对,不能直接删掉其中一条。
|
||||||
|
|
||||||
|
## 追加同一目录的部分记录
|
||||||
|
|
||||||
|
默认 `import records` 把 manifest 当成一份来源快照,覆盖统计使用该文件的数量。
|
||||||
|
若目录已有2条,之后仅补入1条,不能把这一小批当成完整快照,否则会把目录计数改成1。
|
||||||
|
为已核验的部分补充批次,在 manifest 显式声明:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"coverage_mode": "incremental",
|
||||||
|
"coverage_previous_count": 2,
|
||||||
|
"coverage_previous_expected_count": 2,
|
||||||
|
"coverage_expected_count": 4,
|
||||||
|
"status": "partial",
|
||||||
|
"count": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
这只是新增字段示例,manifest仍须保留source、venue、year、track、list_url、文件哈希等来源证据。
|
||||||
|
`count`是本文件行数,`coverage_previous_count`是导入前该来源/届次的实际记录数,
|
||||||
|
`coverage_previous_expected_count`是旧覆盖行的expected_count(未知时用null),
|
||||||
|
`coverage_expected_count`是独立目录证据支持的整份来源预期数,不能小于已知旧预期数。
|
||||||
|
这个例子成功后覆盖统计为预期4、已导入3;文件checkpoint的records_written仍为1。
|
||||||
|
刷新相同记录不重复累加。初次导入部分来源可声明previous_count为0、previous_expected_count为null。
|
||||||
|
|
||||||
|
只支持当前最多一个规范目录URL且旧覆盖状态为partial的来源/届次;多目录、旧状态complete、
|
||||||
|
旧计数与实际来源记录不符,或前置计数已变化,都会中止,先核对来源范围或重新取得完整快照。
|
||||||
|
该模式在写入前检查父状态,并在同一文件事务中按实际来源记录重新计数、检查总量界限和乐观更新覆盖行。
|
||||||
|
失败会回滚本文件的论文、来源、决定、标签和覆盖变化,保留失败运行记录;更早已提交文件不重放。
|
||||||
|
增量模式始终保留partial,不能靠一个小批次推断完整论文集、摘要或公开拒稿覆盖。
|
||||||
|
|
||||||
|
仍需先独立核对新增记录身份与决定,按原生单写入保护串行导入,再独立核验数据与原有历史。
|
||||||
|
这些计数检查不证明外部目录完整,也不代替最终全库去重。
|
||||||
|
|||||||
+54
-11
@@ -14,9 +14,11 @@ paper-atlas query --pg-service foundry-research --status rejected --outcome acce
|
|||||||
paper-atlas query --pg-service foundry-research --paper 123
|
paper-atlas query --pg-service foundry-research --paper 123
|
||||||
```
|
```
|
||||||
|
|
||||||
使用只读一致性事务,支持论文 ID、会议别名、年份、轨道、标签、最新决定和跨会议录用筛选。全文检索使用 PostgreSQL `websearch_to_tsquery('english', ...)`,支持引号短语、`OR` 和减号排除;与 SQLite FTS5 的语法及分词结果不完全相同。JSON 来源列保留原始文本,SQL 分析时可用 `metadata_json::jsonb`、`authors_json::jsonb`。
|
使用只读一致性事务,支持论文 ID、会议别名、年份、轨道、标签、最新决定和跨会议录用筛选。全文检索使用 PostgreSQL `websearch_to_tsquery('english', ...)`,支持引号短语、`OR` 和减号排除;与 SQLite FTS5 的语法及分词结果不完全相同。JSON 来源列保留原始文本;转成 `jsonb` 前须确认原始值符合该类型的限制。
|
||||||
|
|
||||||
迁移与切换见[运行维护](operations.md#postgresql-迁移)。下述 SQLite 导入命令仅用于未切换的索引或独立样本;切换后的生产源拒绝旧写入。尚未适配的覆盖审计可只读检查冻结备份,不能当作 PostgreSQL 当前状态。
|
CLI 自动还原迁移中的 NUL/SOH 文本转义,详情中的来源摘要和筛选参数保留原字符。直接 SQL 读取可能看到 `U+0001` 转义序列;跨项目 ETL 应按迁移元数据中的 `text_codec` 使用 `paper_atlas.postgres.text.encode/decode`。存储格式和无损哈希验收见[运行维护](operations.md#postgresql-迁移)。
|
||||||
|
|
||||||
|
迁移与切换见[运行维护](operations.md#postgresql-迁移)。`import records`、`import arxiv` 和 `maintain backfill` 均支持 `--pg-service` 和可选 `--pg-schema`;与 `--db` 二选一。SQLite 导入仅用于未切换的索引或独立样本。尚未适配 PostgreSQL 的维护、覆盖审计命令仍可只读检查冻结备份,不能当作主库的当前状态。
|
||||||
|
|
||||||
## 1. 下载固定快照
|
## 1. 下载固定快照
|
||||||
|
|
||||||
@@ -39,12 +41,35 @@ paper-atlas audit public-openreview --help
|
|||||||
|
|
||||||
其他会议的公开拒稿使用 `collect public-openreview`,再以 `audit public-openreview` 核对既有投稿身份和决定冲突。来源版本、逐文件状态盘点和未解决记录分别保存在 inventory 与 held 清单中,具体命令见[数据源说明](data-sources.md#其他会议的公开拒稿)。这类公开子集不能当作会议完整投稿名单。
|
其他会议的公开拒稿使用 `collect public-openreview`,再以 `audit public-openreview` 核对既有投稿身份和决定冲突。来源版本、逐文件状态盘点和未解决记录分别保存在 inventory 与 held 清单中,具体命令见[数据源说明](data-sources.md#其他会议的公开拒稿)。这类公开子集不能当作会议完整投稿名单。
|
||||||
|
|
||||||
## 3. 串行导入
|
主库切换后,预检须直接连接当前 PostgreSQL,不能用冻结的 SQLite 备份推断当前决定:
|
||||||
|
|
||||||
首次 arXiv 导入使用 `paper-atlas import arxiv --db ... --archive ...`。现有数据库合并已核验的会议文件使用:
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
paper-atlas maintain merge --db /data/paper-index/papers.sqlite \
|
paper-atlas audit public-openreview --pg-service foundry-research \
|
||||||
|
--inventory /data/paper-index/collected/public-openreview-v1/public-openreview-inventory.json \
|
||||||
|
--out /data/paper-index/collected/public-negative-pg-preflight.json
|
||||||
|
```
|
||||||
|
|
||||||
|
预检使用只读一致性快照,报告会记录实际数据库、schema、文件哈希和同届次/轨道的决定冲突;其他会议的录用保留为独立历史。预检不预留写锁,实际导入这两类公开负面决定时会在写事务中再次复核同一次投稿的最新状态;过期预检报告不能覆盖已经变化的决定。
|
||||||
|
|
||||||
|
## 3. 串行导入
|
||||||
|
|
||||||
|
PostgreSQL 主库逐文件导入已经核验的资料:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
paper-atlas import records --pg-service foundry-research \
|
||||||
|
/data/paper-index/collected/selected-file.jsonl
|
||||||
|
paper-atlas maintain backfill --pg-service foundry-research \
|
||||||
|
--queue /data/paper-index/collected/audited-identity-queue.json
|
||||||
|
```
|
||||||
|
|
||||||
|
文件和 queue 须替换为实际核验通过的路径。会议导入按文件提交,失败时该文件的论文、标签和决定更改全部回滚,失败批次日志保留;重跑会按来源标识与决定证据去重。回填使用已有源记录,只合并经独立身份佐证的投稿,不重新导入文本。所有写入者须遵守同一数据库/schema 的会话锁;其他项目也应通过相同的写入接口更新共享论文和投稿。
|
||||||
|
|
||||||
|
未来需要导入新的 arXiv 元数据包时使用 `paper-atlas import arxiv --pg-service foundry-research --archive ... --storage-path ...`。每 500 行提交断点,恢复同一文件时保留已提交 ID 和标签证据;`--storage-path` 指定运行主机上要监测的文件系统,在数据库主机执行时应指向数据库卷。已完成的历史压缩包不需要重复导入。
|
||||||
|
|
||||||
|
SQLite 独立样本的首次 arXiv 导入使用 `paper-atlas import arxiv --db ... --archive ...`。SQLite 协调器仍适用于未切换的索引:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
paper-atlas maintain merge --db /data/paper-index/samples/example.sqlite \
|
||||||
--archive /data/paper-index/raw/arxiv-metadata.zip \
|
--archive /data/paper-index/raw/arxiv-metadata.zip \
|
||||||
/data/paper-index/collected/selected-file.jsonl
|
/data/paper-index/collected/selected-file.jsonl
|
||||||
```
|
```
|
||||||
@@ -54,10 +79,10 @@ paper-atlas maintain merge --db /data/paper-index/papers.sqlite \
|
|||||||
## 4. 查询与分析前检查
|
## 4. 查询与分析前检查
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
paper-atlas query --db /data/paper-index/papers.sqlite --query '"language model"' --limit 20
|
paper-atlas query --pg-service foundry-research --query '"language model"' --limit 20
|
||||||
paper-atlas query --db /data/paper-index/papers.sqlite --tag arxiv:category=cs.AI
|
paper-atlas query --pg-service foundry-research --tag arxiv:category=cs.AI
|
||||||
paper-atlas query --db /data/paper-index/papers.sqlite --venue ICLR --year 2025 --status rejected
|
paper-atlas query --pg-service foundry-research --venue ICLR --year 2025 --status rejected
|
||||||
paper-atlas query --db /data/paper-index/papers.sqlite --paper 123
|
paper-atlas query --pg-service foundry-research --paper 123
|
||||||
paper-atlas coverage --db /data/paper-index/papers.sqlite --out /data/paper-index/collected/coverage.json
|
paper-atlas coverage --db /data/paper-index/papers.sqlite --out /data/paper-index/collected/coverage.json
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -66,10 +91,28 @@ paper-atlas coverage --db /data/paper-index/papers.sqlite --out /data/paper-inde
|
|||||||
例如,查找在 ICLR 有拒稿记录、但在其他已知投稿中已有录用的论文:
|
例如,查找在 ICLR 有拒稿记录、但在其他已知投稿中已有录用的论文:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
paper-atlas query --db /data/paper-index/papers.sqlite \\
|
paper-atlas query --pg-service foundry-research \
|
||||||
--venue ICLR --status rejected --outcome accepted
|
--venue ICLR --status rejected --outcome accepted
|
||||||
```
|
```
|
||||||
|
|
||||||
`known_acceptances` 列出目前仍为录用状态的各次投稿及会议、年份、track 和决定证据,`publication_outcome` 汇总为 accepted 或 no_known_acceptance。汇总会检查所有会议和 track,不把某次拒稿覆盖到整篇论文;同一次投稿的后续撤稿也会更新该次状态并保留原决定。no_known_acceptance 只表示现有来源中没有查到录用,不能称为“从未录用”。
|
`known_acceptances` 列出目前仍为录用状态的各次投稿及会议、年份、track 和决定证据,`publication_outcome` 汇总为 accepted 或 no_known_acceptance。汇总会检查所有会议和 track,不把某次拒稿覆盖到整篇论文;同一次投稿的后续撤稿也会更新该次状态并保留原决定。no_known_acceptance 只表示现有来源中没有查到录用,不能称为“从未录用”。
|
||||||
|
|
||||||
`--stats` 中 papers 是当前规则下的论文实体数;source_records 是来源记录数;decisions 是决定观察数。一个投稿可能有多个来源和多条观察,不能相加当作论文数。
|
`--stats` 中 papers 是当前规则下的论文实体数;source_records 是来源记录数;decisions 是决定观察数。一个投稿可能有多个来源和多条观察,不能相加当作论文数。
|
||||||
|
|
||||||
|
已有稳定标识或原来源记录匹配时,该身份优先;同标题、同作者的另一 arXiv 存档或扩展稿不会仅因相似标题被并入。没有身份锚点时,作者佐证的精确标题只有一个候选才可匹配;多个候选会报 `Ambiguous paper identity`,整个采集文件回滚并记录失败运行。应补有证据的标识或先核查版本关系,不删除重复候选或任取第一个。一个新来源明确同时携带两个既有稳定标识时,仍可按既有规则归并并保留投稿、标签和合并日志;未归并的同标题关系继续保留为待核查候选。这些规则不证明全库去重完成。
|
||||||
|
|
||||||
|
## 5. 投稿历史关系审计
|
||||||
|
|
||||||
|
```sh
|
||||||
|
PGSERVICEFILE=/etc/paper-atlas/pg_service.conf paper-atlas audit histories \
|
||||||
|
--pg-service foundry-research --pg-schema research --examples 20 \
|
||||||
|
--out /data/paper-index/collected/history-audit.json
|
||||||
|
```
|
||||||
|
|
||||||
|
使用一个只读一致性快照,区分论文实体、投稿和决定观察;分别统计同次投稿曾出现负面与录用观察、不同投稿共存负面与当前已知录用、不同会议之间的共存。workshop/main 是不同 track,属于同一会议时不算跨会议;会议别名也不会增加一个会议。当前已知录用使用与 `query --outcome` 相同的“每次投稿最新观察”规则,后续撤稿的该次投稿不再算当前录用,但旧录用决定仍保留。
|
||||||
|
|
||||||
|
报告不凭观察时间、届次年份或行顺序断言“拒稿发生在录用之前”。样例保留真实 `decision_date`、`observed_at`、原始标签、证据 URL 和决定来源,供进一步核查;日期缺失仍保持未知。`negative` 统计包含 rejected、desk_rejected 和 withdrawn,其原始状态不互相替换。
|
||||||
|
|
||||||
|
总数覆盖快照内全部投稿,样例由 `--examples` 限定为 0–100 篇;每篇最多展示 20 次投稿、每次最多 50 条决定、每条决定最多 100 个来源,并明确标记截断。样例优先保留负面/录用的不同投稿对。四项来源关系检查报告缺失关联和来源论文归属不一致,不自动修复。
|
||||||
|
|
||||||
|
这些统计依赖现有论文身份关联,不证明实体合并正确,也不证明原始决定签名、公开拒稿全集、标签类型或最终全库去重已完成。SQLite 独立样本可使用 `--db`;冻结备份不能代表当前 PostgreSQL 的历史情况。
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
"""Audit linked submission histories without inferring event chronology."""
|
||||||
|
import argparse
|
||||||
|
from collections import Counter
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
from paper_atlas.db.backend import database_arguments, open_read_connection
|
||||||
|
from paper_atlas.db.submissions import timestamp
|
||||||
|
from paper_atlas.querying import LATEST_DECISION
|
||||||
|
|
||||||
|
|
||||||
|
ATTEMPTS = '''SELECT s.paper_id,s.id AS submission_id,e.venue_id,v.acronym AS venue,e.year,e.track,
|
||||||
|
count(d.id) AS decision_observations,
|
||||||
|
MAX(CASE WHEN d.status IN ('rejected','desk_rejected','withdrawn') THEN 1 ELSE 0 END)
|
||||||
|
AS historical_negative,
|
||||||
|
MAX(CASE WHEN d.status='accepted' THEN 1 ELSE 0 END) AS historical_acceptance,
|
||||||
|
COALESCE(MAX(CASE WHEN ''' + LATEST_DECISION + ''' THEN d.status END),'unknown')
|
||||||
|
AS latest_observed_status
|
||||||
|
FROM submissions s JOIN editions e ON e.id=s.edition_id JOIN venues v ON v.id=e.venue_id
|
||||||
|
LEFT JOIN decisions d ON d.submission_id=s.id
|
||||||
|
GROUP BY s.paper_id,s.id,e.venue_id,v.acronym,e.year,e.track
|
||||||
|
ORDER BY s.paper_id,e.venue_id,s.id'''
|
||||||
|
|
||||||
|
|
||||||
|
def _paper_counts(attempts):
|
||||||
|
negative = sum(row['historical_negative'] for row in attempts)
|
||||||
|
accepted = sum(row['latest_observed_status'] == 'accepted' for row in attempts)
|
||||||
|
overlap = sum(row['historical_negative'] and row['latest_observed_status'] == 'accepted'
|
||||||
|
for row in attempts)
|
||||||
|
venues = {}
|
||||||
|
for row in attempts:
|
||||||
|
counts = venues.setdefault(row['venue_id'], [0, 0])
|
||||||
|
counts[0] += row['historical_negative']
|
||||||
|
counts[1] += row['latest_observed_status'] == 'accepted'
|
||||||
|
return {
|
||||||
|
'papers_with_submissions': 1,
|
||||||
|
'papers_with_multiple_submissions': int(len(attempts) > 1),
|
||||||
|
'papers_with_historical_negative': int(negative > 0),
|
||||||
|
'papers_with_current_known_acceptance': int(accepted > 0),
|
||||||
|
'papers_with_negative_and_acceptance_in_distinct_attempts': int(negative * accepted > overlap),
|
||||||
|
'papers_with_negative_and_acceptance_in_distinct_venues':
|
||||||
|
int(negative * accepted > sum(n * a for n, a in venues.values())),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _example(connection, attempts):
|
||||||
|
# Keep a negative/accepted pair visible even when a paper has many attempts.
|
||||||
|
accepted = [row for row in attempts if row['latest_observed_status'] == 'accepted']
|
||||||
|
first = accepted[0] if accepted else None
|
||||||
|
other_venue = next((row for row in accepted if row['venue_id'] != first['venue_id']), None)
|
||||||
|
pair = None
|
||||||
|
for negative in attempts:
|
||||||
|
if negative['historical_negative'] and first:
|
||||||
|
candidate = first if first['venue_id'] != negative['venue_id'] else other_venue
|
||||||
|
if candidate:
|
||||||
|
pair = (negative, candidate)
|
||||||
|
break
|
||||||
|
if pair is None:
|
||||||
|
for negative in attempts:
|
||||||
|
if negative['historical_negative'] and first:
|
||||||
|
candidate = first if first['submission_id'] != negative['submission_id'] else (
|
||||||
|
accepted[1] if len(accepted) > 1 else None)
|
||||||
|
if candidate:
|
||||||
|
pair = (negative, candidate)
|
||||||
|
break
|
||||||
|
selected = {}
|
||||||
|
for row in list(pair or ()) + attempts:
|
||||||
|
if len(selected) < 20:
|
||||||
|
selected.setdefault(row['submission_id'], dict(row))
|
||||||
|
for row in selected.values():
|
||||||
|
row['decisions'] = []
|
||||||
|
for decision in connection.execute('''SELECT id,status,raw_value,evidence_url,
|
||||||
|
decision_date,observed_at FROM decisions WHERE submission_id=?
|
||||||
|
ORDER BY observed_at DESC,COALESCE(decision_date,observed_at) DESC,id DESC LIMIT 50''',
|
||||||
|
(row['submission_id'],)):
|
||||||
|
entry = dict(decision)
|
||||||
|
sources = [dict(source) for source in connection.execute('''
|
||||||
|
SELECT so.name,r.external_id,ds.source_decision_id FROM decision_sources ds
|
||||||
|
JOIN source_records r ON r.id=ds.source_record_id JOIN sources so ON so.id=r.source_id
|
||||||
|
WHERE ds.decision_id=? ORDER BY so.name,r.external_id LIMIT 101''', (entry['id'],))]
|
||||||
|
entry['sources'] = sources[:100]
|
||||||
|
entry['sources_truncated'] = len(sources) > 100
|
||||||
|
row['decisions'].append(entry)
|
||||||
|
row['decisions_truncated'] = row['decision_observations'] > len(row['decisions'])
|
||||||
|
paper_id = attempts[0]['paper_id']
|
||||||
|
return {'paper_id': paper_id,
|
||||||
|
'title': connection.execute('SELECT title FROM papers WHERE id=?', (paper_id,)).fetchone()[0],
|
||||||
|
'counts': _paper_counts(attempts), 'attempt_count': len(attempts),
|
||||||
|
'attempts': list(selected.values()), 'attempts_truncated': len(attempts) > len(selected),
|
||||||
|
'chronology': 'Not inferred from observed_at, edition year, or row order.'}
|
||||||
|
|
||||||
|
|
||||||
|
def report(db=None, *, pg_service=None, pg_schema=None, examples=20):
|
||||||
|
if not 0 <= examples <= 100:
|
||||||
|
raise ValueError('Examples must be between 0 and 100')
|
||||||
|
started_at = timestamp()
|
||||||
|
connection = open_read_connection(db, pg_service=pg_service, pg_schema=pg_schema)
|
||||||
|
if not pg_service:
|
||||||
|
connection.row_factory = sqlite3.Row
|
||||||
|
try:
|
||||||
|
if pg_service:
|
||||||
|
identity = connection.execute('SELECT current_database(),current_schema()').fetchone()
|
||||||
|
database, schema = identity[0], identity[1]
|
||||||
|
else:
|
||||||
|
database, schema = str(Path(db).resolve()), None
|
||||||
|
names = ['papers_with_submissions', 'papers_with_multiple_submissions',
|
||||||
|
'papers_with_historical_negative', 'papers_with_current_known_acceptance',
|
||||||
|
'papers_with_negative_and_acceptance_in_distinct_attempts',
|
||||||
|
'papers_with_negative_and_acceptance_in_distinct_venues']
|
||||||
|
totals = Counter({key: 0 for key in names})
|
||||||
|
totals.update(submissions=0, decision_observations=0,
|
||||||
|
attempts_with_historical_negative=0,
|
||||||
|
attempts_with_historical_negative_and_acceptance=0)
|
||||||
|
latest = Counter()
|
||||||
|
sample_attempts = []
|
||||||
|
|
||||||
|
def consume(rows):
|
||||||
|
if not rows:
|
||||||
|
return
|
||||||
|
counts = _paper_counts(rows)
|
||||||
|
totals.update(counts)
|
||||||
|
if counts['papers_with_negative_and_acceptance_in_distinct_attempts'] and len(sample_attempts) < examples:
|
||||||
|
sample_attempts.append(rows)
|
||||||
|
|
||||||
|
current = []
|
||||||
|
for raw in connection.execute(ATTEMPTS):
|
||||||
|
row = dict(raw)
|
||||||
|
if current and row['paper_id'] != current[0]['paper_id']:
|
||||||
|
consume(current)
|
||||||
|
current = []
|
||||||
|
current.append(row)
|
||||||
|
totals['submissions'] += 1
|
||||||
|
totals['decision_observations'] += row['decision_observations']
|
||||||
|
totals['attempts_with_historical_negative'] += row['historical_negative']
|
||||||
|
totals['attempts_with_historical_negative_and_acceptance'] += int(
|
||||||
|
row['historical_negative'] and row['historical_acceptance'])
|
||||||
|
latest[row['latest_observed_status']] += 1
|
||||||
|
consume(current)
|
||||||
|
# These are relationship checks, not a substitute for the final entity/FK audit.
|
||||||
|
integrity = {}
|
||||||
|
checks = {
|
||||||
|
'submissions_without_source_links': '''SELECT count(*) FROM submissions s
|
||||||
|
WHERE NOT EXISTS(SELECT 1 FROM submission_sources ss WHERE ss.submission_id=s.id)''',
|
||||||
|
'decisions_without_source_links': '''SELECT count(*) FROM decisions d
|
||||||
|
WHERE NOT EXISTS(SELECT 1 FROM decision_sources ds WHERE ds.decision_id=d.id)''',
|
||||||
|
'submission_source_paper_mismatches': '''SELECT count(*) FROM submission_sources ss
|
||||||
|
JOIN submissions s ON s.id=ss.submission_id JOIN source_records r ON r.id=ss.source_record_id
|
||||||
|
WHERE s.paper_id<>r.paper_id''',
|
||||||
|
'decision_source_paper_mismatches': '''SELECT count(*) FROM decision_sources ds
|
||||||
|
JOIN decisions d ON d.id=ds.decision_id JOIN submissions s ON s.id=d.submission_id
|
||||||
|
JOIN source_records r ON r.id=ds.source_record_id WHERE s.paper_id<>r.paper_id''',
|
||||||
|
}
|
||||||
|
for name, query in checks.items():
|
||||||
|
integrity[name] = connection.execute(query).fetchone()[0]
|
||||||
|
return {'report_started_at': started_at, 'checked_at': timestamp(),
|
||||||
|
'engine': 'postgresql' if pg_service else 'sqlite', 'database': database,
|
||||||
|
'schema': schema, 'pg_service': pg_service,
|
||||||
|
'status': 'observed_history_audit_not_final_dedup', 'counts': dict(totals),
|
||||||
|
'latest_attempt_status_counts': dict(sorted(latest.items())),
|
||||||
|
'evidence_relationship_checks': integrity,
|
||||||
|
'examples': [_example(connection, rows) for rows in sample_attempts],
|
||||||
|
'example_limit': examples,
|
||||||
|
'interpretation': [
|
||||||
|
'Counts use existing canonical paper links, not independently verified real-world identity.',
|
||||||
|
'Negative includes rejected, desk_rejected and withdrawn observations; these remain distinct decisions.',
|
||||||
|
'Known acceptance follows the latest observed decision per attempt, matching query --outcome.',
|
||||||
|
'Distinct attempts/venues require separate submission/venue IDs; aliases do not create new venues.',
|
||||||
|
'Coexisting evidence does not prove rejection happened before acceptance. observed_at is collection time.',
|
||||||
|
'No known acceptance is not proof of never accepted. Source signatures and completeness are not established here.',
|
||||||
|
'Examples are bounded; counts include all attempts and observations in one read-only snapshot.',
|
||||||
|
'This does not perform entity merging, final deduplication, or label namespace/type validation.',
|
||||||
|
]}
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv=None, *, prog=None):
|
||||||
|
parser = argparse.ArgumentParser(prog=prog)
|
||||||
|
database_arguments(parser)
|
||||||
|
parser.add_argument('--examples', type=int, default=20)
|
||||||
|
parser.add_argument('--out')
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
result = report(args.db, pg_service=args.pg_service, pg_schema=args.pg_schema, examples=args.examples)
|
||||||
|
text = json.dumps(result, ensure_ascii=False, indent=2)
|
||||||
|
if args.out:
|
||||||
|
path = Path(args.out)
|
||||||
|
stage = path.with_suffix('.part')
|
||||||
|
stage.write_text(text + '\n')
|
||||||
|
stage.replace(path)
|
||||||
|
else:
|
||||||
|
print(text)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@@ -4,14 +4,14 @@ from contextlib import closing
|
|||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import sqlite3
|
|
||||||
|
|
||||||
from paper_atlas.collectors.public_openreview import SOURCE, REVISION
|
from paper_atlas.collectors.public_openreview import SOURCE, REVISION
|
||||||
from paper_atlas.collectors.icml_public_archive import SOURCE as ARCHIVE_SOURCE, REVISION as ARCHIVE_REVISION
|
from paper_atlas.collectors.icml_public_archive import SOURCE as ARCHIVE_SOURCE, REVISION as ARCHIVE_REVISION
|
||||||
from paper_atlas.db.submissions import timestamp
|
from paper_atlas.db.submissions import timestamp, latest_status
|
||||||
|
from paper_atlas.db.backend import database_arguments, open_read_connection
|
||||||
|
|
||||||
|
|
||||||
def audit(db, inventory, out):
|
def audit(db, inventory, out, *, pg_service=None, pg_schema=None):
|
||||||
inventory = Path(inventory).resolve()
|
inventory = Path(inventory).resolve()
|
||||||
result = json.loads(inventory.read_text())
|
result = json.loads(inventory.read_text())
|
||||||
specs = {SOURCE: (REVISION, 'curated_public_status_assertion'),
|
specs = {SOURCE: (REVISION, 'curated_public_status_assertion'),
|
||||||
@@ -20,17 +20,18 @@ def audit(db, inventory, out):
|
|||||||
if source not in specs or result.get('revision') != specs[source][0]:
|
if source not in specs or result.get('revision') != specs[source][0]:
|
||||||
raise ValueError('Unsupported public source inventory')
|
raise ValueError('Unsupported public source inventory')
|
||||||
revision, evidence_kind = specs[source]
|
revision, evidence_kind = specs[source]
|
||||||
db = Path(db).resolve()
|
|
||||||
if not db.is_file():
|
|
||||||
raise ValueError('Existing database required for identity and status audit')
|
|
||||||
conflicts = []
|
conflicts = []
|
||||||
seen = set()
|
seen = set()
|
||||||
checked = 0
|
checked = 0
|
||||||
existing_attempts = 0
|
existing_attempts = 0
|
||||||
files = []
|
files = []
|
||||||
checksums = {}
|
checksums = {}
|
||||||
with closing(sqlite3.connect(db.as_uri() + '?mode=ro', uri=True)) as c:
|
with closing(open_read_connection(db, pg_service=pg_service, pg_schema=pg_schema)) as c:
|
||||||
c.execute('BEGIN')
|
if pg_service:
|
||||||
|
identity = c.execute('SELECT current_database(),current_schema()').fetchone()
|
||||||
|
database, actual_schema = identity[0], identity[1]
|
||||||
|
else:
|
||||||
|
database, actual_schema = str(Path(db).resolve()), None
|
||||||
for manifest in result['files']:
|
for manifest in result['files']:
|
||||||
path = Path(manifest['file'])
|
path = Path(manifest['file'])
|
||||||
if hashlib.sha256(path.read_bytes()).hexdigest() != manifest['file_sha256']:
|
if hashlib.sha256(path.read_bytes()).hexdigest() != manifest['file_sha256']:
|
||||||
@@ -38,7 +39,9 @@ def audit(db, inventory, out):
|
|||||||
if json.loads(path.with_suffix('.manifest.json').read_text()) != manifest:
|
if json.loads(path.with_suffix('.manifest.json').read_text()) != manifest:
|
||||||
raise ValueError('Collected manifest differs from inventory')
|
raise ValueError('Collected manifest differs from inventory')
|
||||||
count = 0
|
count = 0
|
||||||
for line in path.read_text().splitlines():
|
# Unicode paragraph/line separators are valid inside JSON strings;
|
||||||
|
# split physical byte lines, not Unicode text boundaries.
|
||||||
|
for line in path.read_bytes().splitlines():
|
||||||
row = json.loads(line)
|
row = json.loads(line)
|
||||||
if any(row[key] != manifest[key] for key in ('source', 'venue', 'year', 'track')):
|
if any(row[key] != manifest[key] for key in ('source', 'venue', 'year', 'track')):
|
||||||
raise ValueError('Record edition differs from inventory')
|
raise ValueError('Record edition differs from inventory')
|
||||||
@@ -62,12 +65,11 @@ def audit(db, inventory, out):
|
|||||||
raise ValueError('Multiple canonical attempts for one forum')
|
raise ValueError('Multiple canonical attempts for one forum')
|
||||||
if existing:
|
if existing:
|
||||||
existing_attempts += 1
|
existing_attempts += 1
|
||||||
latest = c.execute('''SELECT status FROM decisions WHERE submission_id=?
|
latest = latest_status(c, existing[0][0])
|
||||||
ORDER BY observed_at DESC,COALESCE(decision_date,'') DESC,id DESC LIMIT 1''', existing[0]).fetchone()
|
if latest and latest not in ('unknown', 'under_review', row['status']):
|
||||||
if latest and latest[0] not in ('unknown', 'under_review', row['status']):
|
|
||||||
conflicts.append({'forum': forum, 'venue': row['venue'], 'year': row['year'],
|
conflicts.append({'forum': forum, 'venue': row['venue'], 'year': row['year'],
|
||||||
'track': row['track'], 'submission_id': existing[0][0],
|
'track': row['track'], 'submission_id': existing[0][0],
|
||||||
'existing_status': latest[0], 'curated_status': row['status']})
|
'existing_status': latest, 'curated_status': row['status']})
|
||||||
count += 1
|
count += 1
|
||||||
if count != manifest['count']:
|
if count != manifest['count']:
|
||||||
raise ValueError('Collected record count differs from inventory')
|
raise ValueError('Collected record count differs from inventory')
|
||||||
@@ -76,7 +78,8 @@ def audit(db, inventory, out):
|
|||||||
checksums[str(path)] = manifest['file_sha256']
|
checksums[str(path)] = manifest['file_sha256']
|
||||||
if checked != result['normalized_records']:
|
if checked != result['normalized_records']:
|
||||||
raise ValueError('Normalized inventory total differs from checked records')
|
raise ValueError('Normalized inventory total differs from checked records')
|
||||||
report = {'source': source, 'revision': revision, 'database': str(db),
|
report = {'source': source, 'revision': revision, 'database': database,
|
||||||
|
'engine': 'postgresql' if pg_service else 'sqlite', 'schema': actual_schema, 'pg_service': pg_service,
|
||||||
'inventory': str(inventory), 'checked_at': timestamp(), 'records_checked': checked,
|
'inventory': str(inventory), 'checked_at': timestamp(), 'records_checked': checked,
|
||||||
'existing_same_edition_attempts': existing_attempts, 'conflicts': conflicts,
|
'existing_same_edition_attempts': existing_attempts, 'conflicts': conflicts,
|
||||||
'status': 'blocked' if conflicts else 'ready_for_serial_import',
|
'status': 'blocked' if conflicts else 'ready_for_serial_import',
|
||||||
@@ -94,11 +97,11 @@ def audit(db, inventory, out):
|
|||||||
|
|
||||||
def main(argv=None, *, prog=None):
|
def main(argv=None, *, prog=None):
|
||||||
parser = argparse.ArgumentParser(prog=prog)
|
parser = argparse.ArgumentParser(prog=prog)
|
||||||
parser.add_argument('--db', required=True)
|
database_arguments(parser)
|
||||||
parser.add_argument('--inventory', required=True)
|
parser.add_argument('--inventory', required=True)
|
||||||
parser.add_argument('--out', required=True)
|
parser.add_argument('--out', required=True)
|
||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
report = audit(args.db, args.inventory, args.out)
|
report = audit(args.db, args.inventory, args.out, pg_service=args.pg_service, pg_schema=args.pg_schema)
|
||||||
if report['status'] == 'blocked':
|
if report['status'] == 'blocked':
|
||||||
raise SystemExit('Conflicting existing decisions require review before import')
|
raise SystemExit('Conflicting existing decisions require review before import')
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ COMMANDS = {
|
|||||||
('audit','arxiv'): ('paper_atlas.audits.arxiv', {}, '核验 arXiv 原始编号、重复行与 ZIP 完整性'),
|
('audit','arxiv'): ('paper_atlas.audits.arxiv', {}, '核验 arXiv 原始编号、重复行与 ZIP 完整性'),
|
||||||
('audit','arxiv-tags'): ('paper_atlas.audits.arxiv_tags', {}, '逐记录核验 arXiv 类别及出处完整性'),
|
('audit','arxiv-tags'): ('paper_atlas.audits.arxiv_tags', {}, '逐记录核验 arXiv 类别及出处完整性'),
|
||||||
('audit','public-openreview'): ('paper_atlas.audits.public_openreview', {}, '核对公开负面决定与已有投稿历史'),
|
('audit','public-openreview'): ('paper_atlas.audits.public_openreview', {}, '核对公开负面决定与已有投稿历史'),
|
||||||
|
('audit','histories'): ('paper_atlas.audits.histories', {}, '只读核对跨会议投稿历史及决定来源关系'),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,48 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def database_arguments(parser):
|
||||||
|
target = parser.add_mutually_exclusive_group(required=True)
|
||||||
|
target.add_argument('--db', help='Writable SQLite sample; never the frozen production backup')
|
||||||
|
target.add_argument('--pg-service', help='Protected libpq service for the published PostgreSQL dataset')
|
||||||
|
parser.add_argument('--pg-schema', help='Optional schema; otherwise use the configured search_path')
|
||||||
|
|
||||||
|
|
||||||
|
def open_store(db=None, *, pg_service=None, pg_schema=None):
|
||||||
|
if bool(db) == bool(pg_service) or (pg_schema and not pg_service):
|
||||||
|
raise ValueError('Choose exactly one of SQLite db or PostgreSQL service; schema requires PostgreSQL')
|
||||||
|
if pg_service:
|
||||||
|
from paper_atlas.postgres.writer import PostgresStore
|
||||||
|
return PostgresStore(pg_service, pg_schema)
|
||||||
|
from paper_atlas.db.store import Store
|
||||||
|
return Store(db)
|
||||||
|
|
||||||
|
|
||||||
|
def open_read_connection(db=None, *, pg_service=None, pg_schema=None):
|
||||||
|
if bool(db) == bool(pg_service) or (pg_schema and not pg_service):
|
||||||
|
raise ValueError('Choose exactly one of SQLite db or PostgreSQL service; schema requires PostgreSQL')
|
||||||
|
if pg_service:
|
||||||
|
from paper_atlas.postgres.client import ReadConnection
|
||||||
|
return ReadConnection(pg_service, pg_schema)
|
||||||
|
import sqlite3
|
||||||
|
path = Path(db).resolve()
|
||||||
|
if not path.is_file():
|
||||||
|
raise ValueError('Existing database required for read-only audit')
|
||||||
|
connection = sqlite3.connect(path.as_uri() + '?mode=ro', uri=True)
|
||||||
|
try:
|
||||||
|
connection.execute('BEGIN')
|
||||||
|
except BaseException:
|
||||||
|
connection.close()
|
||||||
|
raise
|
||||||
|
return connection
|
||||||
|
|
||||||
|
|
||||||
|
def inserted_id(connection, sql, params=()):
|
||||||
|
if getattr(connection, 'postgres', False):
|
||||||
|
return connection.execute(sql + ' RETURNING id', params).fetchone()[0]
|
||||||
|
return connection.execute(sql, params).lastrowid
|
||||||
|
|
||||||
|
|
||||||
def require_sqlite_writable(path):
|
def require_sqlite_writable(path):
|
||||||
marker = Path(str(Path(path).resolve()) + '.postgresql-cutover.json')
|
marker = Path(str(Path(path).resolve()) + '.postgresql-cutover.json')
|
||||||
if marker.exists():
|
if marker.exists():
|
||||||
|
|||||||
+34
-23
@@ -5,8 +5,8 @@ import re
|
|||||||
import sqlite3
|
import sqlite3
|
||||||
import unicodedata
|
import unicodedata
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from paper_atlas.db.submissions import attach_submission,migrate_sources,write_decision
|
from paper_atlas.db.submissions import attach_submission,migrate_sources,write_decision,latest_status
|
||||||
from paper_atlas.db.backend import require_sqlite_writable
|
from paper_atlas.db.backend import require_sqlite_writable, inserted_id
|
||||||
|
|
||||||
|
|
||||||
def normalized(value):
|
def normalized(value):
|
||||||
@@ -38,7 +38,7 @@ class Store:
|
|||||||
migrate_sources(self.conn)
|
migrate_sources(self.conn)
|
||||||
|
|
||||||
def source(self,name,url):
|
def source(self,name,url):
|
||||||
self.conn.execute('INSERT OR IGNORE INTO sources(name,homepage) VALUES(?,?)',(name,url))
|
self.conn.execute('INSERT INTO sources(name,homepage) VALUES(?,?) ON CONFLICT DO NOTHING',(name,url))
|
||||||
return self.conn.execute('SELECT id FROM sources WHERE name=?',(name,)).fetchone()[0]
|
return self.conn.execute('SELECT id FROM sources WHERE name=?',(name,)).fetchone()[0]
|
||||||
|
|
||||||
def merge(self,target,other,reason):
|
def merge(self,target,other,reason):
|
||||||
@@ -55,7 +55,7 @@ class Store:
|
|||||||
c.execute('UPDATE identifiers SET paper_id=? WHERE paper_id=?',(target,other))
|
c.execute('UPDATE identifiers SET paper_id=? WHERE paper_id=?',(target,other))
|
||||||
c.execute('UPDATE source_records SET paper_id=? WHERE paper_id=?',(target,other))
|
c.execute('UPDATE source_records SET paper_id=? WHERE paper_id=?',(target,other))
|
||||||
c.execute('UPDATE submissions SET paper_id=? WHERE paper_id=?',(target,other))
|
c.execute('UPDATE submissions SET paper_id=? WHERE paper_id=?',(target,other))
|
||||||
c.execute('INSERT OR IGNORE INTO paper_tags SELECT ?,tag_id,source_record_id,evidence_key FROM paper_tags WHERE paper_id=?',(target,other))
|
c.execute('INSERT INTO paper_tags SELECT ?,tag_id,source_record_id,evidence_key FROM paper_tags WHERE paper_id=? ON CONFLICT DO NOTHING',(target,other))
|
||||||
c.execute('DELETE FROM paper_tags WHERE paper_id=?',(other,))
|
c.execute('DELETE FROM paper_tags WHERE paper_id=?',(other,))
|
||||||
c.execute('UPDATE paper_merges SET surviving_paper_id=? WHERE surviving_paper_id=?',(target,other))
|
c.execute('UPDATE paper_merges SET surviving_paper_id=? WHERE surviving_paper_id=?',(target,other))
|
||||||
# Reattach pending candidate evidence without losing colliding rows.
|
# Reattach pending candidate evidence without losing colliding rows.
|
||||||
@@ -65,7 +65,7 @@ class Store:
|
|||||||
left = target if left == other else left
|
left = target if left == other else left
|
||||||
right = target if right == other else right
|
right = target if right == other else right
|
||||||
if left != right:
|
if left != right:
|
||||||
c.execute('INSERT OR IGNORE INTO match_candidates VALUES(?,?,?,?,?,?)',(min(left,right),max(left,right),method,score,evidence,status))
|
c.execute('INSERT INTO match_candidates VALUES(?,?,?,?,?,?) ON CONFLICT DO NOTHING',(min(left,right),max(left,right),method,score,evidence,status))
|
||||||
c.execute('DELETE FROM papers WHERE id=?',(other,))
|
c.execute('DELETE FROM papers WHERE id=?',(other,))
|
||||||
|
|
||||||
def record(self,source,external_id,url,title,abstract,authors,identifiers=None,metadata=None,published=None):
|
def record(self,source,external_id,url,title,abstract,authors,identifiers=None,metadata=None,published=None):
|
||||||
@@ -95,13 +95,19 @@ class Store:
|
|||||||
matches.add(old[0])
|
matches.add(old[0])
|
||||||
key = normalized(title)
|
key = normalized(title)
|
||||||
title_matches = c.execute('SELECT id,authors_json,abstract FROM papers WHERE title_key=?',(key,)).fetchall()
|
title_matches = c.execute('SELECT id,authors_json,abstract FROM papers WHERE title_key=?',(key,)).fetchall()
|
||||||
authors_set = author_keys(authors)
|
# An existing identifier/source record anchors the entity. Title
|
||||||
for paper,old_authors,old_abstract in title_matches:
|
# similarity must not pull in another deposit or an extended version.
|
||||||
overlap = authors_set & author_keys(json.loads(old_authors))
|
if not matches:
|
||||||
# Cross-platform title matching needs independent author corroboration.
|
title_only_matches = set()
|
||||||
same_source = c.execute('SELECT 1 FROM source_records WHERE paper_id=? AND source_id=? LIMIT 1',(paper,source_id)).fetchone()
|
authors_set = author_keys(authors)
|
||||||
if not same_source and len(key)>=25 and (len(overlap)>=2 or (overlap and abstract and abstract.strip()==(old_abstract or '').strip())):
|
for paper,old_authors,old_abstract in title_matches:
|
||||||
matches.add(paper)
|
overlap = authors_set & author_keys(json.loads(old_authors))
|
||||||
|
same_source = c.execute('SELECT 1 FROM source_records WHERE paper_id=? AND source_id=? LIMIT 1',(paper,source_id)).fetchone()
|
||||||
|
if not same_source and len(key)>=25 and (len(overlap)>=2 or (overlap and abstract and abstract.strip()==(old_abstract or '').strip())):
|
||||||
|
title_only_matches.add(paper)
|
||||||
|
if len(title_only_matches)>1:
|
||||||
|
raise ValueError('Ambiguous paper identity: multiple author-corroborated title matches; provide verified identifiers')
|
||||||
|
matches = title_only_matches
|
||||||
if matches:
|
if matches:
|
||||||
paper = min(matches)
|
paper = min(matches)
|
||||||
for other in sorted(matches-{paper}):
|
for other in sorted(matches-{paper}):
|
||||||
@@ -111,18 +117,19 @@ class Store:
|
|||||||
c.execute("UPDATE papers SET title=?,abstract=COALESCE(NULLIF(?,''),abstract),title_key=?,authors_json=CASE WHEN ?='[]' THEN authors_json ELSE ? END WHERE id=? AND title=? AND COALESCE(abstract,'')=COALESCE(?,'')",(title,abstract,key,json.dumps(authors,ensure_ascii=False),json.dumps(authors,ensure_ascii=False),paper,old[1],old_abstract))
|
c.execute("UPDATE papers SET title=?,abstract=COALESCE(NULLIF(?,''),abstract),title_key=?,authors_json=CASE WHEN ?='[]' THEN authors_json ELSE ? END WHERE id=? AND title=? AND COALESCE(abstract,'')=COALESCE(?,'')",(title,abstract,key,json.dumps(authors,ensure_ascii=False),json.dumps(authors,ensure_ascii=False),paper,old[1],old_abstract))
|
||||||
c.execute("UPDATE papers SET abstract=CASE WHEN abstract IS NULL OR abstract='' THEN ? ELSE abstract END WHERE id=?",(abstract,paper))
|
c.execute("UPDATE papers SET abstract=CASE WHEN abstract IS NULL OR abstract='' THEN ? ELSE abstract END WHERE id=?",(abstract,paper))
|
||||||
else:
|
else:
|
||||||
paper = c.execute('INSERT INTO papers(title,abstract,title_key,authors_json,first_published) VALUES(?,?,?,?,?)',(title,abstract,key,json.dumps(authors,ensure_ascii=False),published)).lastrowid
|
paper = inserted_id(c,'INSERT INTO papers(title,abstract,title_key,authors_json,first_published) VALUES(?,?,?,?,?)',(title,abstract,key,json.dumps(authors,ensure_ascii=False),published))
|
||||||
for scheme,value in ids:
|
for scheme,value in ids:
|
||||||
c.execute('INSERT OR IGNORE INTO identifiers VALUES(?,?,?)',(scheme,value,paper))
|
c.execute('INSERT INTO identifiers VALUES(?,?,?) ON CONFLICT DO NOTHING',(scheme,value,paper))
|
||||||
metadata=dict(metadata or {});metadata['record_identifiers']=dict(ids)
|
metadata=dict(metadata or {});metadata['record_identifiers']=dict(ids)
|
||||||
if old and not abstract:
|
if old and not abstract:
|
||||||
previous={'abstract':old[2],'observed_at':old[3]} if old[2] else json.loads(old[4]).get('last_known_abstract')
|
previous={'abstract':old[2],'observed_at':old[3]} if old[2] else json.loads(old[4]).get('last_known_abstract')
|
||||||
if previous:metadata['last_known_abstract']=previous
|
if previous:metadata['last_known_abstract']=previous
|
||||||
c.execute('INSERT INTO source_records(source_id,external_id,paper_id,url,title,abstract,authors_json,metadata_json) VALUES(?,?,?,?,?,?,?,?) ON CONFLICT(source_id,external_id) DO UPDATE SET paper_id=excluded.paper_id,url=excluded.url,title=excluded.title,abstract=excluded.abstract,authors_json=excluded.authors_json,metadata_json=excluded.metadata_json,retrieved_at=CURRENT_TIMESTAMP',(source_id,external_id,paper,url,title,abstract,json.dumps(authors,ensure_ascii=False),json.dumps(metadata,ensure_ascii=False,default=str)))
|
from paper_atlas.db.submissions import timestamp
|
||||||
|
c.execute('INSERT INTO source_records(source_id,external_id,paper_id,url,title,abstract,authors_json,metadata_json,retrieved_at) VALUES(?,?,?,?,?,?,?,?,?) ON CONFLICT(source_id,external_id) DO UPDATE SET paper_id=excluded.paper_id,url=excluded.url,title=excluded.title,abstract=excluded.abstract,authors_json=excluded.authors_json,metadata_json=excluded.metadata_json,retrieved_at=excluded.retrieved_at',(source_id,external_id,paper,url,title,abstract,json.dumps(authors,ensure_ascii=False),json.dumps(metadata,ensure_ascii=False,default=str),timestamp()))
|
||||||
record = c.execute('SELECT id FROM source_records WHERE source_id=? AND external_id=?',(source_id,external_id)).fetchone()[0]
|
record = c.execute('SELECT id FROM source_records WHERE source_id=? AND external_id=?',(source_id,external_id)).fetchone()[0]
|
||||||
for other,_,_ in title_matches:
|
for other,_,_ in title_matches:
|
||||||
if other != paper and c.execute('SELECT 1 FROM papers WHERE id=?',(other,)).fetchone():
|
if other != paper and c.execute('SELECT 1 FROM papers WHERE id=?',(other,)).fetchone():
|
||||||
c.execute('INSERT OR IGNORE INTO match_candidates VALUES(?,?,?,?,?,?)',(min(paper,other),max(paper,other),'exact_title_unconfirmed',None,json.dumps({'title':title}), 'pending'))
|
c.execute('INSERT INTO match_candidates VALUES(?,?,?,?,?,?) ON CONFLICT DO NOTHING',(min(paper,other),max(paper,other),'exact_title_unconfirmed',None,json.dumps({'title':title}), 'pending'))
|
||||||
return paper,record
|
return paper,record
|
||||||
|
|
||||||
def record_tags(self,paper,record,source,tags):
|
def record_tags(self,paper,record,source,tags):
|
||||||
@@ -137,22 +144,26 @@ class Store:
|
|||||||
c=self.conn
|
c=self.conn
|
||||||
c.execute('DELETE FROM paper_tags WHERE source_record_id=? AND evidence_key=?',(record,evidence))
|
c.execute('DELETE FROM paper_tags WHERE source_record_id=? AND evidence_key=?',(record,evidence))
|
||||||
for namespace,value in sorted(values):
|
for namespace,value in sorted(values):
|
||||||
c.execute('INSERT OR IGNORE INTO tags(namespace,value) VALUES(?,?)',(namespace,value))
|
c.execute('INSERT INTO tags(namespace,value) VALUES(?,?) ON CONFLICT DO NOTHING',(namespace,value))
|
||||||
tag=c.execute('SELECT id FROM tags WHERE namespace=? AND value=?',(namespace,value)).fetchone()[0]
|
tag=c.execute('SELECT id FROM tags WHERE namespace=? AND value=?',(namespace,value)).fetchone()[0]
|
||||||
c.execute('INSERT OR IGNORE INTO paper_tags VALUES(?,?,?,?)',(paper,tag,record,evidence))
|
c.execute('INSERT INTO paper_tags VALUES(?,?,?,?) ON CONFLICT DO NOTHING',(paper,tag,record,evidence))
|
||||||
|
|
||||||
def decision(self,paper,record,venue,year,track,external_id,url,status,raw_value,aliases=None,identities=None,observed_at=None,decision_date=None,source_decision_id='',submitted_at=None):
|
def decision(self,paper,record,venue,year,track,external_id,url,status,raw_value,aliases=None,identities=None,observed_at=None,decision_date=None,source_decision_id='',submitted_at=None,guard_status=False):
|
||||||
c = self.conn
|
c = self.conn
|
||||||
existing=c.execute('SELECT id FROM venues WHERE acronym=? COLLATE NOCASE',(venue,)).fetchone()
|
existing=c.execute('SELECT id FROM venues WHERE lower(acronym)=lower(?)',(venue,)).fetchone()
|
||||||
if existing:venue_id=existing[0]
|
if existing:venue_id=existing[0]
|
||||||
else:venue_id=c.execute("INSERT INTO venues(acronym,name,field,venue_type) VALUES(?,?,?,'conference')",(venue,venue,'unclassified')).lastrowid
|
else:venue_id=inserted_id(c,"INSERT INTO venues(acronym,name,field,venue_type) VALUES(?,?,?,'conference')",(venue,venue,'unclassified'))
|
||||||
for alias in aliases or []:
|
for alias in aliases or []:
|
||||||
c.execute('INSERT OR IGNORE INTO venue_aliases VALUES(?,?)',(venue_id,alias))
|
c.execute('INSERT INTO venue_aliases VALUES(?,?) ON CONFLICT DO NOTHING',(venue_id,alias))
|
||||||
c.execute('INSERT OR IGNORE INTO editions(venue_id,year,track) VALUES(?,?,?)',(venue_id,int(year),track))
|
c.execute('INSERT INTO editions(venue_id,year,track) VALUES(?,?,?) ON CONFLICT DO NOTHING',(venue_id,int(year),track))
|
||||||
edition = c.execute('SELECT id FROM editions WHERE venue_id=? AND year=? AND track=?',(venue_id,int(year),track)).fetchone()[0]
|
edition = c.execute('SELECT id FROM editions WHERE venue_id=? AND year=? AND track=?',(venue_id,int(year),track)).fetchone()[0]
|
||||||
# A previous event in this row may already have merged the input paper.
|
# 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]
|
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 [])
|
submission=attach_submission(self,edition,paper,record,external_id,identities or [])
|
||||||
|
if guard_status and status in ('rejected','desk_rejected','withdrawn'):
|
||||||
|
current = latest_status(c, submission)
|
||||||
|
if current and current not in ('unknown','under_review',status):
|
||||||
|
raise ValueError('Curated decision conflicts with current canonical submission status: ' + current)
|
||||||
if submitted_at:
|
if submitted_at:
|
||||||
from paper_atlas.db.submissions import timestamp
|
from paper_atlas.db.submissions import timestamp
|
||||||
c.execute('UPDATE submissions SET submitted_at=COALESCE(submitted_at,?) WHERE id=?',(timestamp(submitted_at),submission))
|
c.execute('UPDATE submissions SET submitted_at=COALESCE(submitted_at,?) WHERE id=?',(timestamp(submitted_at),submission))
|
||||||
|
|||||||
@@ -58,21 +58,29 @@ def identities_for_row(row):
|
|||||||
def migrate_sources(c):
|
def migrate_sources(c):
|
||||||
if c.execute("SELECT 1 FROM schema_migrations WHERE name='submission_source_baseline_v1'").fetchone():return
|
if c.execute("SELECT 1 FROM schema_migrations WHERE name='submission_source_baseline_v1'").fetchone():return
|
||||||
with c:
|
with c:
|
||||||
c.execute('INSERT OR IGNORE INTO submission_sources SELECT id,source_record_id,source_submission_id FROM submissions')
|
c.execute('INSERT INTO submission_sources SELECT id,source_record_id,source_submission_id FROM submissions WHERE 1=1 ON CONFLICT DO NOTHING')
|
||||||
c.execute("INSERT OR IGNORE INTO decision_sources SELECT d.id,s.source_record_id,'' FROM decisions d JOIN submissions s ON s.id=d.submission_id")
|
c.execute("INSERT INTO decision_sources SELECT d.id,s.source_record_id,'' FROM decisions d JOIN submissions s ON s.id=d.submission_id WHERE 1=1 ON CONFLICT DO NOTHING")
|
||||||
c.execute("INSERT INTO schema_migrations(name) VALUES('submission_source_baseline_v1')")
|
c.execute("INSERT INTO schema_migrations(name) VALUES('submission_source_baseline_v1')")
|
||||||
|
|
||||||
|
|
||||||
def next_id(c,table,audit,former):
|
def next_id(c,table,audit,former):
|
||||||
# Merged IDs are kept in audit; never allocate them to a later unrelated event.
|
# Merged IDs are kept in audit; never allocate them to a later unrelated event.
|
||||||
|
if getattr(c, 'postgres', False):
|
||||||
|
return c.next_id(table)
|
||||||
return max(c.execute('SELECT COALESCE(max(id),0) FROM '+table).fetchone()[0],c.execute('SELECT COALESCE(max('+former+'),0) FROM '+audit).fetchone()[0])+1
|
return max(c.execute('SELECT COALESCE(max(id),0) FROM '+table).fetchone()[0],c.execute('SELECT COALESCE(max('+former+'),0) FROM '+audit).fetchone()[0])+1
|
||||||
|
|
||||||
|
|
||||||
|
def latest_status(c, submission):
|
||||||
|
row = c.execute('''SELECT status FROM decisions WHERE submission_id=?
|
||||||
|
ORDER BY observed_at DESC,COALESCE(decision_date,observed_at) DESC,id DESC LIMIT 1''', (submission,)).fetchone()
|
||||||
|
return row[0] if row else None
|
||||||
|
|
||||||
|
|
||||||
def merge_submissions(c,target,other):
|
def merge_submissions(c,target,other):
|
||||||
if target==other:return
|
if target==other:return
|
||||||
editions=c.execute('SELECT id,edition_id FROM submissions WHERE id IN (?,?)',(target,other)).fetchall()
|
editions=c.execute('SELECT id,edition_id FROM submissions WHERE id IN (?,?)',(target,other)).fetchall()
|
||||||
if len(editions)!=2 or editions[0][1]!=editions[1][1]:raise ValueError('Cannot combine submissions from different editions')
|
if len(editions)!=2 or editions[0][1]!=editions[1][1]:raise ValueError('Cannot combine submissions from different editions')
|
||||||
c.execute('INSERT OR IGNORE INTO submission_sources SELECT ?,source_record_id,source_submission_id FROM submission_sources WHERE submission_id=?',(target,other))
|
c.execute('INSERT INTO submission_sources SELECT ?,source_record_id,source_submission_id FROM submission_sources WHERE submission_id=? ON CONFLICT DO NOTHING',(target,other))
|
||||||
c.execute('DELETE FROM submission_sources WHERE submission_id=?',(other,))
|
c.execute('DELETE FROM submission_sources WHERE submission_id=?',(other,))
|
||||||
for decision,status,raw,url,observed,date in c.execute('SELECT id,status,raw_value,evidence_url,observed_at,decision_date FROM decisions WHERE submission_id=?',(other,)).fetchall():
|
for decision,status,raw,url,observed,date in c.execute('SELECT id,status,raw_value,evidence_url,observed_at,decision_date FROM decisions WHERE submission_id=?',(other,)).fetchall():
|
||||||
duplicate=c.execute('SELECT id,decision_date FROM decisions WHERE submission_id=? AND status=? AND raw_value=? AND evidence_url=? AND observed_at=?',(target,status,raw,url,observed)).fetchone()
|
duplicate=c.execute('SELECT id,decision_date FROM decisions WHERE submission_id=? AND status=? AND raw_value=? AND evidence_url=? AND observed_at=?',(target,status,raw,url,observed)).fetchone()
|
||||||
@@ -80,7 +88,7 @@ def merge_submissions(c,target,other):
|
|||||||
surviving=duplicate[0]
|
surviving=duplicate[0]
|
||||||
if date and duplicate[1] and date!=duplicate[1]:raise ValueError('Conflicting dates for the same decision observation')
|
if date and duplicate[1] and date!=duplicate[1]:raise ValueError('Conflicting dates for the same decision observation')
|
||||||
if date and not duplicate[1]:c.execute('UPDATE decisions SET decision_date=? WHERE id=?',(date,surviving))
|
if date and not duplicate[1]:c.execute('UPDATE decisions SET decision_date=? WHERE id=?',(date,surviving))
|
||||||
c.execute('INSERT OR IGNORE INTO decision_sources SELECT ?,source_record_id,source_decision_id FROM decision_sources WHERE decision_id=?',(surviving,decision))
|
c.execute('INSERT INTO decision_sources SELECT ?,source_record_id,source_decision_id FROM decision_sources WHERE decision_id=? ON CONFLICT DO NOTHING',(surviving,decision))
|
||||||
c.execute('DELETE FROM decision_sources WHERE decision_id=?',(decision,))
|
c.execute('DELETE FROM decision_sources WHERE decision_id=?',(decision,))
|
||||||
c.execute('UPDATE decision_merges SET surviving_decision_id=? WHERE surviving_decision_id=?',(surviving,decision))
|
c.execute('UPDATE decision_merges SET surviving_decision_id=? WHERE surviving_decision_id=?',(surviving,decision))
|
||||||
c.execute("INSERT INTO decision_merges(former_decision_id,surviving_decision_id,reason) VALUES(?,?,'same decision observation on merged submission')",(decision,surviving))
|
c.execute("INSERT INTO decision_merges(former_decision_id,surviving_decision_id,reason) VALUES(?,?,'same decision observation on merged submission')",(decision,surviving))
|
||||||
@@ -114,9 +122,9 @@ def attach_submission(store,edition,paper,record,external_id,identities):
|
|||||||
else:
|
else:
|
||||||
submission=next_id(c,'submissions','submission_merges','former_submission_id')
|
submission=next_id(c,'submissions','submission_merges','former_submission_id')
|
||||||
c.execute('INSERT INTO submissions(id,paper_id,edition_id,source_record_id,source_submission_id) VALUES(?,?,?,?,?)',(submission,paper,edition,record,external_id))
|
c.execute('INSERT INTO submissions(id,paper_id,edition_id,source_record_id,source_submission_id) VALUES(?,?,?,?,?)',(submission,paper,edition,record,external_id))
|
||||||
c.execute('INSERT OR IGNORE INTO submission_sources VALUES(?,?,?)',(submission,record,external_id))
|
c.execute('INSERT INTO submission_sources VALUES(?,?,?) ON CONFLICT DO NOTHING',(submission,record,external_id))
|
||||||
for scheme,value in identities:
|
for scheme,value in identities:
|
||||||
c.execute('INSERT OR IGNORE INTO submission_identifiers VALUES(?,?,?,?)',(edition,scheme,value,submission))
|
c.execute('INSERT INTO submission_identifiers VALUES(?,?,?,?) ON CONFLICT DO NOTHING',(edition,scheme,value,submission))
|
||||||
return submission
|
return submission
|
||||||
|
|
||||||
|
|
||||||
@@ -130,5 +138,5 @@ def write_decision(store,submission,record,status,raw,url,observed_at=None,decis
|
|||||||
else:
|
else:
|
||||||
decision=next_id(c,'decisions','decision_merges','former_decision_id')
|
decision=next_id(c,'decisions','decision_merges','former_decision_id')
|
||||||
c.execute('INSERT INTO decisions(id,submission_id,status,raw_value,evidence_url,observed_at,decision_date) VALUES(?,?,?,?,?,?,?)',(decision,submission,status,raw,url,observed,date))
|
c.execute('INSERT INTO decisions(id,submission_id,status,raw_value,evidence_url,observed_at,decision_date) VALUES(?,?,?,?,?,?,?)',(decision,submission,status,raw,url,observed,date))
|
||||||
c.execute('INSERT OR IGNORE INTO decision_sources VALUES(?,?,?)',(decision,record,source_decision_id or ''))
|
c.execute('INSERT INTO decision_sources VALUES(?,?,?) ON CONFLICT DO NOTHING',(decision,record,source_decision_id or ''))
|
||||||
return decision
|
return decision
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Stream the official metadata ZIP into SQLite. No PDFs, source files or builds."""
|
"""Stream the official metadata ZIP into the configured metadata database."""
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
@@ -7,7 +7,8 @@ import zipfile
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
from paper_atlas.db.store import Store
|
from paper_atlas.db.backend import database_arguments, open_store, inserted_id
|
||||||
|
from paper_atlas.db.submissions import timestamp
|
||||||
|
|
||||||
|
|
||||||
def refresh_categories(store,paper,record,categories):
|
def refresh_categories(store,paper,record,categories):
|
||||||
@@ -17,28 +18,29 @@ def refresh_categories(store,paper,record,categories):
|
|||||||
store.conn.execute("DELETE FROM paper_tags WHERE source_record_id=? AND evidence_key='arxiv:categories' AND tag_id IN (SELECT id FROM tags WHERE namespace='arxiv:category')",(record,))
|
store.conn.execute("DELETE FROM paper_tags WHERE source_record_id=? AND evidence_key='arxiv:categories' AND tag_id IN (SELECT id FROM tags WHERE namespace='arxiv:category')",(record,))
|
||||||
|
|
||||||
|
|
||||||
def ingest(db, archive):
|
def ingest(db, archive, *, pg_service=None, pg_schema=None, storage_path=None):
|
||||||
store = Store(db)
|
store = open_store(db,pg_service=pg_service,pg_schema=pg_schema)
|
||||||
conn = store.conn
|
conn = store.conn
|
||||||
conn.execute("INSERT OR IGNORE INTO sources(name,homepage) VALUES('arxiv','https://arxiv.org')")
|
run = None
|
||||||
source = conn.execute("SELECT id FROM sources WHERE name='arxiv'").fetchone()[0]
|
|
||||||
previous = conn.execute('SELECT id,checkpoint_json FROM ingest_runs WHERE source_id=? AND status IN (\'running\',\'failed\') ORDER BY id DESC LIMIT 1', (source,)).fetchone()
|
|
||||||
fingerprint = {'path':str(Path(archive).resolve()), 'bytes':Path(archive).stat().st_size, 'mtime_ns':Path(archive).stat().st_mtime_ns}
|
|
||||||
start = 0
|
|
||||||
if previous:
|
|
||||||
checkpoint = json.loads(previous[1] or '{}')
|
|
||||||
if checkpoint.get('archive') == fingerprint:
|
|
||||||
run = previous[0]
|
|
||||||
start = checkpoint.get('line', 0)
|
|
||||||
conn.execute("UPDATE ingest_runs SET status='running',error=NULL WHERE id=?", (run,))
|
|
||||||
else:
|
|
||||||
raise RuntimeError('Pending run belongs to a different archive; resolve it explicitly')
|
|
||||||
else:
|
|
||||||
run = conn.execute('INSERT INTO ingest_runs(source_id,checkpoint_json) VALUES(?,?)', (source,json.dumps({'archive':fingerprint,'line':0}))).lastrowid
|
|
||||||
conn.commit()
|
|
||||||
line_number = start
|
|
||||||
last = time.monotonic()
|
|
||||||
try:
|
try:
|
||||||
|
source = store.source('arxiv','https://arxiv.org')
|
||||||
|
previous = conn.execute('SELECT id,checkpoint_json FROM ingest_runs WHERE source_id=? AND status IN (\'running\',\'failed\') ORDER BY id DESC LIMIT 1', (source,)).fetchone()
|
||||||
|
fingerprint = {'path':str(Path(archive).resolve()), 'bytes':Path(archive).stat().st_size, 'mtime_ns':Path(archive).stat().st_mtime_ns}
|
||||||
|
start = 0
|
||||||
|
if previous:
|
||||||
|
checkpoint = json.loads(previous[1] or '{}')
|
||||||
|
if checkpoint.get('archive') == fingerprint:
|
||||||
|
run = previous[0]
|
||||||
|
start = checkpoint.get('line', 0)
|
||||||
|
conn.execute("UPDATE ingest_runs SET status='running',error=NULL WHERE id=?", (run,))
|
||||||
|
else:
|
||||||
|
raise RuntimeError('Pending run belongs to a different archive; resolve it explicitly')
|
||||||
|
else:
|
||||||
|
run = inserted_id(conn,'INSERT INTO ingest_runs(source_id,checkpoint_json) VALUES(?,?)', (source,json.dumps({'archive':fingerprint,'line':0})))
|
||||||
|
conn.commit()
|
||||||
|
line_number = start
|
||||||
|
last = time.monotonic()
|
||||||
|
monitored = Path(storage_path) if storage_path else (Path(db).parent if db else Path(archive).parent)
|
||||||
with zipfile.ZipFile(archive) as zipped:
|
with zipfile.ZipFile(archive) as zipped:
|
||||||
# Reading the member to EOF also checks its CRC.
|
# Reading the member to EOF also checks its CRC.
|
||||||
with zipped.open('arxiv-metadata-oai-snapshot.json') as stream:
|
with zipped.open('arxiv-metadata-oai-snapshot.json') as stream:
|
||||||
@@ -54,20 +56,21 @@ def ingest(db, archive):
|
|||||||
paper,record = store.record('arxiv',external_id,'https://arxiv.org/abs/'+external_id,title,abstract,authors,{'arxiv':external_id,'doi':row.get('doi')},metadata,(row.get('versions') or [{}])[0].get('created'))
|
paper,record = store.record('arxiv',external_id,'https://arxiv.org/abs/'+external_id,title,abstract,authors,{'arxiv':external_id,'doi':row.get('doi')},metadata,(row.get('versions') or [{}])[0].get('created'))
|
||||||
refresh_categories(store,paper,record,row.get('categories') or '')
|
refresh_categories(store,paper,record,row.get('categories') or '')
|
||||||
if line_number % 500 == 0:
|
if line_number % 500 == 0:
|
||||||
if shutil.disk_usage(Path(db).parent).free < 4*1024**3:
|
if shutil.disk_usage(monitored).free < 4*1024**3:
|
||||||
raise RuntimeError('Stopped before exhausting disk: less than 4 GiB free')
|
raise RuntimeError('Stopped before exhausting disk: less than 4 GiB free')
|
||||||
conn.execute('UPDATE ingest_runs SET records_seen=?,records_written=?,checkpoint_json=? WHERE id=?',(line_number,line_number,json.dumps({'archive':fingerprint,'line':line_number}),run))
|
conn.execute('UPDATE ingest_runs SET records_seen=?,records_written=?,checkpoint_json=? WHERE id=?',(line_number,line_number,json.dumps({'archive':fingerprint,'line':line_number}),run))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
if time.monotonic()-last >= 20:
|
if time.monotonic()-last >= 20:
|
||||||
print(json.dumps({'run':run,'rows':line_number}),flush=True)
|
print(json.dumps({'run':run,'rows':line_number}),flush=True)
|
||||||
last = time.monotonic()
|
last = time.monotonic()
|
||||||
conn.execute("UPDATE ingest_runs SET status='complete',finished_at=CURRENT_TIMESTAMP,records_seen=?,records_written=?,checkpoint_json=? WHERE id=?",(line_number,line_number,json.dumps({'archive':fingerprint,'line':line_number}),run))
|
conn.execute("UPDATE ingest_runs SET status='complete',finished_at=?,records_seen=?,records_written=?,checkpoint_json=? WHERE id=?",(timestamp(),line_number,line_number,json.dumps({'archive':fingerprint,'line':line_number}),run))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
print(json.dumps({'status':'complete','rows':line_number}),flush=True)
|
print(json.dumps({'status':'complete','rows':line_number}),flush=True)
|
||||||
except BaseException as exc:
|
except BaseException as exc:
|
||||||
conn.rollback()
|
conn.rollback()
|
||||||
conn.execute("UPDATE ingest_runs SET status='failed',error=? WHERE id=?",(str(exc),run))
|
if run is not None:
|
||||||
conn.commit()
|
conn.execute("UPDATE ingest_runs SET status='failed',error=? WHERE id=?",(str(exc),run))
|
||||||
|
conn.commit()
|
||||||
raise
|
raise
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
@@ -75,10 +78,11 @@ def ingest(db, archive):
|
|||||||
|
|
||||||
def main(argv=None, *, prog=None):
|
def main(argv=None, *, prog=None):
|
||||||
parser = argparse.ArgumentParser(prog=prog)
|
parser = argparse.ArgumentParser(prog=prog)
|
||||||
parser.add_argument('--db', required=True)
|
database_arguments(parser)
|
||||||
parser.add_argument('--archive', required=True)
|
parser.add_argument('--archive', required=True)
|
||||||
|
parser.add_argument('--storage-path', help='Local filesystem to monitor for free space; specify the database volume when running beside PostgreSQL')
|
||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
ingest(args.db,args.archive)
|
ingest(args.db,args.archive,pg_service=args.pg_service,pg_schema=args.pg_schema,storage_path=args.storage_path)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import hashlib
|
|||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from paper_atlas.db.store import Store
|
from paper_atlas.db.backend import database_arguments, open_store
|
||||||
from paper_atlas.db.submissions import attach_submission,identities_for_row
|
from paper_atlas.db.submissions import attach_submission,identities_for_row
|
||||||
from paper_atlas.collectors.pmlr_tracks import require_track_manifest, require_track_record
|
from paper_atlas.collectors.pmlr_tracks import require_track_manifest, require_track_record
|
||||||
|
|
||||||
@@ -19,15 +19,15 @@ def queue_files(path):
|
|||||||
return files
|
return files
|
||||||
|
|
||||||
|
|
||||||
def backfill(db,files):
|
def backfill(db,files, *, pg_service=None, pg_schema=None):
|
||||||
for filename in files:
|
for filename in files:
|
||||||
manifest=json.loads(Path(filename).with_suffix('.manifest.json').read_text())
|
manifest=json.loads(Path(filename).with_suffix('.manifest.json').read_text())
|
||||||
require_track_manifest(manifest)
|
require_track_manifest(manifest)
|
||||||
if manifest['source'] in ('colm','neurips','iclr_program') and manifest.get('parser_version',0)<2:
|
if manifest['source'] in ('colm','neurips','iclr_program') and manifest.get('parser_version',0)<2:
|
||||||
raise ValueError('Recollect old parser output before submission evidence backfill')
|
raise ValueError('Recollect old parser output before submission evidence backfill')
|
||||||
store=Store(db);c=store.conn;count=0
|
store=open_store(db,pg_service=pg_service,pg_schema=pg_schema);c=store.conn;count=0
|
||||||
before=c.execute('SELECT count(*) FROM submissions').fetchone()[0]
|
|
||||||
try:
|
try:
|
||||||
|
before=c.execute('SELECT count(*) FROM submissions').fetchone()[0]
|
||||||
for filename in files:
|
for filename in files:
|
||||||
path=Path(filename);manifest=json.loads(path.with_suffix('.manifest.json').read_text());seen=set()
|
path=Path(filename);manifest=json.loads(path.with_suffix('.manifest.json').read_text());seen=set()
|
||||||
with path.open() as stream:
|
with path.open() as stream:
|
||||||
@@ -39,7 +39,7 @@ def backfill(db,files):
|
|||||||
raise ValueError('Evidence row differs from edition manifest')
|
raise ValueError('Evidence row differs from edition manifest')
|
||||||
require_track_record(row, manifest)
|
require_track_record(row, manifest)
|
||||||
record=c.execute('SELECT r.id,r.paper_id,r.metadata_json FROM source_records r JOIN sources s ON s.id=r.source_id WHERE s.name=? AND r.external_id=?',(row['source'],row['external_id'])).fetchone()
|
record=c.execute('SELECT r.id,r.paper_id,r.metadata_json FROM source_records r JOIN sources s ON s.id=r.source_id WHERE s.name=? AND r.external_id=?',(row['source'],row['external_id'])).fetchone()
|
||||||
edition=c.execute('SELECT e.id FROM editions e JOIN venues v ON v.id=e.venue_id WHERE v.acronym=? COLLATE NOCASE AND e.year=? AND e.track=?',(row['venue'],row['year'],row['track'])).fetchone()
|
edition=c.execute('SELECT e.id FROM editions e JOIN venues v ON v.id=e.venue_id WHERE lower(v.acronym)=lower(?) AND e.year=? AND e.track=?',(row['venue'],row['year'],row['track'])).fetchone()
|
||||||
if not record or not edition:raise ValueError('Evidence backfill requires an already imported record and edition')
|
if not record or not edition:raise ValueError('Evidence backfill requires an already imported record and edition')
|
||||||
if not c.execute('SELECT 1 FROM submission_sources ss JOIN submissions s ON s.id=ss.submission_id WHERE ss.source_record_id=? AND ss.source_submission_id=? AND s.edition_id=?',(record[0],row['external_id'],edition[0])).fetchone():
|
if not c.execute('SELECT 1 FROM submission_sources ss JOIN submissions s ON s.id=ss.submission_id WHERE ss.source_record_id=? AND ss.source_submission_id=? AND s.edition_id=?',(record[0],row['external_id'],edition[0])).fetchone():
|
||||||
raise ValueError('No existing submission to backfill')
|
raise ValueError('No existing submission to backfill')
|
||||||
@@ -62,8 +62,8 @@ def backfill(db,files):
|
|||||||
|
|
||||||
|
|
||||||
def main(argv=None, *, prog=None):
|
def main(argv=None, *, prog=None):
|
||||||
parser=argparse.ArgumentParser(prog=prog);parser.add_argument('--db',required=True);parser.add_argument('--queue',required=True)
|
parser=argparse.ArgumentParser(prog=prog);database_arguments(parser);parser.add_argument('--queue',required=True)
|
||||||
args=parser.parse_args(argv);backfill(args.db,queue_files(args.queue))
|
args=parser.parse_args(argv);backfill(args.db,queue_files(args.queue),pg_service=args.pg_service,pg_schema=args.pg_schema)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
|||||||
@@ -1,15 +1,67 @@
|
|||||||
"""Import normalized conference JSONL files with coverage evidence."""
|
"""Import normalized conference JSONL files with coverage evidence."""
|
||||||
import argparse
|
import argparse
|
||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from paper_atlas.db.store import Store
|
from paper_atlas.db.backend import database_arguments, open_store, inserted_id
|
||||||
from paper_atlas.db.submissions import identities_for_row,timestamp
|
from paper_atlas.db.submissions import identities_for_row,timestamp
|
||||||
from paper_atlas.collectors.pmlr_tracks import require_track_manifest, require_track_record
|
from paper_atlas.collectors.pmlr_tracks import require_track_manifest, require_track_record
|
||||||
|
from paper_atlas.collectors.public_openreview import SOURCE as PUBLIC_SOURCE
|
||||||
|
from paper_atlas.collectors.icml_public_archive import SOURCE as ARCHIVE_SOURCE
|
||||||
|
|
||||||
|
|
||||||
def ingest_file(db,path):
|
def coverage_options(manifest):
|
||||||
|
mode = manifest.get('coverage_mode', 'snapshot')
|
||||||
|
if mode not in ('snapshot', 'incremental'):
|
||||||
|
raise ValueError('Unsupported coverage mode')
|
||||||
|
if mode == 'snapshot':
|
||||||
|
return None
|
||||||
|
required = ('venue', 'year', 'track', 'count', 'coverage_previous_count',
|
||||||
|
'coverage_previous_expected_count', 'coverage_expected_count')
|
||||||
|
if any(key not in manifest for key in required) or manifest.get('status') != 'partial':
|
||||||
|
raise ValueError('Incremental coverage requires a partial, explicitly scoped manifest')
|
||||||
|
previous = manifest['coverage_previous_count']
|
||||||
|
expected = manifest['coverage_expected_count']
|
||||||
|
prior_expected = manifest['coverage_previous_expected_count']
|
||||||
|
if (type(previous) is not int or previous < 0 or type(expected) is not int or expected < 1
|
||||||
|
or type(manifest['count']) is not int or manifest['count'] < 1
|
||||||
|
or expected < max(previous, manifest['count'])
|
||||||
|
or (prior_expected is not None and (type(prior_expected) is not int or prior_expected < 0
|
||||||
|
or expected < prior_expected))):
|
||||||
|
raise ValueError('Invalid incremental coverage counts')
|
||||||
|
return previous, prior_expected, expected
|
||||||
|
|
||||||
|
|
||||||
|
def source_record_count(c, source, edition):
|
||||||
|
return c.execute('''SELECT count(DISTINCT ss.source_record_id) FROM submission_sources ss
|
||||||
|
JOIN submissions s ON s.id=ss.submission_id
|
||||||
|
JOIN source_records sr ON sr.id=ss.source_record_id
|
||||||
|
WHERE s.edition_id=? AND sr.source_id=?''', (edition, source)).fetchone()[0]
|
||||||
|
|
||||||
|
|
||||||
|
def require_incremental_parent(c, source, manifest, options):
|
||||||
|
edition = c.execute('''SELECT e.id FROM editions e JOIN venues v ON v.id=e.venue_id
|
||||||
|
WHERE lower(v.acronym)=lower(?) AND e.year=? AND e.track=?''',
|
||||||
|
(manifest['venue'], manifest['year'], manifest['track'])).fetchone()
|
||||||
|
entries = c.execute('''SELECT list_url,status,expected_count,imported_count
|
||||||
|
FROM edition_sources WHERE edition_id=? AND source_id=?''',
|
||||||
|
(edition[0], source)).fetchall() if edition else []
|
||||||
|
previous, prior_expected, _ = options
|
||||||
|
if entries:
|
||||||
|
if len(entries) != 1 or entries[0][0] != manifest['list_url']:
|
||||||
|
raise ValueError('Incremental coverage requires one unambiguous source listing')
|
||||||
|
if tuple(entries[0][1:]) != ('partial', prior_expected, previous):
|
||||||
|
raise ValueError('Incremental coverage parent changed or is not partial')
|
||||||
|
elif previous != 0 or prior_expected is not None:
|
||||||
|
raise ValueError('Incremental coverage parent is absent')
|
||||||
|
actual = source_record_count(c, source, edition[0]) if edition else 0
|
||||||
|
if actual != previous:
|
||||||
|
raise ValueError('Incremental coverage parent differs from actual source records')
|
||||||
|
|
||||||
|
|
||||||
|
def ingest_file(db,path, *, pg_service=None, pg_schema=None):
|
||||||
path = Path(path)
|
path = Path(path)
|
||||||
manifest_path = path.with_suffix('.manifest.json')
|
manifest_path = path.with_suffix('.manifest.json')
|
||||||
manifest = json.loads(manifest_path.read_text()) if manifest_path.exists() else {}
|
manifest = json.loads(manifest_path.read_text()) if manifest_path.exists() else {}
|
||||||
@@ -18,21 +70,27 @@ def ingest_file(db,path):
|
|||||||
raise ValueError('Recollect proceedings with parser_version>=2 before identity matching')
|
raise ValueError('Recollect proceedings with parser_version>=2 before identity matching')
|
||||||
if manifest.get('source')=='iclr_program' and manifest.get('parser_version',0)<2:
|
if manifest.get('source')=='iclr_program' and manifest.get('parser_version',0)<2:
|
||||||
raise ValueError('Recollect ICLR with parser_version>=2 before submission identity matching')
|
raise ValueError('Recollect ICLR with parser_version>=2 before submission identity matching')
|
||||||
store = Store(db)
|
|
||||||
c = store.conn
|
|
||||||
source_name = manifest.get('source')
|
source_name = manifest.get('source')
|
||||||
if not source_name:
|
if not source_name:
|
||||||
raise ValueError('A source manifest is required')
|
raise ValueError('A source manifest is required')
|
||||||
source_id = store.source(source_name,manifest['list_url'])
|
incremental = coverage_options(manifest)
|
||||||
run = c.execute('INSERT INTO ingest_runs(source_id,checkpoint_json) VALUES(?,?)',(source_id,json.dumps({'file':str(path)}))).lastrowid
|
store = open_store(db, pg_service=pg_service, pg_schema=pg_schema)
|
||||||
c.commit()
|
c = store.conn
|
||||||
|
run = None
|
||||||
count = 0
|
count = 0
|
||||||
missing_abstracts = 0
|
missing_abstracts = 0
|
||||||
seen = set()
|
seen = set()
|
||||||
editions = set()
|
editions = set()
|
||||||
try:
|
try:
|
||||||
with path.open() as stream:
|
source_id = store.source(source_name,manifest['list_url'])
|
||||||
|
run = inserted_id(c,'INSERT INTO ingest_runs(source_id,checkpoint_json) VALUES(?,?)',(source_id,json.dumps({'file':str(path),'expected_sha256':manifest.get('file_sha256')})))
|
||||||
|
c.commit()
|
||||||
|
if incremental is not None:
|
||||||
|
require_incremental_parent(c, source_id, manifest, incremental)
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
with path.open('rb') as stream:
|
||||||
for line in stream:
|
for line in stream:
|
||||||
|
digest.update(line)
|
||||||
row = json.loads(line)
|
row = json.loads(line)
|
||||||
if row['source'] != source_name:
|
if row['source'] != source_name:
|
||||||
raise ValueError('Source differs from manifest')
|
raise ValueError('Source differs from manifest')
|
||||||
@@ -59,7 +117,7 @@ def ingest_file(db,path):
|
|||||||
status=event['status']
|
status=event['status']
|
||||||
if events and status is None:raise ValueError('Explicit decision event requires a status')
|
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')
|
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'))
|
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))
|
||||||
editions.add(edition)
|
editions.add(edition)
|
||||||
else:
|
else:
|
||||||
raise ValueError('This importer requires an explicit supported decision status')
|
raise ValueError('This importer requires an explicit supported decision status')
|
||||||
@@ -67,6 +125,8 @@ def ingest_file(db,path):
|
|||||||
missing_abstracts += not bool((row.get('abstract') or '').strip())
|
missing_abstracts += not bool((row.get('abstract') or '').strip())
|
||||||
if not count:
|
if not count:
|
||||||
raise ValueError('Empty metadata file')
|
raise ValueError('Empty metadata file')
|
||||||
|
if manifest.get('file_sha256') and digest.hexdigest() != manifest['file_sha256']:
|
||||||
|
raise ValueError('Collected file hash differs from manifest')
|
||||||
if len(editions) != 1:
|
if len(editions) != 1:
|
||||||
raise ValueError('A manifest must describe exactly one conference edition and track')
|
raise ValueError('A manifest must describe exactly one conference edition and track')
|
||||||
edition = next(iter(editions))
|
edition = next(iter(editions))
|
||||||
@@ -74,14 +134,32 @@ def ingest_file(db,path):
|
|||||||
if expected is not None and expected != count:
|
if expected is not None and expected != count:
|
||||||
raise ValueError(f'Manifest count {expected} differs from records {count}')
|
raise ValueError(f'Manifest count {expected} differs from records {count}')
|
||||||
complete=expected==count and manifest.get('status') not in ('partial','blocked') and not manifest.get('missing_abstract_count',0) and not missing_abstracts
|
complete=expected==count and manifest.get('status') not in ('partial','blocked') and not manifest.get('missing_abstract_count',0) and not missing_abstracts
|
||||||
c.execute('INSERT INTO edition_sources(edition_id,source_id,list_url,status,expected_count,imported_count,last_success_at) 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_success_at=excluded.last_success_at,last_error=NULL',(edition,source_id,manifest['list_url'],'complete' if complete else 'partial',expected,count,time.strftime('%Y-%m-%dT%H:%M:%SZ',time.gmtime())))
|
imported = count
|
||||||
c.execute("UPDATE ingest_runs SET status='complete',finished_at=CURRENT_TIMESTAMP,records_seen=?,records_written=? WHERE id=?",(count,count,run))
|
condition = ''
|
||||||
|
condition_params = ()
|
||||||
|
if incremental is not None:
|
||||||
|
previous, prior_expected, expected = incremental
|
||||||
|
imported = source_record_count(c, source_id, edition)
|
||||||
|
if imported < previous or imported > expected:
|
||||||
|
raise ValueError('Incremental coverage exceeds the declared source bounds')
|
||||||
|
complete = False
|
||||||
|
condition = " WHERE edition_sources.imported_count=? AND COALESCE(edition_sources.expected_count,-1)=? AND edition_sources.status='partial'"
|
||||||
|
condition_params = (previous, -1 if prior_expected is None else prior_expected)
|
||||||
|
result = c.execute('INSERT INTO edition_sources(edition_id,source_id,list_url,status,expected_count,imported_count,last_success_at) 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_success_at=excluded.last_success_at,last_error=NULL'+condition,(edition,source_id,manifest['list_url'],'complete' if complete else 'partial',expected,imported,time.strftime('%Y-%m-%dT%H:%M:%SZ',time.gmtime()),*condition_params))
|
||||||
|
if incremental is not None and result.rowcount != 1:
|
||||||
|
raise ValueError('Incremental coverage parent changed during import')
|
||||||
|
checkpoint = {'file':str(path),'file_sha256':digest.hexdigest()}
|
||||||
|
if incremental is not None:
|
||||||
|
checkpoint['coverage'] = {'mode':'incremental','previous_count':previous,
|
||||||
|
'expected_count':expected,'imported_count':imported}
|
||||||
|
c.execute("UPDATE ingest_runs SET status='complete',finished_at=?,records_seen=?,records_written=?,checkpoint_json=? WHERE id=?",(timestamp(),count,count,json.dumps(checkpoint),run))
|
||||||
c.commit()
|
c.commit()
|
||||||
print(json.dumps({'file':str(path),'status':'imported','records':count,'missing_abstract_count':missing_abstracts}),flush=True)
|
print(json.dumps({'file':str(path),'status':'imported','records':count,'missing_abstract_count':missing_abstracts}),flush=True)
|
||||||
except BaseException as exc:
|
except BaseException as exc:
|
||||||
c.rollback()
|
c.rollback()
|
||||||
c.execute("UPDATE ingest_runs SET status='failed',finished_at=CURRENT_TIMESTAMP,error=? WHERE id=?",(str(exc),run))
|
if run is not None:
|
||||||
c.commit()
|
c.execute("UPDATE ingest_runs SET status='failed',finished_at=?,records_seen=?,records_written=0,error=? WHERE id=?",(timestamp(),count,str(exc),run))
|
||||||
|
c.commit()
|
||||||
raise
|
raise
|
||||||
finally:
|
finally:
|
||||||
c.close()
|
c.close()
|
||||||
@@ -89,11 +167,11 @@ def ingest_file(db,path):
|
|||||||
|
|
||||||
def main(argv=None, *, prog=None):
|
def main(argv=None, *, prog=None):
|
||||||
parser = argparse.ArgumentParser(prog=prog)
|
parser = argparse.ArgumentParser(prog=prog)
|
||||||
parser.add_argument('--db',required=True)
|
database_arguments(parser)
|
||||||
parser.add_argument('files',nargs='+')
|
parser.add_argument('files',nargs='+')
|
||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
for filename in args.files:
|
for filename in args.files:
|
||||||
ingest_file(args.db,filename)
|
ingest_file(args.db,filename,pg_service=args.pg_service,pg_schema=args.pg_schema)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Libpq service connections; credentials never belong in CLI arguments."""
|
"""Libpq service connections; credentials never belong in CLI arguments."""
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
import re
|
import re
|
||||||
|
from paper_atlas.postgres import text
|
||||||
|
|
||||||
SEARCH_VECTOR = "to_tsvector('english'::regconfig,coalesce(title,'') || ' ' || coalesce(abstract,''))"
|
SEARCH_VECTOR = "to_tsvector('english'::regconfig,coalesce(title,'') || ' ' || coalesce(abstract,''))"
|
||||||
|
|
||||||
@@ -44,9 +45,9 @@ class Row(Mapping):
|
|||||||
return len(self.columns)
|
return len(self.columns)
|
||||||
|
|
||||||
|
|
||||||
def row_factory(cursor):
|
def row_factory(cursor, *, encoded=False):
|
||||||
columns = [column.name for column in cursor.description] if cursor.description else []
|
columns = [column.name for column in cursor.description] if cursor.description else []
|
||||||
return lambda values: Row(columns, values)
|
return lambda values: Row(columns, tuple(map(text.decode, values)) if encoded else values)
|
||||||
|
|
||||||
|
|
||||||
class ReadConnection:
|
class ReadConnection:
|
||||||
@@ -59,10 +60,21 @@ class ReadConnection:
|
|||||||
from paper_atlas.postgres.schema import quote
|
from paper_atlas.postgres.schema import quote
|
||||||
if schema is not None:
|
if schema is not None:
|
||||||
self.connection.execute('SET LOCAL search_path TO ' + quote(schema) + ',pg_catalog')
|
self.connection.execute('SET LOCAL search_path TO ' + quote(schema) + ',pg_catalog')
|
||||||
self.connection.row_factory = row_factory
|
|
||||||
if not self.has_table('papers'):
|
if not self.has_table('papers'):
|
||||||
self.close()
|
self.close()
|
||||||
raise RuntimeError('PostgreSQL publication is not ready in the configured search_path')
|
raise RuntimeError('PostgreSQL publication is not ready in the configured search_path')
|
||||||
|
current = self.connection.execute('SELECT current_schema()').fetchone()[0]
|
||||||
|
# Schema comments are readable by other projects granted table access;
|
||||||
|
# consumers need no privileges on the private migration controller.
|
||||||
|
annotation = self.connection.execute("SELECT obj_description(oid,'pg_namespace') FROM pg_namespace WHERE nspname=%s", (current,)).fetchone()[0]
|
||||||
|
self.encoded = False
|
||||||
|
if annotation and annotation.startswith('paper-atlas:text-codec='):
|
||||||
|
codec = annotation.removeprefix('paper-atlas:text-codec=')
|
||||||
|
if codec != text.CODEC:
|
||||||
|
self.close()
|
||||||
|
raise RuntimeError('Unsupported PostgreSQL text storage codec')
|
||||||
|
self.encoded = codec == text.CODEC
|
||||||
|
self.connection.row_factory = lambda cursor: row_factory(cursor, encoded=self.encoded)
|
||||||
|
|
||||||
def execute(self, query, params=()):
|
def execute(self, query, params=()):
|
||||||
# All caller SQL is static; user values are always bound parameters.
|
# All caller SQL is static; user values are always bound parameters.
|
||||||
@@ -73,7 +85,8 @@ class ReadConnection:
|
|||||||
return self.connection.execute('''SELECT 1 FROM information_schema.tables
|
return self.connection.execute('''SELECT 1 FROM information_schema.tables
|
||||||
WHERE table_schema=current_schema() AND table_name=%s''',
|
WHERE table_schema=current_schema() AND table_name=%s''',
|
||||||
(match[1],))
|
(match[1],))
|
||||||
return self.connection.execute(query.replace('?', '%s'), params)
|
values = tuple(map(text.encode, params)) if self.encoded else params
|
||||||
|
return self.connection.execute(query.replace('?', '%s'), values)
|
||||||
|
|
||||||
def has_table(self, name):
|
def has_table(self, name):
|
||||||
return bool(self.connection.execute('''SELECT 1 FROM information_schema.tables
|
return bool(self.connection.execute('''SELECT 1 FROM information_schema.tables
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import zlib
|
|||||||
from paper_atlas.db.submissions import timestamp
|
from paper_atlas.db.submissions import timestamp
|
||||||
from paper_atlas.postgres.client import connect, SEARCH_VECTOR
|
from paper_atlas.postgres.client import connect, SEARCH_VECTOR
|
||||||
from paper_atlas.postgres.schema import TABLES, inspect, fingerprint, quote, select_sql, table_sql
|
from paper_atlas.postgres.schema import TABLES, inspect, fingerprint, quote, select_sql, table_sql
|
||||||
|
from paper_atlas.postgres import text
|
||||||
|
|
||||||
|
|
||||||
def digest_file(path):
|
def digest_file(path):
|
||||||
@@ -96,6 +97,41 @@ def sequences(pg, source, stage, plan):
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade_text_codec(pg, control, stage, plan):
|
||||||
|
"""Upgrade committed legacy staging rows once; never recopy their source."""
|
||||||
|
changed = {}
|
||||||
|
with pg.transaction():
|
||||||
|
copied = {r[0] for r in pg.execute('SELECT name FROM ' + qualify(control, 'tables'))}
|
||||||
|
if not copied <= plan.keys():
|
||||||
|
raise ValueError('Unknown committed staging tables')
|
||||||
|
# An older run may have reached constraint validation. Replacing SOH
|
||||||
|
# can temporarily equal another legacy key even though the final mapping
|
||||||
|
# is bijective. Rebuild unpublished constraints/indexes after the upgrade.
|
||||||
|
constraints = pg.execute('''SELECT cl.relname,co.conname FROM pg_constraint co
|
||||||
|
JOIN pg_class cl ON cl.oid=co.conrelid JOIN pg_namespace ns ON ns.oid=cl.relnamespace
|
||||||
|
WHERE ns.nspname=%s AND co.contype IN ('f','p','u')
|
||||||
|
ORDER BY CASE WHEN co.contype='f' THEN 0 ELSE 1 END''', (stage,)).fetchall()
|
||||||
|
for table, name in constraints:
|
||||||
|
pg.execute('ALTER TABLE ' + qualify(stage, table) + ' DROP CONSTRAINT ' + quote(name))
|
||||||
|
indexes = pg.execute('''SELECT cl.relname FROM pg_index i JOIN pg_class cl ON cl.oid=i.indexrelid
|
||||||
|
JOIN pg_namespace ns ON ns.oid=cl.relnamespace WHERE ns.nspname=%s AND i.indisunique''', (stage,)).fetchall()
|
||||||
|
for name, in indexes:
|
||||||
|
pg.execute('DROP INDEX ' + qualify(stage, name))
|
||||||
|
for table in plan:
|
||||||
|
if table not in copied:
|
||||||
|
continue
|
||||||
|
columns = [quote(c['name']) for c in plan[table]['columns'] if c['type'] == 'TEXT']
|
||||||
|
if columns:
|
||||||
|
assignments = ','.join(c + '=replace(' + c + ',%s,%s)' for c in columns)
|
||||||
|
predicate = ' OR '.join('strpos(' + c + ',%s)>0' for c in columns)
|
||||||
|
params = [v for c in columns for v in (text.ESCAPE, text.encode(text.ESCAPE))]
|
||||||
|
params += [text.ESCAPE] * len(columns)
|
||||||
|
changed[table] = pg.execute('UPDATE ' + qualify(stage, table) + ' SET ' + assignments +
|
||||||
|
' WHERE ' + predicate, params).rowcount
|
||||||
|
pg.execute('UPDATE ' + qualify(control, 'run') + ' SET text_codec=%s WHERE id=1', (text.CODEC,))
|
||||||
|
return changed
|
||||||
|
|
||||||
|
|
||||||
def migrate(db, service, out, *, schema='research', storage_path=None, min_free_gib=8):
|
def migrate(db, service, out, *, schema='research', storage_path=None, min_free_gib=8):
|
||||||
if not re.fullmatch(r'[a-z][a-z0-9_]{0,35}', schema):
|
if not re.fullmatch(r'[a-z][a-z0-9_]{0,35}', schema):
|
||||||
raise ValueError('Use a short lowercase target schema name')
|
raise ValueError('Use a short lowercase target schema name')
|
||||||
@@ -129,20 +165,28 @@ def migrate(db, service, out, *, schema='research', storage_path=None, min_free_
|
|||||||
'(id INTEGER PRIMARY KEY CHECK(id=1), source_json TEXT NOT NULL, stage TEXT NOT NULL, status TEXT NOT NULL)')
|
'(id INTEGER PRIMARY KEY CHECK(id=1), source_json TEXT NOT NULL, stage TEXT NOT NULL, status TEXT NOT NULL)')
|
||||||
pg.execute('CREATE TABLE IF NOT EXISTS ' + qualify(control, 'tables') +
|
pg.execute('CREATE TABLE IF NOT EXISTS ' + qualify(control, 'tables') +
|
||||||
'(name TEXT PRIMARY KEY, rows BIGINT NOT NULL, sha256 TEXT NOT NULL, verified BOOLEAN NOT NULL DEFAULT false)')
|
'(name TEXT PRIMARY KEY, rows BIGINT NOT NULL, sha256 TEXT NOT NULL, verified BOOLEAN NOT NULL DEFAULT false)')
|
||||||
run = pg.execute('SELECT source_json,stage,status FROM ' + qualify(control, 'run') + ' WHERE id=1').fetchone()
|
pg.execute('ALTER TABLE ' + qualify(control, 'run') + " ADD COLUMN IF NOT EXISTS text_codec TEXT NOT NULL DEFAULT 'legacy'")
|
||||||
|
run = pg.execute('SELECT source_json,stage,status,text_codec FROM ' + qualify(control, 'run') + ' WHERE id=1').fetchone()
|
||||||
if run:
|
if run:
|
||||||
if json.loads(run[0]) != identity or run[2] == 'complete':
|
if json.loads(run[0]) != identity or run[2] == 'complete':
|
||||||
raise ValueError('Resume requires the exact same unpublished SQLite snapshot')
|
raise ValueError('Resume requires the exact same unpublished SQLite snapshot')
|
||||||
stage = run[1]
|
stage = run[1]
|
||||||
|
if run[3] == 'legacy':
|
||||||
|
report['legacy_text_upgrade_rows'] = upgrade_text_codec(pg, control, stage, plan)
|
||||||
|
elif run[3] != text.CODEC:
|
||||||
|
raise ValueError('Unsupported staging text storage codec')
|
||||||
else:
|
else:
|
||||||
stage = schema + '_stage_' + uuid.uuid4().hex[:12]
|
stage = schema + '_stage_' + uuid.uuid4().hex[:12]
|
||||||
with pg.transaction():
|
with pg.transaction():
|
||||||
pg.execute('CREATE SCHEMA ' + quote(stage))
|
pg.execute('CREATE SCHEMA ' + quote(stage))
|
||||||
for table, spec in plan.items():
|
for table, spec in plan.items():
|
||||||
pg.execute(table_sql(stage, table, spec))
|
pg.execute(table_sql(stage, table, spec))
|
||||||
pg.execute('INSERT INTO ' + qualify(control, 'run') + ' VALUES(1,%s,%s,%s)',
|
pg.execute('INSERT INTO ' + qualify(control, 'run') +
|
||||||
(json.dumps(identity, sort_keys=True), stage, 'copying'))
|
'(id,source_json,stage,status,text_codec) VALUES(1,%s,%s,%s,%s)',
|
||||||
report.update(status='copying', stage=stage); save(out, report)
|
(json.dumps(identity, sort_keys=True), stage, 'copying', text.CODEC))
|
||||||
|
report.update(status='copying', stage=stage, text_codec=text.CODEC,
|
||||||
|
content_hash_scope='Decoded original SQLite values; storage escapes are reversible.')
|
||||||
|
save(out, report)
|
||||||
try:
|
try:
|
||||||
for table, spec in plan.items():
|
for table, spec in plan.items():
|
||||||
free_space(storage_path, min_free_gib)
|
free_space(storage_path, min_free_gib)
|
||||||
@@ -157,7 +201,7 @@ def migrate(db, service, out, *, schema='research', storage_path=None, min_free_
|
|||||||
columns = ','.join(quote(c['name']) for c in spec['columns'])
|
columns = ','.join(quote(c['name']) for c in spec['columns'])
|
||||||
with pg.cursor().copy('COPY ' + qualify(stage, table) + '(' + columns + ') FROM STDIN') as copy:
|
with pg.cursor().copy('COPY ' + qualify(stage, table) + '(' + columns + ') FROM STDIN') as copy:
|
||||||
for row in source.execute(select_sql(None, table, spec)):
|
for row in source.execute(select_sql(None, table, spec)):
|
||||||
copy.write_row(row); update_hash(digest, row); rows += 1
|
copy.write_row(tuple(map(text.encode, row))); update_hash(digest, row); rows += 1
|
||||||
if rows % 10000 == 0:
|
if rows % 10000 == 0:
|
||||||
free_space(storage_path, min_free_gib)
|
free_space(storage_path, min_free_gib)
|
||||||
if rows % 100000 == 0:
|
if rows % 100000 == 0:
|
||||||
@@ -172,14 +216,16 @@ def migrate(db, service, out, *, schema='research', storage_path=None, min_free_
|
|||||||
report['sequences'] = sequences(pg, source, stage, plan)
|
report['sequences'] = sequences(pg, source, stage, plan)
|
||||||
report['status'] = 'verifying_contents'; save(out, report)
|
report['status'] = 'verifying_contents'; save(out, report)
|
||||||
for table, spec in plan.items():
|
for table, spec in plan.items():
|
||||||
digest = hashlib.sha256(); rows = 0
|
digest = hashlib.sha256(); storage_digest = hashlib.sha256(); rows = 0
|
||||||
with pg.transaction(), pg.cursor(name='verify_' + table) as cursor:
|
with pg.transaction(), pg.cursor(name='verify_' + table) as cursor:
|
||||||
cursor.itersize = 512
|
cursor.itersize = 512
|
||||||
cursor.execute(select_sql(stage, table, spec))
|
cursor.execute(select_sql(stage, table, spec))
|
||||||
for row in cursor:
|
for row in cursor:
|
||||||
update_hash(digest, row); rows += 1
|
update_hash(storage_digest, row)
|
||||||
|
update_hash(digest, tuple(map(text.decode, row))); rows += 1
|
||||||
entry = report['tables'][table]
|
entry = report['tables'][table]
|
||||||
entry.update(target_rows=rows, target_sha256=digest.hexdigest())
|
entry.update(target_rows=rows, target_sha256=digest.hexdigest(),
|
||||||
|
target_storage_sha256=storage_digest.hexdigest())
|
||||||
if rows != entry['source_rows'] or digest.hexdigest() != entry['source_sha256']:
|
if rows != entry['source_rows'] or digest.hexdigest() != entry['source_sha256']:
|
||||||
raise ValueError('Complete content verification failed for ' + table)
|
raise ValueError('Complete content verification failed for ' + table)
|
||||||
pg.execute('UPDATE ' + qualify(control, 'tables') + ' SET verified=true WHERE name=%s', (table,))
|
pg.execute('UPDATE ' + qualify(control, 'tables') + ' SET verified=true WHERE name=%s', (table,))
|
||||||
@@ -207,6 +253,7 @@ def migrate(db, service, out, *, schema='research', storage_path=None, min_free_
|
|||||||
report.update(status='prepared', foreign_keys='all_validated', checks='all_validated', native_search_index=True)
|
report.update(status='prepared', foreign_keys='all_validated', checks='all_validated', native_search_index=True)
|
||||||
save(out, report)
|
save(out, report)
|
||||||
with pg.transaction():
|
with pg.transaction():
|
||||||
|
pg.execute('COMMENT ON SCHEMA ' + quote(stage) + " IS 'paper-atlas:text-codec=" + text.CODEC + "'")
|
||||||
pg.execute('ALTER SCHEMA ' + quote(stage) + ' RENAME TO ' + quote(schema))
|
pg.execute('ALTER SCHEMA ' + quote(stage) + ' RENAME TO ' + quote(schema))
|
||||||
pg.execute('UPDATE ' + qualify(control, 'run') + " SET status='complete' WHERE id=1")
|
pg.execute('UPDATE ' + qualify(control, 'run') + " SET status='complete' WHERE id=1")
|
||||||
report.update(status='complete', finished_at=timestamp())
|
report.update(status='complete', finished_at=timestamp())
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"""Order-preserving, reversible storage for SQLite text containing NUL.
|
||||||
|
|
||||||
|
PostgreSQL text forbids U+0000. Encode it as SOH/SOH, and encode a literal
|
||||||
|
SOH as SOH/STX. All other characters stay unchanged. The mapping preserves
|
||||||
|
binary lexicographic order, including string prefixes and composite keys.
|
||||||
|
"""
|
||||||
|
|
||||||
|
CODEC = 'nul-soh-v1'
|
||||||
|
ESCAPE = '\x01'
|
||||||
|
|
||||||
|
|
||||||
|
def encode(value):
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value.replace(ESCAPE, ESCAPE + '\x02').replace('\x00', ESCAPE + ESCAPE)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def decode(value):
|
||||||
|
if not isinstance(value, str) or ESCAPE not in value:
|
||||||
|
return value
|
||||||
|
result = []
|
||||||
|
start = 0
|
||||||
|
while (position := value.find(ESCAPE, start)) >= 0:
|
||||||
|
result.append(value[start:position])
|
||||||
|
following = value[position + 1:position + 2]
|
||||||
|
if following not in (ESCAPE, '\x02'):
|
||||||
|
raise ValueError('Invalid nul-soh-v1 text escape')
|
||||||
|
result.append('\x00' if following == ESCAPE else ESCAPE)
|
||||||
|
start = position + 2
|
||||||
|
result.append(value[start:])
|
||||||
|
return ''.join(result)
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"""Native transactional writes reusing the shared identity/provenance policy."""
|
||||||
|
import re
|
||||||
|
import zlib
|
||||||
|
|
||||||
|
from paper_atlas.db.store import Store
|
||||||
|
from paper_atlas.postgres import text
|
||||||
|
from paper_atlas.postgres.client import connect
|
||||||
|
from paper_atlas.postgres.schema import TABLES, quote
|
||||||
|
|
||||||
|
|
||||||
|
class Result:
|
||||||
|
def __init__(self, cursor):
|
||||||
|
self.cursor = cursor
|
||||||
|
|
||||||
|
def fetchone(self):
|
||||||
|
row = self.cursor.fetchone()
|
||||||
|
return tuple(map(text.decode, row)) if row is not None else None
|
||||||
|
|
||||||
|
def fetchall(self):
|
||||||
|
return [tuple(map(text.decode, row)) for row in self.cursor.fetchall()]
|
||||||
|
|
||||||
|
def __iter__(self):
|
||||||
|
return (tuple(map(text.decode, row)) for row in self.cursor)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def rowcount(self):
|
||||||
|
return self.cursor.rowcount
|
||||||
|
|
||||||
|
|
||||||
|
class WriteConnection:
|
||||||
|
postgres = True
|
||||||
|
|
||||||
|
def __init__(self, service, schema=None):
|
||||||
|
self.connection = connect(service)
|
||||||
|
try:
|
||||||
|
if schema is None:
|
||||||
|
schema = self.connection.execute('SELECT current_schema()').fetchone()[0]
|
||||||
|
if not isinstance(schema, str) or not re.fullmatch(r'[a-z][a-z0-9_]{0,35}', schema):
|
||||||
|
raise ValueError('Invalid PostgreSQL write schema')
|
||||||
|
annotation = self.connection.execute("SELECT obj_description(oid,'pg_namespace') FROM pg_namespace WHERE nspname=%s", (schema,)).fetchone()
|
||||||
|
if not annotation or annotation[0] != 'paper-atlas:text-codec=' + text.CODEC:
|
||||||
|
raise RuntimeError('Writes require a published dataset with the supported text codec')
|
||||||
|
tables = {r[0] for r in self.connection.execute("SELECT table_name FROM information_schema.tables WHERE table_schema=%s AND table_type='BASE TABLE'", (schema,))}
|
||||||
|
if not set(TABLES) <= tables:
|
||||||
|
raise RuntimeError('Published business tables are incomplete or inaccessible')
|
||||||
|
if not self.connection.execute('SELECT pg_try_advisory_lock(%s,%s)', (615174, zlib.crc32(schema.encode()) & 0x7fffffff)).fetchone()[0]:
|
||||||
|
raise RuntimeError('Another writer holds this PostgreSQL dataset; retry after it closes')
|
||||||
|
self.schema = schema
|
||||||
|
self.connection.execute('SET search_path TO ' + quote(schema) + ',pg_catalog')
|
||||||
|
if not self.connection.execute("SELECT 1 FROM schema_migrations WHERE name='submission_source_baseline_v1'").fetchone():
|
||||||
|
raise RuntimeError('Submission provenance baseline is missing')
|
||||||
|
self.connection.autocommit = False
|
||||||
|
except BaseException:
|
||||||
|
self.connection.close()
|
||||||
|
raise
|
||||||
|
|
||||||
|
def execute(self, sql, params=()):
|
||||||
|
# The shared domain uses static SQL and bound parameters. It uses no
|
||||||
|
# SQLite-specific syntax; this adapter only binds/decodes text values.
|
||||||
|
return Result(self.connection.execute(sql.replace('?', '%s'), tuple(map(text.encode, params))))
|
||||||
|
|
||||||
|
def next_id(self, table):
|
||||||
|
if table not in ('submissions', 'decisions'):
|
||||||
|
raise ValueError('Unsupported explicit identity sequence')
|
||||||
|
return self.connection.execute("SELECT nextval(pg_get_serial_sequence(%s,'id'))", (quote(self.schema) + '.' + quote(table),)).fetchone()[0]
|
||||||
|
|
||||||
|
def commit(self):
|
||||||
|
self.connection.commit()
|
||||||
|
|
||||||
|
def rollback(self):
|
||||||
|
self.connection.rollback()
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
# Ends the session and releases its advisory lock, including after an
|
||||||
|
# error/rollback. File/chunk commits deliberately retain the writer lock.
|
||||||
|
self.connection.close()
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, kind, error, traceback):
|
||||||
|
self.rollback() if kind else self.commit()
|
||||||
|
|
||||||
|
|
||||||
|
class PostgresStore(Store):
|
||||||
|
def __init__(self, service, schema=None):
|
||||||
|
self.conn = WriteConnection(service, schema)
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from paper_atlas.audits.histories import report
|
||||||
|
from paper_atlas.db.store import Store
|
||||||
|
|
||||||
|
|
||||||
|
def add_history_fixture(store):
|
||||||
|
def event(work, forum, venue, year, track, status, observed):
|
||||||
|
paper, record = store.record('history_fixture', work + '/' + forum,
|
||||||
|
'https://example.org/' + forum, work + ' title\x00 preserved', 'Abstract', ['Jane Doe'],
|
||||||
|
{'arxiv': work})
|
||||||
|
store.decision(paper, record, venue, year, track, forum,
|
||||||
|
'https://example.org/' + forum + '/' + str(status), status, status or '',
|
||||||
|
identities=[('openreview', forum)], observed_at=observed)
|
||||||
|
return paper
|
||||||
|
|
||||||
|
# The negative observation was collected later: observation order is not chronology.
|
||||||
|
event('2501.00001', 'HistoryA01', 'ICLR', 2024, 'main', 'rejected', '2026-02-01T00:00:00Z')
|
||||||
|
event('2501.00001', 'HistoryA02', 'ICML', 2025, 'main', 'accepted', '2026-01-01T00:00:00Z')
|
||||||
|
event('2501.00002', 'HistoryB01', 'ICLR', 2024, 'main', 'rejected', '2026-01-01T00:00:00Z')
|
||||||
|
event('2501.00002', 'HistoryB01', 'ICLR', 2024, 'main', 'accepted', '2026-02-01T00:00:00Z')
|
||||||
|
event('2501.00003', 'HistoryC01', 'ICLR', 2024, 'main', 'desk_rejected', '2026-01-01T00:00:00Z')
|
||||||
|
event('2501.00003', 'HistoryC02', 'ICLR', 2024, 'workshop_example', 'accepted', '2026-02-01T00:00:00Z')
|
||||||
|
event('2501.00004', 'HistoryD01', 'ICML', 2025, 'main', 'accepted', '2026-01-01T00:00:00Z')
|
||||||
|
event('2501.00004', 'HistoryD01', 'ICML', 2025, 'main', 'withdrawn', '2026-02-01T00:00:00Z')
|
||||||
|
event('2501.00004', 'HistoryD02', 'ICLR', 2024, 'main', 'rejected', '2026-01-01T00:00:00Z')
|
||||||
|
event('2501.00005', 'HistoryE01', 'COLM', 2026, 'main', None, '2026-01-01T00:00:00Z')
|
||||||
|
store.conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
class HistoryTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(self.tmp.cleanup)
|
||||||
|
self.db = Path(self.tmp.name) / 'sample.sqlite'
|
||||||
|
self.store = Store(self.db)
|
||||||
|
self.addCleanup(self.store.conn.close)
|
||||||
|
|
||||||
|
def test_distinct_attempts_venues_and_latest_acceptances_are_not_observation_counts(self):
|
||||||
|
add_history_fixture(self.store)
|
||||||
|
result = report(self.db)
|
||||||
|
self.assertEqual(result['counts'], {
|
||||||
|
'papers_with_submissions': 5, 'papers_with_multiple_submissions': 3,
|
||||||
|
'papers_with_historical_negative': 4, 'papers_with_current_known_acceptance': 3,
|
||||||
|
'papers_with_negative_and_acceptance_in_distinct_attempts': 2,
|
||||||
|
'papers_with_negative_and_acceptance_in_distinct_venues': 1,
|
||||||
|
'submissions': 8, 'decision_observations': 9,
|
||||||
|
'attempts_with_historical_negative': 5,
|
||||||
|
'attempts_with_historical_negative_and_acceptance': 2,
|
||||||
|
})
|
||||||
|
self.assertEqual(result['latest_attempt_status_counts'],
|
||||||
|
{'accepted': 3, 'desk_rejected': 1, 'rejected': 2, 'unknown': 1, 'withdrawn': 1})
|
||||||
|
self.assertEqual(len(result['examples']), 2)
|
||||||
|
first = result['examples'][0]
|
||||||
|
self.assertIn('\x00', first['title'])
|
||||||
|
self.assertEqual({r['venue'] for r in first['attempts']}, {'ICLR', 'ICML'})
|
||||||
|
self.assertTrue(all(r['decisions'][0]['sources'] for r in first['attempts']))
|
||||||
|
self.assertTrue(all(r['decisions'][0]['decision_date'] is None for r in first['attempts']))
|
||||||
|
self.assertIn('Not inferred', first['chronology'])
|
||||||
|
self.assertEqual(result['status'], 'observed_history_audit_not_final_dedup')
|
||||||
|
self.assertTrue(all(v == 0 for v in result['evidence_relationship_checks'].values()))
|
||||||
|
|
||||||
|
def test_missing_and_mislinked_source_evidence_is_reported_without_repair(self):
|
||||||
|
add_history_fixture(self.store)
|
||||||
|
self.store.conn.execute('DELETE FROM decision_sources WHERE decision_id=1')
|
||||||
|
self.store.conn.execute('DELETE FROM submission_sources WHERE submission_id=1')
|
||||||
|
# Record3 belongs to paper2; move it to paper1 to create a real mismatch.
|
||||||
|
self.store.conn.execute('UPDATE source_records SET paper_id=1 WHERE id=3')
|
||||||
|
self.store.conn.commit()
|
||||||
|
result = report(self.db, examples=0)
|
||||||
|
checks = result['evidence_relationship_checks']
|
||||||
|
self.assertEqual(checks['decisions_without_source_links'], 1)
|
||||||
|
self.assertEqual(checks['submissions_without_source_links'], 1)
|
||||||
|
self.assertEqual(checks['submission_source_paper_mismatches'], 1)
|
||||||
|
self.assertEqual(checks['decision_source_paper_mismatches'], 2)
|
||||||
|
self.assertEqual(self.store.conn.execute('SELECT paper_id FROM source_records WHERE id=3').fetchone()[0], 1)
|
||||||
|
|
||||||
|
def test_example_limit_does_not_change_global_counts(self):
|
||||||
|
add_history_fixture(self.store)
|
||||||
|
none, one = report(self.db, examples=0), report(self.db, examples=1)
|
||||||
|
self.assertEqual(none['counts'], one['counts'])
|
||||||
|
self.assertEqual(none['examples'], [])
|
||||||
|
self.assertEqual(len(one['examples']), 1)
|
||||||
|
|
||||||
|
def test_empty_snapshot_and_invalid_limit_do_not_create_missing_database(self):
|
||||||
|
empty = report(self.db)
|
||||||
|
self.assertTrue(all(v == 0 for v in empty['counts'].values()))
|
||||||
|
self.assertEqual(empty['latest_attempt_status_counts'], {})
|
||||||
|
absent = self.db.parent / 'absent.sqlite'
|
||||||
|
with self.assertRaisesRegex(ValueError, 'Examples'):
|
||||||
|
report(absent, examples=-1)
|
||||||
|
with self.assertRaisesRegex(ValueError, 'Existing database'):
|
||||||
|
report(absent)
|
||||||
|
self.assertFalse(absent.exists())
|
||||||
|
|
||||||
|
def test_truncated_example_keeps_the_cross_venue_acceptance_visible(self):
|
||||||
|
paper, record = self.store.record('fixture', 'many', 'https://example.org/many',
|
||||||
|
'Many attempts', 'Abstract', [])
|
||||||
|
for index in range(26):
|
||||||
|
accepted = index == 25
|
||||||
|
forum = f'BoundedForum{index}'
|
||||||
|
self.store.decision(paper, record, 'ICML' if accepted else 'ICLR', 2025, 'main', forum,
|
||||||
|
'https://example.org/' + forum, 'accepted' if accepted else 'rejected',
|
||||||
|
'Accept' if accepted else 'Reject', identities=[('openreview', forum)])
|
||||||
|
self.store.conn.commit()
|
||||||
|
result = report(self.db)
|
||||||
|
example, = result['examples']
|
||||||
|
self.assertEqual((example['attempt_count'], len(example['attempts'])), (26, 20))
|
||||||
|
self.assertTrue(example['attempts_truncated'])
|
||||||
|
self.assertEqual({row['latest_observed_status'] for row in example['attempts']},
|
||||||
|
{'accepted', 'rejected'})
|
||||||
|
self.assertEqual(result['counts']['submissions'], 26)
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
from contextlib import redirect_stdout
|
||||||
|
import hashlib
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import sqlite3
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from paper_atlas.ingestion.records import ingest_file
|
||||||
|
|
||||||
|
|
||||||
|
class IncrementalCoverageTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(self.tmp.cleanup)
|
||||||
|
self.root = Path(self.tmp.name)
|
||||||
|
self.db = self.root / 'papers.sqlite'
|
||||||
|
|
||||||
|
def file(self, name, external_ids, **options):
|
||||||
|
rows = [{'source':'example','external_id':key,'url':'https://example.org/'+key,
|
||||||
|
'venue':'Example','year':2026,'track':'main','status':'accepted',
|
||||||
|
'title':'Independent research contribution '+key,'abstract':'Known abstract',
|
||||||
|
'authors':[], 'identifiers':{'doi':'10.1234/'+key}} for key in external_ids]
|
||||||
|
path = self.root / (name+'.jsonl')
|
||||||
|
path.write_bytes((''.join(json.dumps(row)+'\n' for row in rows)).encode())
|
||||||
|
manifest = {key:rows[0][key] for key in ('source','venue','year','track')}
|
||||||
|
manifest.update(list_url='https://example.org/2026', count=len(rows),status='partial',
|
||||||
|
file_sha256=hashlib.sha256(path.read_bytes()).hexdigest())
|
||||||
|
manifest.update(options)
|
||||||
|
path.with_suffix('.manifest.json').write_text(json.dumps(manifest))
|
||||||
|
return path
|
||||||
|
|
||||||
|
def ingest(self, path):
|
||||||
|
with redirect_stdout(io.StringIO()):
|
||||||
|
ingest_file(self.db,path)
|
||||||
|
|
||||||
|
def incremental(self, previous=2, prior_expected=2, expected=4):
|
||||||
|
return {'coverage_mode':'incremental','coverage_previous_count':previous,
|
||||||
|
'coverage_previous_expected_count':prior_expected,'coverage_expected_count':expected}
|
||||||
|
|
||||||
|
def coverage(self):
|
||||||
|
with sqlite3.connect(self.db) as c:
|
||||||
|
return c.execute('SELECT status,expected_count,imported_count FROM edition_sources').fetchall()
|
||||||
|
|
||||||
|
def test_append_and_refresh_count_actual_source_records_without_erasing_earlier_batch(self):
|
||||||
|
self.ingest(self.file('first',['a','b']))
|
||||||
|
self.ingest(self.file('append',['c'],**self.incremental()))
|
||||||
|
self.assertEqual(self.coverage(),[('partial',4,3)])
|
||||||
|
self.ingest(self.file('refresh',['c'],**self.incremental(3,4,4)))
|
||||||
|
self.assertEqual(self.coverage(),[('partial',4,3)])
|
||||||
|
with sqlite3.connect(self.db) as c:
|
||||||
|
self.assertEqual(c.execute('SELECT count(*) FROM source_records').fetchone()[0],3)
|
||||||
|
self.assertEqual(c.execute('SELECT count(*) FROM submissions').fetchone()[0],3)
|
||||||
|
runs = c.execute('SELECT records_written,checkpoint_json FROM ingest_runs ORDER BY id').fetchall()
|
||||||
|
self.assertEqual([r[0] for r in runs],[2,1,1])
|
||||||
|
self.assertEqual(json.loads(runs[-1][1])['coverage'],
|
||||||
|
{'mode':'incremental','previous_count':3,'expected_count':4,'imported_count':3})
|
||||||
|
self.assertEqual(c.execute('PRAGMA foreign_key_check').fetchall(),[])
|
||||||
|
|
||||||
|
def test_stale_parent_stops_before_source_writes(self):
|
||||||
|
self.ingest(self.file('first',['a','b']))
|
||||||
|
with self.assertRaisesRegex(ValueError,'parent changed'):
|
||||||
|
self.ingest(self.file('stale',['c'],**self.incremental(1,2,4)))
|
||||||
|
self.assertEqual(self.coverage(),[('partial',2,2)])
|
||||||
|
with sqlite3.connect(self.db) as c:
|
||||||
|
self.assertEqual(c.execute("SELECT count(*) FROM source_records WHERE external_id='c'").fetchone()[0],0)
|
||||||
|
self.assertEqual(c.execute('SELECT status FROM ingest_runs ORDER BY id DESC LIMIT 1').fetchone()[0],'failed')
|
||||||
|
|
||||||
|
def test_late_source_bound_failure_rolls_back_new_records_and_coverage(self):
|
||||||
|
self.ingest(self.file('first',['a','b']))
|
||||||
|
with self.assertRaisesRegex(ValueError,'source bounds'):
|
||||||
|
self.ingest(self.file('overflow',['c'],**self.incremental(2,2,2)))
|
||||||
|
self.assertEqual(self.coverage(),[('partial',2,2)])
|
||||||
|
with sqlite3.connect(self.db) as c:
|
||||||
|
for table in ('papers','source_records','submissions','decisions'):
|
||||||
|
self.assertEqual(c.execute('SELECT count(*) FROM '+table).fetchone()[0],2)
|
||||||
|
|
||||||
|
def test_multiple_listings_cannot_be_silently_aggregated(self):
|
||||||
|
self.ingest(self.file('one',['a']))
|
||||||
|
self.ingest(self.file('two',['b'],list_url='https://example.org/another-list'))
|
||||||
|
before=self.coverage()
|
||||||
|
with self.assertRaisesRegex(ValueError,'unambiguous source listing'):
|
||||||
|
self.ingest(self.file('ambiguous',['c'],**self.incremental(2,1,4)))
|
||||||
|
self.assertEqual(self.coverage(),before)
|
||||||
|
|
||||||
|
def test_untracked_source_rows_require_reconciliation(self):
|
||||||
|
self.ingest(self.file('first',['a','b']))
|
||||||
|
with sqlite3.connect(self.db) as c:
|
||||||
|
c.execute('UPDATE edition_sources SET imported_count=1')
|
||||||
|
with self.assertRaisesRegex(ValueError,'actual source records'):
|
||||||
|
self.ingest(self.file('stale-summary',['c'],**self.incremental(1,2,4)))
|
||||||
|
self.assertEqual(self.coverage(),[('partial',2,1)])
|
||||||
|
|
||||||
|
def test_first_partial_chunk_can_declare_an_independently_known_source_total(self):
|
||||||
|
self.ingest(self.file('first',['a'],**self.incremental(0,None,4)))
|
||||||
|
self.assertEqual(self.coverage(),[('partial',4,1)])
|
||||||
|
|
||||||
|
def test_complete_listing_is_not_demoted_by_a_partial_chunk(self):
|
||||||
|
self.ingest(self.file('complete',['a','b'],status='complete'))
|
||||||
|
with self.assertRaisesRegex(ValueError,'not partial'):
|
||||||
|
self.ingest(self.file('append',['c'],**self.incremental()))
|
||||||
|
self.assertEqual(self.coverage(),[('complete',2,2)])
|
||||||
|
|
||||||
|
def test_invalid_mode_counts_and_missing_scope_fail_before_database_creation(self):
|
||||||
|
for options in ({'coverage_mode':'unknown'}, self.incremental(True,2,4),
|
||||||
|
self.incremental(2,5,4),self.incremental(-1,2,4),
|
||||||
|
{'coverage_mode':'incremental'},
|
||||||
|
{**self.incremental(),'status':'complete'}):
|
||||||
|
with self.subTest(options=options):
|
||||||
|
with self.assertRaises(ValueError):self.ingest(self.file('invalid',['a'],**options))
|
||||||
|
self.assertFalse(self.db.exists())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__=='__main__':
|
||||||
|
unittest.main()
|
||||||
@@ -53,6 +53,50 @@ class StoreTests(unittest.TestCase):
|
|||||||
self.assertNotEqual(other,paper)
|
self.assertNotEqual(other,paper)
|
||||||
self.assertEqual(self.store.conn.execute('SELECT count(*) FROM match_candidates').fetchone()[0],1)
|
self.assertEqual(self.store.conn.execute('SELECT count(*) FROM match_candidates').fetchone()[0],1)
|
||||||
|
|
||||||
|
def test_identifier_anchor_does_not_absorb_a_same_title_arxiv_version(self):
|
||||||
|
conference,first=self.record('arxiv','1408.1485')
|
||||||
|
extended,second=self.record('arxiv','cs/0307069')
|
||||||
|
self.store.decision(extended,second,'ICLR',2025,'main','separate','https://example.org/reject','rejected','Reject')
|
||||||
|
resolved,_=self.record('dblp','conf/uai/example',{'arxiv':'1408.1485'})
|
||||||
|
self.assertEqual(resolved,conference);self.assertNotEqual(conference,extended)
|
||||||
|
c=self.store.conn
|
||||||
|
self.assertEqual(c.execute('SELECT count(*) FROM papers').fetchone()[0],2)
|
||||||
|
self.assertEqual(c.execute('SELECT paper_id FROM submissions').fetchone()[0],extended)
|
||||||
|
self.assertEqual(c.execute('SELECT count(*) FROM paper_merges').fetchone()[0],0)
|
||||||
|
self.assertEqual(c.execute('SELECT count(*) FROM match_candidates').fetchone()[0],1)
|
||||||
|
# A refresh of the anchored original source is equally unambiguous.
|
||||||
|
self.assertEqual(self.record('arxiv','1408.1485')[0],conference)
|
||||||
|
self.assertEqual(c.execute('SELECT count(*) FROM papers').fetchone()[0],2)
|
||||||
|
|
||||||
|
def test_ambiguous_title_aborts_whole_file_without_merging_existing_entities(self):
|
||||||
|
self.record('arxiv','1408.1485');self.record('arxiv','cs/0307069')
|
||||||
|
self.store.conn.commit()
|
||||||
|
rows=[{'source':'dblp','external_id':'conf/uai/new','url':'https://example.org/new',
|
||||||
|
'title':'An unrelated earlier record in this file','abstract':None,'authors':[],
|
||||||
|
'venue':'UAI','year':2001,'track':'main','status':'accepted'},
|
||||||
|
{'source':'dblp','external_id':'conf/uai/ambiguous','url':'https://example.org/ambiguous',
|
||||||
|
'title':'A sufficiently long example research paper title','abstract':None,
|
||||||
|
'authors':['Jane Doe','John Smith'],'identifiers':{'doi':'10.0000/not-yet-known'},
|
||||||
|
'venue':'UAI','year':2001,'track':'main','status':'accepted'}]
|
||||||
|
path=Path(self.tmp.name)/'ambiguous.jsonl'
|
||||||
|
path.write_text(''.join(json.dumps(row)+'\n' for row in rows))
|
||||||
|
path.with_suffix('.manifest.json').write_text(json.dumps({'source':'dblp','list_url':'https://example.org/list','count':2}))
|
||||||
|
with self.assertRaisesRegex(ValueError,'Ambiguous paper identity'):ingest_file(self.db,path)
|
||||||
|
c=self.store.conn
|
||||||
|
for table,count in [('papers',2),('source_records',2),('submissions',0),('decisions',0),('paper_merges',0)]:
|
||||||
|
self.assertEqual(c.execute('SELECT count(*) FROM '+table).fetchone()[0],count,table)
|
||||||
|
self.assertEqual(c.execute('SELECT status FROM ingest_runs').fetchone()[0],'failed')
|
||||||
|
|
||||||
|
def test_explicit_bridge_merges_only_its_identifier_matches(self):
|
||||||
|
first,_=self.record('arxiv','2601.00001',authors=['First Author'])
|
||||||
|
second,_=self.record('arxiv','2601.00002',{'doi':'10.0000/bridge'},authors=['Second Author'])
|
||||||
|
unrelated,_=self.record('arxiv','2601.00003')
|
||||||
|
resolved,_=self.record('official','bridge',{'arxiv':'2601.00001','doi':'10.0000/bridge'})
|
||||||
|
self.assertEqual(resolved,min(first,second))
|
||||||
|
self.assertEqual(self.store.conn.execute('SELECT count(*) FROM papers').fetchone()[0],2)
|
||||||
|
self.assertIsNotNone(self.store.conn.execute('SELECT 1 FROM papers WHERE id=?',(unrelated,)).fetchone())
|
||||||
|
self.assertEqual(self.store.conn.execute('SELECT count(*) FROM paper_merges').fetchone()[0],1)
|
||||||
|
|
||||||
def test_manifest_count_mismatch_rolls_back_records(self):
|
def test_manifest_count_mismatch_rolls_back_records(self):
|
||||||
path=Path(self.tmp.name)/'source.jsonl'
|
path=Path(self.tmp.name)/'source.jsonl'
|
||||||
path.write_text(json.dumps({'source':'pmlr','external_id':'v1/paper','url':'https://example.org/paper','title':'Paper','abstract':'Abstract','authors':[],'venue':'ICML','year':2023,'track':'main','status':'accepted'})+'\n')
|
path.write_text(json.dumps({'source':'pmlr','external_id':'v1/paper','url':'https://example.org/paper','title':'Paper','abstract':'Abstract','authors':[],'venue':'ICML','year':2023,'track':'main','status':'accepted'})+'\n')
|
||||||
|
|||||||
+114
-1
@@ -1,6 +1,7 @@
|
|||||||
import contextlib
|
import contextlib
|
||||||
from contextlib import closing
|
from contextlib import closing
|
||||||
import io
|
import io
|
||||||
|
import itertools
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -11,7 +12,7 @@ from unittest.mock import patch
|
|||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from paper_atlas.db.store import Store
|
from paper_atlas.db.store import Store
|
||||||
from paper_atlas.postgres import schema
|
from paper_atlas.postgres import schema, text
|
||||||
from paper_atlas.postgres.client import ReadConnection, Row, connect
|
from paper_atlas.postgres.client import ReadConnection, Row, connect
|
||||||
from paper_atlas.postgres.migration import migrate
|
from paper_atlas.postgres.migration import migrate
|
||||||
from paper_atlas import querying
|
from paper_atlas import querying
|
||||||
@@ -68,6 +69,21 @@ class SchemaTests(unittest.TestCase):
|
|||||||
self.assertEqual(schema.normalize_check("x IN ( 'a' )"), schema.normalize_check("x in('a')"))
|
self.assertEqual(schema.normalize_check("x IN ( 'a' )"), schema.normalize_check("x in('a')"))
|
||||||
self.assertNotEqual(schema.normalize_check("x IN ('A')"), schema.normalize_check("x IN ('a')"))
|
self.assertNotEqual(schema.normalize_check("x IN ('A')"), schema.normalize_check("x IN ('a')"))
|
||||||
|
|
||||||
|
def test_text_codec_is_bijective_and_preserves_key_order_and_prefixes(self):
|
||||||
|
alphabet = ['\x00', '\x01', '\x02', 'a', 'é', '🙂']
|
||||||
|
values = [''.join(v) for n in range(4) for v in itertools.product(alphabet, repeat=n)]
|
||||||
|
for value in values:
|
||||||
|
encoded = text.encode(value)
|
||||||
|
self.assertNotIn('\x00', encoded)
|
||||||
|
self.assertEqual(text.decode(encoded), value)
|
||||||
|
self.assertEqual(sorted(map(text.encode, values)), list(map(text.encode, sorted(values))))
|
||||||
|
for value in [None, 7, 0.5]:
|
||||||
|
self.assertEqual(text.encode(value), value)
|
||||||
|
self.assertEqual(text.decode(value), value)
|
||||||
|
for value in ['\x01', 'prefix\x01x']:
|
||||||
|
with self.assertRaisesRegex(ValueError, 'Invalid'):
|
||||||
|
text.decode(value)
|
||||||
|
|
||||||
|
|
||||||
@unittest.skipUnless(os.environ.get('PAPER_ATLAS_TEST_PG_SERVICE'), 'Requires an explicitly configured remote PostgreSQL test service')
|
@unittest.skipUnless(os.environ.get('PAPER_ATLAS_TEST_PG_SERVICE'), 'Requires an explicitly configured remote PostgreSQL test service')
|
||||||
class PostgreSQLIntegrationTests(unittest.TestCase):
|
class PostgreSQLIntegrationTests(unittest.TestCase):
|
||||||
@@ -159,6 +175,22 @@ class PostgreSQLIntegrationTests(unittest.TestCase):
|
|||||||
finally:
|
finally:
|
||||||
reader.close()
|
reader.close()
|
||||||
|
|
||||||
|
def test_history_audit_matches_sqlite_for_actual_postgresql_snapshot(self):
|
||||||
|
from paper_atlas.audits.histories import report
|
||||||
|
from tests.test_histories import add_history_fixture
|
||||||
|
store = Store(self.db)
|
||||||
|
try:
|
||||||
|
add_history_fixture(store)
|
||||||
|
finally:
|
||||||
|
store.conn.close()
|
||||||
|
expected = report(self.db)
|
||||||
|
self.assertEqual(self.migrate()['status'], 'complete')
|
||||||
|
actual = report(pg_service=self.service, pg_schema=self.target)
|
||||||
|
self.assertEqual(actual['engine'], 'postgresql')
|
||||||
|
self.assertEqual(actual['schema'], self.target)
|
||||||
|
for key in ('counts', 'latest_attempt_status_counts', 'evidence_relationship_checks', 'examples'):
|
||||||
|
self.assertEqual(actual[key], expected[key], key)
|
||||||
|
|
||||||
def interrupt_after_first_table(self):
|
def interrupt_after_first_table(self):
|
||||||
calls = 0
|
calls = 0
|
||||||
def interrupt(*args):
|
def interrupt(*args):
|
||||||
@@ -207,6 +239,87 @@ class PostgreSQLIntegrationTests(unittest.TestCase):
|
|||||||
with self.assertRaisesRegex(ValueError, 'never be overwritten'):
|
with self.assertRaisesRegex(ValueError, 'never be overwritten'):
|
||||||
self.migrate()
|
self.migrate()
|
||||||
|
|
||||||
|
def test_nul_and_literal_escape_survive_full_copy_keys_query_and_hash(self):
|
||||||
|
original = 'before\x00survivor; literal\x01\x01 and \x01\x02 — 研究'
|
||||||
|
keys = ['a\x00z', 'a\x01z', 'a\x01\x02z', 'a\x02z']
|
||||||
|
with closing(sqlite3.connect(self.db)) as source, source:
|
||||||
|
source.execute('UPDATE papers SET abstract=? WHERE id=1', (original,))
|
||||||
|
source.execute('UPDATE source_records SET abstract=? WHERE id=1', (original,))
|
||||||
|
source.executemany("INSERT INTO identifiers VALUES('control',?,1)", [(k,) for k in keys])
|
||||||
|
source.execute("INSERT INTO tags(namespace,value) VALUES('custom:control',?)", (original,))
|
||||||
|
tag = source.execute("SELECT id FROM tags WHERE namespace='custom:control'").fetchone()[0]
|
||||||
|
source.executemany('INSERT INTO paper_tags VALUES(1,?,NULL,?)', [(tag, k) for k in keys])
|
||||||
|
report = self.migrate()
|
||||||
|
self.assertEqual(report['text_codec'], text.CODEC)
|
||||||
|
for entry in report['tables'].values():
|
||||||
|
self.assertEqual(entry['source_sha256'], entry['target_sha256'])
|
||||||
|
self.assertNotEqual(report['tables']['source_records']['source_sha256'],
|
||||||
|
report['tables']['source_records']['target_storage_sha256'])
|
||||||
|
with connect(self.service) as pg:
|
||||||
|
self.assertEqual(pg.execute("SELECT obj_description(oid,'pg_namespace') FROM pg_namespace WHERE nspname=%s", (self.target,)).fetchone()[0], 'paper-atlas:text-codec=' + text.CODEC)
|
||||||
|
raw = pg.execute('SELECT abstract FROM ' + schema.quote(self.target) + '.source_records WHERE id=1').fetchone()[0]
|
||||||
|
self.assertNotIn('\x00', raw)
|
||||||
|
self.assertEqual(text.decode(raw), original)
|
||||||
|
reader = ReadConnection(self.service, self.target)
|
||||||
|
try:
|
||||||
|
self.assertEqual(reader.execute('SELECT abstract FROM source_records WHERE id=?', (1,)).fetchone()[0], original)
|
||||||
|
self.assertEqual([r[0] for r in reader.execute("SELECT value FROM identifiers WHERE scheme='control' ORDER BY value")], sorted(keys))
|
||||||
|
self.assertEqual(reader.execute("SELECT value FROM identifiers WHERE scheme='control' AND value=?", (keys[0],)).fetchone()[0], keys[0])
|
||||||
|
finally:
|
||||||
|
reader.close()
|
||||||
|
real = ReadConnection
|
||||||
|
with patch('paper_atlas.postgres.client.ReadConnection', lambda service: real(service, self.target)):
|
||||||
|
output = io.StringIO()
|
||||||
|
with contextlib.redirect_stdout(output):
|
||||||
|
querying.main(['--pg-service', self.service, '--paper', '1', '--tag', 'custom:control=' + original])
|
||||||
|
result = json.loads(output.getvalue())[0]
|
||||||
|
self.assertEqual(result['abstract'], original)
|
||||||
|
self.assertEqual(next(s['abstract'] for s in result['sources'] if s['external_id'] == '2401.00001'), original)
|
||||||
|
output = io.StringIO()
|
||||||
|
with contextlib.redirect_stdout(output):
|
||||||
|
querying.main(['--pg-service', self.service, '--query', 'survivor'])
|
||||||
|
self.assertEqual([r['id'] for r in json.loads(output.getvalue())], [1])
|
||||||
|
|
||||||
|
def test_old_completed_staging_table_is_upgraded_atomically_on_resume(self):
|
||||||
|
original = 'https://example.org/literal\x01\x02'
|
||||||
|
with closing(sqlite3.connect(self.db)) as source, source:
|
||||||
|
source.execute('UPDATE sources SET homepage=? WHERE id=1', (original,))
|
||||||
|
self.interrupt_after_first_table()
|
||||||
|
with connect(self.service) as pg:
|
||||||
|
control = schema.quote(self.target + '_migration')
|
||||||
|
stage = pg.execute('SELECT stage FROM ' + control + '.run WHERE id=1').fetchone()[0]
|
||||||
|
# Reproduce the old implementation: raw SOH text and no codec metadata.
|
||||||
|
pg.execute('UPDATE ' + schema.quote(stage) + '.sources SET homepage=%s WHERE id=1', (original,))
|
||||||
|
pg.execute('ALTER TABLE ' + control + '.run DROP COLUMN text_codec')
|
||||||
|
report = self.migrate()
|
||||||
|
self.assertEqual(report['legacy_text_upgrade_rows']['sources'], 1)
|
||||||
|
self.assertEqual(report['tables']['sources']['source_sha256'], report['tables']['sources']['target_sha256'])
|
||||||
|
reader = ReadConnection(self.service, self.target)
|
||||||
|
try:
|
||||||
|
self.assertEqual(reader.execute('SELECT homepage FROM sources WHERE id=1').fetchone()[0], original)
|
||||||
|
finally:
|
||||||
|
reader.close()
|
||||||
|
|
||||||
|
def test_legacy_unique_keys_can_upgrade_after_constraints_were_built(self):
|
||||||
|
keys = ['x\x01', 'x\x01\x02']
|
||||||
|
with closing(sqlite3.connect(self.db)) as source, source:
|
||||||
|
source.executemany("INSERT INTO identifiers VALUES('control',?,1)", [(k,) for k in keys])
|
||||||
|
# Emulate an old run interrupted after constraints: no storage encoding.
|
||||||
|
with patch('paper_atlas.postgres.text.encode', side_effect=lambda value: value), \
|
||||||
|
patch('paper_atlas.postgres.migration.sequences', side_effect=RuntimeError('after constraints')):
|
||||||
|
with self.assertRaisesRegex(RuntimeError, 'after constraints'):
|
||||||
|
self.migrate()
|
||||||
|
with connect(self.service) as pg:
|
||||||
|
pg.execute('ALTER TABLE ' + schema.quote(self.target + '_migration') + '.run DROP COLUMN text_codec')
|
||||||
|
report = self.migrate()
|
||||||
|
self.assertEqual(report['status'], 'complete')
|
||||||
|
self.assertEqual(report['tables']['identifiers']['source_sha256'], report['tables']['identifiers']['target_sha256'])
|
||||||
|
reader = ReadConnection(self.service, self.target)
|
||||||
|
try:
|
||||||
|
self.assertEqual([r[0] for r in reader.execute("SELECT value FROM identifiers WHERE scheme='control' ORDER BY value")], keys)
|
||||||
|
finally:
|
||||||
|
reader.close()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -0,0 +1,402 @@
|
|||||||
|
from contextlib import closing, redirect_stdout
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import sqlite3
|
||||||
|
import tempfile
|
||||||
|
from types import SimpleNamespace
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
import uuid
|
||||||
|
import zipfile
|
||||||
|
|
||||||
|
from paper_atlas.db.backend import open_store, open_read_connection
|
||||||
|
from paper_atlas.db.store import Store
|
||||||
|
from paper_atlas.ingestion import arxiv
|
||||||
|
from paper_atlas.ingestion.backfill import backfill
|
||||||
|
from paper_atlas.ingestion.records import ingest_file
|
||||||
|
from paper_atlas.postgres.client import connect, ReadConnection
|
||||||
|
from paper_atlas.postgres.migration import migrate
|
||||||
|
from paper_atlas.postgres.schema import quote
|
||||||
|
|
||||||
|
|
||||||
|
class BackendSelectionTests(unittest.TestCase):
|
||||||
|
def test_ambiguous_targets_fail_before_opening_any_database(self):
|
||||||
|
for kwargs in ({}, {'db': 'example', 'pg_service': 'example'}, {'db': 'example', 'pg_schema': 'research'}):
|
||||||
|
with self.assertRaisesRegex(ValueError, 'Choose exactly one'):
|
||||||
|
open_store(**kwargs)
|
||||||
|
|
||||||
|
def test_readonly_audit_does_not_create_an_absent_sqlite_database(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
path = Path(tmp) / 'absent.sqlite'
|
||||||
|
with self.assertRaisesRegex(ValueError, 'Existing database'):
|
||||||
|
open_read_connection(path)
|
||||||
|
self.assertFalse(path.exists())
|
||||||
|
|
||||||
|
|
||||||
|
@unittest.skipUnless(os.environ.get('PAPER_ATLAS_TEST_PG_SERVICE'), 'Requires the approved remote PostgreSQL test service')
|
||||||
|
class PostgreSQLIngestionTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.service = os.environ['PAPER_ATLAS_TEST_PG_SERVICE']
|
||||||
|
self.schema = 'pa_write_' + uuid.uuid4().hex[:12]
|
||||||
|
self.temp = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(self.temp.cleanup)
|
||||||
|
self.root = Path(self.temp.name)
|
||||||
|
source = self.root / 'seed.sqlite'
|
||||||
|
store = Store(source)
|
||||||
|
paper, record = store.record('arxiv', '2401.00001', 'https://arxiv.org/abs/2401.00001',
|
||||||
|
'Existing reference paper', 'Existing abstract', [])
|
||||||
|
store.decision(paper, record, 'ICLR', 2024, 'main', 'seed', 'https://example.org/seed', 'rejected', 'Reject')
|
||||||
|
store.conn.execute("INSERT INTO paper_merges(former_paper_id,surviving_paper_id,reason) VALUES(900,?,'fixture')", (paper,))
|
||||||
|
store.conn.execute("INSERT INTO submission_merges VALUES(5000,1,'fixture',CURRENT_TIMESTAMP)")
|
||||||
|
store.conn.execute("INSERT INTO decision_merges VALUES(6000,1,'fixture',CURRENT_TIMESTAMP)")
|
||||||
|
store.conn.execute("UPDATE sqlite_sequence SET seq=1000 WHERE name='papers'")
|
||||||
|
store.conn.commit(); store.conn.close()
|
||||||
|
self.addCleanup(self.cleanup)
|
||||||
|
with redirect_stdout(io.StringIO()):
|
||||||
|
migrate(source, self.service, self.root / 'migration.json', schema=self.schema, min_free_gib=0)
|
||||||
|
|
||||||
|
def cleanup(self):
|
||||||
|
with connect(self.service) as pg:
|
||||||
|
names = pg.execute('SELECT nspname FROM pg_namespace WHERE starts_with(nspname,%s)', (self.schema,)).fetchall()
|
||||||
|
for name, in names:
|
||||||
|
pg.execute('DROP SCHEMA ' + quote(name) + ' CASCADE')
|
||||||
|
|
||||||
|
def store(self):
|
||||||
|
return open_store(pg_service=self.service, pg_schema=self.schema)
|
||||||
|
|
||||||
|
def row(self, source='openreview_snapshot', external='forumNew1', status='rejected', year=2025, venue='ICLR'):
|
||||||
|
return {'source': source, 'external_id': external, 'url': 'https://example.org/' + external,
|
||||||
|
'venue': venue, 'year': year, 'track': 'main', 'status': status,
|
||||||
|
'title': 'A newly observed shared paper', 'abstract': 'Before\x00survivor and literal\x01\x02',
|
||||||
|
'authors': ['Jane Doe', 'John Smith'], 'identifiers': {'arxiv': '2501.99999'},
|
||||||
|
'observed_at': str(year) + '-01-20T00:00:00Z'}
|
||||||
|
|
||||||
|
def file(self, name, rows, count=None):
|
||||||
|
path = self.root / (name + '.jsonl')
|
||||||
|
path.write_text(''.join(json.dumps(r, ensure_ascii=False) + '\n' for r in rows))
|
||||||
|
first = rows[0]
|
||||||
|
manifest = {key: first[key] for key in ('source', 'venue', 'year', 'track')}
|
||||||
|
manifest.update(parser_version=2, list_url='https://example.org/' + name,
|
||||||
|
count=len(rows) if count is None else count, missing_abstract_count=0, status='complete')
|
||||||
|
path.with_suffix('.manifest.json').write_text(json.dumps(manifest))
|
||||||
|
return path
|
||||||
|
|
||||||
|
def ingest(self, path):
|
||||||
|
with redirect_stdout(io.StringIO()):
|
||||||
|
ingest_file(None, path, pg_service=self.service, pg_schema=self.schema)
|
||||||
|
|
||||||
|
def test_incremental_source_coverage_counts_prior_chunks_and_refreshes_on_postgres(self):
|
||||||
|
rows = [self.row(source='dblp',external=key,status='accepted',year=2001,venue='UAI')
|
||||||
|
for key in ('incremental1','incremental2','incremental3')]
|
||||||
|
listing = 'https://example.org/uai/2001'
|
||||||
|
def manifest(path, **options):
|
||||||
|
target=path.with_suffix('.manifest.json')
|
||||||
|
value=json.loads(target.read_text())
|
||||||
|
value.update(list_url=listing,status='partial',**options)
|
||||||
|
target.write_text(json.dumps(value))
|
||||||
|
first=self.file('initial',rows[:2]);manifest(first);self.ingest(first)
|
||||||
|
second=self.file('append',[rows[2]])
|
||||||
|
manifest(second,coverage_mode='incremental',coverage_previous_count=2,
|
||||||
|
coverage_previous_expected_count=2,coverage_expected_count=4)
|
||||||
|
self.ingest(second)
|
||||||
|
manifest(second,coverage_mode='incremental',coverage_previous_count=3,
|
||||||
|
coverage_previous_expected_count=4,coverage_expected_count=4)
|
||||||
|
self.ingest(second)
|
||||||
|
with closing(ReadConnection(self.service,self.schema)) as c:
|
||||||
|
coverage=c.execute('SELECT status,expected_count,imported_count FROM edition_sources WHERE list_url=?',(listing,)).fetchone()
|
||||||
|
self.assertEqual(tuple(coverage.values),('partial',4,3))
|
||||||
|
stored=c.execute('SELECT abstract FROM source_records WHERE external_id=?',('incremental3',)).fetchone()
|
||||||
|
self.assertEqual(stored[0],rows[2]['abstract'])
|
||||||
|
self.assertEqual(c.execute("SELECT count(*) FROM source_records r JOIN sources s ON s.id=r.source_id WHERE s.name='dblp'").fetchone()[0],3)
|
||||||
|
run=c.execute('SELECT records_written,checkpoint_json FROM ingest_runs ORDER BY id DESC LIMIT 1').fetchone()
|
||||||
|
self.assertEqual(run[0],1)
|
||||||
|
self.assertEqual(json.loads(run[1])['coverage']['imported_count'],3)
|
||||||
|
|
||||||
|
def test_incremental_postgres_bound_failure_rolls_back_rows_and_summary(self):
|
||||||
|
listing='https://example.org/uai/2001'
|
||||||
|
rows=[self.row(source='dblp',external=key,status='accepted',year=2001,venue='UAI')
|
||||||
|
for key in ('bound1','bound2','bound3')]
|
||||||
|
first=self.file('initial',rows[:2]);target=first.with_suffix('.manifest.json')
|
||||||
|
value=json.loads(target.read_text());value.update(list_url=listing,status='partial');target.write_text(json.dumps(value))
|
||||||
|
self.ingest(first)
|
||||||
|
with closing(ReadConnection(self.service,self.schema)) as c:
|
||||||
|
before={table:c.execute('SELECT count(*) FROM '+table).fetchone()[0]
|
||||||
|
for table in ('papers','source_records','submissions','decisions')}
|
||||||
|
path=self.file('overflow',[rows[2]]);target=path.with_suffix('.manifest.json')
|
||||||
|
value=json.loads(target.read_text());value.update(list_url=listing,status='partial',coverage_mode='incremental',
|
||||||
|
coverage_previous_count=2,coverage_previous_expected_count=2,coverage_expected_count=2)
|
||||||
|
target.write_text(json.dumps(value))
|
||||||
|
with self.assertRaisesRegex(ValueError,'source bounds'):self.ingest(path)
|
||||||
|
with closing(ReadConnection(self.service,self.schema)) as c:
|
||||||
|
self.assertEqual({table:c.execute('SELECT count(*) FROM '+table).fetchone()[0] for table in before},before)
|
||||||
|
coverage=c.execute('SELECT status,expected_count,imported_count FROM edition_sources WHERE list_url=?',(listing,)).fetchone()
|
||||||
|
self.assertEqual(tuple(coverage.values),('partial',2,2))
|
||||||
|
self.assertEqual(c.execute('SELECT status FROM ingest_runs ORDER BY id DESC LIMIT 1').fetchone()[0],'failed')
|
||||||
|
|
||||||
|
def test_incremental_postgres_does_not_overwrite_a_concurrently_changed_parent(self):
|
||||||
|
listing='https://example.org/uai/2001'
|
||||||
|
rows=[self.row(source='dblp',external=key,status='accepted',year=2001,venue='UAI')
|
||||||
|
for key in ('concurrent1','concurrent2','concurrent3')]
|
||||||
|
first=self.file('initial',rows[:2]);target=first.with_suffix('.manifest.json')
|
||||||
|
value=json.loads(target.read_text());value.update(list_url=listing,status='partial');target.write_text(json.dumps(value))
|
||||||
|
self.ingest(first)
|
||||||
|
path=self.file('racing',[rows[2]]);target=path.with_suffix('.manifest.json')
|
||||||
|
value=json.loads(target.read_text());value.update(list_url=listing,status='partial',coverage_mode='incremental',
|
||||||
|
coverage_previous_count=2,coverage_previous_expected_count=2,coverage_expected_count=4)
|
||||||
|
target.write_text(json.dumps(value))
|
||||||
|
original=Store.record
|
||||||
|
def change_parent(store,*args,**kwargs):
|
||||||
|
result=original(store,*args,**kwargs)
|
||||||
|
# A separate committed SQL writer need not honor the native
|
||||||
|
# advisory lock. Its update must survive our rejected file.
|
||||||
|
with connect(self.service) as other:
|
||||||
|
other.execute('SET search_path TO '+quote(self.schema)+',pg_catalog')
|
||||||
|
other.execute('UPDATE edition_sources SET expected_count=99 WHERE list_url=%s',(listing,))
|
||||||
|
return result
|
||||||
|
with patch.object(Store,'record',change_parent):
|
||||||
|
with self.assertRaisesRegex(ValueError,'parent changed during import'):self.ingest(path)
|
||||||
|
with closing(ReadConnection(self.service,self.schema)) as c:
|
||||||
|
coverage=c.execute('SELECT status,expected_count,imported_count FROM edition_sources WHERE list_url=?',(listing,)).fetchone()
|
||||||
|
self.assertEqual(tuple(coverage.values),('partial',99,2))
|
||||||
|
self.assertEqual(c.execute('SELECT count(*) FROM source_records WHERE external_id=?',('concurrent3',)).fetchone()[0],0)
|
||||||
|
self.assertEqual(c.execute('SELECT status FROM ingest_runs ORDER BY id DESC LIMIT 1').fetchone()[0],'failed')
|
||||||
|
|
||||||
|
def test_native_writes_keep_nul_historical_ids_and_later_acceptance(self):
|
||||||
|
store = self.store()
|
||||||
|
try:
|
||||||
|
paper, record = store.record('public', 'new-forum', 'https://example.org/new', 'New research identity',
|
||||||
|
'Left\x00right\x01\x02', [], {'arxiv': '2501.99999'})
|
||||||
|
self.assertGreater(paper, 1000)
|
||||||
|
store.decision(paper, record, 'ICLR', 2025, 'main', 'new-forum', 'https://example.org/reject', 'rejected', 'Reject')
|
||||||
|
_, second = store.record('official', 'new-pub', 'https://example.org/pub', 'Changed title', None, [], {'arxiv': '2501.99999'})
|
||||||
|
store.decision(paper, second, 'COLM', 2026, 'main', 'new-pub', 'https://example.org/accept', 'accepted', 'Accept')
|
||||||
|
store.conn.commit()
|
||||||
|
self.assertEqual(store.conn.execute('SELECT abstract FROM papers WHERE id=?', (paper,)).fetchone()[0], 'Left\x00right\x01\x02')
|
||||||
|
ids = store.conn.execute('SELECT s.id,d.id FROM submissions s JOIN decisions d ON d.submission_id=s.id WHERE s.paper_id=?', (paper,)).fetchall()
|
||||||
|
self.assertEqual(len(ids), 2)
|
||||||
|
self.assertTrue(all(sid > 5000 and did > 6000 for sid, did in ids))
|
||||||
|
finally:
|
||||||
|
store.conn.close()
|
||||||
|
reader = ReadConnection(self.service, self.schema)
|
||||||
|
try:
|
||||||
|
from paper_atlas.querying import known_acceptances
|
||||||
|
self.assertEqual(known_acceptances(reader, paper)[0]['venue'], 'COLM')
|
||||||
|
finally:
|
||||||
|
reader.close()
|
||||||
|
|
||||||
|
def test_identity_anchor_keeps_other_arxiv_version_and_ambiguous_file_rolls_back(self):
|
||||||
|
title='An identical long research title shared by conference and extended versions'
|
||||||
|
authors=['Jane Doe','John Smith']
|
||||||
|
store=self.store()
|
||||||
|
try:
|
||||||
|
first,_=store.record('arxiv','1408.1485','https://arxiv.org/abs/1408.1485',title,'Conference abstract',authors)
|
||||||
|
second,_=store.record('arxiv','cs/0307069','https://arxiv.org/abs/cs/0307069',title,'Extended abstract',authors)
|
||||||
|
resolved,_=store.record('dblp','known','https://example.org/known',title,None,authors,{'arxiv':'1408.1485'})
|
||||||
|
self.assertEqual(resolved,first);self.assertNotEqual(first,second)
|
||||||
|
store.conn.commit()
|
||||||
|
self.assertEqual(store.conn.execute('SELECT count(*) FROM papers').fetchone()[0],3)
|
||||||
|
self.assertEqual(store.conn.execute('SELECT count(*) FROM paper_merges').fetchone()[0],1) # migration fixture only
|
||||||
|
self.assertEqual(store.conn.execute('SELECT count(*) FROM match_candidates').fetchone()[0],1)
|
||||||
|
finally:store.conn.close()
|
||||||
|
rows=[self.row('official','earlier',status='accepted',venue='UAI',year=2001),
|
||||||
|
dict(self.row('official','ambiguous',status='accepted',venue='UAI',year=2001),
|
||||||
|
title=title,abstract=None,authors=authors,identifiers={})]
|
||||||
|
with self.assertRaisesRegex(ValueError,'Ambiguous paper identity'):self.ingest(self.file('ambiguous-identity',rows))
|
||||||
|
store=self.store()
|
||||||
|
try:
|
||||||
|
self.assertEqual(store.conn.execute('SELECT count(*) FROM papers').fetchone()[0],3)
|
||||||
|
self.assertEqual(store.conn.execute('SELECT count(*) FROM source_records').fetchone()[0],4)
|
||||||
|
self.assertEqual(store.conn.execute('SELECT count(*) FROM submissions').fetchone()[0],1)
|
||||||
|
self.assertEqual(store.conn.execute('SELECT count(*) FROM paper_merges').fetchone()[0],1)
|
||||||
|
self.assertEqual(store.conn.execute('SELECT status FROM ingest_runs ORDER BY id DESC LIMIT 1').fetchone()[0],'failed')
|
||||||
|
self.assertEqual(store.conn.execute("SELECT count(*) FROM source_records WHERE external_id IN (?,?)",('earlier','ambiguous')).fetchone()[0],0)
|
||||||
|
finally:store.conn.close()
|
||||||
|
|
||||||
|
def test_file_import_is_idempotent_and_refreshes_only_own_tags(self):
|
||||||
|
row = self.row(); row['tags'] = [{'namespace': 'openreview_snapshot:area', 'value': 'theory'}]
|
||||||
|
path = self.file('reject', [row]); self.ingest(path)
|
||||||
|
store = self.store()
|
||||||
|
try:
|
||||||
|
paper = store.conn.execute("SELECT paper_id FROM identifiers WHERE scheme='arxiv' AND value='2501.99999'").fetchone()[0]
|
||||||
|
store.conn.execute("INSERT INTO tags(namespace,value) VALUES('custom:topic','manual') ON CONFLICT DO NOTHING")
|
||||||
|
tag = store.conn.execute("SELECT id FROM tags WHERE namespace='custom:topic'").fetchone()[0]
|
||||||
|
store.conn.execute('INSERT INTO paper_tags VALUES(?,?,NULL,?)', (paper, tag, 'manual'))
|
||||||
|
store.conn.commit()
|
||||||
|
finally:
|
||||||
|
store.conn.close()
|
||||||
|
row['tags'][0]['value'] = 'learning'; self.ingest(self.file('reject', [row]))
|
||||||
|
self.ingest(self.file('accept', [self.row('official', 'published', 'accepted', 2026, 'COLM')]))
|
||||||
|
store = self.store()
|
||||||
|
try:
|
||||||
|
self.assertEqual(store.conn.execute('SELECT count(*) FROM papers').fetchone()[0], 2)
|
||||||
|
self.assertEqual(store.conn.execute('SELECT count(*) FROM submissions WHERE paper_id=?', (paper,)).fetchone()[0], 2)
|
||||||
|
self.assertEqual(store.conn.execute('SELECT count(*) FROM decisions d JOIN submissions s ON s.id=d.submission_id WHERE s.paper_id=?', (paper,)).fetchone()[0], 2)
|
||||||
|
tags = set(store.conn.execute('SELECT t.namespace,t.value FROM paper_tags p JOIN tags t ON t.id=p.tag_id WHERE p.paper_id=?', (paper,)))
|
||||||
|
self.assertEqual(tags, {('custom:topic', 'manual'), ('openreview_snapshot:area', 'learning')})
|
||||||
|
finally:
|
||||||
|
store.conn.close()
|
||||||
|
|
||||||
|
def test_bad_manifest_rolls_back_whole_file_and_keeps_failed_run(self):
|
||||||
|
with self.assertRaisesRegex(ValueError, 'Manifest count'):
|
||||||
|
self.ingest(self.file('bad', [self.row()], count=2))
|
||||||
|
store = self.store()
|
||||||
|
try:
|
||||||
|
self.assertEqual(store.conn.execute('SELECT count(*) FROM papers').fetchone()[0], 1)
|
||||||
|
self.assertEqual(store.conn.execute('SELECT count(*) FROM source_records').fetchone()[0], 1)
|
||||||
|
self.assertEqual(store.conn.execute('SELECT status,records_seen,records_written FROM ingest_runs').fetchone(), ('failed', 1, 0))
|
||||||
|
finally:
|
||||||
|
store.conn.close()
|
||||||
|
|
||||||
|
def test_changed_file_hash_rolls_back_data_before_commit(self):
|
||||||
|
path = self.file('changed', [self.row()])
|
||||||
|
manifest = json.loads(path.with_suffix('.manifest.json').read_text())
|
||||||
|
manifest['file_sha256'] = '0' * 64
|
||||||
|
path.with_suffix('.manifest.json').write_text(json.dumps(manifest))
|
||||||
|
with self.assertRaisesRegex(ValueError, 'hash differs'):
|
||||||
|
self.ingest(path)
|
||||||
|
store = self.store()
|
||||||
|
try:
|
||||||
|
self.assertEqual(store.conn.execute('SELECT count(*) FROM papers').fetchone()[0], 1)
|
||||||
|
self.assertEqual(store.conn.execute('SELECT count(*) FROM source_records').fetchone()[0], 1)
|
||||||
|
self.assertEqual(store.conn.execute('SELECT status,records_written FROM ingest_runs').fetchone(), ('failed', 0))
|
||||||
|
finally:
|
||||||
|
store.conn.close()
|
||||||
|
|
||||||
|
def test_writer_lock_survives_commit_and_rollback_but_readers_continue(self):
|
||||||
|
first = self.store()
|
||||||
|
try:
|
||||||
|
first.conn.commit()
|
||||||
|
with self.assertRaisesRegex(RuntimeError, 'Another writer'):
|
||||||
|
self.store()
|
||||||
|
first.conn.rollback()
|
||||||
|
with self.assertRaisesRegex(RuntimeError, 'Another writer'):
|
||||||
|
self.store()
|
||||||
|
reader = ReadConnection(self.service, self.schema)
|
||||||
|
try:
|
||||||
|
self.assertEqual(reader.execute('SELECT count(*) FROM papers').fetchone()[0], 1)
|
||||||
|
finally:
|
||||||
|
reader.close()
|
||||||
|
finally:
|
||||||
|
first.conn.close()
|
||||||
|
reopened = self.store(); reopened.conn.close()
|
||||||
|
|
||||||
|
def test_unpublished_schema_is_rejected_without_bootstrapping_tables(self):
|
||||||
|
staging = self.schema + '_unfinished'
|
||||||
|
with connect(self.service) as pg:
|
||||||
|
pg.execute('CREATE SCHEMA ' + quote(staging))
|
||||||
|
with self.assertRaisesRegex(RuntimeError, 'published dataset'):
|
||||||
|
open_store(pg_service=self.service, pg_schema=staging)
|
||||||
|
with connect(self.service) as pg:
|
||||||
|
self.assertEqual(pg.execute('SELECT count(*) FROM information_schema.tables WHERE table_schema=%s', (staging,)).fetchone()[0], 0)
|
||||||
|
|
||||||
|
def test_negative_preflight_uses_current_pg_attempt_and_blocks_stale_import(self):
|
||||||
|
import hashlib
|
||||||
|
from paper_atlas.collectors.public_openreview import SOURCE, REVISION
|
||||||
|
from paper_atlas.audits.public_openreview import audit
|
||||||
|
row = self.row(SOURCE, 'negativeForum')
|
||||||
|
row['identifiers']['openreview'] = 'negativeForum'
|
||||||
|
row['metadata'] = {'evidence_kind': 'curated_public_status_assertion',
|
||||||
|
'source_revision': REVISION, 'original_decision_signature_verified': False}
|
||||||
|
path = self.file('negative', [row])
|
||||||
|
manifest = json.loads(path.with_suffix('.manifest.json').read_text())
|
||||||
|
manifest.update(file=str(path), file_sha256=hashlib.sha256(path.read_bytes()).hexdigest())
|
||||||
|
path.with_suffix('.manifest.json').write_text(json.dumps(manifest))
|
||||||
|
inventory = self.root / 'negative-inventory.json'
|
||||||
|
inventory.write_text(json.dumps({'source': SOURCE, 'revision': REVISION,
|
||||||
|
'normalized_records': 1, 'files': [manifest]}))
|
||||||
|
# An acceptance in another conference does not block this rejection.
|
||||||
|
self.ingest(self.file('other-conference', [self.row('official', 'acceptedElsewhere', 'accepted', 2026, 'COLM')]))
|
||||||
|
def preflight(name):
|
||||||
|
with redirect_stdout(io.StringIO()):
|
||||||
|
return audit(None, inventory, self.root / (name + '.json'), pg_service=self.service, pg_schema=self.schema)
|
||||||
|
first = preflight('before')
|
||||||
|
self.assertEqual(first['status'], 'ready_for_serial_import')
|
||||||
|
self.assertEqual(first['engine'], 'postgresql')
|
||||||
|
self.assertEqual(first['schema'], self.schema)
|
||||||
|
self.ingest(path)
|
||||||
|
self.assertEqual(preflight('existing')['existing_same_edition_attempts'], 1)
|
||||||
|
store = self.store()
|
||||||
|
try:
|
||||||
|
paper, record = store.conn.execute('''SELECT r.paper_id,r.id FROM source_records r
|
||||||
|
JOIN sources s ON s.id=r.source_id WHERE s.name=? AND r.external_id=?''', (SOURCE, row['external_id'])).fetchone()
|
||||||
|
store.decision(paper, record, 'ICLR', 2025, 'main', row['external_id'],
|
||||||
|
'https://example.org/formal', 'accepted', 'Accept', observed_at='2027-01-01T00:00:00Z')
|
||||||
|
# Equal observation time, but an explicitly earlier decision date.
|
||||||
|
# NULL dates must fall back to observed_at, not the empty string.
|
||||||
|
store.decision(paper, record, 'ICLR', 2025, 'main', row['external_id'],
|
||||||
|
'https://example.org/older', 'rejected', 'Reject', observed_at='2027-01-01T00:00:00Z',
|
||||||
|
decision_date='2026-12-31T00:00:00Z')
|
||||||
|
store.conn.commit()
|
||||||
|
finally:
|
||||||
|
store.conn.close()
|
||||||
|
blocked = preflight('blocked')
|
||||||
|
self.assertEqual(blocked['status'], 'blocked')
|
||||||
|
self.assertEqual(blocked['conflicts'][0]['existing_status'], 'accepted')
|
||||||
|
self.assertEqual(blocked['files'], [])
|
||||||
|
# The earlier ready report cannot authorize overwriting current status.
|
||||||
|
with self.assertRaisesRegex(ValueError, 'conflicts with current'):
|
||||||
|
self.ingest(path)
|
||||||
|
reader = open_read_connection(pg_service=self.service, pg_schema=self.schema)
|
||||||
|
try:
|
||||||
|
self.assertEqual(reader.execute('SHOW transaction_read_only').fetchone()[0], 'on')
|
||||||
|
from paper_atlas.db.submissions import latest_status
|
||||||
|
sid = reader.execute("SELECT submission_id FROM submission_identifiers WHERE scheme='openreview' AND value=?", ('negativeForum',)).fetchone()[0]
|
||||||
|
self.assertEqual(latest_status(reader, sid), 'accepted')
|
||||||
|
finally:
|
||||||
|
reader.close()
|
||||||
|
|
||||||
|
def test_backfill_merges_evidence_without_reimporting_or_losing_history(self):
|
||||||
|
files = []
|
||||||
|
for source, external, title in [('official', 'legacy', 'First bibliographic name'), ('openreview_snapshot', 'forum', 'Renamed submission')]:
|
||||||
|
row = self.row(source, external, 'accepted')
|
||||||
|
row.update(title=title, authors=[], identifiers={}, evidence_url='https://example.org/same-decision')
|
||||||
|
path = self.file(source, [row]); self.ingest(path)
|
||||||
|
row['identifiers'] = {'openreview': 'sharedForum'}
|
||||||
|
self.file(source, [row]); files.append(path)
|
||||||
|
with redirect_stdout(io.StringIO()):
|
||||||
|
result = backfill(None, files, pg_service=self.service, pg_schema=self.schema)
|
||||||
|
self.assertEqual((result['submissions_before'], result['submissions_after']), (3, 2))
|
||||||
|
store = self.store()
|
||||||
|
try:
|
||||||
|
self.assertEqual(store.conn.execute('SELECT count(*) FROM papers').fetchone()[0], 2)
|
||||||
|
self.assertEqual(store.conn.execute('SELECT count(*) FROM decision_sources').fetchone()[0], 3)
|
||||||
|
self.assertEqual(store.conn.execute('SELECT count(*) FROM decision_merges').fetchone()[0], 2)
|
||||||
|
finally:
|
||||||
|
store.conn.close()
|
||||||
|
|
||||||
|
def test_arxiv_chunk_resume_preserves_committed_ids_and_category_evidence(self):
|
||||||
|
archive = self.root / 'arxiv.zip'
|
||||||
|
rows = [{'id': f'2501.{i:05d}', 'title': f'Checkpoint paper {i}', 'abstract': 'Metadata abstract',
|
||||||
|
'authors': [], 'categories': 'cs.AI'} for i in range(1, 502)]
|
||||||
|
with zipfile.ZipFile(archive, 'w') as zipped:
|
||||||
|
zipped.writestr('arxiv-metadata-oai-snapshot.json', ''.join(json.dumps(r) + '\n' for r in rows))
|
||||||
|
calls = 0
|
||||||
|
original = arxiv.refresh_categories
|
||||||
|
def interrupt(*args):
|
||||||
|
nonlocal calls
|
||||||
|
calls += 1
|
||||||
|
if calls == 501:
|
||||||
|
raise RuntimeError('controlled interruption')
|
||||||
|
return original(*args)
|
||||||
|
with patch('paper_atlas.ingestion.arxiv.shutil.disk_usage', return_value=SimpleNamespace(free=8 * 1024**3)), \
|
||||||
|
patch('paper_atlas.ingestion.arxiv.refresh_categories', side_effect=interrupt), redirect_stdout(io.StringIO()):
|
||||||
|
with self.assertRaisesRegex(RuntimeError, 'controlled interruption'):
|
||||||
|
arxiv.ingest(None, archive, pg_service=self.service, pg_schema=self.schema)
|
||||||
|
store = self.store()
|
||||||
|
try:
|
||||||
|
self.assertEqual(store.conn.execute('SELECT count(*) FROM papers').fetchone()[0], 501)
|
||||||
|
preserved = store.conn.execute("SELECT paper_id FROM identifiers WHERE scheme='arxiv' AND value='2501.00001'").fetchone()[0]
|
||||||
|
finally:
|
||||||
|
store.conn.close()
|
||||||
|
with redirect_stdout(io.StringIO()):
|
||||||
|
arxiv.ingest(None, archive, pg_service=self.service, pg_schema=self.schema)
|
||||||
|
store = self.store()
|
||||||
|
try:
|
||||||
|
self.assertEqual(store.conn.execute('SELECT count(*) FROM papers').fetchone()[0], 502)
|
||||||
|
self.assertEqual(store.conn.execute("SELECT paper_id FROM identifiers WHERE scheme='arxiv' AND value='2501.00001'").fetchone()[0], preserved)
|
||||||
|
self.assertEqual(store.conn.execute('SELECT count(*) FROM paper_tags').fetchone()[0], 501)
|
||||||
|
self.assertEqual(store.conn.execute('SELECT status,records_written FROM ingest_runs').fetchone(), ('complete', 501))
|
||||||
|
finally:
|
||||||
|
store.conn.close()
|
||||||
@@ -152,6 +152,35 @@ class PublicOpenReviewTests(unittest.TestCase):
|
|||||||
finally:
|
finally:
|
||||||
store.conn.close()
|
store.conn.close()
|
||||||
|
|
||||||
|
def test_unicode_separators_in_abstract_survive_audit_and_import(self):
|
||||||
|
for separator in ('\u0085', '\u2028', '\u2029'):
|
||||||
|
with self.subTest(separator=repr(separator)), tempfile.TemporaryDirectory() as tmp:
|
||||||
|
root = Path(tmp)
|
||||||
|
abstract = 'First paragraph' + separator + 'Second paragraph'
|
||||||
|
result = self.collect(root / 'source', {
|
||||||
|
'venues/icml/icml2025.jsonl': [self.row(abstract=abstract)]})
|
||||||
|
path = Path(result['files'][0]['file'])
|
||||||
|
data = path.read_bytes()
|
||||||
|
self.assertIn(separator.encode(), data)
|
||||||
|
self.assertEqual(data.count(b'\n'), 1)
|
||||||
|
db = root / 'db.sqlite'
|
||||||
|
store = Store(db)
|
||||||
|
store.conn.close()
|
||||||
|
inventory = root / 'source/public-openreview-inventory.json'
|
||||||
|
before = audit(db, inventory, root / 'before.json')
|
||||||
|
self.assertEqual(before['status'], 'ready_for_serial_import')
|
||||||
|
self.assertEqual(before['records_checked'], 1)
|
||||||
|
ingest_file(db, path)
|
||||||
|
after = audit(db, inventory, root / 'after.json')
|
||||||
|
self.assertEqual(after['status'], 'ready_for_serial_import')
|
||||||
|
self.assertEqual(after['existing_same_edition_attempts'], 1)
|
||||||
|
store = Store(db)
|
||||||
|
try:
|
||||||
|
self.assertEqual(store.conn.execute(
|
||||||
|
'SELECT abstract FROM source_records').fetchone()[0], abstract)
|
||||||
|
finally:
|
||||||
|
store.conn.close()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user