Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5fad2fc575 | |||
| 1b81d1a30a | |||
| 6cf74672a5 | |||
| 5a925457b0 | |||
| fd3fdb6cf7 | |||
| 346208a272 | |||
| bf8178354f | |||
| 067a67f3f7 | |||
| 6fa59c4d26 | |||
| dc41dfa350 |
@@ -52,8 +52,12 @@ jobs:
|
|||||||
subprocess.run([python,'-I','-B','-m','paper_atlas','import','records','--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','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','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','coverage','--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)
|
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
|
||||||
|
|||||||
@@ -51,7 +51,8 @@ PGSERVICEFILE=/etc/paper-atlas/pg_service.conf paper-atlas query --pg-service fo
|
|||||||
PGSERVICEFILE=/etc/paper-atlas/pg_service.conf paper-atlas query --pg-service foundry-research --paper 123
|
PGSERVICEFILE=/etc/paper-atlas/pg_service.conf paper-atlas query --pg-service foundry-research --paper 123
|
||||||
|
|
||||||
# 列出全部目标会议,包括尚未入库的项目
|
# 列出全部目标会议,包括尚未入库的项目
|
||||||
paper-atlas coverage --db /data/paper-index/papers.sqlite \
|
PGSERVICEFILE=/etc/paper-atlas/pg_service.conf paper-atlas coverage \
|
||||||
|
--pg-service foundry-research --pg-schema research \
|
||||||
--out /data/paper-index/collected/venue-coverage-current.json
|
--out /data/paper-index/collected/venue-coverage-current.json
|
||||||
|
|
||||||
# 采集与导入分开;这里只采集到独立文件
|
# 采集与导入分开;这里只采集到独立文件
|
||||||
|
|||||||
+10
-3
@@ -38,7 +38,11 @@ SQLite 允许文本包含 `U+0000`(NUL),PostgreSQL `text` 不允许。迁
|
|||||||
|
|
||||||
论文身份匹配、跨来源合并、投稿/决定来源和标签维护复用同一套领域逻辑。PostgreSQL 使用原生 RETURNING 和 identity 序列分配 ID,继承迁移时设定的历史 ID 高水位;失败和冲突尝试可以留下序列空洞,不能把最大 ID 当作行数。绑定文本按 `nul-soh-v1` 编码,结果解码,GIN 随论文写入自动更新。
|
论文身份匹配、跨来源合并、投稿/决定来源和标签维护复用同一套领域逻辑。PostgreSQL 使用原生 RETURNING 和 identity 序列分配 ID,继承迁移时设定的历史 ID 高水位;失败和冲突尝试可以留下序列空洞,不能把最大 ID 当作行数。绑定文本按 `nul-soh-v1` 编码,结果解码,GIN 随论文写入自动更新。
|
||||||
|
|
||||||
会议文件的数据变化在同一事务内完成,manifest 数量不符等错误会回滚整份文件,`ingest_runs` 另保留 failed、已读数和零写入数;身份回填整个队列一次提交。arXiv 每 500 行提交数据和 checkpoint,异常只回滚尚未提交部分,重试必须仍为同一压缩包。大批次开始前核对独立审计清单、当前主库冲突和磁盘空间;完成后对数量、实际来源/决定关联及检索做数据验收,代码测试成功不代表生产导入成功。
|
会议文件的数据变化在同一事务内完成,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 格式。
|
||||||
|
|
||||||
@@ -93,13 +97,16 @@ paper-atlas audit arxiv-tags --db /data/paper-index/papers.sqlite \
|
|||||||
采集文件、已入库的来源记录和去重论文实体是不同的计数。来源可能覆盖同一篇论文,同一篇论文也可能有多次投稿;不能把各批文件数量相加作为独立论文总数。实时统计与覆盖报告使用:
|
采集文件、已入库的来源记录和去重论文实体是不同的计数。来源可能覆盖同一篇论文,同一篇论文也可能有多次投稿;不能把各批文件数量相加作为独立论文总数。实时统计与覆盖报告使用:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
paper-atlas query --db /data/paper-index/papers.sqlite --stats
|
PGSERVICEFILE=/etc/paper-atlas/pg_service.conf paper-atlas query --pg-service foundry-research --pg-schema research --stats
|
||||||
paper-atlas coverage --db /data/paper-index/papers.sqlite \
|
PGSERVICEFILE=/etc/paper-atlas/pg_service.conf paper-atlas coverage \
|
||||||
|
--pg-service foundry-research --pg-schema research \
|
||||||
--out /data/paper-index/collected/venue-coverage-current.json
|
--out /data/paper-index/collected/venue-coverage-current.json
|
||||||
```
|
```
|
||||||
|
|
||||||
manifest 和采集器状态清单用于确认文件是否齐全及是否缺摘要;`ingest_runs` 和协调器日志用于确认主库导入是否成功。官方程序、书目目录和公开投稿快照分别保留,未出现在某份清单不等于被拒稿;第三方快照也不能代表平台当前全量状态。代码测试成功不能代替最终全库去重、完整性和逐届覆盖核验。
|
manifest 和采集器状态清单用于确认文件是否齐全及是否缺摘要;`ingest_runs` 和协调器日志用于确认主库导入是否成功。官方程序、书目目录和公开投稿快照分别保留,未出现在某份清单不等于被拒稿;第三方快照也不能代表平台当前全量状态。代码测试成功不能代替最终全库去重、完整性和逐届覆盖核验。
|
||||||
|
|
||||||
|
`coverage` 的所有计数来自同一个只读快照。`latest_observed_status_counts` 按每次投稿的最新观察决定统计,`decision_observation_counts` 保留所有历史观察;重复来源不增加投稿数,缺少决定的投稿计入最新状态 unknown,但不凭空制造 unknown 决定。`source_record_counts` 统计来源在该届次的实际记录与缺摘要数,和 `sources` 中逐清单的预期/导入数量分开;身份补充清单与原始论文集清单可以描述同一条记录。来源缺摘要与论文实体从其他来源获得摘要也分别报告。报告仍将历史覆盖标为待独立审计,不能用某个清单 complete 或没有拒稿记录推断全会议覆盖。
|
||||||
|
|
||||||
当前代码与合并状态以 [Pull Requests](https://gitea.entroia.com/Zayden-Jung/paper-atlas/pulls) 为准;CI 结果以对应提交的 Actions 检查为准。工作分支交付后等待用户手动合并。合并代码不自动部署、不自动启动采集,也不代表数据批次已完成。
|
当前代码与合并状态以 [Pull Requests](https://gitea.entroia.com/Zayden-Jung/paper-atlas/pulls) 为准;CI 结果以对应提交的 Actions 检查为准。工作分支交付后等待用户手动合并。合并代码不自动部署、不自动启动采集,也不代表数据批次已完成。
|
||||||
## 修正已有 PMLR 投稿的轨道
|
## 修正已有 PMLR 投稿的轨道
|
||||||
|
|
||||||
|
|||||||
+35
-6
@@ -41,6 +41,16 @@ 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#其他会议的公开拒稿)。这类公开子集不能当作会议完整投稿名单。
|
||||||
|
|
||||||
|
主库切换后,预检须直接连接当前 PostgreSQL,不能用冻结的 SQLite 备份推断当前决定:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
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. 串行导入
|
## 3. 串行导入
|
||||||
|
|
||||||
PostgreSQL 主库逐文件导入已经核验的资料:
|
PostgreSQL 主库逐文件导入已经核验的资料:
|
||||||
@@ -69,22 +79,41 @@ paper-atlas maintain merge --db /data/paper-index/samples/example.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
|
PGSERVICEFILE=/etc/paper-atlas/pg_service.conf paper-atlas coverage \
|
||||||
|
--pg-service foundry-research --pg-schema research --out /data/paper-index/collected/coverage.json
|
||||||
```
|
```
|
||||||
|
|
||||||
`--status rejected` 筛选某次投稿的最新已知决定。它并不表示论文在所有会议均未录用;分析转投情况时查看该论文的完整投稿历史。年份表示会议届次,观察时间和决定日期分别保留。
|
`--status rejected` 筛选某次投稿的最新已知决定。它并不表示论文在所有会议均未录用;分析转投情况时查看该论文的完整投稿历史。年份表示会议届次,观察时间和决定日期分别保留。
|
||||||
|
|
||||||
|
覆盖报告使用只读一致性快照,标明实际数据库和 schema,并列出零入库目标与注册表外的已入库会议。逐届、逐 track 分别列出最新观察状态的投稿数、全部决定观察数、各来源记录数,以及来源摘要和论文实体摘要的缺失数。摘要缺失指 NULL 或空字符串;有文字不代表内容已经完整核验。同一来源的多个清单 URL 可以重叠,不能把清单计数相加当作来源总量;零条拒稿也不证明该会议没有公开拒稿。SQLite 样本仍可显式使用 `--db`,生产报告应查询当前 PostgreSQL。
|
||||||
|
|
||||||
例如,查找在 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 是决定观察数。一个投稿可能有多个来源和多条观察,不能相加当作论文数。
|
||||||
|
|
||||||
|
## 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 的历史情况。
|
||||||
|
|||||||
+118
-27
@@ -1,45 +1,136 @@
|
|||||||
"""Show every target venue, including those with no imported records."""
|
"""Describe imported venue coverage without implying a complete census."""
|
||||||
import argparse
|
import argparse
|
||||||
from paper_atlas.config import DEFAULT_REGISTRY
|
from collections import defaultdict
|
||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
|
||||||
|
from paper_atlas.config import DEFAULT_REGISTRY
|
||||||
|
from paper_atlas.db.backend import database_arguments, open_read_connection
|
||||||
from paper_atlas.db.submissions import timestamp
|
from paper_atlas.db.submissions import timestamp
|
||||||
|
from paper_atlas.querying import LATEST_DECISION
|
||||||
|
|
||||||
|
|
||||||
def report(db,registry):
|
STATUSES = ('accepted', 'rejected', 'desk_rejected', 'withdrawn', 'under_review', 'unknown')
|
||||||
registry=json.loads(Path(registry).read_text());targets=registry['venues']
|
|
||||||
names=[row['acronym'].casefold() for row in targets]
|
|
||||||
if len(names)!=len(set(names)):raise ValueError('Duplicate target venue')
|
def _status_counts(connection, *, latest):
|
||||||
scope='(v.acronym=? COLLATE NOCASE OR EXISTS(SELECT 1 FROM venue_aliases a WHERE a.venue_id=v.id AND a.alias=? COLLATE NOCASE))'
|
if latest:
|
||||||
c=sqlite3.connect(Path(db).resolve().as_uri()+'?mode=ro',uri=True);c.row_factory=sqlite3.Row
|
query = '''SELECT s.edition_id,COALESCE(d.status,'unknown') AS status,count(*) AS count
|
||||||
|
FROM submissions s LEFT JOIN decisions d ON d.submission_id=s.id AND ''' + LATEST_DECISION
|
||||||
|
else:
|
||||||
|
query = '''SELECT s.edition_id,COALESCE(d.status,'unknown') AS status,count(*) AS count
|
||||||
|
FROM submissions s JOIN decisions d ON d.submission_id=s.id'''
|
||||||
|
counts = defaultdict(dict)
|
||||||
|
for row in connection.execute(query + " GROUP BY s.edition_id,COALESCE(d.status,'unknown')"):
|
||||||
|
counts[row[0]][row[1]] = row[2]
|
||||||
|
return counts
|
||||||
|
|
||||||
|
|
||||||
|
def report(db, registry, *, pg_service=None, pg_schema=None):
|
||||||
|
started_at = timestamp()
|
||||||
|
registry = json.loads(Path(registry).read_text())
|
||||||
|
targets = registry['venues']
|
||||||
|
names = [row['acronym'].casefold() for row in targets]
|
||||||
|
if len(names) != len(set(names)):
|
||||||
|
raise ValueError('Duplicate target venue')
|
||||||
|
scope = '''(lower(v.acronym)=lower(?) OR EXISTS(
|
||||||
|
SELECT 1 FROM venue_aliases a WHERE a.venue_id=v.id AND lower(a.alias)=lower(?)))'''
|
||||||
|
connection = open_read_connection(db, pg_service=pg_service, pg_schema=pg_schema)
|
||||||
|
if not pg_service:
|
||||||
|
connection.row_factory = sqlite3.Row
|
||||||
try:
|
try:
|
||||||
c.execute('BEGIN');venues=[]
|
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
|
||||||
|
latest = _status_counts(connection, latest=True)
|
||||||
|
observations = _status_counts(connection, latest=False)
|
||||||
|
# Aggregate the paper join once for the whole snapshot. Repeating it
|
||||||
|
# for each registry target caused repeated scans on the production DB.
|
||||||
|
edition_counts = {}
|
||||||
|
for row in connection.execute('''SELECT s.edition_id,count(s.id) AS submissions,
|
||||||
|
count(DISTINCT s.paper_id) AS papers,
|
||||||
|
count(DISTINCT CASE WHEN p.abstract IS NULL OR p.abstract='' THEN s.paper_id END)
|
||||||
|
AS papers_without_abstract_text
|
||||||
|
FROM submissions s LEFT JOIN papers p ON p.id=s.paper_id GROUP BY s.edition_id'''):
|
||||||
|
entry = dict(row)
|
||||||
|
edition_counts[entry.pop('edition_id')] = entry
|
||||||
|
source_counts = defaultdict(list)
|
||||||
|
for row in connection.execute('''SELECT s.edition_id,so.name AS source,
|
||||||
|
count(DISTINCT sr.id) AS source_records,
|
||||||
|
count(DISTINCT CASE WHEN sr.abstract IS NULL OR sr.abstract='' THEN sr.id END)
|
||||||
|
AS source_records_without_abstract_text
|
||||||
|
FROM submission_sources ss JOIN submissions s ON s.id=ss.submission_id
|
||||||
|
JOIN source_records sr ON sr.id=ss.source_record_id JOIN sources so ON so.id=sr.source_id
|
||||||
|
GROUP BY s.edition_id,so.name ORDER BY s.edition_id,so.name'''):
|
||||||
|
entry = dict(row)
|
||||||
|
source_counts[entry.pop('edition_id')].append(entry)
|
||||||
|
venues, covered_venue_ids = [], set()
|
||||||
for target in targets:
|
for target in targets:
|
||||||
acronym=target['acronym'];params=(acronym,acronym)
|
acronym = target['acronym']
|
||||||
editions=[]
|
params = (acronym, acronym)
|
||||||
for row in c.execute('SELECT e.id,v.acronym AS canonical_venue,e.year,e.track,count(s.id) AS submissions,count(DISTINCT s.paper_id) AS papers FROM editions e JOIN venues v ON v.id=e.venue_id LEFT JOIN submissions s ON s.edition_id=e.id WHERE '+scope+' GROUP BY e.id ORDER BY e.year,e.track',params):
|
editions = []
|
||||||
edition=dict(row)
|
for row in connection.execute('''SELECT e.id,v.id AS venue_id,v.acronym AS canonical_venue,
|
||||||
edition['sources']=[dict(source) for source in c.execute('SELECT so.name,es.status,es.expected_count,es.imported_count,es.list_url,es.last_success_at,es.last_error FROM edition_sources es JOIN sources so ON so.id=es.source_id WHERE es.edition_id=? ORDER BY so.name',(row['id'],))]
|
e.year,e.track FROM editions e JOIN venues v ON v.id=e.venue_id
|
||||||
|
WHERE ''' + scope + '''
|
||||||
|
ORDER BY e.year,e.track,v.acronym''', params):
|
||||||
|
edition = dict(row)
|
||||||
|
edition.update(edition_counts.get(row['id'], {
|
||||||
|
'submissions': 0, 'papers': 0, 'papers_without_abstract_text': 0}))
|
||||||
|
covered_venue_ids.add(edition['venue_id'])
|
||||||
|
edition['latest_observed_status_counts'] = {status: latest[row['id']].get(status, 0) for status in STATUSES}
|
||||||
|
edition['decision_observation_counts'] = {status: observations[row['id']].get(status, 0) for status in STATUSES}
|
||||||
|
edition['source_record_counts'] = source_counts[row['id']]
|
||||||
|
edition['sources'] = [dict(source) for source in connection.execute('''SELECT so.name,
|
||||||
|
es.status,es.expected_count,es.imported_count,es.list_url,es.last_success_at,es.last_error
|
||||||
|
FROM edition_sources es JOIN sources so ON so.id=es.source_id
|
||||||
|
WHERE es.edition_id=? ORDER BY so.name,es.list_url''', (row['id'],))]
|
||||||
editions.append(edition)
|
editions.append(edition)
|
||||||
totals=c.execute('SELECT count(s.id),count(DISTINCT s.paper_id) FROM submissions s JOIN editions e ON e.id=s.edition_id JOIN venues v ON v.id=e.venue_id WHERE '+scope,params).fetchone()
|
totals = connection.execute('''SELECT count(s.id),count(DISTINCT s.paper_id)
|
||||||
venues.append({'acronym':acronym,'field':target['field'],'imported_submissions':totals[0],'distinct_papers':totals[1],
|
FROM submissions s JOIN editions e ON e.id=s.edition_id
|
||||||
'history_status':'partial_history_pending_independent_audit' if totals[0] else 'no_imported_records',
|
JOIN venues v ON v.id=e.venue_id WHERE ''' + scope, params).fetchone()
|
||||||
'discovery_sources':target.get('discovery_sources',[]),'editions':editions})
|
venues.append({'acronym': acronym, 'field': target['field'],
|
||||||
return {'checked_at':timestamp(),'target_venue_count':len(targets),'venues_with_imported_records':sum(bool(v['imported_submissions']) for v in venues),
|
'imported_submissions': totals[0], 'distinct_papers': totals[1],
|
||||||
'scope_note':registry['scope_note'],'interpretation':'All registry targets are listed. Edition source complete means that source file passed import checks; it does not prove full historical or abstract coverage. Papers can appear in multiple venues/years, so do not sum venue paper counts as a global total.',
|
'history_status': 'partial_history_pending_independent_audit' if totals[0] else 'no_imported_records',
|
||||||
'venues':venues}
|
'discovery_sources': target.get('discovery_sources', []), 'editions': editions})
|
||||||
finally:c.close()
|
outside_registry = [dict(row) for row in connection.execute('''SELECT v.id AS venue_id,
|
||||||
|
v.acronym,count(s.id) AS submissions,count(DISTINCT s.paper_id) AS papers
|
||||||
|
FROM submissions s JOIN editions e ON e.id=s.edition_id JOIN venues v ON v.id=e.venue_id
|
||||||
|
GROUP BY v.id,v.acronym ORDER BY v.acronym''') if row[0] not in covered_venue_ids]
|
||||||
|
return {'report_started_at': started_at, 'checked_at': timestamp(),
|
||||||
|
'engine': 'postgresql' if pg_service else 'sqlite', 'database': database, 'schema': schema,
|
||||||
|
'pg_service': pg_service, 'target_venue_count': len(targets),
|
||||||
|
'venues_with_imported_records': sum(bool(v['imported_submissions']) for v in venues),
|
||||||
|
'scope_note': registry['scope_note'],
|
||||||
|
'interpretation': 'All registry targets are listed, including zero imports. '
|
||||||
|
'Latest status counts use the latest observed decision for each attempt; decision observations retain history. '
|
||||||
|
'Source record totals are per source and edition, not per list URL; multiple list entries may describe overlapping records. '
|
||||||
|
'Missing abstract text means NULL or empty string; nonempty text does not prove a complete abstract. '
|
||||||
|
'An edition source marked complete is not a proof of complete conference history or public rejection coverage. '
|
||||||
|
'Zero imported rejections does not establish that no rejections are public. '
|
||||||
|
'Papers can appear in several venues/years; do not sum venue paper counts as a global total.',
|
||||||
|
'venues': venues, 'imported_venues_outside_registry': outside_registry}
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
|
||||||
def main(argv=None, *, prog=None):
|
def main(argv=None, *, prog=None):
|
||||||
parser=argparse.ArgumentParser(prog=prog);parser.add_argument('--db',required=True)
|
parser = argparse.ArgumentParser(prog=prog)
|
||||||
parser.add_argument('--registry',default=str(DEFAULT_REGISTRY));parser.add_argument('--out')
|
database_arguments(parser)
|
||||||
args=parser.parse_args(argv);result=report(args.db,args.registry);text=json.dumps(result,ensure_ascii=False,indent=2)
|
parser.add_argument('--registry', default=str(DEFAULT_REGISTRY))
|
||||||
|
parser.add_argument('--out')
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
result = report(args.db, args.registry, pg_service=args.pg_service, pg_schema=args.pg_schema)
|
||||||
|
text = json.dumps(result, ensure_ascii=False, indent=2)
|
||||||
if args.out:
|
if args.out:
|
||||||
path=Path(args.out);stage=path.with_suffix('.part');stage.write_text(text+'\n');stage.replace(path)
|
path = Path(args.out)
|
||||||
else:print(text)
|
stage = path.with_suffix('.part')
|
||||||
|
stage.write_text(text + '\n')
|
||||||
|
stage.replace(path)
|
||||||
|
else:
|
||||||
|
print(text)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
|||||||
@@ -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', {}, '只读核对跨会议投稿历史及决定来源关系'),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,25 @@ def open_store(db=None, *, pg_service=None, pg_schema=None):
|
|||||||
return Store(db)
|
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=()):
|
def inserted_id(connection, sql, params=()):
|
||||||
if getattr(connection, 'postgres', False):
|
if getattr(connection, 'postgres', False):
|
||||||
return connection.execute(sql + ' RETURNING id', params).fetchone()[0]
|
return connection.execute(sql + ' RETURNING id', params).fetchone()[0]
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ 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, inserted_id
|
from paper_atlas.db.backend import require_sqlite_writable, inserted_id
|
||||||
|
|
||||||
|
|
||||||
@@ -142,7 +142,7 @@ class Store:
|
|||||||
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 INTO paper_tags VALUES(?,?,?,?) ON CONFLICT DO NOTHING',(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 lower(acronym)=lower(?)',(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]
|
||||||
@@ -154,6 +154,10 @@ class Store:
|
|||||||
# 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))
|
||||||
|
|||||||
@@ -70,6 +70,12 @@ def next_id(c,table,audit,former):
|
|||||||
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()
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""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
|
||||||
@@ -7,6 +8,8 @@ from pathlib import Path
|
|||||||
from paper_atlas.db.backend import database_arguments, open_store, inserted_id
|
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, *, pg_service=None, pg_schema=None):
|
def ingest_file(db,path, *, pg_service=None, pg_schema=None):
|
||||||
@@ -30,10 +33,12 @@ def ingest_file(db,path, *, pg_service=None, pg_schema=None):
|
|||||||
editions = set()
|
editions = set()
|
||||||
try:
|
try:
|
||||||
source_id = store.source(source_name,manifest['list_url'])
|
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)})))
|
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()
|
c.commit()
|
||||||
with path.open() as stream:
|
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')
|
||||||
@@ -60,7 +65,7 @@ def ingest_file(db,path, *, pg_service=None, pg_schema=None):
|
|||||||
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')
|
||||||
@@ -68,6 +73,8 @@ def ingest_file(db,path, *, pg_service=None, pg_schema=None):
|
|||||||
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))
|
||||||
@@ -76,7 +83,7 @@ def ingest_file(db,path, *, pg_service=None, pg_schema=None):
|
|||||||
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())))
|
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())))
|
||||||
c.execute("UPDATE ingest_runs SET status='complete',finished_at=?,records_seen=?,records_written=? WHERE id=?",(timestamp(),count,count,run))
|
c.execute("UPDATE ingest_runs SET status='complete',finished_at=?,records_seen=?,records_written=?,checkpoint_json=? WHERE id=?",(timestamp(),count,count,json.dumps({'file':str(path),'file_sha256':digest.hexdigest()}),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:
|
||||||
|
|||||||
@@ -7,6 +7,34 @@ from paper_atlas.audits.coverage import report
|
|||||||
from paper_atlas.db.store import Store
|
from paper_atlas.db.store import Store
|
||||||
|
|
||||||
|
|
||||||
|
def add_coverage_evidence(store):
|
||||||
|
paper, record = store.record('official_metadata', 'known', 'https://example.org/known',
|
||||||
|
'Known paper with two source observations', 'Complete canonical abstract', ['Jane Doe', 'John Smith'],
|
||||||
|
{'openreview': 'coverageForum', 'arxiv': '2401.98765'})
|
||||||
|
edition = store.decision(paper, record, 'COLING+LREC', 2024, 'main', 'known',
|
||||||
|
'https://example.org/accept', 'accepted', 'Accept', aliases=['COLING', 'LREC'],
|
||||||
|
identities=[('openreview', 'coverageForum')], observed_at='2025-05-01T00:00:00Z')
|
||||||
|
same, mirror = store.record('bibliography', 'mirror', 'https://example.org/mirror',
|
||||||
|
'Known paper with two source observations', None, ['Jane Doe', 'John Smith'], {'openreview': 'coverageForum'})
|
||||||
|
store.decision(same, mirror, 'COLING+LREC', 2024, 'main', 'mirror', 'https://example.org/older',
|
||||||
|
'rejected', 'Reject', identities=[('openreview', 'coverageForum')],
|
||||||
|
observed_at='2025-05-01T00:00:00Z', decision_date='2025-04-30T00:00:00Z')
|
||||||
|
unknown, unknown_record = store.record('unknown_catalogue', 'unknown', 'https://example.org/unknown',
|
||||||
|
'Indexed work without a public decision', None, [])
|
||||||
|
store.decision(unknown, unknown_record, 'COLING+LREC', 2024, 'main', 'unknown',
|
||||||
|
'https://example.org/unknown', None, '', identities=[('openreview', 'unknownForum')])
|
||||||
|
same, later = store.record('official_metadata', 'later', 'https://example.org/later',
|
||||||
|
'Known paper with two source observations', 'Complete canonical abstract', ['Jane Doe', 'John Smith'],
|
||||||
|
{'openreview': 'coverageNext', 'arxiv': '2401.98765'})
|
||||||
|
store.decision(same, later, 'COLING+LREC', 2025, 'findings', 'later', 'https://example.org/later',
|
||||||
|
'accepted', 'Accept', identities=[('openreview', 'coverageNext')], observed_at='2025-06-01T00:00:00Z')
|
||||||
|
source = store.source('official_metadata', 'https://example.org')
|
||||||
|
for url, status in [('https://example.org/list', 'complete'), ('https://example.org/identity-refresh', 'partial')]:
|
||||||
|
store.conn.execute('''INSERT INTO edition_sources(edition_id,source_id,list_url,status,expected_count,imported_count)
|
||||||
|
VALUES(?,?,?,?,?,?)''', (edition, source, url, status, 1, 1))
|
||||||
|
store.conn.commit()
|
||||||
|
|
||||||
|
|
||||||
class CoverageTests(unittest.TestCase):
|
class CoverageTests(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.tmp=tempfile.TemporaryDirectory();self.addCleanup(self.tmp.cleanup)
|
self.tmp=tempfile.TemporaryDirectory();self.addCleanup(self.tmp.cleanup)
|
||||||
@@ -31,5 +59,46 @@ class CoverageTests(unittest.TestCase):
|
|||||||
self.store.conn.commit();coling=report(self.db,self.registry)['venues'][0]
|
self.store.conn.commit();coling=report(self.db,self.registry)['venues'][0]
|
||||||
self.assertEqual(coling['imported_submissions'],2);self.assertEqual(coling['distinct_papers'],1)
|
self.assertEqual(coling['imported_submissions'],2);self.assertEqual(coling['distinct_papers'],1)
|
||||||
|
|
||||||
|
def test_observations_attempts_and_missing_source_abstracts_stay_separate(self):
|
||||||
|
add_coverage_evidence(self.store)
|
||||||
|
result = report(self.db, self.registry)
|
||||||
|
self.assertEqual(result['engine'], 'sqlite')
|
||||||
|
self.assertEqual(result['database'], str(self.db))
|
||||||
|
coling = result['venues'][0]
|
||||||
|
self.assertEqual((coling['imported_submissions'], coling['distinct_papers']), (3, 2))
|
||||||
|
edition, findings = coling['editions']
|
||||||
|
self.assertEqual((edition['submissions'], edition['papers'], edition['papers_without_abstract_text']), (2, 2, 1))
|
||||||
|
self.assertEqual(edition['latest_observed_status_counts']['accepted'], 1)
|
||||||
|
self.assertEqual(edition['latest_observed_status_counts']['rejected'], 0)
|
||||||
|
self.assertEqual(edition['latest_observed_status_counts']['unknown'], 1)
|
||||||
|
self.assertEqual(edition['decision_observation_counts']['accepted'], 1)
|
||||||
|
self.assertEqual(edition['decision_observation_counts']['rejected'], 1)
|
||||||
|
self.assertEqual(edition['decision_observation_counts']['unknown'], 0)
|
||||||
|
counts = {row['source']: row for row in edition['source_record_counts']}
|
||||||
|
self.assertEqual(counts['bibliography']['source_records_without_abstract_text'], 1)
|
||||||
|
self.assertEqual(counts['official_metadata']['source_records'], 1)
|
||||||
|
self.assertEqual(len(edition['sources']), 2)
|
||||||
|
self.assertEqual(findings['track'], 'findings')
|
||||||
|
self.assertEqual(result['imported_venues_outside_registry'], [])
|
||||||
|
|
||||||
|
def test_missing_sqlite_audit_does_not_create_a_database(self):
|
||||||
|
path = self.root / 'absent.sqlite'
|
||||||
|
with self.assertRaisesRegex(ValueError, 'Existing database'):
|
||||||
|
report(path, self.registry)
|
||||||
|
self.assertFalse(path.exists())
|
||||||
|
|
||||||
|
def test_empty_editions_remain_visible_after_shared_aggregation(self):
|
||||||
|
self.store.conn.execute("INSERT INTO venues(acronym,name,field,venue_type) VALUES('AAAI','AAAI','general_ai','conference')")
|
||||||
|
venue = self.store.conn.execute("SELECT id FROM venues WHERE acronym='AAAI'").fetchone()[0]
|
||||||
|
self.store.conn.execute("INSERT INTO editions(venue_id,year,track) VALUES(?,2026,'main')", (venue,))
|
||||||
|
self.store.conn.commit()
|
||||||
|
aaai = report(self.db, self.registry)['venues'][1]
|
||||||
|
self.assertEqual(aaai['history_status'], 'no_imported_records')
|
||||||
|
self.assertEqual(len(aaai['editions']), 1)
|
||||||
|
edition = aaai['editions'][0]
|
||||||
|
self.assertEqual((edition['submissions'], edition['papers'], edition['papers_without_abstract_text']), (0, 0, 0))
|
||||||
|
self.assertEqual(sum(edition['latest_observed_status_counts'].values()), 0)
|
||||||
|
self.assertEqual(edition['source_record_counts'], [])
|
||||||
|
|
||||||
|
|
||||||
if __name__=='__main__':unittest.main()
|
if __name__=='__main__':unittest.main()
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -175,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):
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from unittest.mock import patch
|
|||||||
import uuid
|
import uuid
|
||||||
import zipfile
|
import zipfile
|
||||||
|
|
||||||
from paper_atlas.db.backend import open_store
|
from paper_atlas.db.backend import open_store, open_read_connection
|
||||||
from paper_atlas.db.store import Store
|
from paper_atlas.db.store import Store
|
||||||
from paper_atlas.ingestion import arxiv
|
from paper_atlas.ingestion import arxiv
|
||||||
from paper_atlas.ingestion.backfill import backfill
|
from paper_atlas.ingestion.backfill import backfill
|
||||||
@@ -27,6 +27,13 @@ class BackendSelectionTests(unittest.TestCase):
|
|||||||
with self.assertRaisesRegex(ValueError, 'Choose exactly one'):
|
with self.assertRaisesRegex(ValueError, 'Choose exactly one'):
|
||||||
open_store(**kwargs)
|
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')
|
@unittest.skipUnless(os.environ.get('PAPER_ATLAS_TEST_PG_SERVICE'), 'Requires the approved remote PostgreSQL test service')
|
||||||
class PostgreSQLIngestionTests(unittest.TestCase):
|
class PostgreSQLIngestionTests(unittest.TestCase):
|
||||||
@@ -138,6 +145,21 @@ class PostgreSQLIngestionTests(unittest.TestCase):
|
|||||||
finally:
|
finally:
|
||||||
store.conn.close()
|
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):
|
def test_writer_lock_survives_commit_and_rollback_but_readers_continue(self):
|
||||||
first = self.store()
|
first = self.store()
|
||||||
try:
|
try:
|
||||||
@@ -165,6 +187,106 @@ class PostgreSQLIngestionTests(unittest.TestCase):
|
|||||||
with connect(self.service) as pg:
|
with connect(self.service) as pg:
|
||||||
self.assertEqual(pg.execute('SELECT count(*) FROM information_schema.tables WHERE table_schema=%s', (staging,)).fetchone()[0], 0)
|
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_coverage_matches_sqlite_with_history_aliases_and_missing_source_abstracts(self):
|
||||||
|
from paper_atlas.audits.coverage import report
|
||||||
|
from tests.test_coverage_report import add_coverage_evidence
|
||||||
|
registry = self.root / 'venues.json'
|
||||||
|
registry.write_text(json.dumps({'scope_note': 'Candidate scope', 'venues': [
|
||||||
|
{'acronym': 'coling', 'field': 'language'}, {'acronym': 'AAAI', 'field': 'general_ai'}]}))
|
||||||
|
sample = Store(self.root / 'seed.sqlite')
|
||||||
|
try:
|
||||||
|
add_coverage_evidence(sample)
|
||||||
|
finally:
|
||||||
|
sample.conn.close()
|
||||||
|
store = self.store()
|
||||||
|
try:
|
||||||
|
add_coverage_evidence(store)
|
||||||
|
finally:
|
||||||
|
store.conn.close()
|
||||||
|
sqlite_report = report(self.root / 'seed.sqlite', registry)
|
||||||
|
observed = []
|
||||||
|
def reader(*args, **kwargs):
|
||||||
|
connection = open_read_connection(*args, **kwargs)
|
||||||
|
observed.append((connection.execute('SHOW transaction_read_only').fetchone()[0],
|
||||||
|
connection.execute('SHOW transaction_isolation').fetchone()[0]))
|
||||||
|
return connection
|
||||||
|
with patch('paper_atlas.audits.coverage.open_read_connection', side_effect=reader):
|
||||||
|
actual = report(None, registry, pg_service=self.service, pg_schema=self.schema)
|
||||||
|
self.assertEqual(observed, [('on', 'repeatable read')])
|
||||||
|
self.assertEqual(actual['engine'], 'postgresql')
|
||||||
|
self.assertEqual(actual['schema'], self.schema)
|
||||||
|
self.assertEqual(actual['pg_service'], self.service)
|
||||||
|
def content(result):
|
||||||
|
values = json.loads(json.dumps(result['venues']))
|
||||||
|
for venue in values:
|
||||||
|
for edition in venue['editions']:
|
||||||
|
for key in ('id', 'venue_id'):
|
||||||
|
edition.pop(key)
|
||||||
|
return values
|
||||||
|
self.assertEqual(content(actual), content(sqlite_report))
|
||||||
|
self.assertEqual([r['acronym'] for r in actual['imported_venues_outside_registry']], ['ICLR'])
|
||||||
|
edition = actual['venues'][0]['editions'][0]
|
||||||
|
self.assertEqual(edition['latest_observed_status_counts']['accepted'], 1)
|
||||||
|
self.assertEqual(edition['latest_observed_status_counts']['unknown'], 1)
|
||||||
|
self.assertEqual(edition['decision_observation_counts']['rejected'], 1)
|
||||||
|
self.assertEqual(edition['papers_without_abstract_text'], 1)
|
||||||
|
|
||||||
def test_backfill_merges_evidence_without_reimporting_or_losing_history(self):
|
def test_backfill_merges_evidence_without_reimporting_or_losing_history(self):
|
||||||
files = []
|
files = []
|
||||||
for source, external, title in [('official', 'legacy', 'First bibliographic name'), ('openreview_snapshot', 'forum', 'Renamed submission')]:
|
for source, external, title in [('official', 'legacy', 'First bibliographic name'), ('openreview_snapshot', 'forum', 'Renamed submission')]:
|
||||||
|
|||||||
@@ -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