Compare commits

..

1 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
6 changed files with 450 additions and 1 deletions
+1
View File
@@ -54,6 +54,7 @@ jobs:
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)
+49
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、命令行或仓库。
+2 -1
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', {}, '下载并校验固定版本快照'),
@@ -48,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))
+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()