From 1cf1d228bc5637f2221779cdeb194317f369af2e Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Sun, 8 Nov 2026 16:25:33 +0800 Subject: [PATCH] Prepare evidence-bound identifier additions without rewriting source records --- .gitea/workflows/check.yml | 1 + docs/usage.md | 49 +++++++ paper_atlas/cli.py | 3 +- paper_atlas/curation/__init__.py | 1 + paper_atlas/curation/identifiers.py | 212 ++++++++++++++++++++++++++++ tests/test_identifier_curation.py | 185 ++++++++++++++++++++++++ 6 files changed, 450 insertions(+), 1 deletion(-) create mode 100644 paper_atlas/curation/__init__.py create mode 100644 paper_atlas/curation/identifiers.py create mode 100644 tests/test_identifier_curation.py diff --git a/.gitea/workflows/check.yml b/.gitea/workflows/check.yml index 950c95a..8eac610 100644 --- a/.gitea/workflows/check.yml +++ b/.gitea/workflows/check.yml @@ -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) diff --git a/docs/usage.md b/docs/usage.md index 452f3d0..7b1471c 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -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": "", + "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、命令行或仓库。 diff --git a/paper_atlas/cli.py b/paper_atlas/cli.py index 549bfc0..4c4abe0 100644 --- a/paper_atlas/cli.py +++ b/paper_atlas/cli.py @@ -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)) diff --git a/paper_atlas/curation/__init__.py b/paper_atlas/curation/__init__.py new file mode 100644 index 0000000..9c5e8f6 --- /dev/null +++ b/paper_atlas/curation/__init__.py @@ -0,0 +1 @@ +"""Offline, evidence-bound preparation of metadata for separate ingestion.""" diff --git a/paper_atlas/curation/identifiers.py b/paper_atlas/curation/identifiers.py new file mode 100644 index 0000000..592195c --- /dev/null +++ b/paper_atlas/curation/identifiers.py @@ -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)) diff --git a/tests/test_identifier_curation.py b/tests/test_identifier_curation.py new file mode 100644 index 0000000..eee2f37 --- /dev/null +++ b/tests/test_identifier_curation.py @@ -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() -- 2.52.0