Compare commits

..

2 Commits

Author SHA1 Message Date
codex-bot 1cf1d228bc Prepare evidence-bound identifier additions without rewriting source records
Metadata integrity checks / metadata (push) Successful in 1m20s
2026-11-08 16:25:33 +08:00
codex-bot 9cc7b5b050 Keep stable paper identities separate from ambiguous title matches
Metadata integrity checks / metadata (push) Successful in 1m16s
2026-11-08 15:47:07 +08:00
21 changed files with 549 additions and 584 deletions
+1 -1
View File
@@ -49,12 +49,12 @@ jobs:
subprocess.run([str(venv/'bin/paper-atlas'),'--help'],cwd=outside,check=True)
subprocess.run([python,'-I','-B','-m','paper_atlas','collect','cvf','--help'],cwd=outside,check=True)
subprocess.run([python,'-I','-B','-m','paper_atlas','maintain','postgres','--help'],cwd=outside,check=True)
subprocess.run([python,'-I','-B','-m','paper_atlas','maintain','event-identity','--help'],cwd=outside,check=True)
subprocess.run([python,'-I','-B','-m','paper_atlas','import','records','--help'],cwd=outside,check=True)
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','-m','paper_atlas','curate','identifiers','--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()"
subprocess.run([python,'-I','-B','-c',smoke],cwd=outside,check=True)
-2
View File
@@ -86,5 +86,3 @@ paper-atlas collect colm --cache /data/paper-index/raw/http-cache \
CI 在已授权的远程 runner 上运行测试、构建 wheel,并从源码目录外验证安装后的命令及随包资源。数据库全量去重和覆盖率是独立的数据验收步骤,不因代码测试通过而自动完成。
开发通过工作分支和 PR 交付,由用户手动合并。
同年多届的 `event_key`、显式迁移、旧文件刷新与查询边界见 [会议事件身份](docs/event-identity.md)。
-44
View File
@@ -1,44 +0,0 @@
# 同年多届会议的事件身份
`venue`、`year`、`track` 描述系列、年份和投稿轨道;`event_key` 区分同系列、同年、同轨道的实际会议事件。例如 WWW 1995 的春季与秋季会议分别使用 `www-1995-spring` 和 `www-1995-fall`,两者仍是 `main`。相同论文可以在两届有不同投稿与决定;论文去重不会把这两次投稿合成一次。
普通单届数据继续使用空键。新输入在同一组已有具名事件或已知同年多届时必须提供明确键;不能任选第一个 edition。已有来源刷新可依据自己的原投稿关系回到原事件,不能被显式改到另一事件。精确固定 DBLP 快照、WWW Fall 书目名、1995 年及 `www1995f.html` 卷共同支持旧秋季文件的归属;不按标题猜年份或季节。
规范 JSONL 行及 manifest 都可提供 `event_key`,非空值限制为最多80个小写字母、数字及 `._:-`,首字符为字母或数字。manifest 提供键时,每行必须一致;一份文件仍仅对应一个事件及轨道。跨行冲突会回滚整份业务导入。身份回填同样按事件选择,不能借机把来源移到另一届。
## 新样本与既有数据
新建 SQLite 样本的 `editions` 唯一键为 `(venue_id, year, track, event_key)`。已有 SQLite/PostgreSQL 不会因普通读写命令自动升级。其普通单届导入继续可用;WWW1994/1995或带事件键的导入会要求先迁移。冻结的 SQLite 生产备份仍受 cutover 标记保护,不能写入或升级。
先在 PostgreSQL 生成只读计划:
```sh
PGSERVICEFILE=/etc/paper-atlas/pg_service.conf paper-atlas maintain event-identity \
--pg-service foundry-research --pg-schema research --out /data/paper-index/event-plan.json
```
检查计划的数据库身份、原 edition IDs、业务表数量、目标来源与历史指纹。精确计数在同一快照中按主键分页,每页最多5000行,只读取主键;不使用估计行数。CLI向stderr持续报告计数进度,避免大库长时间无输出。已有未标记 WWW1995 edition 必须逐投稿都有固定 DBLP Fall 卷证据,才会只为原 edition 填入 `www-1995-fall`;缺证或混合卷会拒绝计划。本文命令不表示生产已经迁移。
应用时传入同一文件及命令输出的文件 SHA256:
```sh
PGSERVICEFILE=/etc/paper-atlas/pg_service.conf paper-atlas maintain event-identity \
--pg-service foundry-research --pg-schema research \
--apply-plan /data/paper-index/event-plan.json --plan-sha256 PLAN_FILE_SHA256 \
--out /data/paper-index/event-applied.json
```
原生 PostgreSQL writer 使用现有 schema advisory lock,并在事务中锁定 editions,核对计划后添加列及替换唯一约束。SQLite 样本使用独占写事务,重建父表时保留 IDs,并在提交前检查外键;不级联删除子记录。额外列、未知唯一约束或 SQLite 额外索引会拒绝自动升级,须单独评估。未改变原版四列的排序内容哈希、任何业务表数量或目标投稿/决定/来源内容,才可提交;失败回滚。迁移标记与 DDL 同事务。原计划立即重试为 `already_applied`;若后续业务已经变化,应重新生成计划,不重放旧计划。
ECML 与 PKDD 的联合举办前事件已有不同 venue/edition,保持原身份即可。这项迁移不会把它们合并,也不会创建新的论文、投稿或决定。WWW 春季及1994两届的逐篇采集、来源核验和导入仍须另做。
## 查询与验收
```sh
paper-atlas query --pg-service foundry-research --pg-schema research \
--venue WWW --year 1995 --track main --event-key www-1995-fall
```
详细投稿历史及 `known_acceptances` 返回 `event_key`。`--event-key` 可与 `--status` 一起限定同一事件中的最新已知决定。未迁移的旧库仍可普通查询,其事件键为空;不能声称已经区分季节。`audit histories` 的新库事件输出也保留键。
远程测试应覆盖同一作品的两届独立投稿、拒稿后另一届录用、原 IDs 与 Unicode/NUL 来源内容保留、旧文件刷新、歧义及错误键拒绝、整文件回滚、明确事件的身份回填、旧计划失效、重试与真实 PostgreSQL 迁移。生产应用后还须独立核验约束、来源和历史、全文索引及 foundry-core 健康;单元测试不替代生产或最终全库去重验收。
+51
View File
@@ -2,6 +2,55 @@
先在已确认的远程环境安装项目,使用 `paper-atlas`。从源码运行时,将命令前缀替换为 `python3 -B -m paper_atlas`,其余参数相同。数据目录位于仓库外。
## 按证据补充稳定标识
`paper-atlas curate identifiers --plan /data/review/plan.json --output /data/review/prepared`
只生成新的 JSONL、manifest、证据副本及 `curation-report.json`,不会连接数据库。
输出目录必须不存在,父目录必须已存在;任何输入或断言失败,都不发布本批输出。
原始文件不变,未选中的记录逐字节保留。题名、作者、年份、页码、投稿决定、标签和既有 metadata
均不重写,仅补充 `identifiers` 并追加 `metadata.identifier_curation` 证据链。
计划格式如下(哈希占位符须替换为真实 SHA256,所有路径按命令当前目录解析):
```json
{
"format_version": 1,
"inputs": [{
"file": "/data/review/uai2001.jsonl",
"sha256": "<JSONL 文件 SHA256>",
"manifest_sha256": "<同名 .manifest.json 文件 SHA256>"
}],
"assertions": [{
"source": "dblp",
"external_id": "conf/uai/example",
"record_sha256": "<原始物理行字节 SHA256,包含该行原有换行符>",
"identifiers": {"arxiv": "1408.1485"},
"evidence": [{
"file": "/data/review/primary-metadata.json",
"sha256": "<证据 JSON 文件 SHA256>",
"url": "https://example.org/primary-record",
"checked_at": "2026-11-08T08:00:00Z",
"note": "说明人工核对的题名、作者、出版版本和稳定标识证据"
}]
}]
}
```
输入 manifest 必须包含 `source/venue/year/track/count/list_url/file_sha256`,且与逐条记录一致。
断言必须唯一、准确命中一条记录。仅支持无版本后缀的 arXiv 编号与小写规范 DOI;
已有同类标识不允许覆盖,相同值可以补记证据。证据只接受不超过 16 MiB 的 JSON 元数据文件,
来源 URL 要求 HTTPS 且不含凭据,核查时间必须带时区;不下载论文 PDF。
`verification_scope=input_and_evidence_integrity_only` 和 `semantic_identity_verified=false`
明确表示哈希校验不能证明两条记录属于同一作品。请先独立核对版本关系;仅年份、页码或相同题名
不能作为合并依据。存在来源冲突的行应继续暂缓。新 manifest 保留原来的 partial 状态及摘要缺失数,
不会把补充编号伪装成来源覆盖或摘要补全。证据副本按 SHA256 保存在输出目录 `evidence/`,
原计划也保存在 `plan.json`,供之后复查。
文件生成后仍需另做当前 PostgreSQL 身份、投稿和决定冲突预检;导入运行时必须包含
“稳定标识优先、多个标题候选则整文件回滚”的身份保护,再以 `import records` 独立导入并验收。
该命令不会自动导入或合并实体,也不代表最终全库去重通过。
## PostgreSQL 查询
在远程环境安装 `.[postgres]`,通过权限为 `0600` 的 libpq service 文件连接,不把密码放入 URL、命令行或仓库。
@@ -99,6 +148,8 @@ paper-atlas query --pg-service foundry-research \
`--stats` 中 papers 是当前规则下的论文实体数;source_records 是来源记录数;decisions 是决定观察数。一个投稿可能有多个来源和多条观察,不能相加当作论文数。
已有稳定标识或原来源记录匹配时,该身份优先;同标题、同作者的另一 arXiv 存档或扩展稿不会仅因相似标题被并入。没有身份锚点时,作者佐证的精确标题只有一个候选才可匹配;多个候选会报 `Ambiguous paper identity`,整个采集文件回滚并记录失败运行。应补有证据的标识或先核查版本关系,不删除重复候选或任取第一个。一个新来源明确同时携带两个既有稳定标识时,仍可按既有规则归并并保留投稿、标签和合并日志;未归并的同标题关系继续保留为待核查候选。这些规则不证明全库去重完成。
## 5. 投稿历史关系审计
```sh
+1 -5
View File
@@ -124,11 +124,7 @@ def report(db=None, *, pg_service=None, pg_schema=None, examples=20):
sample_attempts.append(rows)
current = []
from paper_atlas.db.events import has_event_key
attempts_sql = ATTEMPTS
if has_event_key(connection):
attempts_sql = attempts_sql.replace('e.year,e.track,', 'e.year,e.track,e.event_key,', 1).replace('e.year,e.track\n', 'e.year,e.track,e.event_key\n')
for raw in connection.execute(attempts_sql):
for raw in connection.execute(ATTEMPTS):
row = dict(raw)
if current and row['paper_id'] != current[0]['paper_id']:
consume(current)
+2 -2
View File
@@ -6,6 +6,7 @@ from paper_atlas import __version__
COMMANDS = {
('curate','identifiers'): ('paper_atlas.curation.identifiers', {}, '按证据补充稳定标识并生成独立待导入文件'),
('query',): ('paper_atlas.querying', {}, '查询论文、标签和投稿历史'),
('coverage',): ('paper_atlas.audits.coverage', {}, '查看全部候选会议的覆盖情况'),
('download',): ('paper_atlas.io.snapshots', {}, '下载并校验固定版本快照'),
@@ -25,7 +26,6 @@ COMMANDS = {
('catalogue','dblp'): ('paper_atlas.collectors.dblp', {}, '盘点 DBLP 历史会议目录'),
('import','arxiv'): ('paper_atlas.ingestion.arxiv', {}, '导入 arXiv 元数据 ZIP'),
('import','records'): ('paper_atlas.ingestion.records', {}, '导入核验过的会议文件'),
('maintain','event-identity'): ('paper_atlas.ingestion.event_identity', {}, '按核验计划增加同年会议事件身份'),
('maintain','merge'): ('paper_atlas.ingestion.maintenance', {}, '协调单写入合并与恢复'),
('maintain','backfill'): ('paper_atlas.ingestion.backfill', {}, '回填既有投稿的来源身份'),
('maintain','arxiv-tags'): ('paper_atlas.ingestion.arxiv_tags', {}, '恢复逐来源记录的 arXiv 类别证据'),
@@ -49,7 +49,7 @@ def main(argv=None):
leaf=commands.add_parser(route[0],help=help_text,add_help=False)
else:
if route[0] not in groups:
group=commands.add_parser(route[0],help={'collect':'采集来源元数据','catalogue':'盘点历史目录','import':'导入数据库','maintain':'维护数据库','audit':'核验数据'}[route[0]])
group=commands.add_parser(route[0],help={'collect':'采集来源元数据','catalogue':'盘点历史目录','import':'导入数据库','maintain':'维护数据库','audit':'核验数据','curate':'按证据整理元数据'}[route[0]])
groups[route[0]]=group.add_subparsers(dest='operation',required=True)
leaf=groups[route[0]].add_parser(route[1],help=help_text,add_help=False)
leaf.set_defaults(handler=(route,module,kwargs))
+1
View File
@@ -0,0 +1 @@
"""Offline, evidence-bound preparation of metadata for separate ingestion."""
+212
View File
@@ -0,0 +1,212 @@
"""Add reviewed stable identifiers without rewriting bibliographic source fields.
Hashes establish file integrity, not the truth of an identity assertion. Identity
review and a fresh database preflight remain separate requirements.
"""
import argparse
from datetime import datetime
import hashlib
import json
from pathlib import Path
import re
import tempfile
from urllib.parse import urlsplit
SCOPE = 'input_and_evidence_integrity_only'
MAX_METADATA_BYTES = 16 * 1024 * 1024
def digest(data):
return hashlib.sha256(data).hexdigest()
def object_keys(value, required, optional=()):
if not isinstance(value, dict) or not set(required) <= value.keys() or value.keys() - set(required) - set(optional):
raise ValueError('Missing or unsupported plan fields')
def json_bytes(value):
return (json.dumps(value, ensure_ascii=False, separators=(',', ':'), allow_nan=False) + '\n').encode('utf-8')
def decode(raw):
def unique(pairs):
result = {}
for key, value in pairs:
if key in result:
raise ValueError('Duplicate JSON key: ' + key)
result[key] = value
return result
return json.loads(raw, object_pairs_hook=unique,
parse_constant=lambda value: (_ for _ in ()).throw(ValueError('Non-finite JSON value')))
def metadata(path, expected=None):
with Path(path).open('rb') as stream:
raw = stream.read(MAX_METADATA_BYTES + 1)
if len(raw) > MAX_METADATA_BYTES:
raise ValueError('Metadata exceeds size limit')
if expected is not None:
check_hash(raw, expected)
return raw, decode(raw)
def check_hash(raw, expected):
if not isinstance(expected, str) or not re.fullmatch('[0-9a-f]{64}', expected) or digest(raw) != expected:
raise ValueError('SHA256 mismatch')
def identifier(scheme, value):
if not isinstance(value, str) or any(char.isspace() or ord(char) < 32 or ord(char) == 127 for char in value):
raise ValueError('Invalid identifier')
if scheme == 'arxiv':
pattern = r'(?:\d{2}(?:0[1-9]|1[0-2])\.\d{4,5}|[a-zA-Z][a-zA-Z.-]*/\d{2}(?:0[1-9]|1[0-2])\d{3})'
valid = re.fullmatch(pattern, value)
elif scheme == 'doi':
valid = re.fullmatch(r'10\.\d{4,9}/\S+', value) and value == value.lower()
else:
valid = False
if not valid:
raise ValueError('Unsupported or noncanonical identifier: ' + scheme)
def prepare(plan_path, output):
plan_raw, plan = metadata(plan_path)
object_keys(plan, ('format_version', 'inputs', 'assertions'))
if type(plan['format_version']) is not int or plan['format_version'] != 1:
raise ValueError('Unsupported format version')
if not isinstance(plan['inputs'], list) or not plan['inputs'] or not isinstance(plan['assertions'], list) or not plan['assertions']:
raise ValueError('Inputs and assertions must be nonempty lists')
output = Path(output).absolute()
if output.exists():
raise ValueError('Output directory must not already exist')
if not output.parent.is_dir():
raise ValueError('Output parent directory must exist')
assertions = {}
evidence_files = {}
checked_evidence = {}
for assertion in plan['assertions']:
object_keys(assertion, ('source', 'external_id', 'record_sha256', 'identifiers', 'evidence'))
if any(not isinstance(assertion[key], str) or not assertion[key] for key in ('source', 'external_id')):
raise ValueError('Invalid source identity')
key = (assertion['source'], assertion['external_id'])
if key in assertions:
raise ValueError('Duplicate identity assertion')
ids = assertion['identifiers']
if not isinstance(ids, dict) or not ids:
raise ValueError('Identifiers must be a nonempty object')
for scheme, value in ids.items():
identifier(scheme, value)
if not isinstance(assertion['evidence'], list) or not assertion['evidence']:
raise ValueError('Evidence is required')
references = []
for evidence in assertion['evidence']:
object_keys(evidence, ('file', 'sha256', 'url', 'checked_at', 'note'))
if not all(isinstance(value, str) and value for value in evidence.values()):
raise ValueError('Invalid evidence fields')
url = urlsplit(evidence['url'])
if url.scheme != 'https' or not url.hostname or url.username or url.password:
raise ValueError('Evidence requires an HTTPS URL without credentials')
if datetime.fromisoformat(evidence['checked_at'].replace('Z', '+00:00')).utcoffset() is None:
raise ValueError('Evidence timestamp requires a timezone')
evidence_key = (str(Path(evidence['file']).absolute()), evidence['sha256'])
if evidence_key not in checked_evidence:
checked_evidence[evidence_key] = metadata(evidence['file'], evidence['sha256'])[0]
raw = checked_evidence[evidence_key]
evidence_files[evidence['sha256']] = raw
references.append({**evidence, 'file': 'evidence/' + evidence['sha256'] + '.json'})
assertions[key] = (assertion, references)
seen = set()
applied = set()
names = set()
report = {'format_version': 1, 'plan_sha256': digest(plan_raw),
'verification_scope': SCOPE, 'semantic_identity_verified': False,
'database_written': False, 'files': [], 'assertions_applied': 0, 'identifiers_added': 0}
with tempfile.TemporaryDirectory(prefix='.identifier-curation-', dir=output.parent) as temporary:
staging = Path(temporary) / 'prepared'
staging.mkdir()
(staging / 'evidence').mkdir()
(staging / 'plan.json').write_bytes(plan_raw)
for sha, raw in evidence_files.items():
(staging / 'evidence' / (sha + '.json')).write_bytes(raw)
for entry in plan['inputs']:
object_keys(entry, ('file', 'sha256', 'manifest_sha256'))
path = Path(entry['file'])
if path.suffix != '.jsonl' or path.name in names:
raise ValueError('Inputs require distinct JSONL basenames')
names.add(path.name)
_, manifest = metadata(path.with_suffix('.manifest.json'), entry['manifest_sha256'])
if not isinstance(manifest, dict) or not {'source','venue','year','track','count','list_url','file_sha256'} <= manifest.keys():
raise ValueError('Complete source manifest required')
if manifest['file_sha256'] != entry['sha256']:
raise ValueError('Manifest and plan hashes differ')
before = hashlib.sha256()
after = hashlib.sha256()
count = 0
with path.open('rb') as source, (staging / path.name).open('wb') as target:
for raw in source:
before.update(raw)
row = decode(raw)
if any(row[field] != manifest[field] for field in ('source', 'venue', 'year', 'track')):
raise ValueError('Record differs from source manifest')
key = (row['source'], row['external_id'])
if key in seen:
raise ValueError('Duplicate source identity in input batch')
seen.add(key)
if key in assertions:
assertion, references = assertions[key]
check_hash(raw, assertion['record_sha256'])
existing = row.get('identifiers', {})
if existing is None:
existing = {}
if not isinstance(existing, dict):
raise ValueError('Invalid existing identifiers')
for scheme, value in assertion['identifiers'].items():
if scheme in existing and existing[scheme] != value:
raise ValueError('Existing identifier conflict')
details = row.setdefault('metadata', {})
if not isinstance(details, dict):
raise ValueError('Invalid record metadata')
history = details.setdefault('identifier_curation', [])
if not isinstance(history, list):
raise ValueError('Invalid curation history')
history.append({'plan_sha256': digest(plan_raw), 'input_file_sha256': entry['sha256'],
'record_sha256': digest(raw), 'previous_identifiers': dict(existing),
'identifiers_asserted': assertion['identifiers'], 'evidence': references,
'verification_scope': SCOPE})
report['identifiers_added'] += len(assertion['identifiers'].keys() - existing.keys())
row['identifiers'] = {**existing, **assertion['identifiers']}
raw = json_bytes(row)
applied.add(key)
target.write(raw)
after.update(raw)
count += 1
if before.hexdigest() != entry['sha256'] or not re.fullmatch('[0-9a-f]{64}', entry['sha256']):
raise ValueError('Input SHA256 mismatch')
if not count or type(manifest['count']) is not int or count != manifest['count']:
raise ValueError('Manifest count mismatch')
provenance = {'plan_sha256': digest(plan_raw), 'input_file_sha256': before.hexdigest(),
'input_manifest_sha256': entry['manifest_sha256'], 'verification_scope': SCOPE}
prepared = {**manifest, 'file': str(output / path.name), 'file_sha256': after.hexdigest(),
'identifier_curation': provenance}
(staging / path.with_suffix('.manifest.json').name).write_bytes(json_bytes(prepared))
report['files'].append({'file': str(output / path.name), 'count': count,
'input_sha256': before.hexdigest(), 'sha256': after.hexdigest()})
if applied != assertions.keys():
raise ValueError('Assertion target absent from input batch')
report['assertions_applied'] = len(applied)
(staging / 'curation-report.json').write_bytes(json_bytes(report))
# All files are on the same filesystem. No database or input mutation.
if output.exists():
raise ValueError('Output directory appeared during preparation')
staging.rename(output)
return report
def main(argv=None, *, prog=None):
parser = argparse.ArgumentParser(prog=prog, description=__doc__)
parser.add_argument('--plan', required=True, type=Path)
parser.add_argument('--output', required=True, type=Path)
args = parser.parse_args(argv)
print(json.dumps(prepare(args.plan, args.output), ensure_ascii=False))
-71
View File
@@ -1,71 +0,0 @@
"""Stable event identity within a venue/year/track, independent of paper identity."""
import json
import re
def has_event_key(c):
if getattr(c, 'postgres', False):
return bool(c.execute("SELECT 1 FROM information_schema.columns WHERE table_schema=current_schema() AND table_name='editions' AND column_name='event_key'").fetchone())
return any(row[1] == 'event_key' for row in c.execute('PRAGMA table_info(editions)'))
def validate_event_key(value):
if value is not None and (not isinstance(value, str) or
(value != '' and not re.fullmatch(r'[a-z0-9][a-z0-9._:-]{0,79}', value))):
raise ValueError('Invalid conference event_key')
return value
def pinned_www_fall(source, metadata):
from paper_atlas.collectors.dblp import XML_SHA
b = metadata.get('bibliography') or {}
urls = b.get('url') or []
return (source == 'dblp' and metadata.get('snapshot_sha256') == XML_SHA
and metadata.get('bibliographic_year') == 1995
and str(metadata.get('proceedings_key', '')).startswith('conf/www/')
and b.get('booktitle') == ['WWW (Fall)'] and len(urls) == 1
and urls[0].split('#')[0] == 'db/conf/www/www1995f.html')
def find_edition(store, venue_id, venue, year, track, record, external_id,
event_key=None, *, create=True):
c = store.conn
validate_event_key(event_key)
enabled = has_event_key(c) if getattr(store, 'event_identity', None) is None else store.event_identity
known_multi = venue.upper() == 'WWW' and int(year) in (1994, 1995)
source = c.execute('SELECT src.name,r.metadata_json FROM source_records r JOIN sources src ON src.id=r.source_id WHERE r.id=?', (record,)).fetchone()
inferred = ('www-1995-fall' if known_multi and int(year) == 1995 and source
and pinned_www_fall(source[0], json.loads(source[1])) else None)
if inferred:
if event_key is not None and event_key != inferred:
raise ValueError('event_key conflicts with pinned WWW Fall source evidence')
event_key = inferred
if not enabled:
if event_key or known_multi:
raise ValueError('Explicit event identity migration required before this conference import')
row = c.execute('SELECT id FROM editions WHERE venue_id=? AND year=? AND track=?', (venue_id, year, track)).fetchone()
if row or not create:
return row[0] if row else None
c.execute('INSERT INTO editions(venue_id,year,track) VALUES(?,?,?) ON CONFLICT DO NOTHING', (venue_id, year, track))
return c.execute('SELECT id FROM editions WHERE venue_id=? AND year=? AND track=?', (venue_id, year, track)).fetchone()[0]
anchored = c.execute('''SELECT DISTINCT e.id,e.event_key FROM editions e JOIN submissions s ON s.edition_id=e.id
JOIN submission_sources ss ON ss.submission_id=s.id WHERE e.venue_id=? AND e.year=? AND e.track=?
AND ss.source_record_id=? AND ss.source_submission_id=?''', (venue_id, year, track, record, external_id)).fetchall()
if len(anchored) > 1:
raise ValueError('Source record already has ambiguous conference events')
if anchored:
if event_key is not None and event_key != anchored[0][1]:
raise ValueError('Cannot move an existing source to another conference event')
event_key = anchored[0][1]
if event_key is None:
rows = c.execute('SELECT event_key FROM editions WHERE venue_id=? AND year=? AND track=?', (venue_id, year, track)).fetchall()
if len(rows) > 1 or any(row[0] for row in rows):
raise ValueError('Explicit event_key required; conference event is ambiguous')
event_key = ''
if known_multi and not event_key:
raise ValueError('Explicit event_key required for same-year WWW events')
row = c.execute('SELECT id FROM editions WHERE venue_id=? AND year=? AND track=? AND event_key=?', (venue_id, year, track, event_key)).fetchone()
if row or not create:
return row[0] if row else None
c.execute('INSERT INTO editions(venue_id,year,track,event_key) VALUES(?,?,?,?) ON CONFLICT DO NOTHING', (venue_id, year, track, event_key))
return c.execute('SELECT id FROM editions WHERE venue_id=? AND year=? AND track=? AND event_key=?', (venue_id, year, track, event_key)).fetchone()[0]
+1 -2
View File
@@ -29,8 +29,7 @@ CREATE TABLE IF NOT EXISTS venues (
CREATE TABLE IF NOT EXISTS editions (
id INTEGER PRIMARY KEY, venue_id INTEGER NOT NULL REFERENCES venues(id),
year INTEGER NOT NULL, track TEXT NOT NULL DEFAULT 'main',
event_key TEXT NOT NULL DEFAULT '',
UNIQUE(venue_id,year,track,event_key)
UNIQUE(venue_id,year,track)
);
CREATE TABLE IF NOT EXISTS venue_aliases (
venue_id INTEGER NOT NULL REFERENCES venues(id), alias TEXT NOT NULL,
+16 -12
View File
@@ -36,8 +36,6 @@ class Store:
self.conn.execute('PRAGMA synchronous=NORMAL')
self.conn.execute('PRAGMA cache_size=-32768')
migrate_sources(self.conn)
from paper_atlas.db.events import has_event_key
self.event_identity = has_event_key(self.conn)
def source(self,name,url):
self.conn.execute('INSERT INTO sources(name,homepage) VALUES(?,?) ON CONFLICT DO NOTHING',(name,url))
@@ -97,13 +95,19 @@ class Store:
matches.add(old[0])
key = normalized(title)
title_matches = c.execute('SELECT id,authors_json,abstract FROM papers WHERE title_key=?',(key,)).fetchall()
authors_set = author_keys(authors)
for paper,old_authors,old_abstract in title_matches:
overlap = authors_set & author_keys(json.loads(old_authors))
# Cross-platform title matching needs independent author corroboration.
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())):
matches.add(paper)
# An existing identifier/source record anchors the entity. Title
# similarity must not pull in another deposit or an extended version.
if not matches:
title_only_matches = set()
authors_set = author_keys(authors)
for paper,old_authors,old_abstract in title_matches:
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:
paper = min(matches)
for other in sorted(matches-{paper}):
@@ -144,15 +148,15 @@ class Store:
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))
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,event_key=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
existing=c.execute('SELECT id FROM venues WHERE lower(acronym)=lower(?)',(venue,)).fetchone()
if existing:venue_id=existing[0]
else:venue_id=inserted_id(c,"INSERT INTO venues(acronym,name,field,venue_type) VALUES(?,?,?,'conference')",(venue,venue,'unclassified'))
for alias in aliases or []:
c.execute('INSERT INTO venue_aliases VALUES(?,?) ON CONFLICT DO NOTHING',(venue_id,alias))
from paper_atlas.db.events import find_edition
edition = find_edition(self,venue_id,venue,int(year),track,record,external_id,event_key)
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]
# A previous event in this row may already have merged the input paper.
paper=c.execute('SELECT paper_id FROM source_records WHERE id=?',(record,)).fetchone()[0]
submission=attach_submission(self,edition,paper,record,external_id,identities or [])
+1 -6
View File
@@ -37,14 +37,9 @@ def backfill(db,files, *, pg_service=None, pg_schema=None):
seen.add(row['external_id'])
if any(row.get(key)!=manifest.get(key) for key in ('source','venue','year','track')):
raise ValueError('Evidence row differs from edition manifest')
if 'event_key' in manifest and row.get('event_key')!=manifest['event_key']:
raise ValueError('Event identity differs from 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()
from paper_atlas.db.events import find_edition
venue=c.execute('SELECT id FROM venues WHERE lower(acronym)=lower(?)',(row['venue'],)).fetchone()
eid=find_edition(store,venue[0],row['venue'],row['year'],row['track'],record[0],row['external_id'],row.get('event_key'),create=False) if record and venue else None
edition=(eid,) if eid is not None else None
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 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')
-217
View File
@@ -1,217 +0,0 @@
"""Explicit, plan-bound edition event-key upgrade; never reimport submissions."""
import argparse
from contextlib import closing
import hashlib
import json
from pathlib import Path
import sqlite3
import sys
from paper_atlas.db.backend import database_arguments, open_read_connection, require_sqlite_writable
from paper_atlas.db.events import has_event_key, pinned_www_fall
from paper_atlas.postgres.schema import TABLES, quote
MARKER = 'edition_event_identity_v1:'
def digest(value):
return hashlib.sha256(json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(',', ':')).encode()).hexdigest()
def values(rows):
return [[r[i] for i in range(len(r))] for r in rows]
def exact_counts(c, *, page_size=5000, progress=None):
"""Count every row in the same snapshot through bounded primary-key pages."""
if not isinstance(page_size, int) or page_size < 1:
raise ValueError('Positive count page size required')
result = {}
for table in TABLES:
if table == 'schema_migrations':
continue
if getattr(c, 'postgres', False):
columns = [r[0] for r in c.execute("SELECT a.attname FROM pg_constraint pc JOIN pg_attribute a ON a.attrelid=pc.conrelid AND a.attnum=ANY(pc.conkey) WHERE pc.conrelid=?::regclass AND pc.contype='p' ORDER BY array_position(pc.conkey,a.attnum)", (table,))]
else:
columns = [r[1] for r in sorted(c.execute('PRAGMA table_info('+quote(table)+')'), key=lambda r:r[5]) if r[5]]
if not columns:
raise ValueError('Primary key required for exact count: '+table)
fields = ','.join(quote(column) for column in columns)
last = None; count = 0; pages = 0
if progress:
progress({'table':table, 'rows_counted':0, 'complete':False})
while True:
where = (' WHERE ('+fields+') > ('+','.join('?' for _ in columns)+')') if last is not None else ''
batch = values(c.execute('SELECT '+fields+' FROM '+quote(table)+where+' ORDER BY '+fields+' LIMIT ?', (*last, page_size) if last is not None else (page_size,)).fetchall())
count += len(batch); pages += 1
complete = len(batch) < page_size
if progress and (complete or pages % 20 == 0):
progress({'table':table, 'rows_counted':count, 'complete':complete})
if complete:
break
last = batch[-1]
result[table] = count
return result
def inspect(c, *, progress=None):
enabled = has_event_key(c)
if getattr(c, 'postgres', False):
columns = [r[0] for r in c.execute("SELECT column_name FROM information_schema.columns WHERE table_schema=current_schema() AND table_name='editions' ORDER BY ordinal_position")]
unique = [r[0] for r in c.execute("SELECT pg_get_constraintdef(oid) FROM pg_constraint WHERE conrelid='editions'::regclass AND contype='u'")]
expected_unique = ['UNIQUE (venue_id, year, track, event_key)' if enabled else 'UNIQUE (venue_id, year, track)']
else:
columns = [r[1] for r in c.execute('PRAGMA table_info(editions)')]
indexes = list(c.execute('PRAGMA index_list(editions)'))
unique = [tuple(r[2] for r in c.execute('PRAGMA index_info('+quote(index[1])+')')) for index in indexes if index[2]]
expected_unique = [('venue_id','year','track','event_key') if enabled else ('venue_id','year','track')]
if len(indexes) != 1:
raise ValueError('Unexpected edition indexes; require a reviewed migration')
if columns != ['id','venue_id','year','track'] + (['event_key'] if enabled else []) or unique != expected_unique:
raise ValueError('Unexpected edition schema; require a reviewed migration')
rows = values(c.execute('SELECT id,venue_id,year,track FROM editions ORDER BY id'))
keys = values(c.execute('SELECT id,event_key FROM editions ORDER BY id')) if enabled else [[r[0], ''] for r in rows]
counts = exact_counts(c, progress=progress)
targets = values(c.execute("SELECT e.id FROM editions e JOIN venues v ON v.id=e.venue_id WHERE v.acronym='WWW' AND e.year=1995 AND e.track='main' ORDER BY e.id"))
assignments = []
for (eid,) in targets:
current_key = dict(keys)[eid]
if current_key:
continue
evidence = values(c.execute('''SELECT s.id,src.name,r.id,r.external_id,r.metadata_json FROM submissions s
JOIN submission_sources ss ON ss.submission_id=s.id JOIN source_records r ON r.id=ss.source_record_id
JOIN sources src ON src.id=r.source_id WHERE s.edition_id=? ORDER BY s.id,r.id''', (eid,)))
count = c.execute('SELECT count(*) FROM submissions WHERE edition_id=?', (eid,)).fetchone()[0]
supported = {r[0] for r in evidence if pinned_www_fall(r[1], json.loads(r[4]))}
if not count or len(supported) != count:
raise ValueError('Existing unlabelled WWW1995 edition lacks complete pinned Fall evidence')
assignments.append({'edition_id':eid, 'event_key':'www-1995-fall', 'submissions':count,
'source_evidence_sha256':digest(evidence)})
# Protect all rows in the affected attempt history, including non-DBLP evidence.
target_ids = [r[0] for r in targets]
protected = {}
for table,sql in {
'submissions': 'SELECT s.* FROM submissions s WHERE s.edition_id IN ({}) ORDER BY s.id',
'decisions': 'SELECT d.* FROM decisions d JOIN submissions s ON s.id=d.submission_id WHERE s.edition_id IN ({}) ORDER BY d.id',
'submission_sources': 'SELECT ss.* FROM submission_sources ss JOIN submissions s ON s.id=ss.submission_id WHERE s.edition_id IN ({}) ORDER BY ss.submission_id,ss.source_record_id,ss.source_submission_id',
'decision_sources': 'SELECT ds.* FROM decision_sources ds JOIN decisions d ON d.id=ds.decision_id JOIN submissions s ON s.id=d.submission_id WHERE s.edition_id IN ({}) ORDER BY ds.decision_id,ds.source_record_id,ds.source_decision_id',
'source_records': 'SELECT DISTINCT r.* FROM source_records r JOIN submission_sources ss ON ss.source_record_id=r.id JOIN submissions s ON s.id=ss.submission_id WHERE s.edition_id IN ({}) ORDER BY r.id',
}.items():
records = values(c.execute(sql.format(','.join('?' for _ in target_ids)), target_ids)) if target_ids else []
protected[table] = {'count':len(records), 'sha256':digest(records)}
return {'event_column':enabled, 'editions_sha256':digest(rows), 'edition_ids':[r[0] for r in rows],
'event_keys':keys, 'counts':counts, 'assignments':assignments, 'protected':protected}
def database_identity(c, db=None):
if getattr(c, 'postgres', False):
r = c.execute('SELECT current_database(),current_schema()').fetchone()
return {'engine':'postgres', 'database':r[0], 'schema':r[1]}
return {'engine':'sqlite', 'path':str(Path(db).resolve())}
def plan(db=None, *, pg_service=None, pg_schema=None, progress=None):
with closing(open_read_connection(db, pg_service=pg_service, pg_schema=pg_schema)) as c:
return {'format':1, 'database':database_identity(c, db), 'before':inspect(c, progress=progress)}
def apply_plan(c, proposal, *, progress=None):
before = proposal['before']
if proposal.get('format') != 1:
raise ValueError('Unsupported event identity plan')
if getattr(c, 'postgres', False):
c.execute('LOCK TABLE editions IN ACCESS EXCLUSIVE MODE')
current = inspect(c, progress=progress)
signature = digest(proposal)
marker = MARKER + signature
if c.execute('SELECT 1 FROM schema_migrations WHERE name=?', (marker,)).fetchone():
expected_keys = dict(before['event_keys'])
expected_keys.update({a['edition_id']:a['event_key'] for a in before['assignments']})
if (not current['event_column'] or current['editions_sha256'] != before['editions_sha256']
or current['protected'] != before['protected'] or current['counts'] != before['counts']
or dict(current['event_keys']) != expected_keys):
raise ValueError('Applied plan no longer matches current data; generate a new plan')
return {'status':'already_applied', 'updated_editions':0, 'after':current}
if current != before:
raise ValueError('Event identity plan is stale; generate a new plan')
if not current['event_column']:
if getattr(c, 'postgres', False):
constraints = c.execute("SELECT conname,pg_get_constraintdef(oid) FROM pg_constraint WHERE conrelid='editions'::regclass AND contype='u'").fetchall()
matches = [name for name,definition in constraints if definition == 'UNIQUE (venue_id, year, track)']
if len(matches) != 1 or len(constraints) != 1:
raise ValueError('Unexpected legacy edition uniqueness')
c.execute("ALTER TABLE editions ADD COLUMN event_key TEXT COLLATE \"C\" NOT NULL DEFAULT ''")
c.execute('ALTER TABLE editions DROP CONSTRAINT '+quote(matches[0]))
c.execute('ALTER TABLE editions ADD CONSTRAINT editions_event_identity_unique UNIQUE(venue_id,year,track,event_key)')
else:
unique = [tuple(r[2] for r in c.execute('PRAGMA index_info('+quote(index[1])+')'))
for index in c.execute('PRAGMA index_list(editions)') if index[2]]
if unique != [('venue_id','year','track')]:
raise ValueError('Unexpected legacy edition uniqueness')
c.execute("CREATE TABLE editions_event_upgrade(id INTEGER PRIMARY KEY,venue_id INTEGER NOT NULL REFERENCES venues(id),year INTEGER NOT NULL,track TEXT NOT NULL DEFAULT 'main',event_key TEXT NOT NULL DEFAULT '',UNIQUE(venue_id,year,track,event_key))")
c.execute('INSERT INTO editions_event_upgrade(id,venue_id,year,track) SELECT id,venue_id,year,track FROM editions')
c.execute('DROP TABLE editions')
c.execute('ALTER TABLE editions_event_upgrade RENAME TO editions')
for item in before['assignments']:
result = c.execute("UPDATE editions SET event_key=? WHERE id=? AND event_key=''", (item['event_key'],item['edition_id']))
if result.rowcount != 1:
raise ValueError('Edition assignment changed during upgrade')
c.execute('INSERT INTO schema_migrations(name) VALUES(?)', (marker,))
after = inspect(c, progress=progress)
expected_keys = dict(before['event_keys'])
expected_keys.update({a['edition_id']:a['event_key'] for a in before['assignments']})
if (after['editions_sha256'] != before['editions_sha256'] or after['protected'] != before['protected']
or after['counts'] != before['counts'] or dict(after['event_keys']) != expected_keys):
raise ValueError('Event upgrade changed protected data')
if not getattr(c, 'postgres', False) and c.execute('PRAGMA foreign_key_check').fetchall():
raise ValueError('Foreign key violation after event upgrade')
return {'status':'applied', 'updated_editions':len(before['assignments']), 'after':after}
def upgrade(proposal, db=None, *, pg_service=None, pg_schema=None, progress=None):
if pg_service:
from paper_atlas.postgres.writer import WriteConnection
with closing(WriteConnection(pg_service, pg_schema)) as c:
if proposal['database'] != database_identity(c):
raise ValueError('Plan targets a different database')
try:
result = apply_plan(c, proposal, progress=progress); c.commit(); return result
except BaseException:
c.rollback(); raise
require_sqlite_writable(db)
if not Path(db).is_file():
raise ValueError('Existing SQLite sample required')
with closing(sqlite3.connect(db, timeout=30)) as c:
if proposal['database'] != database_identity(c, db):
raise ValueError('Plan targets a different database')
c.execute('PRAGMA foreign_keys=OFF') # Outside the transaction; no cascading deletes.
try:
c.execute('BEGIN IMMEDIATE')
result = apply_plan(c, proposal, progress=progress); c.commit(); return result
except BaseException:
c.rollback(); raise
finally:
c.execute('PRAGMA foreign_keys=ON')
def main(argv=None, *, prog=None):
p = argparse.ArgumentParser(prog=prog); database_arguments(p)
p.add_argument('--out', required=True)
p.add_argument('--apply-plan'); p.add_argument('--plan-sha256')
args = p.parse_args(argv)
if bool(args.apply_plan) != bool(args.plan_sha256):
p.error('--apply-plan and --plan-sha256 must be supplied together')
target = Path(args.out)
if target.exists():
raise ValueError('Refusing to overwrite an existing plan/report')
progress = lambda event: print(json.dumps({'phase':'exact_row_count', **event}), file=sys.stderr, flush=True)
if args.apply_plan:
raw = Path(args.apply_plan).read_bytes()
if hashlib.sha256(raw).hexdigest() != args.plan_sha256:
raise ValueError('Plan file SHA256 differs from approved bytes')
result = upgrade(json.loads(raw), args.db, pg_service=args.pg_service, pg_schema=args.pg_schema, progress=progress)
else:
result = plan(args.db, pg_service=args.pg_service, pg_schema=args.pg_schema, progress=progress)
target.write_text(json.dumps(result, ensure_ascii=False, indent=2)+'\n')
print(json.dumps({'out':str(target), 'sha256':hashlib.sha256(target.read_bytes()).hexdigest(),
'status':result.get('status','planned')}), flush=True)
+2 -4
View File
@@ -42,11 +42,9 @@ def ingest_file(db,path, *, pg_service=None, pg_schema=None):
row = json.loads(line)
if row['source'] != source_name:
raise ValueError('Source differs from manifest')
if any(key in manifest and manifest[key]!=row.get(key) for key in ('venue','year','track','event_key')):
if any(key in manifest and manifest[key]!=row[key] for key in ('venue','year','track')):
raise ValueError('Conference edition differs from manifest')
require_track_record(row, manifest)
from paper_atlas.db.events import validate_event_key
validate_event_key(row.get('event_key'))
external = row['external_id']
if external in seen:
raise ValueError('Duplicate external ID in one collected file: '+external)
@@ -67,7 +65,7 @@ def ingest_file(db,path, *, pg_service=None, pg_schema=None):
status=event['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')
edition=store.decision(paper,record,row['venue'],row['year'],row['track'],external,event.get('evidence_url',row['url']),status,event.get('raw_value',''),row.get('venue_aliases'),identities=identities,observed_at=event.get('observed_at') or row_observed,decision_date=event.get('decision_date'),source_decision_id=event.get('source_decision_id',''),submitted_at=row.get('submitted_at'),guard_status=source_name in (PUBLIC_SOURCE,ARCHIVE_SOURCE),event_key=row.get('event_key'))
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)
else:
raise ValueError('This importer requires an explicit supported decision status')
-2
View File
@@ -85,5 +85,3 @@ class WriteConnection:
class PostgresStore(Store):
def __init__(self, service, schema=None):
self.conn = WriteConnection(service, schema)
from paper_atlas.db.events import has_event_key
self.event_identity = has_event_key(self.conn)
+3 -13
View File
@@ -14,9 +14,7 @@ ACCEPTED_PAPERS = "SELECT s.paper_id FROM submissions s JOIN decisions d ON d.su
def known_acceptances(conn,paper):
from paper_atlas.db.events import has_event_key
event = 'e.event_key' if has_event_key(conn) else "''"
return [dict(row) for row in conn.execute('''SELECT s.id AS submission_id,v.acronym AS venue,e.year,e.track,'''+event+''' AS event_key,
return [dict(row) for row in conn.execute('''SELECT s.id AS submission_id,v.acronym AS venue,e.year,e.track,
d.id AS decision_id,d.decision_date,d.observed_at,d.evidence_url
FROM submissions s JOIN editions e ON e.id=s.edition_id JOIN venues v ON v.id=e.venue_id
JOIN decisions d ON d.submission_id=s.id WHERE s.paper_id=? AND d.status='accepted' AND '''+LATEST_DECISION+
@@ -35,7 +33,6 @@ def main(argv=None, *, prog=None):
parser.add_argument('--venue', help='Conference acronym')
parser.add_argument('--year', type=int)
parser.add_argument('--track',help='Exact track, e.g. main, findings, workshop')
parser.add_argument('--event-key',help='Exact event within a venue/year/track')
parser.add_argument('--status', choices=['accepted','rejected','desk_rejected','withdrawn','under_review','unknown'])
parser.add_argument('--outcome',choices=['accepted','no_known_acceptance'],help='跨会议的已知录用情况;未查到录用不等于从未录用')
parser.add_argument('--limit', type=int, default=20)
@@ -50,11 +47,6 @@ def main(argv=None, *, prog=None):
conn = sqlite3.connect(Path(args.db).resolve().as_uri()+'?mode=ro', uri=True)
conn.row_factory = sqlite3.Row
conn.execute('BEGIN') # One consistent read snapshot while ingestion continues.
from paper_atlas.db.events import has_event_key
event = 'e.event_key' if has_event_key(conn) else "''"
if args.event_key is not None and event == "''":
conn.close()
raise ValueError('Event identity migration required for event-key queries')
if args.stats:
result = {name:conn.execute('SELECT count(*) FROM '+name).fetchone()[0] for name in ('papers','source_records','submissions','decisions','tags','match_candidates')}
result['records_by_source'] = [dict(r) for r in conn.execute('SELECT s.name,count(r.id) AS imported_records FROM sources s LEFT JOIN source_records r ON r.source_id=s.id GROUP BY s.id ORDER BY s.name')]
@@ -81,15 +73,13 @@ def main(argv=None, *, prog=None):
where.append('EXISTS(SELECT 1 FROM paper_tags pt JOIN tags t ON t.id=pt.tag_id WHERE pt.paper_id=p.id AND t.namespace=? AND t.value=?)'); params.extend([namespace,value])
if args.outcome:
where.append('p.id '+('IN' if args.outcome=='accepted' else 'NOT IN')+' ('+ACCEPTED_PAPERS+')')
if args.venue or args.year or args.status or args.track or args.event_key is not None:
if args.venue or args.year or args.status or args.track:
clause = ['1=1']
if args.venue:
if conn.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='venue_aliases'").fetchone():
clause.append('(lower(v.acronym)=lower(?) OR EXISTS(SELECT 1 FROM venue_aliases va WHERE va.venue_id=v.id AND lower(va.alias)=lower(?)))'); params.extend([args.venue,args.venue])
else:
clause.append('lower(v.acronym)=lower(?)');params.append(args.venue)
if args.event_key is not None:
clause.append('e.event_key=?');params.append(args.event_key)
if args.track:
clause.append('e.track=?');params.append(args.track)
if args.year:
@@ -111,7 +101,7 @@ def main(argv=None, *, prog=None):
paper['tags'] = [dict(r) for r in conn.execute('SELECT t.namespace,t.value,pt.evidence_key FROM paper_tags pt JOIN tags t ON t.id=pt.tag_id WHERE pt.paper_id=?',(row['id'],))]
paper['sources'] = [dict(r) for r in conn.execute('SELECT s.name,r.external_id,r.url,r.title,r.abstract,r.retrieved_at FROM source_records r JOIN sources s ON s.id=r.source_id WHERE r.paper_id=?',(row['id'],))]
submissions = []
for sub in conn.execute('SELECT s.id,s.source_submission_id,s.submitted_at,v.acronym,e.year,e.track,'+event+' AS event_key FROM submissions s JOIN editions e ON e.id=s.edition_id JOIN venues v ON v.id=e.venue_id WHERE s.paper_id=? ORDER BY e.year,s.id',(row['id'],)):
for sub in conn.execute('SELECT s.id,s.source_submission_id,s.submitted_at,v.acronym,e.year,e.track FROM submissions s JOIN editions e ON e.id=s.edition_id JOIN venues v ON v.id=e.venue_id WHERE s.paper_id=? ORDER BY e.year,s.id',(row['id'],)):
submission = dict(sub)
submission['decisions'] = []
for d in conn.execute('SELECT id,status,raw_value,evidence_url,observed_at,decision_date FROM decisions WHERE submission_id=? ORDER BY observed_at,COALESCE(decision_date,observed_at),id',(sub['id'],)):
-202
View File
@@ -1,202 +0,0 @@
from contextlib import closing, redirect_stdout, contextmanager
import io
import json
import os
from pathlib import Path
import sqlite3
import tempfile
import unittest
import uuid
from importlib.resources import files
from paper_atlas.db.store import Store
from paper_atlas.db.backend import open_store
from paper_atlas.db.events import has_event_key
from paper_atlas.collectors.dblp import XML_SHA
from paper_atlas.ingestion.event_identity import plan, upgrade, exact_counts
from paper_atlas.ingestion.records import ingest_file
from paper_atlas.ingestion.backfill import backfill
from paper_atlas.postgres.client import connect
from paper_atlas.postgres.schema import quote
from paper_atlas.postgres.migration import migrate
from paper_atlas import querying
@contextmanager
def opened_store(**kwargs):
store=open_store(**kwargs)
try:
yield store
finally:
store.conn.close()
def legacy(db):
sql = files('paper_atlas.db').joinpath('schema.sql').read_text().replace(" event_key TEXT NOT NULL DEFAULT '',\n", '').replace('UNIQUE(venue_id,year,track,event_key)', 'UNIQUE(venue_id,year,track)')
c = sqlite3.connect(db); c.executescript(sql); c.close()
s = Store(db)
p,r = s.record('dblp','conf/www/Fall95','https://example.org/fall','Same paper in two events',
'Abstract\x00with\x01text',['A Person'],{'arxiv':'2501.00001'})
s.record_tags(p,r,'dblp',[{'namespace':'dblp:booktitle','value':'WWW (Fall)'}])
s.decision(p,r,'Legacy',1995,'main','conf/www/Fall95','https://example.org/accept','accepted','Accept')
s.conn.execute("UPDATE venues SET acronym='WWW',name='WWW'")
meta={'snapshot_sha256':XML_SHA,'proceedings_key':'conf/www/1995f','bibliographic_year':1995,
'bibliography':{'booktitle':['WWW (Fall)'],'url':['db/conf/www/www1995f.html#Fall95']}}
s.conn.execute('UPDATE source_records SET metadata_json=?',(json.dumps(meta),));s.conn.commit();s.conn.close()
return p,r
def add(store, external, event=None, status='accepted', venue='WWW', year=1995):
p,r = store.record('fixture',external,'https://example.org/'+external,'Same paper in two events',
'Abstract\x00with\x01text',['A Person'],{'arxiv':'2501.00001'})
edition = store.decision(p,r,venue,year,'main',external,'https://example.org/'+status,status,status,event_key=event)
return p,r,edition
def file(root, name, rows, event=None):
p=root/(name+'.jsonl');p.write_text(''.join(json.dumps(r)+'\n'for r in rows))
manifest={'source':'fixture','venue':'WWW','year':1995,'track':'main','count':len(rows),'list_url':'https://example.org/'+name}
if event is not None:manifest['event_key']=event
p.with_suffix('.manifest.json').write_text(json.dumps(manifest));return p
class EventIdentityTests(unittest.TestCase):
def setUp(self):
t=tempfile.TemporaryDirectory();self.addCleanup(t.cleanup);self.root=Path(t.name);self.db=self.root/'sample.sqlite'
def test_same_paper_distinct_events_keep_both_attempts_and_decisions(self):
s=Store(self.db);self.addCleanup(s.conn.close)
paper,_,spring=add(s,'spring','www-1995-spring','rejected');_,_,fall=add(s,'fall','www-1995-fall')
s.conn.commit();self.assertNotEqual(spring,fall)
with redirect_stdout(io.StringIO())as out:querying.main(['--db',str(self.db),'--paper',str(paper)])
row=json.loads(out.getvalue())[0];self.assertEqual({a['event_key']for a in row['submissions']},{'www-1995-spring','www-1995-fall'})
self.assertEqual([a['event_key']for a in row['known_acceptances']],['www-1995-fall'])
self.assertEqual({a['latest_status']for a in row['submissions']},{'rejected','accepted'})
with redirect_stdout(io.StringIO())as out:querying.main(['--db',str(self.db),'--venue','WWW','--event-key','www-1995-spring','--status','rejected'])
self.assertEqual(len(json.loads(out.getvalue())),1)
def test_explicit_migration_preserves_ids_history_and_source_bytes(self):
paper,record=legacy(self.db);proposal=plan(self.db);self.assertFalse(proposal['before']['event_column'])
result=upgrade(proposal,self.db);self.assertEqual(result['updated_editions'],1)
self.assertEqual(result['after']['protected'],proposal['before']['protected'])
self.assertEqual(result['after']['editions_sha256'],proposal['before']['editions_sha256'])
self.assertEqual(upgrade(proposal,self.db)['status'],'already_applied')
s=Store(self.db);self.addCleanup(s.conn.close)
self.assertEqual(s.conn.execute('SELECT id,event_key FROM editions').fetchall(),[(1,'www-1995-fall')])
# An old file without event_key still has its original source anchor.
self.assertEqual(s.decision(paper,record,'WWW',1995,'main','conf/www/Fall95','https://example.org/accept','accepted','Accept'),1)
self.assertEqual(s.conn.execute('SELECT count(*) FROM submissions').fetchone()[0],1)
self.assertEqual(s.conn.execute('PRAGMA foreign_key_check').fetchall(),[])
def test_unverified_legacy_volume_is_not_automatically_labelled(self):
legacy(self.db)
with sqlite3.connect(self.db)as c:c.execute("UPDATE source_records SET metadata_json='{}'")
with self.assertRaisesRegex(ValueError,'complete pinned Fall'):plan(self.db)
with sqlite3.connect(self.db)as c:self.assertFalse(has_event_key(c))
def test_stale_plan_rolls_back_before_schema_change(self):
legacy(self.db);proposal=plan(self.db)
with sqlite3.connect(self.db)as c:c.execute("UPDATE editions SET track='unspecified'")
with self.assertRaisesRegex(ValueError,'stale'):upgrade(proposal,self.db)
with sqlite3.connect(self.db)as c:self.assertFalse(has_event_key(c));self.assertEqual(c.execute('SELECT track FROM editions').fetchone()[0],'unspecified')
def test_unscoped_source_and_wrong_refresh_event_fail(self):
s=Store(self.db);self.addCleanup(s.conn.close);_,r,e=add(s,'first','www-1995-fall');s.conn.commit()
p,r2=s.record('fixture','unscoped','https://example.org/u','Other work',None,[])
with self.assertRaisesRegex(ValueError,'event_key'):s.decision(p,r2,'WWW',1995,'main','unscoped','https://example.org/u','accepted','Accept')
with self.assertRaisesRegex(ValueError,'Cannot move'):s.decision(p,r,'WWW',1995,'main','first','https://example.org/u','accepted','Accept',event_key='www-1995-spring')
self.assertEqual(s.conn.execute('SELECT count(*) FROM editions').fetchone()[0],1)
def test_pinned_fall_cannot_claim_spring(self):
p,r=legacy(self.db);upgrade(plan(self.db),self.db);s=Store(self.db);self.addCleanup(s.conn.close)
with self.assertRaisesRegex(ValueError,'pinned WWW Fall'):s.decision(p,r,'WWW',1995,'main','conf/www/Fall95','https://example.org/u','accepted','Accept',event_key='www-1995-spring')
def test_manifest_event_mismatch_rolls_back_the_whole_file(self):
s=Store(self.db);s.conn.close()
base={'source':'fixture','venue':'WWW','year':1995,'track':'main','status':'accepted','title':'Input work','url':'https://example.org/p','authors':[]}
path=file(self.root,'bad',[dict(base,external_id='a',event_key='www-1995-spring'),dict(base,external_id='b',event_key='www-1995-fall')],'www-1995-spring')
with self.assertRaisesRegex(ValueError,'edition differs'):ingest_file(self.db,path)
with sqlite3.connect(self.db)as c:
self.assertEqual(c.execute('SELECT count(*) FROM papers').fetchone()[0],0)
self.assertEqual(c.execute('SELECT count(*) FROM editions').fetchone()[0],0)
def test_backfill_selects_the_explicit_event_and_preserves_attempts(self):
s=Store(self.db);add(s,'spring','www-1995-spring','rejected');add(s,'fall','www-1995-fall');s.conn.commit();s.conn.close()
row={'source':'fixture','external_id':'fall','venue':'WWW','year':1995,'track':'main','event_key':'www-1995-fall','status':'accepted','identifiers':{'openreview':'forumFall'}}
path=file(self.root,'backfill',[row],'www-1995-fall')
with redirect_stdout(io.StringIO()):result=backfill(self.db,[path])
self.assertEqual(result['submissions_after'],2)
with sqlite3.connect(self.db)as c:
self.assertEqual(c.execute("SELECT e.event_key FROM submission_identifiers si JOIN submissions s ON s.id=si.submission_id JOIN editions e ON e.id=s.edition_id WHERE si.scheme='openreview' AND si.value='forumFall'").fetchone()[0],'www-1995-fall')
def test_exact_paged_counts_cover_composite_key_boundaries(self):
s=Store(self.db);self.addCleanup(s.conn.close)
for i in range(7):add(s,'entry'+str(i),'www-1995-fall')
s.conn.commit();events=[];counts=exact_counts(s.conn,page_size=2,progress=events.append)
self.assertEqual(counts['source_records'],7)
self.assertEqual(counts['identifiers'],8)
self.assertEqual(counts['submission_sources'],7)
self.assertEqual([e['rows_counted']for e in events if e['table']=='identifiers'and e['complete']],[8])
def test_legacy_single_event_writes_and_reads_remain_available(self):
legacy(self.db);s=Store(self.db);self.addCleanup(s.conn.close)
add(s,'ordinary',venue='COLM',year=2025);s.conn.commit()
with redirect_stdout(io.StringIO())as out:querying.main(['--db',str(self.db),'--venue','COLM'])
self.assertEqual(json.loads(out.getvalue())[0]['known_acceptances'][-1]['event_key'],'')
with self.assertRaisesRegex(ValueError,'migration required'):
querying.main(['--db',str(self.db),'--event-key','www-1995-fall'])
def test_unexpected_extra_edition_column_requires_review(self):
legacy(self.db)
with sqlite3.connect(self.db)as c:c.execute('ALTER TABLE editions ADD COLUMN custom_data TEXT')
with self.assertRaisesRegex(ValueError,'Unexpected edition schema'):plan(self.db)
def test_file_spanning_two_events_without_manifest_key_rolls_back(self):
Store(self.db).conn.close()
base={'source':'fixture','venue':'WWW','year':1995,'track':'main','status':'accepted','title':'Input work','url':'https://example.org/p','authors':[]}
path=file(self.root,'two',[dict(base,external_id='a',event_key='www-1995-spring'),dict(base,external_id='b',event_key='www-1995-fall')])
with self.assertRaisesRegex(ValueError,'exactly one'):ingest_file(self.db,path)
with sqlite3.connect(self.db)as c:self.assertEqual(c.execute('SELECT count(*) FROM submissions').fetchone()[0],0)
def test_cutover_sqlite_backup_remains_protected(self):
legacy(self.db);proposal=plan(self.db);Path(str(self.db.resolve())+'.postgresql-cutover.json').write_text('{}')
with self.assertRaisesRegex(RuntimeError,'cut over'):upgrade(proposal,self.db)
@unittest.skipUnless(os.environ.get('PAPER_ATLAS_TEST_PG_SERVICE'),'Requires approved remote PostgreSQL test service')
class PostgreSQLEventIdentityTests(unittest.TestCase):
def setUp(self):
self.service=os.environ['PAPER_ATLAS_TEST_PG_SERVICE'];self.schema='pa_event_'+uuid.uuid4().hex[:12]
t=tempfile.TemporaryDirectory();self.addCleanup(t.cleanup);self.root=Path(t.name);self.db=self.root/'seed.sqlite';legacy(self.db)
self.addCleanup(self.cleanup)
with redirect_stdout(io.StringIO()):migrate(self.db,self.service,self.root/'migrate.json',schema=self.schema,min_free_gib=0)
def cleanup(self):
with connect(self.service)as c:
for (name,)in c.execute('SELECT nspname FROM pg_namespace WHERE starts_with(nspname,%s)',(self.schema,)).fetchall():c.execute('DROP SCHEMA '+quote(name)+' CASCADE')
def test_native_upgrade_preserves_ids_and_codec_then_accepts_separate_event(self):
proposal=plan(pg_service=self.service,pg_schema=self.schema)
result=upgrade(proposal,pg_service=self.service,pg_schema=self.schema)
self.assertEqual(result['after']['protected'],proposal['before']['protected'])
self.assertEqual(upgrade(proposal,pg_service=self.service,pg_schema=self.schema)['status'],'already_applied')
with opened_store(pg_service=self.service,pg_schema=self.schema)as s:
paper,_,spring=add(s,'spring','www-1995-spring','rejected');s.conn.commit()
self.assertNotEqual(spring,1)
self.assertEqual(s.conn.execute('SELECT abstract FROM papers WHERE id=?',(paper,)).fetchone()[0],'Abstract\x00with\x01text')
with redirect_stdout(io.StringIO())as out:querying.main(['--pg-service',self.service,'--pg-schema',self.schema,'--paper',str(paper)])
row=json.loads(out.getvalue())[0];self.assertEqual(len(row['submissions']),2);self.assertEqual(len(row['known_acceptances']),1)
def test_native_paged_counts_preserve_encoded_composite_key_order(self):
with opened_store(pg_service=self.service,pg_schema=self.schema)as s:
for value in ['a\x00b','a\x01b','a\x02b','z']:
s.conn.execute('INSERT INTO identifiers(scheme,value,paper_id) VALUES(?,?,?)',('custom',value,1))
s.conn.commit();counts=exact_counts(s.conn,page_size=2)
self.assertEqual(counts['identifiers'],6)
self.assertEqual(counts['submissions'],1)
def test_stale_native_plan_is_rejected_and_lock_released(self):
proposal=plan(pg_service=self.service,pg_schema=self.schema)
with opened_store(pg_service=self.service,pg_schema=self.schema)as s:s.conn.execute("UPDATE editions SET track='unspecified'");s.conn.commit()
with self.assertRaisesRegex(ValueError,'stale'):upgrade(proposal,pg_service=self.service,pg_schema=self.schema)
with opened_store(pg_service=self.service,pg_schema=self.schema)as s:self.assertFalse(has_event_key(s.conn))
+185
View File
@@ -0,0 +1,185 @@
import copy
import json
from pathlib import Path
import tempfile
import unittest
from paper_atlas.curation.identifiers import SCOPE, digest, json_bytes, prepare
from paper_atlas.db.store import Store
from paper_atlas.ingestion.records import ingest_file
class IdentifierCurationTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.root = Path(self.tmp.name)
self.output = self.root / 'prepared'
self.row = {'source':'dblp','external_id':'conf/uai/example','url':'https://example.org/paper',
'title':'A sufficiently long example research paper title','abstract':None,
'authors':['Jane Doe','John Smith'],'venue':'UAI','year':2001,'track':'main',
'status':'accepted','tags':[{'namespace':'dblp:topic','value':'version\u2029study\x00'}],
'metadata':{'pages':'203-210','raw_title':'Original\u2029title'}}
self.evidence = self.root / 'evidence.json'
self.evidence.write_bytes(json_bytes({'url':'https://example.org/archive','comment':'Conference version'}))
self.path = self.root / 'records.jsonl'
self.raw = json_bytes(self.row)
self.path.write_bytes(self.raw)
self.manifest = {'source':'dblp','venue':'UAI','year':2001,'track':'main','count':1,
'list_url':'https://example.org/list','file_sha256':digest(self.raw),
'status':'partial','missing_abstract_count':1,'parser_version':2}
self.path.with_suffix('.manifest.json').write_bytes(json_bytes(self.manifest))
self.plan = {'format_version':1,'inputs':[self.input(self.path)],'assertions':[
{'source':'dblp','external_id':self.row['external_id'],'record_sha256':digest(self.raw),
'identifiers':{'arxiv':'1408.1485'},'evidence':[{
'file':str(self.evidence),'sha256':digest(self.evidence.read_bytes()),
'url':'https://example.org/archive','checked_at':'2026-11-08T08:00:00Z',
'note':'Reviewed title, authors, publication and version evidence'}]}]}
def input(self, path):
return {'file':str(path),'sha256':digest(path.read_bytes()),
'manifest_sha256':digest(path.with_suffix('.manifest.json').read_bytes())}
def run_plan(self, plan=None):
path = self.root / 'plan.json'
path.write_bytes(json_bytes(self.plan if plan is None else plan))
return prepare(path, self.output)
def test_only_identifiers_and_provenance_change_and_raw_inputs_survive(self):
result = self.run_plan()
self.assertEqual(self.path.read_bytes(), self.raw)
prepared = json.loads((self.output / self.path.name).read_bytes())
provenance = prepared['metadata'].pop('identifier_curation')
self.assertEqual(prepared.pop('identifiers'), {'arxiv':'1408.1485'})
self.assertEqual(prepared, self.row)
self.assertEqual(provenance[0]['record_sha256'], digest(self.raw))
self.assertEqual((self.output / provenance[0]['evidence'][0]['file']).read_bytes(), self.evidence.read_bytes())
manifest = json.loads((self.output / 'records.manifest.json').read_bytes())
for key, value in self.manifest.items():
if key != 'file_sha256': self.assertEqual(manifest[key], value)
self.assertEqual(manifest['file_sha256'], digest((self.output / self.path.name).read_bytes()))
self.assertEqual(result['assertions_applied'], 1)
self.assertEqual(result['identifiers_added'], 1)
self.assertEqual(result['verification_scope'], SCOPE)
self.assertFalse(result['semantic_identity_verified'])
self.assertFalse(result['database_written'])
self.assertEqual(list(self.output.glob('*.jsonl')), [self.output / self.path.name])
with self.assertRaisesRegex(ValueError, 'already exist'): self.run_plan()
def test_late_invalid_file_publishes_nothing(self):
second = self.root / 'second.jsonl'
second.write_bytes(json_bytes({**self.row,'external_id':'second'}))
manifest = {**self.manifest,'file_sha256':digest(second.read_bytes()),'count':2}
second.with_suffix('.manifest.json').write_bytes(json_bytes(manifest))
self.plan['inputs'].append(self.input(second))
with self.assertRaisesRegex(ValueError, 'count mismatch'): self.run_plan()
self.assertFalse(self.output.exists())
self.assertFalse(list(self.root.glob('.identifier-curation-*')))
self.assertEqual(self.path.read_bytes(), self.raw)
def test_unasserted_rows_keep_exact_physical_bytes_and_doi_is_additive(self):
untouched = (json.dumps({**self.row,'external_id':'unselected'},ensure_ascii=False,indent=None) + ' ').encode()
self.path.write_bytes(self.raw + untouched)
self.path.with_suffix('.manifest.json').write_bytes(json_bytes({**self.manifest,
'file_sha256':digest(self.path.read_bytes()),'count':2,'missing_abstract_count':2}))
self.plan['inputs'] = [self.input(self.path)]
self.plan['assertions'][0]['identifiers']['doi'] = '10.1234/example'
result = self.run_plan()
with (self.output / self.path.name).open('rb') as stream:
first = json.loads(next(stream))
self.assertEqual(next(stream), untouched)
self.assertEqual(first['identifiers'], {'arxiv':'1408.1485','doi':'10.1234/example'})
self.assertEqual(result['identifiers_added'], 2)
def test_missing_duplicate_and_mismatched_assertions_fail_closed(self):
for mode in ('missing','duplicate','line_hash','duplicate_input'):
with self.subTest(mode=mode):
plan = copy.deepcopy(self.plan)
assertion = plan['assertions'][0]
if mode == 'missing': assertion['external_id'] = 'absent'
elif mode == 'duplicate': plan['assertions'].append(copy.deepcopy(assertion))
elif mode == 'line_hash': assertion['record_sha256'] = '0' * 64
else:
other = self.root / 'duplicate.jsonl'
other.write_bytes(self.raw)
other.with_suffix('.manifest.json').write_bytes(json_bytes(self.manifest))
plan['inputs'].append(self.input(other))
with self.assertRaises(ValueError): self.run_plan(plan)
self.assertFalse(self.output.exists())
def test_identifier_conflict_does_not_overwrite_and_same_value_is_auditable(self):
self.row['identifiers'] = {'arxiv':'cs/0307069'}
self.path.write_bytes(json_bytes(self.row))
self.manifest['file_sha256'] = digest(self.path.read_bytes())
self.path.with_suffix('.manifest.json').write_bytes(json_bytes(self.manifest))
self.plan['inputs'] = [self.input(self.path)]
self.plan['assertions'][0]['record_sha256'] = digest(self.path.read_bytes())
with self.assertRaisesRegex(ValueError, 'identifier conflict'): self.run_plan()
self.assertFalse(self.output.exists())
self.plan['assertions'][0]['identifiers']['arxiv'] = 'cs/0307069'
self.assertEqual(self.run_plan()['identifiers_added'], 0)
def test_unsupported_changes_identifiers_and_unbound_evidence_are_rejected(self):
for mode in ('title','bad_month','version','unknown_scheme','doi_case','evidence_hash','credentials','timestamp'):
with self.subTest(mode=mode):
plan = copy.deepcopy(self.plan)
assertion = plan['assertions'][0]
evidence = assertion['evidence'][0]
if mode == 'title': assertion['title'] = 'Rewrite forbidden'
elif mode == 'bad_month': assertion['identifiers'] = {'arxiv':'1413.12345'}
elif mode == 'version': assertion['identifiers'] = {'arxiv':'1408.1485v2'}
elif mode == 'unknown_scheme': assertion['identifiers'] = {'url':'https://example.org'}
elif mode == 'doi_case': assertion['identifiers'] = {'doi':'10.1234/UPPER'}
elif mode == 'evidence_hash': evidence['sha256'] = '0' * 64
elif mode == 'credentials': evidence['url'] = 'https://user:secret@example.org'
else: evidence['checked_at'] = '2026-11-08T08:00:00'
with self.assertRaises(ValueError): self.run_plan(plan)
self.assertFalse(self.output.exists())
def test_input_and_manifest_hashes_and_duplicate_json_keys_are_checked(self):
for mode in ('input','manifest','duplicate_json','invalid_evidence_json'):
with self.subTest(mode=mode):
plan = copy.deepcopy(self.plan)
if mode == 'input':
plan['inputs'][0]['sha256'] = '0' * 64
elif mode == 'manifest':
plan['inputs'][0]['manifest_sha256'] = '0' * 64
elif mode == 'duplicate_json':
raw = self.raw[:-2] + b',"source":"dblp"}\n'
self.path.write_bytes(raw)
self.path.with_suffix('.manifest.json').write_bytes(json_bytes({**self.manifest,'file_sha256':digest(raw)}))
plan['inputs'] = [self.input(self.path)]
else:
self.evidence.write_bytes(b'not json')
plan['assertions'][0]['evidence'][0]['sha256'] = digest(self.evidence.read_bytes())
with self.assertRaises(ValueError): self.run_plan(plan)
self.assertFalse(self.output.exists())
self.path.write_bytes(self.raw)
self.path.with_suffix('.manifest.json').write_bytes(json_bytes(self.manifest))
def test_curated_anchor_import_does_not_merge_extended_version_or_its_history(self):
db = self.root / 'papers.sqlite'
store = Store(db)
try:
original, _ = store.record('arxiv','1408.1485','https://example.org/one',self.row['title'],None,self.row['authors'],{'arxiv':'1408.1485'})
extended, record = store.record('arxiv','cs/0307069','https://example.org/two',self.row['title'],None,self.row['authors'],{'arxiv':'cs/0307069'})
store.decision(extended,record,'ICLR',2025,'main','another-attempt','https://example.org/reject','rejected','Reject')
store.conn.commit()
finally:
store.conn.close()
self.run_plan()
ingest_file(db,self.output / self.path.name)
store = Store(db)
try:
c = store.conn
self.assertEqual(c.execute('SELECT count(*) FROM papers').fetchone()[0], 2)
self.assertEqual(c.execute('SELECT paper_id FROM source_records WHERE external_id=?',(self.row['external_id'],)).fetchone()[0], original)
self.assertEqual(c.execute('SELECT 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 v.acronym=?',('ICLR',)).fetchone()[0], extended)
self.assertEqual(c.execute('SELECT count(*) FROM paper_merges').fetchone()[0], 0)
self.assertEqual(c.execute('PRAGMA foreign_key_check').fetchall(), [])
finally:
store.conn.close()
if __name__ == '__main__':
unittest.main()
+44
View File
@@ -53,6 +53,50 @@ class StoreTests(unittest.TestCase):
self.assertNotEqual(other,paper)
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):
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')
+28
View File
@@ -110,6 +110,34 @@ class PostgreSQLIngestionTests(unittest.TestCase):
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)
+1 -1
View File
@@ -133,7 +133,7 @@ class SubmissionTests(unittest.TestCase):
c.execute("INSERT INTO papers(id,title,title_key) VALUES(1,'Paper','paper')")
c.execute("INSERT INTO source_records VALUES(1,1,'paper',1,'https://example.org/p','Paper','Abstract','[]','{}',CURRENT_TIMESTAMP)")
c.execute("INSERT INTO venues VALUES(1,'ICLR','ICLR','machine_learning','conference')")
c.execute("INSERT INTO editions(id,venue_id,year,track) VALUES(1,1,2026,'main')")
c.execute("INSERT INTO editions VALUES(1,1,2026,'main')")
c.execute("INSERT INTO submissions VALUES(1,1,1,1,'paper',NULL)")
c.execute("INSERT INTO decisions VALUES(1,1,'accepted','Accept','https://example.org/decision','2026-01-01T00:00:00Z',NULL)")
c.commit();c.close()